Skip to main content

msime_engine/user_dictionary/
generation.rs

1//! 代次准备(core-session.md §12、data-formats.md §3、`runtime_paths.cpp:116-182`):`user_data/dictionaries/<content id>` 里是经 backup API 复制的 `msime-pinyin.db` 与 `msime-english.db`(资源单独发布的 `msime-wubi.db` 五笔码表并回前者),回放过用户日志,旁边还有 n-gram 表。不读 `msime-pinyin.db` 的方案集合(见 `SchemeSet::reads_main_dictionary`)准备的代次没有它;不要求 `msime-english.db` 的集合(见 `SchemeSet::requires_english_dictionary`)在资源目录没带它时,代次里也没有它。
2
3use std::fs::{self, OpenOptions};
4use std::io::{self, ErrorKind};
5use std::path::{Component, Path, PathBuf};
6use std::time::{Duration, SystemTime};
7
8use rusqlite::backup::{Backup, StepResult};
9use rusqlite::{Connection, OpenFlags};
10
11use crate::assets;
12use crate::diagnostics;
13use crate::error::{EngineError, Result};
14use crate::paths::RuntimePaths;
15use crate::types::SchemeSet;
16use crate::user_dictionary::journal::{open_database, quarantine_corrupt_journal};
17use crate::user_dictionary::replay::{replay, replay_english};
18use crate::user_dictionary::reset::restore_interrupted_reset;
19use crate::wubi::provider::ensure_reverse_indexes;
20
21pub const MAX_CONTENT_ID_LENGTH: usize = 128;
22
23/// Tables the lattice reads through `RuntimePaths::dictionary`. They are built from one generation's vocabulary, so they live beside it, and a resource set without them simply leaves the decoder without them (RP:56-59).
24const GENERATION_COPIES: [&str; 2] = [assets::BIGRAM_TABLE, assets::TRIGRAM_TABLE];
25
26/// 暂存目录名的后缀,见 [`prepare_runtime_paths_for`]。
27const STAGE_SUFFIX: &str = ".incoming";
28
29/// 清理时先把被丢下的暂存目录改名成这个后缀认领,再删。
30const CLAIMED_STAGE_SUFFIX: &str = ".removing";
31
32/// 暂存目录多久没有任何写入才算被丢下。真在准备的进程一直在写:复制约 200 MB 的词库、回放日志、建索引,最慢的设备上两次写入之间也只隔几十秒,一次完整准备在几分钟以内;一小时留出几十倍余量。代价是被杀留下的半截目录要等下一次在一小时后的准备才被删,期间只占空间,不再挡住准备。
33const ABANDONED_STAGE_AGE: Duration = Duration::from_secs(60 * 60);
34
35/// `msime-wubi.db` 里单独发布、准备代次时并回工作主词库的五笔码表。
36const SPLIT_WUBI_TABLES: [&str; 2] = ["wubi86", "wubi98"];
37
38/// 校验代次 id(1 到 128 个 `[0-9A-Za-z_-]`)和三个互不重叠的绝对路径根,建好 `user_data` 和 `cache`,清掉被丢下的暂存目录,然后要么对已就绪的代次重新回放日志(补上它缺的 n-gram 表),要么在这次尝试独有的 `<id>.<随机串>.incoming` 里暂存、回放、写 `.ready` 再改名就位。暂存失败只删自己的目录;同一个代次被别的进程抢先发布时用那一份。
39pub fn prepare_runtime_paths(
40    resources: &Path,
41    user_data: &Path,
42    cache: &Path,
43    content_id: &str,
44) -> Result<RuntimePaths> {
45    prepare_runtime_paths_for(resources, user_data, cache, content_id, SchemeSet::ALL)
46}
47
48/// 按会话允许的方案准备代次,资源目录只要有这些方案读的词库。`schemes` 读 `msime-pinyin.db`([`SchemeSet::reads_main_dictionary`],即含全拼、双拼或五笔)时与 [`prepare_runtime_paths`] 完全相同:`msime-pinyin.db` 和 `msime-english.db` 都必须在资源目录里,都复制进代次。不读它时(只有日文、韩语、越南语、藏文、粤语、注音、笔画这类方案)资源目录不必有它,代次里也没有它;这样的集合也不要求 `msime-english.db`([`SchemeSet::requires_english_dictionary`]),资源目录带了它就复制进代次、日志只回放英文行(`replay_english`),没带就连它也没有、日志一行也不回放(记录留在日志里,以后带英文库的代次照样回放)。粤语、注音、笔画的语言库和日文模型不经过代次,会话从 `SessionOptions` 给的路径或资源目录直接读。
49pub fn prepare_runtime_paths_for(
50    resources: &Path,
51    user_data: &Path,
52    cache: &Path,
53    content_id: &str,
54    schemes: SchemeSet,
55) -> Result<RuntimePaths> {
56    let main_dictionary = schemes.reads_main_dictionary();
57    let english_required = main_dictionary || schemes.requires_english_dictionary();
58    if !valid_content_id(content_id) {
59        return Err(EngineError::invalid(
60            diagnostics::INVALID_RUNTIME_CONTENT_ID,
61        ));
62    }
63    let result = RuntimePaths {
64        resources: resources.to_owned(),
65        user_data: user_data.to_owned(),
66        cache: cache.to_owned(),
67        dictionaries: user_data.join("dictionaries").join(content_id),
68    };
69    result.validate()?;
70    // Each root has a different lifetime, so validate before creating anything: clearing the cache must not delete durable data, and preparation must not write inside immutable resources (RP:126-128).
71    let resource_root = weakly_canonical(resources)?;
72    let user_root = weakly_canonical(user_data)?;
73    let cache_root = weakly_canonical(cache)?;
74    if roots_overlap(&resource_root, &user_root)
75        || roots_overlap(&resource_root, &cache_root)
76        || roots_overlap(&user_root, &cache_root)
77    {
78        return Err(EngineError::invalid(diagnostics::RUNTIME_ROOTS_OVERLAP));
79    }
80    reject_redirected_directory(user_data)?;
81    reject_redirected_directory(cache)?;
82    fs::create_dir_all(user_data)?;
83    fs::create_dir_all(cache)?;
84    reject_redirected_directory(user_data)?;
85    reject_redirected_directory(cache)?;
86    // 重置途中被杀时日志可能还挪在备份里,先放回来,否则下面会当作没有日志、从空日志开始。
87    restore_interrupted_reset(&result.user(assets::USER_JOURNAL));
88    // 不是数据库的日志会在下面的回放中每次启动都失败,让宿主永远无法启动;先把它挪开,从空日志开始。宿主 API 会在进入这里之前上报挪动结果;这里覆盖其余调用方。
89    if let Some(moved) = quarantine_corrupt_journal(&result.user(assets::USER_JOURNAL))? {
90        crate::logging::warn(format_args!(
91            "user dictionary journal is not a database, moved aside to {}",
92            moved.display()
93        ));
94    }
95
96    let parent = user_data.join("dictionaries");
97    sweep_abandoned_stages(&parent, SystemTime::now());
98
99    let marker = result.dictionaries.join(assets::GENERATION_READY);
100    if generation_ready(&marker)? {
101        return open_ready_generation(result, resources, main_dictionary, english_required);
102    }
103    // 不要求英文库的集合有没有它,看资源目录里有没有这一项:符号链接之类也算有,交给下面的复制按原来的方式拒绝。
104    let dictionaries = GenerationDictionaries {
105        main: main_dictionary,
106        english: english_required
107            || fs::symlink_metadata(resources.join(assets::ENGLISH_DICTIONARY)).is_ok(),
108    };
109
110    fs::create_dir_all(&parent)?;
111    // 每次尝试在 `<id>.<随机串>.incoming` 里暂存,目录由 `tempdir_in` 独占新建(同名已存在就换一个名字),两次准备绝不共用一个暂存目录,这是 RP:155-157 独占新建要保证的;名字每次不同,进程在中途被杀留下的暂存目录也就挡不住下一次准备,由 [`sweep_abandoned_stages`] 收拾。出错时 `TempDir` 被丢弃,只删这次自己的目录(RP:174-179)。
112    let stage = tempfile::Builder::new()
113        .prefix(&format!("{content_id}."))
114        .suffix(STAGE_SUFFIX)
115        .tempdir_in(&parent)?;
116    let staged = (|| -> Result<()> {
117        if main_dictionary {
118            check_dictionary_format(
119                &resources.join(assets::MAIN_DICTIONARY),
120                assets::MAIN_DICTIONARY,
121            )?;
122        }
123        for name in dictionaries.names() {
124            copy_database(&resources.join(name), &stage.path().join(name))?;
125        }
126        if main_dictionary {
127            merge_split_wubi(resources, &stage.path().join(assets::MAIN_DICTIONARY))?;
128        }
129        stage_generation_copies(resources, stage.path(), true)?;
130        replay_into(&result, stage.path(), dictionaries)?;
131        index_reverse_lookup(stage.path(), dictionaries);
132        write_private_file(
133            &stage.path().join(assets::GENERATION_READY),
134            format!("{content_id}\n").as_bytes(),
135        )
136        .map_err(|_| EngineError::failed(diagnostics::RUNTIME_FINALIZE_FAILED))?;
137        // 只把整套做完的目录改名成代次,读的一方要么看不到代次,要么看到带 `.ready` 的完整代次。代次已经存在且非空时改名一定失败(Unix 报 `ENOTEMPTY`/`EEXIST`,Windows 报 `ERROR_DIR_NOT_EMPTY` 或拒绝访问),不会顶掉别人发布的代次。
138        fs::rename(stage.path(), &result.dictionaries)?;
139        Ok(())
140    })();
141    match staged {
142        Ok(()) => {
143            // 目录已经改名成代次,不能再让 `TempDir` 按原路径去删。
144            let _ = stage.keep();
145            Ok(result)
146        }
147        Err(error) => {
148            drop(stage);
149            // 另一个进程同时在准备同一个代次并抢先发布了它(这次的改名因此失败),或者这次停顿太久、暂存目录被当作丢下的清掉了:用已经就绪的那一份,和一开始就看到它一样再回放一遍日志。
150            if generation_ready(&marker).unwrap_or(false) {
151                return open_ready_generation(result, resources, main_dictionary, english_required);
152            }
153            Err(error)
154        }
155    }
156}
157
158/// 代次目录里是不是一个普通文件的 `.ready`。目录、设备或符号链接不算完成的代次,也不跟随、不去替换它所在的代次。
159fn generation_ready(marker: &Path) -> Result<bool> {
160    match fs::symlink_metadata(marker) {
161        Ok(metadata) if metadata.file_type().is_file() => Ok(true),
162        Ok(_) => Err(EngineError::failed(
163            diagnostics::INCOMPLETE_RUNTIME_GENERATION,
164        )),
165        Err(error) if error.kind() == ErrorKind::NotFound => Ok(false),
166        Err(error) => Err(error.into()),
167    }
168}
169
170/// 打开已经就绪的代次:放回重置途中被杀时挪开的词库,检查要求的词库副本都在,补上五笔表、反查索引和后来才随资源发布的 n-gram 表,再把当前日志回放一遍。不要求的英文库在不在,以代次准备时有没有复制它为准。
171fn open_ready_generation(
172    result: RuntimePaths,
173    resources: &Path,
174    main_dictionary: bool,
175    english_required: bool,
176) -> Result<RuntimePaths> {
177    // 重置途中被杀时词库可能还挪在备份里,先放回来再看在不在,见 `restore_interrupted_reset`。
178    if main_dictionary {
179        restore_interrupted_reset(&result.dictionary(assets::MAIN_DICTIONARY));
180        if !is_real_file(&result.dictionary(assets::MAIN_DICTIONARY)) {
181            return Err(EngineError::failed(
182                diagnostics::INCOMPLETE_RUNTIME_GENERATION,
183            ));
184        }
185    }
186    restore_interrupted_reset(&result.dictionary(assets::ENGLISH_DICTIONARY));
187    let english = is_real_file(&result.dictionary(assets::ENGLISH_DICTIONARY));
188    if english_required && !english {
189        return Err(EngineError::failed(
190            diagnostics::INCOMPLETE_RUNTIME_GENERATION,
191        ));
192    }
193    let dictionaries = GenerationDictionaries {
194        main: main_dictionary,
195        english,
196    };
197    // 拆分五笔码表之后准备的代次也要有五笔表;表已在时这一步只读不写。没有工作主词库的代次没有地方放五笔表。
198    if main_dictionary {
199        // 代次里的主库副本带着来源文件的 `metadata`。就绪的代次可能是更新的引擎用更新格式的词库准备的(宿主降级后仍选着那份下载的词库更新),这里不查,会话就会照旧格式去读它。
200        check_dictionary_format(
201            &result.dictionary(assets::MAIN_DICTIONARY),
202            assets::MAIN_DICTIONARY,
203        )?;
204        merge_split_wubi(resources, &result.dictionary(assets::MAIN_DICTIONARY))?;
205    }
206    // A host can switch back to a previously prepared generation. Replay the current journal again so changes learned on a newer generation survive that switch (RP:143-144).
207    replay_into(&result, &result.dictionaries, dictionaries)?;
208    // 旧版本准备的代次没有反查索引,重新打开时补上;已有索引时只读一次 schema。
209    index_reverse_lookup(&result.dictionaries, dictionaries);
210    stage_generation_copies(resources, &result.dictionaries, false)?;
211    Ok(result)
212}
213
214/// 清掉 `dictionaries` 下被丢下的暂存目录:准备途中被杀的进程留下的 `<id>.<随机串>.incoming`、旧版本固定名字的 `<id>.incoming`,以及上一次清理没删完的 `.removing`。每次准备都先做一遍,出什么错都不影响准备本身。
215///
216/// 暂存目录本身和里面每一项最近一次修改都早于 [`ABANDONED_STAGE_AGE`] 才算被丢下。不是自己的暂存目录无从判断主人是否还活着:引擎不能用文件锁(只有 client-base 知道 Android 上怎么锁,引擎不依赖它),进程号在 Linux 的不同 PID 命名空间之间、iOS 沙盒的 App 与扩展之间都不可靠,只剩“多久没写”这一条。被丢下的目录先改名成 `.removing` 认领再删:万一主人只是停顿太久(iOS 把 App 挂起在准备中途),它之后的写入和改名找不到原路径,只会让那次准备失败,不会把删到一半的目录发布成代次。
217fn sweep_abandoned_stages(parent: &Path, now: SystemTime) {
218    let Ok(entries) = fs::read_dir(parent) else {
219        return;
220    };
221    for entry in entries.flatten() {
222        let name = entry.file_name();
223        let Some(name) = name.to_str() else {
224            continue;
225        };
226        let path = entry.path();
227        if is_stage_name(name, CLAIMED_STAGE_SUFFIX) {
228            // 已经认领的目录不会再被谁发布,不必等它变旧。
229            if is_real_directory(&path) {
230                let _ = fs::remove_dir_all(&path);
231            }
232            continue;
233        }
234        if !is_stage_name(name, STAGE_SUFFIX) || !abandoned(&path, now) {
235            continue;
236        }
237        let mut claimed = name[..name.len() - STAGE_SUFFIX.len()].to_owned();
238        claimed.push_str(CLAIMED_STAGE_SUFFIX);
239        let claimed = parent.join(claimed);
240        // Windows 上目录里还有打开的文件时改名失败,跳过即可。
241        if fs::rename(&path, &claimed).is_ok() {
242            let _ = fs::remove_dir_all(&claimed);
243        }
244    }
245}
246
247/// `<id><suffix>` 或 `<id>.<字母数字><suffix>`,`<id>` 是合法的代次 id。
248fn is_stage_name(name: &str, suffix: &str) -> bool {
249    let Some(stem) = name.strip_suffix(suffix) else {
250        return false;
251    };
252    let (content_id, attempt) = match stem.split_once('.') {
253        Some((content_id, attempt)) => (content_id, Some(attempt)),
254        None => (stem, None),
255    };
256    valid_content_id(content_id)
257        && attempt.is_none_or(|attempt| {
258            !attempt.is_empty() && attempt.bytes().all(|byte| byte.is_ascii_alphanumeric())
259        })
260}
261
262/// 暂存目录(不是符号链接)和里面每一项的修改时间都早于 `now - ABANDONED_STAGE_AGE`。读不到时间、时间在将来、或者读的同时有项目消失,都当作还有人在写。
263fn abandoned(stage: &Path, now: SystemTime) -> bool {
264    let Some(cutoff) = now.checked_sub(ABANDONED_STAGE_AGE) else {
265        return false;
266    };
267    let untouched = |path: &Path| {
268        fs::symlink_metadata(path)
269            .and_then(|metadata| metadata.modified())
270            .is_ok_and(|modified| modified < cutoff)
271    };
272    if !is_real_directory(stage) || !untouched(stage) {
273        return false;
274    }
275    let Ok(entries) = fs::read_dir(stage) else {
276        return false;
277    };
278    entries
279        .into_iter()
280        .all(|entry| entry.is_ok_and(|entry| untouched(&entry.path())))
281}
282
283fn is_real_directory(path: &Path) -> bool {
284    fs::symlink_metadata(path)
285        .map(|metadata| metadata.file_type().is_dir())
286        .unwrap_or(false)
287}
288
289fn valid_content_id(content_id: &str) -> bool {
290    !content_id.is_empty()
291        && content_id.len() <= MAX_CONTENT_ID_LENGTH
292        && content_id
293            .bytes()
294            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
295}
296
297/// 代次里有哪些词库工作副本。有 `msime-pinyin.db` 时一定有 `msime-english.db`(读主库的方案集合也要求英文库);只有不要求英文库、资源目录又没带它的集合,两者都没有。
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299struct GenerationDictionaries {
300    main: bool,
301    english: bool,
302}
303
304impl GenerationDictionaries {
305    fn names(self) -> impl Iterator<Item = &'static str> {
306        [
307            (self.main, assets::MAIN_DICTIONARY),
308            (self.english, assets::ENGLISH_DICTIONARY),
309        ]
310        .into_iter()
311        .filter_map(|(present, name)| present.then_some(name))
312    }
313}
314
315/// Replay the journal of `paths` into the dictionaries in `generation`; any failed row or error refuses the generation. 没有 `msime-pinyin.db` 的代次只回放英文行,两个库都没有的代次什么也不回放。
316fn replay_into(
317    paths: &RuntimePaths,
318    generation: &Path,
319    dictionaries: GenerationDictionaries,
320) -> Result<()> {
321    let journal = paths.user(assets::USER_JOURNAL);
322    let english = generation.join(assets::ENGLISH_DICTIONARY);
323    let replayed = if dictionaries.main {
324        replay(
325            &journal,
326            &generation.join(assets::MAIN_DICTIONARY),
327            &english,
328        )
329    } else if dictionaries.english {
330        replay_english(&journal, &english)
331    } else {
332        return Ok(());
333    };
334    if replayed.failed != 0 || !replayed.error.is_empty() {
335        return Err(EngineError::failed(format!(
336            "{}{}",
337            diagnostics::RUNTIME_REPLAY_FAILED,
338            replayed.error
339        )));
340    }
341    Ok(())
342}
343
344/// 给代次里每个词库副本(没有主库时只有英文词库,或者一个也没有)补上五笔反查索引(见 `wubi::provider::ensure_reverse_indexes`)。代次是用户可写的副本,资源目录保持只读、原样;索引不进日志,也不影响词库状态的摘要。
345///
346/// 只是提速,从不失败:建索引要写盘(约 1.3 MB 加回滚日志),磁盘满、文件只读或被别的进程长时间锁住时记一条日志、照常用没有索引的副本,反查仍然正确,只是慢。这里报错会让宿主起不来。新词库在 dict-builder 里就带着同名索引,这里只为旧词库和旧代次补建。
347fn index_reverse_lookup(generation: &Path, dictionaries: GenerationDictionaries) {
348    for name in dictionaries.names() {
349        let database = generation.join(name);
350        let indexed = open_database(&database, OpenFlags::SQLITE_OPEN_READ_WRITE)
351            .and_then(|connection| ensure_reverse_indexes(&connection).map_err(Into::into));
352        if let Err(error) = indexed {
353            crate::logging::warn(format_args!(
354                "wubi reverse lookup index unavailable in {}, lookups stay unindexed: {error}",
355                database.display()
356            ));
357        }
358    }
359}
360
361/// Copy through the SQLite backup API, which includes committed WAL content that a plain file copy of a live database would lose (RP:38-55).
362fn copy_database(source: &Path, target: &Path) -> Result<()> {
363    if !is_real_file(source) {
364        return Err(EngineError::failed(format!(
365            "{}{}",
366            diagnostics::RUNTIME_COPY_FAILED,
367            source.display()
368        )));
369    }
370    let copied = (|| -> rusqlite::Result<StepResult> {
371        let source = crate::paths::sqlite_read_only_path_no_follow(source)
372            .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
373        let target = crate::paths::sqlite_path_no_follow(target)
374            .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
375        let input = Connection::open_with_flags(
376            &source,
377            OpenFlags::SQLITE_OPEN_READ_ONLY
378                | OpenFlags::SQLITE_OPEN_NOFOLLOW
379                | OpenFlags::SQLITE_OPEN_NO_MUTEX,
380        )?;
381        let mut output = Connection::open_with_flags(
382            &target,
383            OpenFlags::SQLITE_OPEN_READ_WRITE
384                | OpenFlags::SQLITE_OPEN_CREATE
385                | OpenFlags::SQLITE_OPEN_NOFOLLOW
386                | OpenFlags::SQLITE_OPEN_NO_MUTEX,
387        )?;
388        // runtime_paths.cpp:43-44 set no busy timeout on either side; rusqlite would otherwise add a 5 s wait the reference never had.
389        input.busy_timeout(std::time::Duration::ZERO)?;
390        output.busy_timeout(std::time::Duration::ZERO)?;
391        let backup = Backup::new(&input, &mut output)?;
392        backup.step(-1)
393    })();
394    if !matches!(copied, Ok(StepResult::Done)) {
395        return Err(EngineError::failed(format!(
396            "{}{}",
397            diagnostics::RUNTIME_COPY_FAILED,
398            source.display()
399        )));
400    }
401    Ok(())
402}
403
404pub(super) fn copy_private_file(source: &Path, target: &Path) -> io::Result<u64> {
405    let mut input = crate::paths::open_file_no_follow(source)?;
406    let mut output = create_private_file(target)?;
407    let copied = io::copy(&mut input, &mut output)?;
408    output.sync_all()?;
409    Ok(copied)
410}
411
412fn create_private_file(path: &Path) -> io::Result<fs::File> {
413    let mut options = OpenOptions::new();
414    options.write(true).create_new(true);
415    #[cfg(unix)]
416    {
417        use std::os::unix::fs::OpenOptionsExt;
418        options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
419    }
420    #[cfg(windows)]
421    {
422        use std::os::windows::fs::OpenOptionsExt;
423        const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
424        options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
425    }
426    options.open(path)
427}
428
429fn write_private_file(path: &Path, bytes: &[u8]) -> io::Result<()> {
430    let mut output = create_private_file(path)?;
431    io::Write::write_all(&mut output, bytes)?;
432    output.sync_all()?;
433    Ok(())
434}
435
436/// 资源目录不带 `msime-wubi.db`、五笔码表来自宿主另行下载的那份 `wubi`(`EngineOptions::wubi_dictionary`,macOS 的 `wubi` 资源包)时,把它并进代次的工作主词库,再回放一遍日志:准备代次时码表还不在,日志里的五笔行被跳过(`replay`),并进来之后才有地方写。返回是否并入了码表。
437///
438/// 工作主词库已有五笔表、代次就是资源目录(只读布局)、没有工作主词库(不读拼音库的版本),或者 `wubi` 不是普通文件 `msime-wubi.db` 时什么也不做。已有五笔表时只读一次 schema,所以每次建会话都调用也不贵。
439pub(crate) fn merge_downloaded_wubi(paths: &RuntimePaths, wubi: &Path) -> Result<bool> {
440    let main_db = paths.dictionary(assets::MAIN_DICTIONARY);
441    if paths.dictionaries == paths.resources
442        || !is_real_file(&main_db)
443        || wubi.file_name() != Some(std::ffi::OsStr::new(assets::WUBI_DICTIONARY))
444        || !is_real_file(wubi)
445    {
446        return Ok(false);
447    }
448    if has_split_wubi_tables(&main_db)? {
449        return Ok(false);
450    }
451    let Some(directory) = wubi.parent() else {
452        return Ok(false);
453    };
454    merge_split_wubi(directory, &main_db)?;
455    // 有工作主词库的代次一定也有英文库(读主库的集合要求它)。
456    let dictionaries = GenerationDictionaries {
457        main: true,
458        english: true,
459    };
460    replay_into(paths, &paths.dictionaries, dictionaries)?;
461    index_reverse_lookup(&paths.dictionaries, dictionaries);
462    Ok(true)
463}
464
465/// 工作主词库里是否已有 `wubi86` 或 `wubi98`。
466pub(crate) fn has_split_wubi_tables(main_db: &Path) -> Result<bool> {
467    let present = (|| -> rusqlite::Result<bool> {
468        let path = crate::paths::sqlite_read_only_path_no_follow(main_db)
469            .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
470        let connection = Connection::open_with_flags(
471            &path,
472            OpenFlags::SQLITE_OPEN_READ_ONLY
473                | OpenFlags::SQLITE_OPEN_NOFOLLOW
474                | OpenFlags::SQLITE_OPEN_NO_MUTEX,
475        )?;
476        connection.query_row(
477            "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name IN ('wubi86','wubi98'))",
478            [],
479            |row| row.get(0),
480        )
481    })();
482    present.map_err(|error| {
483        EngineError::failed(format!("cannot read {}: {error}", assets::MAIN_DICTIONARY))
484    })
485}
486
487/// 词库发布把五笔码表单独放在只读的 `msime-wubi.db` 里,`msime-pinyin.db` 不再含 `wubi86`/`wubi98`。五笔的学习调序、删词、个人词典编辑与日志回放都写代次里的工作主词库,五笔 provider 也从它读,所以准备代次(以及重置学习数据)时把这两张表连同索引并回工作副本:读写落在同一个文件上,学到的权重立即可见。资源目录没有 `msime-wubi.db`(旧的合并发布)或工作副本里已有同名表时不动。
488pub(crate) fn merge_split_wubi(resources: &Path, main_db: &Path) -> Result<()> {
489    let source = resources.join(assets::WUBI_DICTIONARY);
490    if !is_real_file(&source) {
491        return Ok(());
492    }
493    check_dictionary_format(&source, assets::WUBI_DICTIONARY)?;
494    let merged = (|| -> rusqlite::Result<()> {
495        let source = crate::paths::sqlite_read_only_path_no_follow(&source)
496            .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
497        let main_db = crate::paths::sqlite_path_no_follow(main_db)
498            .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
499        let input = Connection::open_with_flags(
500            &source,
501            OpenFlags::SQLITE_OPEN_READ_ONLY
502                | OpenFlags::SQLITE_OPEN_NOFOLLOW
503                | OpenFlags::SQLITE_OPEN_NO_MUTEX,
504        )?;
505        let mut output = Connection::open_with_flags(
506            &main_db,
507            OpenFlags::SQLITE_OPEN_READ_WRITE
508                | OpenFlags::SQLITE_OPEN_NOFOLLOW
509                | OpenFlags::SQLITE_OPEN_NO_MUTEX,
510        )?;
511        input.busy_timeout(std::time::Duration::ZERO)?;
512        output.busy_timeout(std::time::Duration::ZERO)?;
513        let transaction = output.transaction()?;
514        for table in SPLIT_WUBI_TABLES {
515            let present: bool = transaction.query_row(
516                "SELECT EXISTS(SELECT 1 FROM main.sqlite_master WHERE type='table' AND name=?1)",
517                [table],
518                |row| row.get(0),
519            )?;
520            if present {
521                continue;
522            }
523            // 先建表(`UNIQUE` 约束的自动索引随表建立),灌完数据再建显式索引。
524            let schema = input
525                .prepare("SELECT sql FROM sqlite_master WHERE tbl_name=?1 AND sql IS NOT NULL ORDER BY type='index', rowid")?
526                .query_map([table], |row| row.get::<_, String>(0))?
527                .collect::<rusqlite::Result<Vec<_>>>()?;
528            let Some((create, indexes)) = schema.split_first() else {
529                continue;
530            };
531            transaction.execute_batch(create)?;
532            let mut select = input.prepare(&format!("SELECT rowid,* FROM \"{table}\""))?;
533            let columns: Vec<String> = select
534                .column_names()
535                .iter()
536                .map(|name| format!("\"{name}\""))
537                .collect();
538            let placeholders = vec!["?"; columns.len()].join(",");
539            // 保留 rowid:反查五笔编码时用 rowid 作最后的排序键。
540            let mut insert = transaction.prepare(&format!(
541                "INSERT INTO \"{table}\"({}) VALUES({placeholders})",
542                columns.join(",")
543            ))?;
544            let mut rows = select.query([])?;
545            while let Some(row) = rows.next()? {
546                let values = (0..columns.len())
547                    .map(|index| row.get::<_, rusqlite::types::Value>(index))
548                    .collect::<rusqlite::Result<Vec<_>>>()?;
549                insert.execute(rusqlite::params_from_iter(values))?;
550            }
551            for index in indexes {
552                transaction.execute_batch(index)?;
553            }
554            transaction.execute_batch(&format!("ANALYZE \"{table}\""))?;
555        }
556        transaction.commit()
557    })();
558    merged.map_err(|_| {
559        EngineError::failed(format!(
560            "{}{}",
561            diagnostics::RUNTIME_COPY_FAILED,
562            source.display()
563        ))
564    })
565}
566
567/// 资源目录里的主库或五笔库在复制、合并进代次之前先查文件内版本([`crate::format::check_format_version`]):比这个引擎新的格式拒绝,旧格式和没有 `metadata` 表的旧发布照常使用。`name` 只用于错误信息。文件不在、打不开或不是数据库时这里不报错,复制与合并照原来的方式报告它。
568pub(crate) fn check_dictionary_format(path: &Path, name: &str) -> Result<()> {
569    let Ok(resolved) = crate::paths::sqlite_read_only_path_no_follow(path) else {
570        return Ok(());
571    };
572    let Ok(connection) = Connection::open_with_flags(
573        &resolved,
574        OpenFlags::SQLITE_OPEN_READ_ONLY
575            | OpenFlags::SQLITE_OPEN_NOFOLLOW
576            | OpenFlags::SQLITE_OPEN_NO_MUTEX,
577    ) else {
578        return Ok(());
579    };
580    // 与 `copy_database` 一样不等锁:资源文件没有写者,读不了就交给后面的步骤报告。
581    if connection.busy_timeout(Duration::ZERO).is_err() {
582        return Ok(());
583    }
584    match crate::format::check_format_version(&connection, name) {
585        Ok(_) | Err(EngineError::Sqlite(_)) => Ok(()),
586        Err(error) => Err(error),
587    }
588}
589
590/// 只读布局(代次目录就是资源目录,网页引擎、后端和部分测试这样用)没有准备代次这一步,建会话时在这里查主库和五笔库的文件内版本,规则与准备代次时相同。其他布局什么也不做:它们的文件在准备代次时已经查过。
591pub(crate) fn check_read_only_layout(paths: &RuntimePaths) -> Result<()> {
592    if paths.dictionaries != paths.resources {
593        return Ok(());
594    }
595    for name in [assets::MAIN_DICTIONARY, assets::WUBI_DICTIONARY] {
596        check_dictionary_format(&paths.dictionary(name), name)?;
597    }
598    Ok(())
599}
600
601/// 把资源带的可选 n-gram 表复制进代次。已就绪的代次也会以 `replace_existing` 为假调用它:后来的版本才开始发布的表不用换代次就能补上,会话可能已经映射着的那张保持不动(RP:76-90)。
602///
603/// 每张表先写进这次尝试独有的 `<表名>.<随机串>.incoming`(独占新建,重名就换一个),落盘后再改名就位:会话可能映射着旧文件,映射下面的表写到一半就是崩溃而不是查不到(RP:62-63)。以前用固定的 `<表名>.incoming`,两个进程同时补同一张表时,一方会删掉另一方正在写的文件,再把对方写到一半的副本改名就位。几方同时补同一张表时,后改名的一方在 Unix 上以同样的字节替换前一方的文件(映射着前一份的会话不受影响);Windows 上目标被映射时改名失败,这时目标已经是完整的表,丢掉自己的副本即可。
604pub(super) fn stage_generation_copies(
605    resources: &Path,
606    generation: &Path,
607    replace_existing: bool,
608) -> Result<()> {
609    sweep_abandoned_copies(generation, SystemTime::now());
610    for name in GENERATION_COPIES {
611        let source = resources.join(name);
612        if !is_real_file(&source) {
613            continue;
614        }
615        let target = generation.join(name);
616        if !replace_existing && is_real_file(&target) {
617            continue;
618        }
619        let mut input = crate::paths::open_file_no_follow(&source)?;
620        // 出错时丢弃 `TempPath` 会删掉这次自己的临时文件。写入句柄在块结束时关闭,Windows 不能改名打开着的文件。
621        let incoming = {
622            let (mut output, incoming) = tempfile::Builder::new()
623                .prefix(&format!("{name}."))
624                .suffix(STAGE_SUFFIX)
625                .make_in(generation, create_private_file)?
626                .into_parts();
627            io::copy(&mut input, &mut output)?;
628            output.sync_all()?;
629            incoming
630        };
631        match fs::rename(&incoming, &target) {
632            Ok(()) => {
633                // 文件已经改名就位,不能再让 `TempPath` 按原路径去删。
634                let _ = incoming.keep();
635            }
636            Err(_) if !replace_existing && is_real_file(&target) => {}
637            Err(error) => return Err(error.into()),
638        }
639    }
640    Ok(())
641}
642
643/// 删掉代次目录里一小时没被写过的 n-gram 临时副本:准备途中被杀的进程留下的 `<表名>.<随机串>.incoming`,以及旧版本固定名字的 `<表名>.incoming`。判据与 [`sweep_abandoned_stages`] 相同;文件按路径删除,主人若其实还活着,之后的改名找不到原路径,只会让那次准备失败,已就位的表不受影响。出什么错都不影响准备本身。
644fn sweep_abandoned_copies(generation: &Path, now: SystemTime) {
645    let Some(cutoff) = now.checked_sub(ABANDONED_STAGE_AGE) else {
646        return;
647    };
648    let Ok(entries) = fs::read_dir(generation) else {
649        return;
650    };
651    for entry in entries.flatten() {
652        let name = entry.file_name();
653        if !name.to_str().is_some_and(is_copy_name) {
654            continue;
655        }
656        let path = entry.path();
657        let stale = fs::symlink_metadata(&path).is_ok_and(|metadata| {
658            metadata.file_type().is_file()
659                && metadata.modified().is_ok_and(|modified| modified < cutoff)
660        });
661        if stale {
662            let _ = fs::remove_file(&path);
663        }
664    }
665}
666
667/// `<表名>.incoming` 或 `<表名>.<字母数字>.incoming`,表名是 [`GENERATION_COPIES`] 之一。
668fn is_copy_name(name: &str) -> bool {
669    let Some(stem) = name.strip_suffix(STAGE_SUFFIX) else {
670        return false;
671    };
672    GENERATION_COPIES.iter().any(|copy| {
673        stem == *copy
674            || stem
675                .strip_prefix(copy)
676                .and_then(|rest| rest.strip_prefix('.'))
677                .is_some_and(|attempt| {
678                    !attempt.is_empty() && attempt.bytes().all(|byte| byte.is_ascii_alphanumeric())
679                })
680    })
681}
682
683fn is_real_file(path: &Path) -> bool {
684    fs::symlink_metadata(path)
685        .map(|metadata| metadata.file_type().is_file())
686        .unwrap_or(false)
687}
688
689/// `std::filesystem::weakly_canonical`: the longest existing prefix resolved through symlinks, the rest appended with `.` and `..` folded lexically, so roots that do not exist yet compare by what they will resolve to. No crate resolves symlinks for a partly missing path, which the overlap check needs (a symlinked resource alias must still be caught).
690pub(super) fn weakly_canonical(path: &Path) -> Result<PathBuf> {
691    let components: Vec<Component<'_>> = path.components().collect();
692    for split in (1..=components.len()).rev() {
693        let head: PathBuf = components[..split].iter().collect();
694        match head.canonicalize() {
695            Ok(mut resolved) => {
696                append_lexically(&mut resolved, &components[split..]);
697                return Ok(resolved);
698            }
699            Err(error) if error.kind() == ErrorKind::NotFound => {}
700            Err(error) => return Err(error.into()),
701        }
702    }
703    let mut resolved = PathBuf::new();
704    append_lexically(&mut resolved, &components);
705    Ok(resolved)
706}
707
708fn append_lexically(base: &mut PathBuf, rest: &[Component<'_>]) {
709    for component in rest {
710        match component {
711            Component::CurDir => {}
712            Component::ParentDir => {
713                base.pop();
714            }
715            other => base.push(other),
716        }
717    }
718}
719
720fn reject_redirected_directory(path: &Path) -> std::io::Result<()> {
721    for ancestor in path.ancestors() {
722        match fs::symlink_metadata(ancestor) {
723            Ok(metadata) if metadata.file_type().is_symlink() => {
724                if crate::paths::is_trusted_system_alias(ancestor) {
725                    continue;
726                }
727                return Err(std::io::Error::new(
728                    std::io::ErrorKind::InvalidInput,
729                    "runtime directory has a symbolic-link ancestor",
730                ));
731            }
732            Ok(metadata) if !metadata.is_dir() => {
733                return Err(std::io::Error::new(
734                    std::io::ErrorKind::NotADirectory,
735                    "runtime directory parent is not a directory",
736                ));
737            }
738            Ok(_) => {}
739            Err(error) if error.kind() == ErrorKind::NotFound => {}
740            Err(error) => return Err(error),
741        }
742    }
743    Ok(())
744}
745
746/// One root contains the other, compared by whole components (RP:34-37).
747pub(super) fn roots_overlap(first: &Path, second: &Path) -> bool {
748    first.starts_with(second) || second.starts_with(first)
749}
750
751#[cfg(test)]
752mod tests {
753    use super::*;
754    use crate::user_dictionary::replay::tests::{sql, weight, OPERATIONS_DDL};
755
756    fn resources(root: &Path) -> PathBuf {
757        let resources = root.join("resources");
758        fs::create_dir_all(&resources).unwrap();
759        sql(
760            &resources.join(assets::MAIN_DICTIONARY),
761            "CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
762             INSERT INTO tbl_1_n VALUES('ni','n','你',100);",
763        );
764        crate::dictionary::english::ensure_english_schema(
765            &resources.join(assets::ENGLISH_DICTIONARY),
766        )
767        .unwrap();
768        resources
769    }
770
771    /// `user/dictionaries` 下还剩的暂存目录。
772    fn stages(user: &Path) -> Vec<String> {
773        fs::read_dir(user.join("dictionaries"))
774            .map(|entries| {
775                entries
776                    .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
777                    .filter(|name| name.ends_with(STAGE_SUFFIX))
778                    .collect()
779            })
780            .unwrap_or_default()
781    }
782
783    /// 目录下名字以 `.incoming` 结尾的项。
784    fn incoming_entries(directory: &Path) -> Vec<String> {
785        let mut names: Vec<String> = fs::read_dir(directory)
786            .unwrap()
787            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
788            .filter(|name| name.ends_with(STAGE_SUFFIX))
789            .collect();
790        names.sort();
791        names
792    }
793
794    fn learn(journal: &Path, word: &str, updated_at: i64) {
795        sql(journal, OPERATIONS_DDL);
796        sql(
797            journal,
798            &format!("INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,user_inserted,updated_at) VALUES('pinyin','ni','{word}','upsert',50,1,{updated_at})"),
799        );
800    }
801
802    #[test]
803    fn a_journal_that_is_not_a_database_does_not_stop_preparation() {
804        let root = tempfile::tempdir().unwrap();
805        let resources = resources(root.path());
806        let user = root.path().join("user");
807        fs::create_dir_all(&user).unwrap();
808        let journal = user.join(assets::USER_JOURNAL);
809        fs::write(&journal, vec![0u8; 8 * 4096]).unwrap();
810
811        let prepared =
812            prepare_runtime_paths(&resources, &user, &root.path().join("cache"), "v1").unwrap();
813        assert!(prepared
814            .dictionaries
815            .join(assets::GENERATION_READY)
816            .exists());
817        assert!(!journal.exists());
818        let moved: Vec<_> = fs::read_dir(&user)
819            .unwrap()
820            .filter_map(|entry| entry.ok())
821            .map(|entry| entry.file_name().to_string_lossy().into_owned())
822            .filter(|name| name.starts_with("msime_user.db.corrupt-"))
823            .collect();
824        assert_eq!(moved.len(), 1, "{moved:?}");
825        // 之后写入的日志仍会按原流程回放到代次中。
826        learn(&journal, "你", 1);
827        prepare_runtime_paths(&resources, &user, &root.path().join("cache"), "v1").unwrap();
828    }
829
830    #[test]
831    fn content_ids_are_validated_before_anything_is_created() {
832        let root = tempfile::tempdir().unwrap();
833        let resources = resources(root.path());
834        let user = root.path().join("user");
835        for id in ["", "../escape", "a b", "é", &"x".repeat(129)] {
836            let error = prepare_runtime_paths(&resources, &user, &root.path().join("cache"), id)
837                .unwrap_err();
838            assert_eq!(error.to_string(), diagnostics::INVALID_RUNTIME_CONTENT_ID);
839        }
840        assert!(!user.exists());
841        let relative = prepare_runtime_paths(
842            Path::new("resources"),
843            &user,
844            &root.path().join("cache"),
845            "v1",
846        )
847        .unwrap_err();
848        assert_eq!(
849            relative.to_string(),
850            diagnostics::RUNTIME_DIRECTORIES_MUST_BE_ABSOLUTE
851        );
852        assert!(prepare_runtime_paths(
853            &resources,
854            &user,
855            &root.path().join("cache"),
856            &"x".repeat(128)
857        )
858        .is_ok());
859    }
860
861    #[cfg(unix)]
862    #[test]
863    fn resource_dictionary_symlinks_are_rejected_before_staging() {
864        use std::os::unix::fs::symlink;
865
866        let root = tempfile::tempdir().unwrap();
867        let resources = resources(root.path());
868        let external = root.path().join("external-msime-pinyin.db");
869        fs::rename(resources.join(assets::MAIN_DICTIONARY), &external).unwrap();
870        symlink(&external, resources.join(assets::MAIN_DICTIONARY)).unwrap();
871
872        let error = prepare_runtime_paths(
873            &resources,
874            &root.path().join("user"),
875            &root.path().join("cache"),
876            "v1",
877        )
878        .unwrap_err();
879        assert!(error
880            .to_string()
881            .starts_with(diagnostics::RUNTIME_COPY_FAILED));
882        assert!(!root.path().join("user/dictionaries/v1").exists());
883    }
884
885    #[cfg(unix)]
886    #[test]
887    fn user_and_cache_roots_reject_symlinked_directories() {
888        use std::os::unix::fs::symlink;
889
890        for linked_name in ["user", "cache"] {
891            let root = tempfile::tempdir().unwrap();
892            let resources = resources(root.path());
893            let outside = tempfile::tempdir().unwrap();
894            let user = root.path().join("user");
895            let cache = root.path().join("cache");
896            symlink(outside.path(), root.path().join(linked_name)).unwrap();
897
898            assert!(prepare_runtime_paths(&resources, &user, &cache, "v1").is_err());
899            assert!(!outside.path().join("dictionaries").exists());
900        }
901    }
902
903    // test_runtime_isolation.cpp:140-168.
904    #[test]
905    fn overlapping_roots_are_rejected_without_creating_directories() {
906        let temporary = tempfile::tempdir().unwrap();
907        let root = temporary.path();
908        let resource_a = resources(root);
909        let reject = |resources: &Path, user: &Path, cache: &Path| {
910            let error = prepare_runtime_paths(resources, user, cache, "overlap").unwrap_err();
911            assert!(matches!(error, EngineError::InvalidArgument(_)), "{error}");
912            assert_eq!(error.to_string(), diagnostics::RUNTIME_ROOTS_OVERLAP);
913            assert!(!user.join("dictionaries/overlap").exists());
914        };
915        reject(
916            &resource_a,
917            &root.join("same-user-cache"),
918            &root.join("same-user-cache"),
919        );
920        reject(
921            &resource_a,
922            &root.join("cache-parent/user"),
923            &root.join("cache-parent"),
924        );
925        reject(
926            &resource_a,
927            &root.join("user-parent"),
928            &root.join("user-parent/cache"),
929        );
930        reject(
931            &resource_a,
932            &resource_a.join("user"),
933            &root.join("separate-cache"),
934        );
935        reject(
936            &resource_a,
937            &root.join("separate-user"),
938            &resource_a.join("cache"),
939        );
940        reject(&resource_a, root, &root.join("separate-cache"));
941        reject(
942            &resource_a,
943            &root.join("dotted/../resources/user"),
944            &root.join("separate-cache"),
945        );
946        for created in [
947            root.join("same-user-cache"),
948            root.join("cache-parent"),
949            resource_a.join("user"),
950            resource_a.join("cache"),
951        ] {
952            assert!(!created.exists(), "{}", created.display());
953        }
954        #[cfg(unix)]
955        {
956            let alias = root.join("resource-alias");
957            std::os::unix::fs::symlink(&resource_a, &alias).unwrap();
958            reject(&resource_a, &root.join("alias-user"), &alias.join("cache"));
959        }
960        // Siblings that share a name prefix do not overlap.
961        assert!(prepare_runtime_paths(
962            &resource_a,
963            &root.join("resources-user"),
964            &root.join("resources-cache"),
965            "v1"
966        )
967        .is_ok());
968    }
969
970    #[test]
971    fn a_new_generation_is_a_replayed_backup_copy_marked_ready() {
972        let root = tempfile::tempdir().unwrap();
973        let resources = resources(root.path());
974        let original = fs::read(resources.join(assets::MAIN_DICTIONARY)).unwrap();
975        let user = root.path().join("user");
976        let cache = root.path().join("cache");
977        fs::create_dir_all(&user).unwrap();
978        learn(&user.join(assets::USER_JOURNAL), "伱", 1);
979
980        let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
981        assert_eq!(paths.dictionaries, user.join("dictionaries/v1"));
982        assert!(cache.is_dir());
983        assert_eq!(
984            fs::read_to_string(paths.dictionary(assets::GENERATION_READY)).unwrap(),
985            "v1\n"
986        );
987        assert!(stages(&user).is_empty());
988        assert_eq!(
989            weight(
990                &paths.dictionary(assets::MAIN_DICTIONARY),
991                "SELECT weight FROM tbl_1_n WHERE value='伱'"
992            ),
993            Some(50)
994        );
995        assert!(paths.dictionary(assets::ENGLISH_DICTIONARY).is_file());
996        assert_eq!(
997            fs::read(resources.join(assets::MAIN_DICTIONARY)).unwrap(),
998            original
999        );
1000
1001        // Clearing the cache keeps the journal and the working copies.
1002        fs::remove_dir_all(&cache).unwrap();
1003        assert!(user.join(assets::USER_JOURNAL).is_file());
1004        let again = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1005        assert_eq!(again, paths);
1006    }
1007
1008    // test_runtime_isolation.cpp:648-723: learning on a newer generation survives switching back, and a replay failure publishes nothing.
1009    #[test]
1010    fn switching_back_replays_newer_learning_and_a_failed_replay_publishes_nothing() {
1011        let root = tempfile::tempdir().unwrap();
1012        let resources = resources(root.path());
1013        let user = root.path().join("user");
1014        let cache = root.path().join("cache");
1015        let journal = user.join(assets::USER_JOURNAL);
1016        let v1 = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1017        learn(&journal, "伱", 1);
1018        let v2 = prepare_runtime_paths(&resources, &user, &cache, "v2").unwrap();
1019        assert_eq!(
1020            weight(
1021                &v2.dictionary(assets::MAIN_DICTIONARY),
1022                "SELECT weight FROM tbl_1_n WHERE value='伱'"
1023            ),
1024            Some(50)
1025        );
1026        learn(&journal, "倪", 2);
1027        let restored = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1028        assert_eq!(restored, v1);
1029        assert_eq!(
1030            weight(
1031                &v1.dictionary(assets::MAIN_DICTIONARY),
1032                "SELECT weight FROM tbl_1_n WHERE value='倪'"
1033            ),
1034            Some(50)
1035        );
1036
1037        let before_failure = fs::read(v2.dictionary(assets::MAIN_DICTIONARY)).unwrap();
1038        sql(
1039            &journal,
1040            "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight) VALUES('pinyin','@','无效词','upsert',10)",
1041        );
1042        let error = prepare_runtime_paths(&resources, &user, &cache, "v3").unwrap_err();
1043        assert!(matches!(error, EngineError::Failed(_)));
1044        assert_eq!(
1045            error.to_string(),
1046            format!(
1047                "{}one or more operations failed; changes were rolled back",
1048                diagnostics::RUNTIME_REPLAY_FAILED
1049            )
1050        );
1051        assert_eq!(
1052            fs::read(v2.dictionary(assets::MAIN_DICTIONARY)).unwrap(),
1053            before_failure
1054        );
1055        assert!(!user.join("dictionaries/v3").exists());
1056        assert!(stages(&user).is_empty());
1057        // A ready generation refuses the same journal as well.
1058        assert!(prepare_runtime_paths(&resources, &user, &cache, "v2").is_err());
1059    }
1060
1061    #[test]
1062    fn staging_failures_clean_up_and_incomplete_generations_are_refused() {
1063        let root = tempfile::tempdir().unwrap();
1064        let resources = resources(root.path());
1065        let user = root.path().join("user");
1066        let cache = root.path().join("cache");
1067        let missing = root.path().join("missing-resources");
1068        fs::create_dir_all(&missing).unwrap();
1069        let error = prepare_runtime_paths(&missing, &user, &cache, "v1").unwrap_err();
1070        assert_eq!(
1071            error.to_string(),
1072            format!(
1073                "{}{}",
1074                diagnostics::RUNTIME_COPY_FAILED,
1075                missing.join(assets::MAIN_DICTIONARY).display()
1076            )
1077        );
1078        assert_eq!(fs::read_dir(user.join("dictionaries")).unwrap().count(), 0);
1079        assert!(!missing.join(assets::MAIN_DICTIONARY).exists());
1080
1081        let incomplete = user.join("dictionaries/v2");
1082        fs::create_dir_all(&incomplete).unwrap();
1083        fs::write(incomplete.join(assets::GENERATION_READY), "v2\n").unwrap();
1084        let error = prepare_runtime_paths(&resources, &user, &cache, "v2").unwrap_err();
1085        assert_eq!(
1086            error.to_string(),
1087            diagnostics::INCOMPLETE_RUNTIME_GENERATION
1088        );
1089    }
1090
1091    /// 进程在准备途中被杀(iOS 键盘扩展超出内存上限、Android 与 HarmonyOS 退到后台被系统回收)会留下复制到一半的暂存目录。下一次准备照常成功,不再每次都报暂存目录已存在;刚留下的暂存目录分不清是不是另一个进程正在写的,原样留着。
1092    #[test]
1093    fn a_stage_left_by_a_killed_preparation_does_not_block_the_next_one() {
1094        let root = tempfile::tempdir().unwrap();
1095        let resources = resources(root.path());
1096        let user = root.path().join("user");
1097        let cache = root.path().join("cache");
1098        let parent = user.join("dictionaries");
1099        // 旧版本用的固定暂存名,和现在每次尝试各自的暂存名。
1100        let leftovers = ["v1.incoming", "v1.k3Xq9z.incoming"];
1101        for leftover in leftovers {
1102            fs::create_dir_all(parent.join(leftover)).unwrap();
1103            fs::write(
1104                parent.join(leftover).join(assets::MAIN_DICTIONARY),
1105                b"SQLite format 3\0partial",
1106            )
1107            .unwrap();
1108        }
1109        learn(&user.join(assets::USER_JOURNAL), "伱", 1);
1110
1111        let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1112        assert_eq!(paths.dictionaries, parent.join("v1"));
1113        assert_eq!(
1114            fs::read_to_string(paths.dictionary(assets::GENERATION_READY)).unwrap(),
1115            "v1\n"
1116        );
1117        assert_eq!(
1118            weight(
1119                &paths.dictionary(assets::MAIN_DICTIONARY),
1120                "SELECT weight FROM tbl_1_n WHERE value='伱'"
1121            ),
1122            Some(50)
1123        );
1124        for leftover in leftovers {
1125            assert_eq!(
1126                fs::read(parent.join(leftover).join(assets::MAIN_DICTIONARY)).unwrap(),
1127                b"SQLite format 3\0partial",
1128                "{leftover}"
1129            );
1130        }
1131        assert_eq!(
1132            prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap(),
1133            paths
1134        );
1135    }
1136
1137    /// 重置学习数据在把工作主词库挪成 `.msime-pinyin.db.backup.<时间戳>` 之后、新副本改名就位之前被杀:下一次准备把时间戳最新的那份备份放回去、删掉同一时间戳没发布的新副本,学到的词仍在;更旧的备份、以及词库在时出现的备份都不动。
1138    #[test]
1139    fn a_dictionary_left_aside_by_a_killed_reset_is_put_back() {
1140        let root = tempfile::tempdir().unwrap();
1141        let resources = resources(root.path());
1142        let user = root.path().join("user");
1143        let cache = root.path().join("cache");
1144        fs::create_dir_all(&user).unwrap();
1145        learn(&user.join(assets::USER_JOURNAL), "伱", 1);
1146        let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1147        let main = paths.dictionary(assets::MAIN_DICTIONARY);
1148        let sibling = |name: &str| paths.dictionaries.join(name);
1149        fs::rename(
1150            &main,
1151            sibling(".msime-pinyin.db.backup.1700000000000000000"),
1152        )
1153        .unwrap();
1154        fs::write(
1155            sibling(".msime-pinyin.db.reset.1700000000000000000"),
1156            b"fresh copy",
1157        )
1158        .unwrap();
1159        fs::write(sibling(".msime-pinyin.db.backup.999"), b"older").unwrap();
1160
1161        assert_eq!(
1162            prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap(),
1163            paths
1164        );
1165        assert_eq!(
1166            weight(&main, "SELECT weight FROM tbl_1_n WHERE value='伱'"),
1167            Some(50)
1168        );
1169        assert!(!sibling(".msime-pinyin.db.backup.1700000000000000000").exists());
1170        assert!(!sibling(".msime-pinyin.db.reset.1700000000000000000").exists());
1171        assert_eq!(
1172            fs::read(sibling(".msime-pinyin.db.backup.999")).unwrap(),
1173            b"older"
1174        );
1175
1176        let restored = fs::read(&main).unwrap();
1177        fs::write(
1178            sibling(".msime-pinyin.db.backup.1800000000000000000"),
1179            b"newer",
1180        )
1181        .unwrap();
1182        prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1183        assert_eq!(fs::read(&main).unwrap(), restored);
1184        assert_eq!(
1185            fs::read(sibling(".msime-pinyin.db.backup.1800000000000000000")).unwrap(),
1186            b"newer"
1187        );
1188    }
1189
1190    fn assert_a_legacy_dictionary_left_aside_is_put_back(name: &str, legacy: &str) {
1191        let root = tempfile::tempdir().unwrap();
1192        let resources = resources(root.path());
1193        let user = root.path().join("user");
1194        let cache = root.path().join("cache");
1195        fs::create_dir_all(&user).unwrap();
1196        let journal = user.join(assets::USER_JOURNAL);
1197        learn(&journal, "伱", 1);
1198        sql(
1199            &journal,
1200            "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,display,user_inserted,updated_at) VALUES('english','zzfixture','Zzfixture','upsert',11,'Zzfixture',1,2)",
1201        );
1202        let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1203        let current = paths.dictionaries.join(name);
1204        let old = paths.dictionaries.join(legacy);
1205        fs::rename(&current, &old).unwrap();
1206        assert_eq!(paths.dictionary(name), old);
1207        let backup = paths.dictionaries.join(format!(".{legacy}.backup.200"));
1208        let staged = paths.dictionaries.join(format!(".{legacy}.reset.200"));
1209        fs::rename(&old, &backup).unwrap();
1210        fs::write(&staged, b"unpublished synthetic dictionary").unwrap();
1211
1212        assert_eq!(
1213            prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap(),
1214            paths
1215        );
1216        assert_eq!(paths.dictionary(name), current);
1217        assert!(!old.exists());
1218        assert!(!backup.exists());
1219        assert!(!staged.exists());
1220        assert_eq!(
1221            weight(
1222                &paths.dictionary(assets::MAIN_DICTIONARY),
1223                "SELECT weight FROM tbl_1_n WHERE value='伱'"
1224            ),
1225            Some(50)
1226        );
1227        assert_eq!(
1228            weight(
1229                &paths.dictionary(assets::ENGLISH_DICTIONARY),
1230                "SELECT weight FROM english_words WHERE word='zzfixture' AND display='Zzfixture'"
1231            ),
1232            Some(11)
1233        );
1234    }
1235
1236    /// 旧主词库被挪成备份后,路径解析已看不到旧名,准备仍应找到它。
1237    #[test]
1238    fn a_legacy_main_dictionary_left_aside_by_a_reset_is_put_back() {
1239        assert_a_legacy_dictionary_left_aside_is_put_back(assets::MAIN_DICTIONARY, "msime.db");
1240    }
1241
1242    /// 英文词库也从旧名备份恢复到现名,继续使用同一套回放路径。
1243    #[test]
1244    fn a_legacy_english_dictionary_left_aside_by_a_reset_is_put_back() {
1245        assert_a_legacy_dictionary_left_aside_is_put_back(assets::ENGLISH_DICTIONARY, "english.db");
1246    }
1247
1248    /// 重置在把日志挪成 `.msime_user.db.backup.<时间戳>` 之后被杀:日志放回去,学到的词不会因为下一次准备新建了空日志而丢掉。
1249    #[test]
1250    fn a_journal_left_aside_by_a_killed_reset_is_put_back() {
1251        let root = tempfile::tempdir().unwrap();
1252        let resources = resources(root.path());
1253        let user = root.path().join("user");
1254        let cache = root.path().join("cache");
1255        fs::create_dir_all(&user).unwrap();
1256        let journal = user.join(assets::USER_JOURNAL);
1257        learn(&journal, "伱", 1);
1258        prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1259        fs::rename(
1260            &journal,
1261            user.join(".msime_user.db.backup.1700000000000000000"),
1262        )
1263        .unwrap();
1264        fs::write(
1265            user.join(".msime_user.db.reset.1700000000000000000"),
1266            b"empty journal",
1267        )
1268        .unwrap();
1269
1270        let paths = prepare_runtime_paths(&resources, &user, &cache, "v2").unwrap();
1271        assert_eq!(
1272            weight(&journal, "SELECT count(*) FROM user_dictionary_operations"),
1273            Some(1)
1274        );
1275        assert_eq!(
1276            weight(
1277                &paths.dictionary(assets::MAIN_DICTIONARY),
1278                "SELECT weight FROM tbl_1_n WHERE value='伱'"
1279            ),
1280            Some(50)
1281        );
1282        assert!(!user
1283            .join(".msime_user.db.backup.1700000000000000000")
1284            .exists());
1285        assert!(!user
1286            .join(".msime_user.db.reset.1700000000000000000")
1287            .exists());
1288    }
1289
1290    /// 同一个代次被几方同时准备(iOS 的 App 与键盘扩展、Windows 的 TIP 与 Server、IBus 与 Fcitx5):每一方都拿到同一个可用的代次,谁也不报错,输掉的一方不留下暂存目录,也不动赢的一方发布的代次。
1291    #[test]
1292    fn concurrent_preparations_of_one_generation_all_succeed() {
1293        const PREPARERS: usize = 8;
1294        let root = tempfile::tempdir().unwrap();
1295        let resources = resources(root.path());
1296        fs::write(resources.join(assets::BIGRAM_TABLE), "bigram").unwrap();
1297        let user = root.path().join("user");
1298        let cache = root.path().join("cache");
1299        fs::create_dir_all(&user).unwrap();
1300        learn(&user.join(assets::USER_JOURNAL), "伱", 1);
1301
1302        let barrier = std::sync::Barrier::new(PREPARERS);
1303        let results: Vec<_> = std::thread::scope(|scope| {
1304            let preparers: Vec<_> = (0..PREPARERS)
1305                .map(|_| {
1306                    scope.spawn(|| {
1307                        barrier.wait();
1308                        prepare_runtime_paths(&resources, &user, &cache, "v1")
1309                            .map_err(|error| error.to_string())
1310                    })
1311                })
1312                .collect();
1313            preparers
1314                .into_iter()
1315                .map(|preparer| preparer.join().unwrap())
1316                .collect()
1317        });
1318        let generation = user.join("dictionaries/v1");
1319        for result in &results {
1320            assert_eq!(result.as_ref().unwrap().dictionaries, generation);
1321        }
1322        assert_eq!(
1323            fs::read_to_string(generation.join(assets::GENERATION_READY)).unwrap(),
1324            "v1\n"
1325        );
1326        assert_eq!(
1327            weight(
1328                &generation.join(assets::MAIN_DICTIONARY),
1329                "SELECT weight FROM tbl_1_n WHERE value='伱'"
1330            ),
1331            Some(50)
1332        );
1333        assert_eq!(
1334            fs::read(generation.join(assets::BIGRAM_TABLE)).unwrap(),
1335            b"bigram"
1336        );
1337        let entries: Vec<_> = fs::read_dir(user.join("dictionaries"))
1338            .unwrap()
1339            .map(|entry| entry.unwrap().file_name())
1340            .collect();
1341        assert_eq!(entries, ["v1"]);
1342    }
1343
1344    /// 清理只删一小时没被写过的暂存目录和已经认领的 `.removing`;代次、名字对不上的目录、普通文件和符号链接都不动。
1345    #[test]
1346    fn only_stages_untouched_for_an_hour_are_swept() {
1347        let root = tempfile::tempdir().unwrap();
1348        let parent = root.path().join("dictionaries");
1349        let directories = [
1350            "v1.incoming",
1351            "v1.k3Xq9z.incoming",
1352            "v2.Ab12cd.removing",
1353            "v1",
1354            "a b.incoming",
1355            "v1.k3.x.incoming",
1356            "v1..incoming",
1357            "v1.incoming.bak",
1358        ];
1359        for name in directories {
1360            fs::create_dir_all(parent.join(name)).unwrap();
1361            fs::write(parent.join(name).join(assets::MAIN_DICTIONARY), b"partial").unwrap();
1362        }
1363        fs::write(parent.join("v3.incoming"), b"not a directory").unwrap();
1364        let outside = tempfile::tempdir().unwrap();
1365        fs::write(outside.path().join("keep"), b"keep").unwrap();
1366        #[cfg(unix)]
1367        std::os::unix::fs::symlink(outside.path(), parent.join("v4.incoming")).unwrap();
1368        let names = || {
1369            let mut names: Vec<_> = fs::read_dir(&parent)
1370                .unwrap()
1371                .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
1372                .collect();
1373            names.sort();
1374            names
1375        };
1376        let mut untouched: Vec<String> = [
1377            "a b.incoming",
1378            "v1",
1379            "v1..incoming",
1380            "v1.incoming.bak",
1381            "v1.k3.x.incoming",
1382            "v3.incoming",
1383        ]
1384        .map(String::from)
1385        .to_vec();
1386        if cfg!(unix) {
1387            untouched.push("v4.incoming".into());
1388        }
1389        untouched.sort();
1390
1391        // 刚写过的暂存目录可能还有人在写,留着;认领过的不会再被发布,直接删。
1392        sweep_abandoned_stages(&parent, SystemTime::now());
1393        let mut fresh = untouched.clone();
1394        fresh.extend(["v1.incoming".into(), "v1.k3Xq9z.incoming".into()]);
1395        fresh.sort();
1396        assert_eq!(names(), fresh);
1397
1398        sweep_abandoned_stages(
1399            &parent,
1400            SystemTime::now() + ABANDONED_STAGE_AGE + Duration::from_secs(60),
1401        );
1402        assert_eq!(names(), untouched);
1403        assert_eq!(fs::read(outside.path().join("keep")).unwrap(), b"keep");
1404    }
1405
1406    /// 一小时前被丢下的暂存目录和 n-gram 临时副本在下一次准备时删掉,哪怕那次准备走的是已就绪代次的路径。
1407    #[cfg(unix)]
1408    #[test]
1409    fn preparation_sweeps_stages_and_table_copies_abandoned_an_hour_ago() {
1410        let root = tempfile::tempdir().unwrap();
1411        let resources = resources(root.path());
1412        let user = root.path().join("user");
1413        let cache = root.path().join("cache");
1414        prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1415        let parent = user.join("dictionaries");
1416        let abandoned = parent.join("v1.k3Xq9z.incoming");
1417        fs::create_dir_all(&abandoned).unwrap();
1418        let partial = abandoned.join(assets::MAIN_DICTIONARY);
1419        fs::write(&partial, b"partial").unwrap();
1420        let long_ago = SystemTime::now() - ABANDONED_STAGE_AGE - Duration::from_secs(60);
1421        let backdate = |path: &Path| {
1422            fs::File::open(path)
1423                .unwrap()
1424                .set_modified(long_ago)
1425                .unwrap()
1426        };
1427        // 目录本身一小时没变、里面的文件却刚写过(正在复制的大词库就是这样),仍算有人在写。
1428        backdate(&abandoned);
1429        prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1430        assert_eq!(fs::read(&partial).unwrap(), b"partial");
1431
1432        // 代次里补 n-gram 表留下的临时副本同理:刚写过的留着,一小时没写过的删掉。
1433        let copy = parent.join("v1").join("msime-trigram.bin.k3Xq9z.incoming");
1434        fs::write(&copy, b"partial").unwrap();
1435        prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1436        assert!(copy.is_file());
1437
1438        backdate(&partial);
1439        backdate(&abandoned);
1440        backdate(&copy);
1441        prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1442        assert!(!abandoned.exists());
1443        assert!(!copy.exists());
1444        assert_eq!(fs::read_dir(&parent).unwrap().count(), 1);
1445    }
1446
1447    #[cfg(unix)]
1448    #[test]
1449    fn a_symlinked_generation_marker_is_not_treated_as_ready() {
1450        use std::os::unix::fs::symlink;
1451
1452        let root = tempfile::tempdir().unwrap();
1453        let resources = resources(root.path());
1454        let user = root.path().join("user");
1455        let cache = root.path().join("cache");
1456        let generation = user.join("dictionaries/v1");
1457        fs::create_dir_all(&generation).unwrap();
1458        for name in [assets::MAIN_DICTIONARY, assets::ENGLISH_DICTIONARY] {
1459            fs::copy(resources.join(name), generation.join(name)).unwrap();
1460        }
1461        let outside = tempfile::tempdir().unwrap();
1462        let marker_target = outside.path().join("marker");
1463        fs::write(&marker_target, b"v1\n").unwrap();
1464        symlink(&marker_target, generation.join(assets::GENERATION_READY)).unwrap();
1465
1466        assert_eq!(
1467            prepare_runtime_paths(&resources, &user, &cache, "v1")
1468                .unwrap_err()
1469                .to_string(),
1470            diagnostics::INCOMPLETE_RUNTIME_GENERATION
1471        );
1472    }
1473
1474    #[test]
1475    fn the_backup_copy_includes_committed_wal_content() {
1476        let root = tempfile::tempdir().unwrap();
1477        let resources = resources(root.path());
1478        let live = Connection::open(resources.join(assets::MAIN_DICTIONARY)).unwrap();
1479        live.query_row("PRAGMA journal_mode=WAL", [], |_| Ok(()))
1480            .unwrap();
1481        live.execute_batch(
1482            "PRAGMA wal_autocheckpoint=0; INSERT INTO tbl_1_n VALUES('ni','n','妮',70);",
1483        )
1484        .unwrap();
1485        let paths = prepare_runtime_paths(
1486            &resources,
1487            &root.path().join("user"),
1488            &root.path().join("cache"),
1489            "v1",
1490        )
1491        .unwrap();
1492        assert_eq!(
1493            weight(
1494                &paths.dictionary(assets::MAIN_DICTIONARY),
1495                "SELECT weight FROM tbl_1_n WHERE value='妮'"
1496            ),
1497            Some(70)
1498        );
1499    }
1500
1501    fn reverse_indexes(database: &Path) -> Vec<String> {
1502        let connection = Connection::open(database).unwrap();
1503        let mut statement = connection
1504            .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_wubi%_value' ORDER BY name")
1505            .unwrap();
1506        statement
1507            .query_map([], |row| row.get(0))
1508            .unwrap()
1509            .map(Result::unwrap)
1510            .collect()
1511    }
1512
1513    #[test]
1514    fn generations_get_the_wubi_reverse_index_and_resources_stay_untouched() {
1515        let root = tempfile::tempdir().unwrap();
1516        let resources = resources(root.path());
1517        // 只有 wubi86:没有的 wubi98 不会凭空建表或建索引。
1518        sql(
1519            &resources.join(assets::MAIN_DICTIONARY),
1520            "CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER,UNIQUE(key,value));
1521             INSERT INTO wubi86 VALUES('wqvb','你好',300);",
1522        );
1523        let original = fs::read(resources.join(assets::MAIN_DICTIONARY)).unwrap();
1524        let user = root.path().join("user");
1525        let cache = root.path().join("cache");
1526
1527        let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1528        let main = paths.dictionary(assets::MAIN_DICTIONARY);
1529        assert_eq!(reverse_indexes(&main), ["idx_wubi86_value"]);
1530        assert!(reverse_indexes(&paths.dictionary(assets::ENGLISH_DICTIONARY)).is_empty());
1531        assert_eq!(
1532            fs::read(resources.join(assets::MAIN_DICTIONARY)).unwrap(),
1533            original
1534        );
1535
1536        sql(&main, "DROP INDEX idx_wubi86_value;");
1537        // 补建索引只是提速:副本写不进去(磁盘满、只读、被锁)时照常打开代次,只是没有索引。
1538        #[cfg(unix)]
1539        {
1540            use std::os::unix::fs::PermissionsExt;
1541            fs::set_permissions(&main, fs::Permissions::from_mode(0o444)).unwrap();
1542            let reopened = prepare_runtime_paths(&resources, &user, &cache, "v1");
1543            fs::set_permissions(&main, fs::Permissions::from_mode(0o644)).unwrap();
1544            reopened.unwrap();
1545            assert!(reverse_indexes(&main).is_empty());
1546        }
1547        // 旧版本准备的代次没有这个索引:再次打开同一代次时补上。
1548        prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1549        assert_eq!(reverse_indexes(&main), ["idx_wubi86_value"]);
1550        // 已有索引时什么也不改。
1551        let indexed = fs::read(&main).unwrap();
1552        prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1553        assert_eq!(reverse_indexes(&main), ["idx_wubi86_value"]);
1554        assert_eq!(fs::read(&main).unwrap(), indexed);
1555        assert_eq!(
1556            weight(&main, "SELECT weight FROM wubi86 WHERE value='你好'"),
1557            Some(300)
1558        );
1559    }
1560
1561    // test_runtime_isolation.cpp:754-782.
1562    #[test]
1563    fn generation_tables_are_staged_and_later_ones_added_without_replacing() {
1564        let root = tempfile::tempdir().unwrap();
1565        let resources = resources(root.path());
1566        fs::write(resources.join(assets::BIGRAM_TABLE), "first").unwrap();
1567        let user = root.path().join("user");
1568        let cache = root.path().join("cache");
1569        let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1570        assert_eq!(
1571            fs::read(paths.dictionary(assets::BIGRAM_TABLE)).unwrap(),
1572            b"first"
1573        );
1574        assert!(!paths.dictionary(assets::TRIGRAM_TABLE).exists());
1575
1576        fs::write(resources.join(assets::TRIGRAM_TABLE), "third").unwrap();
1577        fs::write(resources.join(assets::BIGRAM_TABLE), "second").unwrap();
1578        let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1579        assert_eq!(
1580            fs::read(paths.dictionary(assets::TRIGRAM_TABLE)).unwrap(),
1581            b"third"
1582        );
1583        assert_eq!(
1584            fs::read(paths.dictionary(assets::BIGRAM_TABLE)).unwrap(),
1585            b"first"
1586        );
1587        assert!(incoming_entries(&paths.dictionaries).is_empty());
1588        let fresh = prepare_runtime_paths(&resources, &user, &cache, "v2").unwrap();
1589        assert_eq!(
1590            fs::read(fresh.dictionary(assets::BIGRAM_TABLE)).unwrap(),
1591            b"second"
1592        );
1593    }
1594
1595    /// 补 n-gram 表时被杀留下的临时副本(旧版本的固定名字和现在的名字)不挡住下一次补表,刚写过的原样留着;一小时没被写过的在之后的清理里删掉,代次里别的文件不动。
1596    #[test]
1597    fn table_copies_left_by_a_killed_preparation_do_not_block_and_are_swept_later() {
1598        let root = tempfile::tempdir().unwrap();
1599        let resources = resources(root.path());
1600        let user = root.path().join("user");
1601        let cache = root.path().join("cache");
1602        let generation = prepare_runtime_paths(&resources, &user, &cache, "v1")
1603            .unwrap()
1604            .dictionaries;
1605        let leftovers = [
1606            "msime-trigram.bin.incoming",
1607            "msime-trigram.bin.k3Xq9z.incoming",
1608        ];
1609        for leftover in leftovers {
1610            fs::write(generation.join(leftover), b"partial").unwrap();
1611        }
1612        let unrelated = [
1613            "other.incoming",
1614            "msime-trigram.bin.k3.x.incoming",
1615            "msime-trigram.bin..incoming",
1616            "msime-trigram.bin.incoming.bak",
1617        ];
1618        for name in unrelated {
1619            fs::write(generation.join(name), b"keep").unwrap();
1620        }
1621        fs::create_dir(generation.join("msime-bigram.bin.Ab12cd.incoming")).unwrap();
1622        fs::write(resources.join(assets::TRIGRAM_TABLE), "third").unwrap();
1623
1624        prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1625        assert_eq!(
1626            fs::read(generation.join(assets::TRIGRAM_TABLE)).unwrap(),
1627            b"third"
1628        );
1629        for leftover in leftovers {
1630            assert_eq!(fs::read(generation.join(leftover)).unwrap(), b"partial");
1631        }
1632
1633        sweep_abandoned_copies(
1634            &generation,
1635            SystemTime::now() + ABANDONED_STAGE_AGE + Duration::from_secs(60),
1636        );
1637        for leftover in leftovers {
1638            assert!(!generation.join(leftover).exists(), "{leftover}");
1639        }
1640        for name in unrelated {
1641            assert_eq!(fs::read(generation.join(name)).unwrap(), b"keep", "{name}");
1642        }
1643        assert!(generation.join("msime-bigram.bin.Ab12cd.incoming").is_dir());
1644        for name in [
1645            assets::MAIN_DICTIONARY,
1646            assets::ENGLISH_DICTIONARY,
1647            assets::GENERATION_READY,
1648            assets::TRIGRAM_TABLE,
1649        ] {
1650            assert!(generation.join(name).is_file(), "{name}");
1651        }
1652    }
1653
1654    /// 后来的版本开始随资源发布一张 n-gram 表,几个进程同时打开同一个已就绪的代次、同时补这张表:每一方都成功,就位的表与资源逐字节一致,谁也不会把别人写到一半的副本改名就位,也不留下临时文件。
1655    #[test]
1656    fn concurrent_preparations_add_a_newly_shipped_table_once() {
1657        const PREPARERS: usize = 8;
1658        let root = tempfile::tempdir().unwrap();
1659        let resources = resources(root.path());
1660        let user = root.path().join("user");
1661        let cache = root.path().join("cache");
1662        let generation = prepare_runtime_paths(&resources, &user, &cache, "v1")
1663            .unwrap()
1664            .dictionaries;
1665        // 大到复制要花一点时间,几方的复制才会互相重叠。
1666        let table: Vec<u8> = (0..8 * 1024 * 1024u32).map(|index| index as u8).collect();
1667        fs::write(resources.join(assets::TRIGRAM_TABLE), &table).unwrap();
1668
1669        let barrier = std::sync::Barrier::new(PREPARERS);
1670        let results: Vec<_> = std::thread::scope(|scope| {
1671            let preparers: Vec<_> = (0..PREPARERS)
1672                .map(|_| {
1673                    scope.spawn(|| {
1674                        barrier.wait();
1675                        prepare_runtime_paths(&resources, &user, &cache, "v1")
1676                            .map_err(|error| error.to_string())
1677                    })
1678                })
1679                .collect();
1680            preparers
1681                .into_iter()
1682                .map(|preparer| preparer.join().unwrap())
1683                .collect()
1684        });
1685        for result in &results {
1686            assert_eq!(result.as_ref().unwrap().dictionaries, generation);
1687        }
1688        assert!(fs::read(generation.join(assets::TRIGRAM_TABLE)).unwrap() == table);
1689        assert!(incoming_entries(&generation).is_empty());
1690    }
1691
1692    /// 发布把五笔码表拆进 `msime-wubi.db` 后,准备代次要把它并回工作主词库:日志里的五笔行照常回放,不会让整个代次因为缺表被拒;索引与 rowid 原样保留;再次准备已就绪的代次时不重复导入。
1693    #[test]
1694    fn split_wubi_tables_are_merged_into_the_generation_before_replay() {
1695        let root = tempfile::tempdir().unwrap();
1696        let resources = resources(root.path());
1697        sql(
1698            &resources.join(assets::WUBI_DICTIONARY),
1699            "CREATE TABLE wubi86(\"key\" TEXT NOT NULL,\"value\" TEXT NOT NULL,\"weight\" INTEGER NOT NULL DEFAULT 0,UNIQUE(\"key\",\"value\"));
1700             CREATE INDEX idx_wubi86_key_weight ON wubi86(\"key\",\"weight\" DESC);
1701             CREATE TABLE wubi98(\"key\" TEXT NOT NULL,\"value\" TEXT NOT NULL,\"weight\" INTEGER NOT NULL DEFAULT 0,UNIQUE(\"key\",\"value\"));
1702             INSERT INTO wubi86(rowid,\"key\",\"value\",weight) VALUES(7,'wqvb','你好',100),(3,'aaaa','工',90);
1703             INSERT INTO wubi98 VALUES('wqvb','你好',80);",
1704        );
1705        let user = root.path().join("user");
1706        let journal = user.join(assets::USER_JOURNAL);
1707        fs::create_dir_all(&user).unwrap();
1708        sql(&journal, OPERATIONS_DDL);
1709        sql(
1710            &journal,
1711            "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,user_inserted,updated_at) VALUES
1712               ('wubi','wqvb','合成五笔','upsert',9,1,1),
1713               ('wubi98','wqvb','你好','upsert',99,1,2)",
1714        );
1715        let cache = root.path().join("cache");
1716        let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1717        let main = paths.dictionary(assets::MAIN_DICTIONARY);
1718        assert_eq!(
1719            weight(&main, "SELECT weight FROM wubi86 WHERE value='合成五笔'"),
1720            Some(9)
1721        );
1722        assert_eq!(
1723            weight(&main, "SELECT weight FROM wubi98 WHERE value='你好'"),
1724            Some(99)
1725        );
1726        assert_eq!(
1727            weight(&main, "SELECT rowid FROM wubi86 WHERE value='你好'"),
1728            Some(7)
1729        );
1730        assert_eq!(
1731            weight(
1732                &main,
1733                "SELECT count(*) FROM sqlite_master WHERE name='idx_wubi86_key_weight'"
1734            ),
1735            Some(1)
1736        );
1737        // 只读资源不变。
1738        assert_eq!(
1739            weight(
1740                &resources.join(assets::WUBI_DICTIONARY),
1741                "SELECT count(*) FROM wubi86"
1742            ),
1743            Some(2)
1744        );
1745        assert!(!paths.dictionary(assets::WUBI_DICTIONARY).exists());
1746
1747        let again = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1748        assert_eq!(
1749            weight(
1750                &again.dictionary(assets::MAIN_DICTIONARY),
1751                "SELECT count(*) FROM wubi86"
1752            ),
1753            Some(3)
1754        );
1755    }
1756
1757    /// 资源目录不带 `msime-wubi.db`(macOS 发布包改为按需下载):准备代次时五笔行跳过、代次照常就绪;宿主交来下载的码表后,建会话时并进工作主词库并补上日志里的五笔行,再来一次什么也不做。
1758    #[test]
1759    fn a_downloaded_wubi_dictionary_is_merged_into_a_generation_prepared_without_one() {
1760        let root = tempfile::tempdir().unwrap();
1761        let resources = resources(root.path());
1762        assert!(!resources.join(assets::WUBI_DICTIONARY).exists());
1763        let user = root.path().join("user");
1764        fs::create_dir_all(&user).unwrap();
1765        let journal = user.join(assets::USER_JOURNAL);
1766        sql(&journal, OPERATIONS_DDL);
1767        sql(
1768            &journal,
1769            "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,user_inserted,updated_at) VALUES
1770               ('wubi','wqvb','合成五笔','upsert',9,1,1),
1771               ('wubi98','wqvb','你好','upsert',99,1,2)",
1772        );
1773        let cache = root.path().join("cache");
1774        let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1775        let main = paths.dictionary(assets::MAIN_DICTIONARY);
1776        assert_eq!(
1777            weight(
1778                &main,
1779                "SELECT count(*) FROM sqlite_master WHERE name IN ('wubi86','wubi98')"
1780            ),
1781            Some(0)
1782        );
1783
1784        let pack = root.path().join("resource-packs").join("wubi");
1785        fs::create_dir_all(&pack).unwrap();
1786        let downloaded = pack.join(assets::WUBI_DICTIONARY);
1787        sql(
1788            &downloaded,
1789            "CREATE TABLE wubi86(\"key\" TEXT NOT NULL,\"value\" TEXT NOT NULL,\"weight\" INTEGER NOT NULL DEFAULT 0,UNIQUE(\"key\",\"value\"));
1790             CREATE TABLE wubi98(\"key\" TEXT NOT NULL,\"value\" TEXT NOT NULL,\"weight\" INTEGER NOT NULL DEFAULT 0,UNIQUE(\"key\",\"value\"));
1791             INSERT INTO wubi86 VALUES('aaaa','工',90);
1792             INSERT INTO wubi98 VALUES('wqvb','你好',80);",
1793        );
1794        // 不是 `msime-wubi.db` 的文件不认。
1795        let renamed = pack.join("other.db");
1796        fs::copy(&downloaded, &renamed).unwrap();
1797        assert!(!merge_downloaded_wubi(&paths, &renamed).unwrap());
1798        assert!(!merge_downloaded_wubi(
1799            &paths,
1800            &pack.join("missing").join(assets::WUBI_DICTIONARY)
1801        )
1802        .unwrap());
1803
1804        assert!(merge_downloaded_wubi(&paths, &downloaded).unwrap());
1805        assert_eq!(
1806            weight(&main, "SELECT weight FROM wubi86 WHERE value='合成五笔'"),
1807            Some(9)
1808        );
1809        assert_eq!(
1810            weight(&main, "SELECT weight FROM wubi98 WHERE value='你好'"),
1811            Some(99)
1812        );
1813        assert_eq!(
1814            weight(&main, "SELECT weight FROM wubi86 WHERE value='工'"),
1815            Some(90)
1816        );
1817        // 下载的码表不变;已有五笔表时不再导入。
1818        assert_eq!(weight(&downloaded, "SELECT count(*) FROM wubi86"), Some(1));
1819        assert!(!merge_downloaded_wubi(&paths, &downloaded).unwrap());
1820        assert_eq!(weight(&main, "SELECT count(*) FROM wubi86"), Some(2));
1821        // 代次就是资源目录(只读布局)时不写。
1822        let read_only = RuntimePaths {
1823            dictionaries: paths.resources.clone(),
1824            ..paths.clone()
1825        };
1826        assert!(!merge_downloaded_wubi(&read_only, &downloaded).unwrap());
1827    }
1828
1829    #[test]
1830    fn weakly_canonical_resolves_the_existing_prefix() {
1831        let root = tempfile::tempdir().unwrap();
1832        let base = root.path().canonicalize().unwrap();
1833        assert_eq!(
1834            weakly_canonical(&root.path().join("a/./b/../c")).unwrap(),
1835            base.join("a/c")
1836        );
1837        assert_eq!(weakly_canonical(root.path()).unwrap(), base);
1838        assert!(roots_overlap(Path::new("/a/b"), Path::new("/a")));
1839        assert!(!roots_overlap(Path::new("/a/bc"), Path::new("/a/b")));
1840    }
1841
1842    /// 发布的整套词库能完整准备:两个数据库复制进来(五笔码表并回主词库),两张 n-gram 表逐字节一致,每种日志行都回放到真实的表上。
1843    #[test]
1844    fn the_real_dictionary_set_stages_and_replays() {
1845        let Some(resources) = std::env::var_os("MSIME_EVAL_RESOURCES") else {
1846            eprintln!(
1847                "skipped: MSIME_EVAL_RESOURCES is not set to the dict-v2.0.5 resource directory"
1848            );
1849            return;
1850        };
1851        let resources = PathBuf::from(resources);
1852        let root = tempfile::tempdir().unwrap();
1853        let user = root.path().join("user");
1854        fs::create_dir_all(&user).unwrap();
1855        let journal = user.join(assets::USER_JOURNAL);
1856        sql(&journal, OPERATIONS_DDL);
1857        sql(
1858            &journal,
1859            "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,display,user_inserted) VALUES
1860               ('pinyin','ni''hao','拟好蒿','upsert',12345,'',1),
1861               ('pinyin','ni''hao''ni''hao''ni''hao''ni''hao','你好你好你好你好','upsert',7,'',1),
1862               ('wubi','wqvb','合成五笔','upsert',9,'',1),
1863               ('quick','zzfixture','合成短语','upsert',10,'',1),
1864               ('english','zzfixture','Zzfixture','upsert',11,'Zzfixture',1)",
1865        );
1866        let paths =
1867            prepare_runtime_paths(&resources, &user, &root.path().join("cache"), "real").unwrap();
1868        for name in [assets::BIGRAM_TABLE, assets::TRIGRAM_TABLE] {
1869            assert_eq!(
1870                fs::read(paths.dictionary(name)).unwrap(),
1871                fs::read(resources.join(name)).unwrap(),
1872                "{name}"
1873            );
1874        }
1875        let main = paths.dictionary(assets::MAIN_DICTIONARY);
1876        assert_eq!(
1877            weight(
1878                &main,
1879                "SELECT weight FROM tbl_2_n WHERE key='ni''hao' AND value='拟好蒿'"
1880            ),
1881            Some(12345)
1882        );
1883        assert_eq!(
1884            weight(
1885                &main,
1886                "SELECT weight FROM tbl_others_n WHERE value='你好你好你好你好'"
1887            ),
1888            Some(7)
1889        );
1890        assert_eq!(
1891            weight(&main, "SELECT weight FROM wubi86 WHERE value='合成五笔'"),
1892            Some(9)
1893        );
1894        assert_eq!(
1895            weight(
1896                &main,
1897                "SELECT weight FROM quick_parases WHERE value='合成短语'"
1898            ),
1899            Some(10)
1900        );
1901        assert_eq!(
1902            weight(
1903                &paths.dictionary(assets::ENGLISH_DICTIONARY),
1904                "SELECT weight FROM english_words WHERE word='zzfixture' AND display='Zzfixture'"
1905            ),
1906            Some(11)
1907        );
1908        let shipped =
1909            |path: &Path| weight(path, "SELECT count(*) FROM tbl_2_n WHERE key='ni''hao'").unwrap();
1910        assert_eq!(
1911            shipped(&main),
1912            shipped(&resources.join(assets::MAIN_DICTIONARY)) + 1
1913        );
1914    }
1915
1916    fn japanese_only() -> SchemeSet {
1917        SchemeSet::of(&[crate::types::SchemeType::JapaneseRomaji])
1918    }
1919
1920    /// 只有日文(或越南文、藏文)的版本不带 `msime-pinyin.db`:代次只复制 `msime-english.db`,日志里的英文词照样回放,拼音行跳过而不拒绝这个代次;再次准备同一个代次也不要求 `msime-pinyin.db`。读 `msime-pinyin.db` 的集合缺了它仍然失败,与以前相同。
1921    #[test]
1922    fn a_generation_without_the_main_dictionary_holds_only_english() {
1923        let root = tempfile::tempdir().unwrap();
1924        let resources = resources(root.path());
1925        fs::remove_file(resources.join(assets::MAIN_DICTIONARY)).unwrap();
1926        let user = root.path().join("user");
1927        let cache = root.path().join("cache");
1928        let journal = user.join(assets::USER_JOURNAL);
1929        fs::create_dir_all(&user).unwrap();
1930        learn(&journal, "你", 1);
1931        sql(
1932            &journal,
1933            "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,display,user_inserted,updated_at) VALUES('english','zzfixture','Zzfixture','upsert',11,'Zzfixture',1,2)",
1934        );
1935
1936        let paths =
1937            prepare_runtime_paths_for(&resources, &user, &cache, "v1", japanese_only()).unwrap();
1938        assert!(paths.dictionaries.join(assets::GENERATION_READY).is_file());
1939        assert!(!paths.dictionary(assets::MAIN_DICTIONARY).exists());
1940        assert!(!resources.join(assets::MAIN_DICTIONARY).exists());
1941        assert_eq!(
1942            weight(
1943                &paths.dictionary(assets::ENGLISH_DICTIONARY),
1944                "SELECT weight FROM english_words WHERE word='zzfixture' AND display='Zzfixture'"
1945            ),
1946            Some(11)
1947        );
1948
1949        // 已经准备好的代次再准备一次:不要求 msime-pinyin.db,日志再回放一次。
1950        let again =
1951            prepare_runtime_paths_for(&resources, &user, &cache, "v1", japanese_only()).unwrap();
1952        assert_eq!(again, paths);
1953        assert!(!again.dictionary(assets::MAIN_DICTIONARY).exists());
1954
1955        // 同一个资源目录给读 msime-pinyin.db 的集合准备,照旧因为缺 msime-pinyin.db 失败。
1956        let error = prepare_runtime_paths(&resources, &user, &cache, "v2").unwrap_err();
1957        assert_eq!(
1958            error.to_string(),
1959            format!(
1960                "{}{}",
1961                diagnostics::RUNTIME_COPY_FAILED,
1962                resources.join(assets::MAIN_DICTIONARY).display()
1963            )
1964        );
1965        assert!(!user.join("dictionaries/v2").exists());
1966        // 读 msime-pinyin.db 的集合也不接受一个没有 msime-pinyin.db 的现成代次。
1967        let error = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap_err();
1968        assert_eq!(
1969            error.to_string(),
1970            diagnostics::INCOMPLETE_RUNTIME_GENERATION
1971        );
1972    }
1973
1974    /// 不要求 `msime-english.db` 的集合(不含全拼、双拼、五笔)在资源目录没带它时,代次里两个库都没有,日志一行也不回放、原样留着;就绪的代次再准备也不要求它,后来才放进资源目录的英文库不改变这个代次。中文集合缺了英文库仍然失败,不留下暂存目录。
1975    #[test]
1976    fn english_follows_the_scheme_set() {
1977        let root = tempfile::tempdir().unwrap();
1978        let empty = root.path().join("empty-resources");
1979        fs::create_dir_all(&empty).unwrap();
1980        let user = root.path().join("user");
1981        let cache = root.path().join("cache");
1982        let journal = user.join(assets::USER_JOURNAL);
1983        fs::create_dir_all(&user).unwrap();
1984        learn(&journal, "你", 1);
1985        sql(
1986            &journal,
1987            "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,display,user_inserted,updated_at) VALUES('english','zzfixture','Zzfixture','upsert',11,'Zzfixture',1,2)",
1988        );
1989
1990        let paths =
1991            prepare_runtime_paths_for(&empty, &user, &cache, "v1", japanese_only()).unwrap();
1992        assert!(paths.dictionaries.join(assets::GENERATION_READY).is_file());
1993        assert!(!paths.dictionary(assets::MAIN_DICTIONARY).exists());
1994        assert!(!paths.dictionary(assets::ENGLISH_DICTIONARY).exists());
1995        assert_eq!(
1996            weight(
1997                &journal,
1998                "SELECT weight FROM user_dictionary_operations WHERE key='zzfixture'"
1999            ),
2000            Some(11)
2001        );
2002        let full = resources(root.path());
2003        fs::copy(
2004            full.join(assets::ENGLISH_DICTIONARY),
2005            empty.join(assets::ENGLISH_DICTIONARY),
2006        )
2007        .unwrap();
2008        let again =
2009            prepare_runtime_paths_for(&empty, &user, &cache, "v1", japanese_only()).unwrap();
2010        assert_eq!(again, paths);
2011        assert!(!again.dictionary(assets::ENGLISH_DICTIONARY).exists());
2012        // 换一个代次 id 才会把新放进来的英文库复制进去,日志里的英文词也回放进去。
2013        let shipped =
2014            prepare_runtime_paths_for(&empty, &user, &cache, "v2", japanese_only()).unwrap();
2015        assert_eq!(
2016            weight(
2017                &shipped.dictionary(assets::ENGLISH_DICTIONARY),
2018                "SELECT weight FROM english_words WHERE word='zzfixture' AND display='Zzfixture'"
2019            ),
2020            Some(11)
2021        );
2022
2023        let chinese = root.path().join("chinese-resources");
2024        fs::create_dir_all(&chinese).unwrap();
2025        fs::copy(
2026            full.join(assets::MAIN_DICTIONARY),
2027            chinese.join(assets::MAIN_DICTIONARY),
2028        )
2029        .unwrap();
2030        let error = prepare_runtime_paths(&chinese, &user, &cache, "v3").unwrap_err();
2031        assert_eq!(
2032            error.to_string(),
2033            format!(
2034                "{}{}",
2035                diagnostics::RUNTIME_COPY_FAILED,
2036                chinese.join(assets::ENGLISH_DICTIONARY).display()
2037            )
2038        );
2039        assert!(stages(&user).is_empty());
2040        assert!(!user.join("dictionaries/v3").exists());
2041        // 读主库的集合也不接受一个没有英文库的现成代次。
2042        let error = prepare_runtime_paths(&empty, &user, &cache, "v1").unwrap_err();
2043        assert_eq!(
2044            error.to_string(),
2045            diagnostics::INCOMPLETE_RUNTIME_GENERATION
2046        );
2047    }
2048
2049    #[cfg(unix)]
2050    #[test]
2051    fn generation_copy_rejects_a_symlinked_source() {
2052        use std::os::unix::fs::symlink;
2053
2054        let directory = tempfile::tempdir().unwrap();
2055        let outside = tempfile::tempdir().unwrap();
2056        let source = outside.path().join("source.bin");
2057        let target = directory.path().join("target.bin");
2058        std::fs::write(&source, b"outside").unwrap();
2059        symlink(&source, directory.path().join("linked.bin")).unwrap();
2060
2061        assert!(copy_private_file(&directory.path().join("linked.bin"), &target).is_err());
2062        assert!(!target.exists());
2063    }
2064
2065    #[cfg(unix)]
2066    #[test]
2067    fn generation_marker_writing_rejects_a_symlink() {
2068        use std::os::unix::fs::symlink;
2069
2070        let directory = tempfile::tempdir().unwrap();
2071        let outside = tempfile::tempdir().unwrap();
2072        let target = outside.path().join("outside.txt");
2073        std::fs::write(&target, b"keep").unwrap();
2074        let marker = directory.path().join(".ready");
2075        symlink(&target, &marker).unwrap();
2076
2077        assert!(write_private_file(&marker, b"ready\n").is_err());
2078        assert_eq!(std::fs::read(&target).unwrap(), b"keep");
2079    }
2080
2081    fn stamp(path: &Path, version: i32) {
2082        sql(
2083            path,
2084            &format!(
2085                "{} INSERT OR REPLACE INTO metadata VALUES('format_version', '{version}');",
2086                crate::format::METADATA_SCHEMA
2087            ),
2088        );
2089    }
2090
2091    fn assert_unsupported(error: &EngineError, file: &str) {
2092        let text = error.to_string();
2093        assert!(
2094            text.starts_with(diagnostics::DICTIONARY_FORMAT_UNSUPPORTED),
2095            "{text}"
2096        );
2097        assert!(text.contains(file), "{text}");
2098    }
2099
2100    /// 带文件内版本的主库照常准备,`metadata` 随备份一起进代次;比引擎新的主库拒绝准备,不留下代次也不留下暂存目录。没有 `metadata` 的旧发布由其余测试覆盖。
2101    #[test]
2102    fn preparation_checks_the_main_dictionary_format_version() {
2103        let root = tempfile::tempdir().unwrap();
2104        let resources = resources(root.path());
2105        let user = root.path().join("user");
2106        let cache = root.path().join("cache");
2107        let main = resources.join(assets::MAIN_DICTIONARY);
2108
2109        stamp(&main, crate::format::FORMAT_VERSION);
2110        let prepared = prepare_runtime_paths(&resources, &user, &cache, "current").unwrap();
2111        let copy = Connection::open(prepared.dictionary(assets::MAIN_DICTIONARY)).unwrap();
2112        assert_eq!(
2113            crate::format::check_format_version(&copy, assets::MAIN_DICTIONARY).unwrap(),
2114            crate::format::FORMAT_VERSION
2115        );
2116
2117        stamp(&main, crate::format::FORMAT_VERSION + 1);
2118        let error = prepare_runtime_paths(&resources, &user, &cache, "newer").unwrap_err();
2119        assert_unsupported(&error, assets::MAIN_DICTIONARY);
2120        assert!(!user.join("dictionaries").join("newer").exists());
2121        assert!(stages(&user).is_empty());
2122    }
2123
2124    /// 已经就绪的代次再次打开时也查主库副本:更新的引擎用更新格式准备好的代次,降级后的引擎不能照旧格式去读。
2125    #[test]
2126    fn reopening_a_ready_generation_checks_the_main_dictionary_format_version() {
2127        let root = tempfile::tempdir().unwrap();
2128        let resources = resources(root.path());
2129        let user = root.path().join("user");
2130        let cache = root.path().join("cache");
2131        let prepared = prepare_runtime_paths(&resources, &user, &cache, "current").unwrap();
2132        let copy = prepared.dictionary(assets::MAIN_DICTIONARY);
2133        assert!(prepared
2134            .dictionaries
2135            .join(assets::GENERATION_READY)
2136            .is_file());
2137
2138        stamp(&copy, crate::format::FORMAT_VERSION);
2139        prepare_runtime_paths(&resources, &user, &cache, "current").unwrap();
2140
2141        stamp(&copy, crate::format::FORMAT_VERSION + 1);
2142        let error = prepare_runtime_paths(&resources, &user, &cache, "current").unwrap_err();
2143        assert_unsupported(&error, assets::MAIN_DICTIONARY);
2144    }
2145
2146    /// 单独发布的五笔库同样检查:比引擎新的 `msime-wubi.db` 不会并进代次。
2147    #[test]
2148    fn preparation_checks_the_wubi_dictionary_format_version() {
2149        let root = tempfile::tempdir().unwrap();
2150        let resources = resources(root.path());
2151        let user = root.path().join("user");
2152        let cache = root.path().join("cache");
2153        let wubi = resources.join(assets::WUBI_DICTIONARY);
2154        sql(
2155            &wubi,
2156            "CREATE TABLE wubi86(key TEXT NOT NULL,value TEXT NOT NULL,weight INTEGER NOT NULL DEFAULT 0,UNIQUE(key,value));
2157             INSERT INTO wubi86 VALUES('wq','你',10);",
2158        );
2159        stamp(&wubi, crate::format::FORMAT_VERSION + 1);
2160        let error = prepare_runtime_paths(&resources, &user, &cache, "newer").unwrap_err();
2161        assert_unsupported(&error, assets::WUBI_DICTIONARY);
2162        assert!(!user.join("dictionaries").join("newer").exists());
2163
2164        stamp(&wubi, crate::format::FORMAT_VERSION);
2165        let prepared = prepare_runtime_paths(&resources, &user, &cache, "current").unwrap();
2166        assert_eq!(
2167            weight(
2168                &prepared.dictionary(assets::MAIN_DICTIONARY),
2169                "SELECT weight FROM wubi86 WHERE value='你'"
2170            ),
2171            Some(10)
2172        );
2173    }
2174
2175    /// 只读布局在建会话时检查;其他布局不在这里查。
2176    #[test]
2177    fn the_read_only_layout_checks_the_format_version() {
2178        let root = tempfile::tempdir().unwrap();
2179        let resources = resources(root.path());
2180        let paths = RuntimePaths {
2181            resources: resources.clone(),
2182            user_data: root.path().join("user"),
2183            cache: root.path().join("cache"),
2184            dictionaries: resources.clone(),
2185        };
2186        check_read_only_layout(&paths).unwrap();
2187        stamp(
2188            &resources.join(assets::MAIN_DICTIONARY),
2189            crate::format::FORMAT_VERSION + 1,
2190        );
2191        let error = check_read_only_layout(&paths).unwrap_err();
2192        assert_unsupported(&error, assets::MAIN_DICTIONARY);
2193        let error = crate::Session::new(crate::SessionOptions::new(paths.clone()))
2194            .err()
2195            .expect("a newer dictionary format refuses the session");
2196        assert_unsupported(&error, assets::MAIN_DICTIONARY);
2197        let generation = RuntimePaths {
2198            dictionaries: root.path().join("user/dictionaries/v1"),
2199            ..paths
2200        };
2201        check_read_only_layout(&generation).unwrap();
2202    }
2203}