1use 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
23const GENERATION_COPIES: [&str; 2] = [assets::BIGRAM_TABLE, assets::TRIGRAM_TABLE];
25
26const STAGE_SUFFIX: &str = ".incoming";
28
29const CLAIMED_STAGE_SUFFIX: &str = ".removing";
31
32const ABANDONED_STAGE_AGE: Duration = Duration::from_secs(60 * 60);
34
35const SPLIT_WUBI_TABLES: [&str; 2] = ["wubi86", "wubi98"];
37
38pub 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
48pub 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 if !valid_content_id(content_id) {
58 return Err(EngineError::invalid(
59 diagnostics::INVALID_RUNTIME_CONTENT_ID,
60 ));
61 }
62 let result = RuntimePaths {
63 resources: resources.to_owned(),
64 user_data: user_data.to_owned(),
65 cache: cache.to_owned(),
66 dictionaries: user_data.join("dictionaries").join(content_id),
67 };
68 result.validate()?;
69 let resource_root = weakly_canonical(resources)?;
71 let user_root = weakly_canonical(user_data)?;
72 let cache_root = weakly_canonical(cache)?;
73 if roots_overlap(&resource_root, &user_root)
74 || roots_overlap(&resource_root, &cache_root)
75 || roots_overlap(&user_root, &cache_root)
76 {
77 return Err(EngineError::invalid(diagnostics::RUNTIME_ROOTS_OVERLAP));
78 }
79 reject_redirected_directory(user_data)?;
80 reject_redirected_directory(cache)?;
81 fs::create_dir_all(user_data)?;
82 fs::create_dir_all(cache)?;
83 reject_redirected_directory(user_data)?;
84 reject_redirected_directory(cache)?;
85 restore_interrupted_reset(&result.user(assets::USER_JOURNAL));
87 if let Some(moved) = quarantine_corrupt_journal(&result.user(assets::USER_JOURNAL))? {
89 crate::logging::warn(format_args!(
90 "user dictionary journal is not a database, moved aside to {}",
91 moved.display()
92 ));
93 }
94
95 let parent = user_data.join("dictionaries");
96 sweep_abandoned_stages(&parent, SystemTime::now());
97
98 let marker = result.dictionaries.join(assets::GENERATION_READY);
99 if generation_ready(&marker)? {
100 return open_ready_generation(result, resources, main_dictionary);
101 }
102
103 fs::create_dir_all(&parent)?;
104 let stage = tempfile::Builder::new()
106 .prefix(&format!("{content_id}."))
107 .suffix(STAGE_SUFFIX)
108 .tempdir_in(&parent)?;
109 let staged = (|| -> Result<()> {
110 if main_dictionary {
111 check_dictionary_format(
112 &resources.join(assets::MAIN_DICTIONARY),
113 assets::MAIN_DICTIONARY,
114 )?;
115 }
116 for name in generation_dictionaries(main_dictionary) {
117 copy_database(&resources.join(name), &stage.path().join(name))?;
118 }
119 if main_dictionary {
120 merge_split_wubi(resources, &stage.path().join(assets::MAIN_DICTIONARY))?;
121 }
122 stage_generation_copies(resources, stage.path(), true)?;
123 replay_into(&result, stage.path(), main_dictionary)?;
124 index_reverse_lookup(stage.path(), main_dictionary);
125 write_private_file(
126 &stage.path().join(assets::GENERATION_READY),
127 format!("{content_id}\n").as_bytes(),
128 )
129 .map_err(|_| EngineError::failed(diagnostics::RUNTIME_FINALIZE_FAILED))?;
130 fs::rename(stage.path(), &result.dictionaries)?;
132 Ok(())
133 })();
134 match staged {
135 Ok(()) => {
136 let _ = stage.keep();
138 Ok(result)
139 }
140 Err(error) => {
141 drop(stage);
142 if generation_ready(&marker).unwrap_or(false) {
144 return open_ready_generation(result, resources, main_dictionary);
145 }
146 Err(error)
147 }
148 }
149}
150
151fn generation_ready(marker: &Path) -> Result<bool> {
153 match fs::symlink_metadata(marker) {
154 Ok(metadata) if metadata.file_type().is_file() => Ok(true),
155 Ok(_) => Err(EngineError::failed(
156 diagnostics::INCOMPLETE_RUNTIME_GENERATION,
157 )),
158 Err(error) if error.kind() == ErrorKind::NotFound => Ok(false),
159 Err(error) => Err(error.into()),
160 }
161}
162
163fn open_ready_generation(
165 result: RuntimePaths,
166 resources: &Path,
167 main_dictionary: bool,
168) -> Result<RuntimePaths> {
169 for name in generation_dictionaries(main_dictionary) {
170 restore_interrupted_reset(&result.dictionary(name));
172 if !is_real_file(&result.dictionary(name)) {
173 return Err(EngineError::failed(
174 diagnostics::INCOMPLETE_RUNTIME_GENERATION,
175 ));
176 }
177 }
178 if main_dictionary {
180 check_dictionary_format(
182 &result.dictionary(assets::MAIN_DICTIONARY),
183 assets::MAIN_DICTIONARY,
184 )?;
185 merge_split_wubi(resources, &result.dictionary(assets::MAIN_DICTIONARY))?;
186 }
187 replay_into(&result, &result.dictionaries, main_dictionary)?;
189 index_reverse_lookup(&result.dictionaries, main_dictionary);
191 stage_generation_copies(resources, &result.dictionaries, false)?;
192 Ok(result)
193}
194
195fn sweep_abandoned_stages(parent: &Path, now: SystemTime) {
199 let Ok(entries) = fs::read_dir(parent) else {
200 return;
201 };
202 for entry in entries.flatten() {
203 let name = entry.file_name();
204 let Some(name) = name.to_str() else {
205 continue;
206 };
207 let path = entry.path();
208 if is_stage_name(name, CLAIMED_STAGE_SUFFIX) {
209 if is_real_directory(&path) {
211 let _ = fs::remove_dir_all(&path);
212 }
213 continue;
214 }
215 if !is_stage_name(name, STAGE_SUFFIX) || !abandoned(&path, now) {
216 continue;
217 }
218 let mut claimed = name[..name.len() - STAGE_SUFFIX.len()].to_owned();
219 claimed.push_str(CLAIMED_STAGE_SUFFIX);
220 let claimed = parent.join(claimed);
221 if fs::rename(&path, &claimed).is_ok() {
223 let _ = fs::remove_dir_all(&claimed);
224 }
225 }
226}
227
228fn is_stage_name(name: &str, suffix: &str) -> bool {
230 let Some(stem) = name.strip_suffix(suffix) else {
231 return false;
232 };
233 let (content_id, attempt) = match stem.split_once('.') {
234 Some((content_id, attempt)) => (content_id, Some(attempt)),
235 None => (stem, None),
236 };
237 valid_content_id(content_id)
238 && attempt.is_none_or(|attempt| {
239 !attempt.is_empty() && attempt.bytes().all(|byte| byte.is_ascii_alphanumeric())
240 })
241}
242
243fn abandoned(stage: &Path, now: SystemTime) -> bool {
245 let Some(cutoff) = now.checked_sub(ABANDONED_STAGE_AGE) else {
246 return false;
247 };
248 let untouched = |path: &Path| {
249 fs::symlink_metadata(path)
250 .and_then(|metadata| metadata.modified())
251 .is_ok_and(|modified| modified < cutoff)
252 };
253 if !is_real_directory(stage) || !untouched(stage) {
254 return false;
255 }
256 let Ok(entries) = fs::read_dir(stage) else {
257 return false;
258 };
259 entries
260 .into_iter()
261 .all(|entry| entry.is_ok_and(|entry| untouched(&entry.path())))
262}
263
264fn is_real_directory(path: &Path) -> bool {
265 fs::symlink_metadata(path)
266 .map(|metadata| metadata.file_type().is_dir())
267 .unwrap_or(false)
268}
269
270fn valid_content_id(content_id: &str) -> bool {
271 !content_id.is_empty()
272 && content_id.len() <= MAX_CONTENT_ID_LENGTH
273 && content_id
274 .bytes()
275 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
276}
277
278fn generation_dictionaries(main_dictionary: bool) -> &'static [&'static str] {
280 if main_dictionary {
281 &[assets::MAIN_DICTIONARY, assets::ENGLISH_DICTIONARY]
282 } else {
283 &[assets::ENGLISH_DICTIONARY]
284 }
285}
286
287fn replay_into(paths: &RuntimePaths, generation: &Path, main_dictionary: bool) -> Result<()> {
289 let journal = paths.user(assets::USER_JOURNAL);
290 let english = generation.join(assets::ENGLISH_DICTIONARY);
291 let replayed = if main_dictionary {
292 replay(
293 &journal,
294 &generation.join(assets::MAIN_DICTIONARY),
295 &english,
296 )
297 } else {
298 replay_english(&journal, &english)
299 };
300 if replayed.failed != 0 || !replayed.error.is_empty() {
301 return Err(EngineError::failed(format!(
302 "{}{}",
303 diagnostics::RUNTIME_REPLAY_FAILED,
304 replayed.error
305 )));
306 }
307 Ok(())
308}
309
310fn index_reverse_lookup(generation: &Path, main_dictionary: bool) {
314 for name in generation_dictionaries(main_dictionary) {
315 let database = generation.join(name);
316 let indexed = open_database(&database, OpenFlags::SQLITE_OPEN_READ_WRITE)
317 .and_then(|connection| ensure_reverse_indexes(&connection).map_err(Into::into));
318 if let Err(error) = indexed {
319 crate::logging::warn(format_args!(
320 "wubi reverse lookup index unavailable in {}, lookups stay unindexed: {error}",
321 database.display()
322 ));
323 }
324 }
325}
326
327fn copy_database(source: &Path, target: &Path) -> Result<()> {
329 if !is_real_file(source) {
330 return Err(EngineError::failed(format!(
331 "{}{}",
332 diagnostics::RUNTIME_COPY_FAILED,
333 source.display()
334 )));
335 }
336 let copied = (|| -> rusqlite::Result<StepResult> {
337 let source = crate::paths::sqlite_read_only_path_no_follow(source)
338 .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
339 let target = crate::paths::sqlite_path_no_follow(target)
340 .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
341 let input = Connection::open_with_flags(
342 &source,
343 OpenFlags::SQLITE_OPEN_READ_ONLY
344 | OpenFlags::SQLITE_OPEN_NOFOLLOW
345 | OpenFlags::SQLITE_OPEN_NO_MUTEX,
346 )?;
347 let mut output = Connection::open_with_flags(
348 &target,
349 OpenFlags::SQLITE_OPEN_READ_WRITE
350 | OpenFlags::SQLITE_OPEN_CREATE
351 | OpenFlags::SQLITE_OPEN_NOFOLLOW
352 | OpenFlags::SQLITE_OPEN_NO_MUTEX,
353 )?;
354 input.busy_timeout(std::time::Duration::ZERO)?;
356 output.busy_timeout(std::time::Duration::ZERO)?;
357 let backup = Backup::new(&input, &mut output)?;
358 backup.step(-1)
359 })();
360 if !matches!(copied, Ok(StepResult::Done)) {
361 return Err(EngineError::failed(format!(
362 "{}{}",
363 diagnostics::RUNTIME_COPY_FAILED,
364 source.display()
365 )));
366 }
367 Ok(())
368}
369
370pub(super) fn copy_private_file(source: &Path, target: &Path) -> io::Result<u64> {
371 let mut input = crate::paths::open_file_no_follow(source)?;
372 let mut output = create_private_file(target)?;
373 let copied = io::copy(&mut input, &mut output)?;
374 output.sync_all()?;
375 Ok(copied)
376}
377
378fn create_private_file(path: &Path) -> io::Result<fs::File> {
379 let mut options = OpenOptions::new();
380 options.write(true).create_new(true);
381 #[cfg(unix)]
382 {
383 use std::os::unix::fs::OpenOptionsExt;
384 options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
385 }
386 #[cfg(windows)]
387 {
388 use std::os::windows::fs::OpenOptionsExt;
389 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
390 options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
391 }
392 options.open(path)
393}
394
395fn write_private_file(path: &Path, bytes: &[u8]) -> io::Result<()> {
396 let mut output = create_private_file(path)?;
397 io::Write::write_all(&mut output, bytes)?;
398 output.sync_all()?;
399 Ok(())
400}
401
402pub(crate) fn merge_downloaded_wubi(paths: &RuntimePaths, wubi: &Path) -> Result<bool> {
406 let main_db = paths.dictionary(assets::MAIN_DICTIONARY);
407 if paths.dictionaries == paths.resources
408 || !is_real_file(&main_db)
409 || wubi.file_name() != Some(std::ffi::OsStr::new(assets::WUBI_DICTIONARY))
410 || !is_real_file(wubi)
411 {
412 return Ok(false);
413 }
414 if has_split_wubi_tables(&main_db)? {
415 return Ok(false);
416 }
417 let Some(directory) = wubi.parent() else {
418 return Ok(false);
419 };
420 merge_split_wubi(directory, &main_db)?;
421 replay_into(paths, &paths.dictionaries, true)?;
422 index_reverse_lookup(&paths.dictionaries, true);
423 Ok(true)
424}
425
426pub(crate) fn has_split_wubi_tables(main_db: &Path) -> Result<bool> {
428 let present = (|| -> rusqlite::Result<bool> {
429 let path = crate::paths::sqlite_read_only_path_no_follow(main_db)
430 .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
431 let connection = Connection::open_with_flags(
432 &path,
433 OpenFlags::SQLITE_OPEN_READ_ONLY
434 | OpenFlags::SQLITE_OPEN_NOFOLLOW
435 | OpenFlags::SQLITE_OPEN_NO_MUTEX,
436 )?;
437 connection.query_row(
438 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name IN ('wubi86','wubi98'))",
439 [],
440 |row| row.get(0),
441 )
442 })();
443 present.map_err(|error| {
444 EngineError::failed(format!("cannot read {}: {error}", assets::MAIN_DICTIONARY))
445 })
446}
447
448pub(crate) fn merge_split_wubi(resources: &Path, main_db: &Path) -> Result<()> {
450 let source = resources.join(assets::WUBI_DICTIONARY);
451 if !is_real_file(&source) {
452 return Ok(());
453 }
454 check_dictionary_format(&source, assets::WUBI_DICTIONARY)?;
455 let merged = (|| -> rusqlite::Result<()> {
456 let source = crate::paths::sqlite_read_only_path_no_follow(&source)
457 .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
458 let main_db = crate::paths::sqlite_path_no_follow(main_db)
459 .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
460 let input = Connection::open_with_flags(
461 &source,
462 OpenFlags::SQLITE_OPEN_READ_ONLY
463 | OpenFlags::SQLITE_OPEN_NOFOLLOW
464 | OpenFlags::SQLITE_OPEN_NO_MUTEX,
465 )?;
466 let mut output = Connection::open_with_flags(
467 &main_db,
468 OpenFlags::SQLITE_OPEN_READ_WRITE
469 | OpenFlags::SQLITE_OPEN_NOFOLLOW
470 | OpenFlags::SQLITE_OPEN_NO_MUTEX,
471 )?;
472 input.busy_timeout(std::time::Duration::ZERO)?;
473 output.busy_timeout(std::time::Duration::ZERO)?;
474 let transaction = output.transaction()?;
475 for table in SPLIT_WUBI_TABLES {
476 let present: bool = transaction.query_row(
477 "SELECT EXISTS(SELECT 1 FROM main.sqlite_master WHERE type='table' AND name=?1)",
478 [table],
479 |row| row.get(0),
480 )?;
481 if present {
482 continue;
483 }
484 let schema = input
486 .prepare("SELECT sql FROM sqlite_master WHERE tbl_name=?1 AND sql IS NOT NULL ORDER BY type='index', rowid")?
487 .query_map([table], |row| row.get::<_, String>(0))?
488 .collect::<rusqlite::Result<Vec<_>>>()?;
489 let Some((create, indexes)) = schema.split_first() else {
490 continue;
491 };
492 transaction.execute_batch(create)?;
493 let mut select = input.prepare(&format!("SELECT rowid,* FROM \"{table}\""))?;
494 let columns: Vec<String> = select
495 .column_names()
496 .iter()
497 .map(|name| format!("\"{name}\""))
498 .collect();
499 let placeholders = vec!["?"; columns.len()].join(",");
500 let mut insert = transaction.prepare(&format!(
502 "INSERT INTO \"{table}\"({}) VALUES({placeholders})",
503 columns.join(",")
504 ))?;
505 let mut rows = select.query([])?;
506 while let Some(row) = rows.next()? {
507 let values = (0..columns.len())
508 .map(|index| row.get::<_, rusqlite::types::Value>(index))
509 .collect::<rusqlite::Result<Vec<_>>>()?;
510 insert.execute(rusqlite::params_from_iter(values))?;
511 }
512 for index in indexes {
513 transaction.execute_batch(index)?;
514 }
515 transaction.execute_batch(&format!("ANALYZE \"{table}\""))?;
516 }
517 transaction.commit()
518 })();
519 merged.map_err(|_| {
520 EngineError::failed(format!(
521 "{}{}",
522 diagnostics::RUNTIME_COPY_FAILED,
523 source.display()
524 ))
525 })
526}
527
528pub(crate) fn check_dictionary_format(path: &Path, name: &str) -> Result<()> {
530 let Ok(resolved) = crate::paths::sqlite_read_only_path_no_follow(path) else {
531 return Ok(());
532 };
533 let Ok(connection) = Connection::open_with_flags(
534 &resolved,
535 OpenFlags::SQLITE_OPEN_READ_ONLY
536 | OpenFlags::SQLITE_OPEN_NOFOLLOW
537 | OpenFlags::SQLITE_OPEN_NO_MUTEX,
538 ) else {
539 return Ok(());
540 };
541 if connection.busy_timeout(Duration::ZERO).is_err() {
543 return Ok(());
544 }
545 match crate::format::check_format_version(&connection, name) {
546 Ok(_) | Err(EngineError::Sqlite(_)) => Ok(()),
547 Err(error) => Err(error),
548 }
549}
550
551pub(crate) fn check_read_only_layout(paths: &RuntimePaths) -> Result<()> {
553 if paths.dictionaries != paths.resources {
554 return Ok(());
555 }
556 for name in [assets::MAIN_DICTIONARY, assets::WUBI_DICTIONARY] {
557 check_dictionary_format(&paths.dictionary(name), name)?;
558 }
559 Ok(())
560}
561
562pub(super) fn stage_generation_copies(
566 resources: &Path,
567 generation: &Path,
568 replace_existing: bool,
569) -> Result<()> {
570 sweep_abandoned_copies(generation, SystemTime::now());
571 for name in GENERATION_COPIES {
572 let source = resources.join(name);
573 if !is_real_file(&source) {
574 continue;
575 }
576 let target = generation.join(name);
577 if !replace_existing && is_real_file(&target) {
578 continue;
579 }
580 let mut input = crate::paths::open_file_no_follow(&source)?;
581 let incoming = {
583 let (mut output, incoming) = tempfile::Builder::new()
584 .prefix(&format!("{name}."))
585 .suffix(STAGE_SUFFIX)
586 .make_in(generation, create_private_file)?
587 .into_parts();
588 io::copy(&mut input, &mut output)?;
589 output.sync_all()?;
590 incoming
591 };
592 match fs::rename(&incoming, &target) {
593 Ok(()) => {
594 let _ = incoming.keep();
596 }
597 Err(_) if !replace_existing && is_real_file(&target) => {}
598 Err(error) => return Err(error.into()),
599 }
600 }
601 Ok(())
602}
603
604fn sweep_abandoned_copies(generation: &Path, now: SystemTime) {
606 let Some(cutoff) = now.checked_sub(ABANDONED_STAGE_AGE) else {
607 return;
608 };
609 let Ok(entries) = fs::read_dir(generation) else {
610 return;
611 };
612 for entry in entries.flatten() {
613 let name = entry.file_name();
614 if !name.to_str().is_some_and(is_copy_name) {
615 continue;
616 }
617 let path = entry.path();
618 let stale = fs::symlink_metadata(&path).is_ok_and(|metadata| {
619 metadata.file_type().is_file()
620 && metadata.modified().is_ok_and(|modified| modified < cutoff)
621 });
622 if stale {
623 let _ = fs::remove_file(&path);
624 }
625 }
626}
627
628fn is_copy_name(name: &str) -> bool {
630 let Some(stem) = name.strip_suffix(STAGE_SUFFIX) else {
631 return false;
632 };
633 GENERATION_COPIES.iter().any(|copy| {
634 stem == *copy
635 || stem
636 .strip_prefix(copy)
637 .and_then(|rest| rest.strip_prefix('.'))
638 .is_some_and(|attempt| {
639 !attempt.is_empty() && attempt.bytes().all(|byte| byte.is_ascii_alphanumeric())
640 })
641 })
642}
643
644fn is_real_file(path: &Path) -> bool {
645 fs::symlink_metadata(path)
646 .map(|metadata| metadata.file_type().is_file())
647 .unwrap_or(false)
648}
649
650pub(super) fn weakly_canonical(path: &Path) -> Result<PathBuf> {
652 let components: Vec<Component<'_>> = path.components().collect();
653 for split in (1..=components.len()).rev() {
654 let head: PathBuf = components[..split].iter().collect();
655 match head.canonicalize() {
656 Ok(mut resolved) => {
657 append_lexically(&mut resolved, &components[split..]);
658 return Ok(resolved);
659 }
660 Err(error) if error.kind() == ErrorKind::NotFound => {}
661 Err(error) => return Err(error.into()),
662 }
663 }
664 let mut resolved = PathBuf::new();
665 append_lexically(&mut resolved, &components);
666 Ok(resolved)
667}
668
669fn append_lexically(base: &mut PathBuf, rest: &[Component<'_>]) {
670 for component in rest {
671 match component {
672 Component::CurDir => {}
673 Component::ParentDir => {
674 base.pop();
675 }
676 other => base.push(other),
677 }
678 }
679}
680
681fn reject_redirected_directory(path: &Path) -> std::io::Result<()> {
682 for ancestor in path.ancestors() {
683 match fs::symlink_metadata(ancestor) {
684 Ok(metadata) if metadata.file_type().is_symlink() => {
685 if crate::paths::is_trusted_system_alias(ancestor) {
686 continue;
687 }
688 return Err(std::io::Error::new(
689 std::io::ErrorKind::InvalidInput,
690 "runtime directory has a symbolic-link ancestor",
691 ));
692 }
693 Ok(metadata) if !metadata.is_dir() => {
694 return Err(std::io::Error::new(
695 std::io::ErrorKind::NotADirectory,
696 "runtime directory parent is not a directory",
697 ));
698 }
699 Ok(_) => {}
700 Err(error) if error.kind() == ErrorKind::NotFound => {}
701 Err(error) => return Err(error),
702 }
703 }
704 Ok(())
705}
706
707pub(super) fn roots_overlap(first: &Path, second: &Path) -> bool {
709 first.starts_with(second) || second.starts_with(first)
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715 use crate::user_dictionary::replay::tests::{sql, weight, OPERATIONS_DDL};
716
717 fn resources(root: &Path) -> PathBuf {
718 let resources = root.join("resources");
719 fs::create_dir_all(&resources).unwrap();
720 sql(
721 &resources.join(assets::MAIN_DICTIONARY),
722 "CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
723 INSERT INTO tbl_1_n VALUES('ni','n','你',100);",
724 );
725 crate::dictionary::english::ensure_english_schema(
726 &resources.join(assets::ENGLISH_DICTIONARY),
727 )
728 .unwrap();
729 resources
730 }
731
732 fn stages(user: &Path) -> Vec<String> {
734 fs::read_dir(user.join("dictionaries"))
735 .map(|entries| {
736 entries
737 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
738 .filter(|name| name.ends_with(STAGE_SUFFIX))
739 .collect()
740 })
741 .unwrap_or_default()
742 }
743
744 fn incoming_entries(directory: &Path) -> Vec<String> {
746 let mut names: Vec<String> = fs::read_dir(directory)
747 .unwrap()
748 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
749 .filter(|name| name.ends_with(STAGE_SUFFIX))
750 .collect();
751 names.sort();
752 names
753 }
754
755 fn learn(journal: &Path, word: &str, updated_at: i64) {
756 sql(journal, OPERATIONS_DDL);
757 sql(
758 journal,
759 &format!("INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,user_inserted,updated_at) VALUES('pinyin','ni','{word}','upsert',50,1,{updated_at})"),
760 );
761 }
762
763 #[test]
764 fn a_journal_that_is_not_a_database_does_not_stop_preparation() {
765 let root = tempfile::tempdir().unwrap();
766 let resources = resources(root.path());
767 let user = root.path().join("user");
768 fs::create_dir_all(&user).unwrap();
769 let journal = user.join(assets::USER_JOURNAL);
770 fs::write(&journal, vec![0u8; 8 * 4096]).unwrap();
771
772 let prepared =
773 prepare_runtime_paths(&resources, &user, &root.path().join("cache"), "v1").unwrap();
774 assert!(prepared
775 .dictionaries
776 .join(assets::GENERATION_READY)
777 .exists());
778 assert!(!journal.exists());
779 let moved: Vec<_> = fs::read_dir(&user)
780 .unwrap()
781 .filter_map(|entry| entry.ok())
782 .map(|entry| entry.file_name().to_string_lossy().into_owned())
783 .filter(|name| name.starts_with("msime_user.db.corrupt-"))
784 .collect();
785 assert_eq!(moved.len(), 1, "{moved:?}");
786 learn(&journal, "你", 1);
788 prepare_runtime_paths(&resources, &user, &root.path().join("cache"), "v1").unwrap();
789 }
790
791 #[test]
792 fn content_ids_are_validated_before_anything_is_created() {
793 let root = tempfile::tempdir().unwrap();
794 let resources = resources(root.path());
795 let user = root.path().join("user");
796 for id in ["", "../escape", "a b", "é", &"x".repeat(129)] {
797 let error = prepare_runtime_paths(&resources, &user, &root.path().join("cache"), id)
798 .unwrap_err();
799 assert_eq!(error.to_string(), diagnostics::INVALID_RUNTIME_CONTENT_ID);
800 }
801 assert!(!user.exists());
802 let relative = prepare_runtime_paths(
803 Path::new("resources"),
804 &user,
805 &root.path().join("cache"),
806 "v1",
807 )
808 .unwrap_err();
809 assert_eq!(
810 relative.to_string(),
811 diagnostics::RUNTIME_DIRECTORIES_MUST_BE_ABSOLUTE
812 );
813 assert!(prepare_runtime_paths(
814 &resources,
815 &user,
816 &root.path().join("cache"),
817 &"x".repeat(128)
818 )
819 .is_ok());
820 }
821
822 #[cfg(unix)]
823 #[test]
824 fn resource_dictionary_symlinks_are_rejected_before_staging() {
825 use std::os::unix::fs::symlink;
826
827 let root = tempfile::tempdir().unwrap();
828 let resources = resources(root.path());
829 let external = root.path().join("external-msime-pinyin.db");
830 fs::rename(resources.join(assets::MAIN_DICTIONARY), &external).unwrap();
831 symlink(&external, resources.join(assets::MAIN_DICTIONARY)).unwrap();
832
833 let error = prepare_runtime_paths(
834 &resources,
835 &root.path().join("user"),
836 &root.path().join("cache"),
837 "v1",
838 )
839 .unwrap_err();
840 assert!(error
841 .to_string()
842 .starts_with(diagnostics::RUNTIME_COPY_FAILED));
843 assert!(!root.path().join("user/dictionaries/v1").exists());
844 }
845
846 #[cfg(unix)]
847 #[test]
848 fn user_and_cache_roots_reject_symlinked_directories() {
849 use std::os::unix::fs::symlink;
850
851 for linked_name in ["user", "cache"] {
852 let root = tempfile::tempdir().unwrap();
853 let resources = resources(root.path());
854 let outside = tempfile::tempdir().unwrap();
855 let user = root.path().join("user");
856 let cache = root.path().join("cache");
857 symlink(outside.path(), root.path().join(linked_name)).unwrap();
858
859 assert!(prepare_runtime_paths(&resources, &user, &cache, "v1").is_err());
860 assert!(!outside.path().join("dictionaries").exists());
861 }
862 }
863
864 #[test]
866 fn overlapping_roots_are_rejected_without_creating_directories() {
867 let temporary = tempfile::tempdir().unwrap();
868 let root = temporary.path();
869 let resource_a = resources(root);
870 let reject = |resources: &Path, user: &Path, cache: &Path| {
871 let error = prepare_runtime_paths(resources, user, cache, "overlap").unwrap_err();
872 assert!(matches!(error, EngineError::InvalidArgument(_)), "{error}");
873 assert_eq!(error.to_string(), diagnostics::RUNTIME_ROOTS_OVERLAP);
874 assert!(!user.join("dictionaries/overlap").exists());
875 };
876 reject(
877 &resource_a,
878 &root.join("same-user-cache"),
879 &root.join("same-user-cache"),
880 );
881 reject(
882 &resource_a,
883 &root.join("cache-parent/user"),
884 &root.join("cache-parent"),
885 );
886 reject(
887 &resource_a,
888 &root.join("user-parent"),
889 &root.join("user-parent/cache"),
890 );
891 reject(
892 &resource_a,
893 &resource_a.join("user"),
894 &root.join("separate-cache"),
895 );
896 reject(
897 &resource_a,
898 &root.join("separate-user"),
899 &resource_a.join("cache"),
900 );
901 reject(&resource_a, root, &root.join("separate-cache"));
902 reject(
903 &resource_a,
904 &root.join("dotted/../resources/user"),
905 &root.join("separate-cache"),
906 );
907 for created in [
908 root.join("same-user-cache"),
909 root.join("cache-parent"),
910 resource_a.join("user"),
911 resource_a.join("cache"),
912 ] {
913 assert!(!created.exists(), "{}", created.display());
914 }
915 #[cfg(unix)]
916 {
917 let alias = root.join("resource-alias");
918 std::os::unix::fs::symlink(&resource_a, &alias).unwrap();
919 reject(&resource_a, &root.join("alias-user"), &alias.join("cache"));
920 }
921 assert!(prepare_runtime_paths(
923 &resource_a,
924 &root.join("resources-user"),
925 &root.join("resources-cache"),
926 "v1"
927 )
928 .is_ok());
929 }
930
931 #[test]
932 fn a_new_generation_is_a_replayed_backup_copy_marked_ready() {
933 let root = tempfile::tempdir().unwrap();
934 let resources = resources(root.path());
935 let original = fs::read(resources.join(assets::MAIN_DICTIONARY)).unwrap();
936 let user = root.path().join("user");
937 let cache = root.path().join("cache");
938 fs::create_dir_all(&user).unwrap();
939 learn(&user.join(assets::USER_JOURNAL), "伱", 1);
940
941 let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
942 assert_eq!(paths.dictionaries, user.join("dictionaries/v1"));
943 assert!(cache.is_dir());
944 assert_eq!(
945 fs::read_to_string(paths.dictionary(assets::GENERATION_READY)).unwrap(),
946 "v1\n"
947 );
948 assert!(stages(&user).is_empty());
949 assert_eq!(
950 weight(
951 &paths.dictionary(assets::MAIN_DICTIONARY),
952 "SELECT weight FROM tbl_1_n WHERE value='伱'"
953 ),
954 Some(50)
955 );
956 assert!(paths.dictionary(assets::ENGLISH_DICTIONARY).is_file());
957 assert_eq!(
958 fs::read(resources.join(assets::MAIN_DICTIONARY)).unwrap(),
959 original
960 );
961
962 fs::remove_dir_all(&cache).unwrap();
964 assert!(user.join(assets::USER_JOURNAL).is_file());
965 let again = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
966 assert_eq!(again, paths);
967 }
968
969 #[test]
971 fn switching_back_replays_newer_learning_and_a_failed_replay_publishes_nothing() {
972 let root = tempfile::tempdir().unwrap();
973 let resources = resources(root.path());
974 let user = root.path().join("user");
975 let cache = root.path().join("cache");
976 let journal = user.join(assets::USER_JOURNAL);
977 let v1 = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
978 learn(&journal, "伱", 1);
979 let v2 = prepare_runtime_paths(&resources, &user, &cache, "v2").unwrap();
980 assert_eq!(
981 weight(
982 &v2.dictionary(assets::MAIN_DICTIONARY),
983 "SELECT weight FROM tbl_1_n WHERE value='伱'"
984 ),
985 Some(50)
986 );
987 learn(&journal, "倪", 2);
988 let restored = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
989 assert_eq!(restored, v1);
990 assert_eq!(
991 weight(
992 &v1.dictionary(assets::MAIN_DICTIONARY),
993 "SELECT weight FROM tbl_1_n WHERE value='倪'"
994 ),
995 Some(50)
996 );
997
998 let before_failure = fs::read(v2.dictionary(assets::MAIN_DICTIONARY)).unwrap();
999 sql(
1000 &journal,
1001 "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight) VALUES('pinyin','@','无效词','upsert',10)",
1002 );
1003 let error = prepare_runtime_paths(&resources, &user, &cache, "v3").unwrap_err();
1004 assert!(matches!(error, EngineError::Failed(_)));
1005 assert_eq!(
1006 error.to_string(),
1007 format!(
1008 "{}one or more operations failed; changes were rolled back",
1009 diagnostics::RUNTIME_REPLAY_FAILED
1010 )
1011 );
1012 assert_eq!(
1013 fs::read(v2.dictionary(assets::MAIN_DICTIONARY)).unwrap(),
1014 before_failure
1015 );
1016 assert!(!user.join("dictionaries/v3").exists());
1017 assert!(stages(&user).is_empty());
1018 assert!(prepare_runtime_paths(&resources, &user, &cache, "v2").is_err());
1020 }
1021
1022 #[test]
1023 fn staging_failures_clean_up_and_incomplete_generations_are_refused() {
1024 let root = tempfile::tempdir().unwrap();
1025 let resources = resources(root.path());
1026 let user = root.path().join("user");
1027 let cache = root.path().join("cache");
1028 let missing = root.path().join("missing-resources");
1029 fs::create_dir_all(&missing).unwrap();
1030 let error = prepare_runtime_paths(&missing, &user, &cache, "v1").unwrap_err();
1031 assert_eq!(
1032 error.to_string(),
1033 format!(
1034 "{}{}",
1035 diagnostics::RUNTIME_COPY_FAILED,
1036 missing.join(assets::MAIN_DICTIONARY).display()
1037 )
1038 );
1039 assert_eq!(fs::read_dir(user.join("dictionaries")).unwrap().count(), 0);
1040 assert!(!missing.join(assets::MAIN_DICTIONARY).exists());
1041
1042 let incomplete = user.join("dictionaries/v2");
1043 fs::create_dir_all(&incomplete).unwrap();
1044 fs::write(incomplete.join(assets::GENERATION_READY), "v2\n").unwrap();
1045 let error = prepare_runtime_paths(&resources, &user, &cache, "v2").unwrap_err();
1046 assert_eq!(
1047 error.to_string(),
1048 diagnostics::INCOMPLETE_RUNTIME_GENERATION
1049 );
1050 }
1051
1052 #[test]
1054 fn a_stage_left_by_a_killed_preparation_does_not_block_the_next_one() {
1055 let root = tempfile::tempdir().unwrap();
1056 let resources = resources(root.path());
1057 let user = root.path().join("user");
1058 let cache = root.path().join("cache");
1059 let parent = user.join("dictionaries");
1060 let leftovers = ["v1.incoming", "v1.k3Xq9z.incoming"];
1062 for leftover in leftovers {
1063 fs::create_dir_all(parent.join(leftover)).unwrap();
1064 fs::write(
1065 parent.join(leftover).join(assets::MAIN_DICTIONARY),
1066 b"SQLite format 3\0partial",
1067 )
1068 .unwrap();
1069 }
1070 learn(&user.join(assets::USER_JOURNAL), "伱", 1);
1071
1072 let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1073 assert_eq!(paths.dictionaries, parent.join("v1"));
1074 assert_eq!(
1075 fs::read_to_string(paths.dictionary(assets::GENERATION_READY)).unwrap(),
1076 "v1\n"
1077 );
1078 assert_eq!(
1079 weight(
1080 &paths.dictionary(assets::MAIN_DICTIONARY),
1081 "SELECT weight FROM tbl_1_n WHERE value='伱'"
1082 ),
1083 Some(50)
1084 );
1085 for leftover in leftovers {
1086 assert_eq!(
1087 fs::read(parent.join(leftover).join(assets::MAIN_DICTIONARY)).unwrap(),
1088 b"SQLite format 3\0partial",
1089 "{leftover}"
1090 );
1091 }
1092 assert_eq!(
1093 prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap(),
1094 paths
1095 );
1096 }
1097
1098 #[test]
1100 fn a_dictionary_left_aside_by_a_killed_reset_is_put_back() {
1101 let root = tempfile::tempdir().unwrap();
1102 let resources = resources(root.path());
1103 let user = root.path().join("user");
1104 let cache = root.path().join("cache");
1105 fs::create_dir_all(&user).unwrap();
1106 learn(&user.join(assets::USER_JOURNAL), "伱", 1);
1107 let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1108 let main = paths.dictionary(assets::MAIN_DICTIONARY);
1109 let sibling = |name: &str| paths.dictionaries.join(name);
1110 fs::rename(
1111 &main,
1112 sibling(".msime-pinyin.db.backup.1700000000000000000"),
1113 )
1114 .unwrap();
1115 fs::write(
1116 sibling(".msime-pinyin.db.reset.1700000000000000000"),
1117 b"fresh copy",
1118 )
1119 .unwrap();
1120 fs::write(sibling(".msime-pinyin.db.backup.999"), b"older").unwrap();
1121
1122 assert_eq!(
1123 prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap(),
1124 paths
1125 );
1126 assert_eq!(
1127 weight(&main, "SELECT weight FROM tbl_1_n WHERE value='伱'"),
1128 Some(50)
1129 );
1130 assert!(!sibling(".msime-pinyin.db.backup.1700000000000000000").exists());
1131 assert!(!sibling(".msime-pinyin.db.reset.1700000000000000000").exists());
1132 assert_eq!(
1133 fs::read(sibling(".msime-pinyin.db.backup.999")).unwrap(),
1134 b"older"
1135 );
1136
1137 let restored = fs::read(&main).unwrap();
1138 fs::write(
1139 sibling(".msime-pinyin.db.backup.1800000000000000000"),
1140 b"newer",
1141 )
1142 .unwrap();
1143 prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1144 assert_eq!(fs::read(&main).unwrap(), restored);
1145 assert_eq!(
1146 fs::read(sibling(".msime-pinyin.db.backup.1800000000000000000")).unwrap(),
1147 b"newer"
1148 );
1149 }
1150
1151 fn assert_a_legacy_dictionary_left_aside_is_put_back(name: &str, legacy: &str) {
1152 let root = tempfile::tempdir().unwrap();
1153 let resources = resources(root.path());
1154 let user = root.path().join("user");
1155 let cache = root.path().join("cache");
1156 fs::create_dir_all(&user).unwrap();
1157 let journal = user.join(assets::USER_JOURNAL);
1158 learn(&journal, "伱", 1);
1159 sql(
1160 &journal,
1161 "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,display,user_inserted,updated_at) VALUES('english','zzfixture','Zzfixture','upsert',11,'Zzfixture',1,2)",
1162 );
1163 let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1164 let current = paths.dictionaries.join(name);
1165 let old = paths.dictionaries.join(legacy);
1166 fs::rename(¤t, &old).unwrap();
1167 assert_eq!(paths.dictionary(name), old);
1168 let backup = paths.dictionaries.join(format!(".{legacy}.backup.200"));
1169 let staged = paths.dictionaries.join(format!(".{legacy}.reset.200"));
1170 fs::rename(&old, &backup).unwrap();
1171 fs::write(&staged, b"unpublished synthetic dictionary").unwrap();
1172
1173 assert_eq!(
1174 prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap(),
1175 paths
1176 );
1177 assert_eq!(paths.dictionary(name), current);
1178 assert!(!old.exists());
1179 assert!(!backup.exists());
1180 assert!(!staged.exists());
1181 assert_eq!(
1182 weight(
1183 &paths.dictionary(assets::MAIN_DICTIONARY),
1184 "SELECT weight FROM tbl_1_n WHERE value='伱'"
1185 ),
1186 Some(50)
1187 );
1188 assert_eq!(
1189 weight(
1190 &paths.dictionary(assets::ENGLISH_DICTIONARY),
1191 "SELECT weight FROM english_words WHERE word='zzfixture' AND display='Zzfixture'"
1192 ),
1193 Some(11)
1194 );
1195 }
1196
1197 #[test]
1199 fn a_legacy_main_dictionary_left_aside_by_a_reset_is_put_back() {
1200 assert_a_legacy_dictionary_left_aside_is_put_back(assets::MAIN_DICTIONARY, "msime.db");
1201 }
1202
1203 #[test]
1205 fn a_legacy_english_dictionary_left_aside_by_a_reset_is_put_back() {
1206 assert_a_legacy_dictionary_left_aside_is_put_back(assets::ENGLISH_DICTIONARY, "english.db");
1207 }
1208
1209 #[test]
1211 fn a_journal_left_aside_by_a_killed_reset_is_put_back() {
1212 let root = tempfile::tempdir().unwrap();
1213 let resources = resources(root.path());
1214 let user = root.path().join("user");
1215 let cache = root.path().join("cache");
1216 fs::create_dir_all(&user).unwrap();
1217 let journal = user.join(assets::USER_JOURNAL);
1218 learn(&journal, "伱", 1);
1219 prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1220 fs::rename(
1221 &journal,
1222 user.join(".msime_user.db.backup.1700000000000000000"),
1223 )
1224 .unwrap();
1225 fs::write(
1226 user.join(".msime_user.db.reset.1700000000000000000"),
1227 b"empty journal",
1228 )
1229 .unwrap();
1230
1231 let paths = prepare_runtime_paths(&resources, &user, &cache, "v2").unwrap();
1232 assert_eq!(
1233 weight(&journal, "SELECT count(*) FROM user_dictionary_operations"),
1234 Some(1)
1235 );
1236 assert_eq!(
1237 weight(
1238 &paths.dictionary(assets::MAIN_DICTIONARY),
1239 "SELECT weight FROM tbl_1_n WHERE value='伱'"
1240 ),
1241 Some(50)
1242 );
1243 assert!(!user
1244 .join(".msime_user.db.backup.1700000000000000000")
1245 .exists());
1246 assert!(!user
1247 .join(".msime_user.db.reset.1700000000000000000")
1248 .exists());
1249 }
1250
1251 #[test]
1253 fn concurrent_preparations_of_one_generation_all_succeed() {
1254 const PREPARERS: usize = 8;
1255 let root = tempfile::tempdir().unwrap();
1256 let resources = resources(root.path());
1257 fs::write(resources.join(assets::BIGRAM_TABLE), "bigram").unwrap();
1258 let user = root.path().join("user");
1259 let cache = root.path().join("cache");
1260 fs::create_dir_all(&user).unwrap();
1261 learn(&user.join(assets::USER_JOURNAL), "伱", 1);
1262
1263 let barrier = std::sync::Barrier::new(PREPARERS);
1264 let results: Vec<_> = std::thread::scope(|scope| {
1265 let preparers: Vec<_> = (0..PREPARERS)
1266 .map(|_| {
1267 scope.spawn(|| {
1268 barrier.wait();
1269 prepare_runtime_paths(&resources, &user, &cache, "v1")
1270 .map_err(|error| error.to_string())
1271 })
1272 })
1273 .collect();
1274 preparers
1275 .into_iter()
1276 .map(|preparer| preparer.join().unwrap())
1277 .collect()
1278 });
1279 let generation = user.join("dictionaries/v1");
1280 for result in &results {
1281 assert_eq!(result.as_ref().unwrap().dictionaries, generation);
1282 }
1283 assert_eq!(
1284 fs::read_to_string(generation.join(assets::GENERATION_READY)).unwrap(),
1285 "v1\n"
1286 );
1287 assert_eq!(
1288 weight(
1289 &generation.join(assets::MAIN_DICTIONARY),
1290 "SELECT weight FROM tbl_1_n WHERE value='伱'"
1291 ),
1292 Some(50)
1293 );
1294 assert_eq!(
1295 fs::read(generation.join(assets::BIGRAM_TABLE)).unwrap(),
1296 b"bigram"
1297 );
1298 let entries: Vec<_> = fs::read_dir(user.join("dictionaries"))
1299 .unwrap()
1300 .map(|entry| entry.unwrap().file_name())
1301 .collect();
1302 assert_eq!(entries, ["v1"]);
1303 }
1304
1305 #[test]
1307 fn only_stages_untouched_for_an_hour_are_swept() {
1308 let root = tempfile::tempdir().unwrap();
1309 let parent = root.path().join("dictionaries");
1310 let directories = [
1311 "v1.incoming",
1312 "v1.k3Xq9z.incoming",
1313 "v2.Ab12cd.removing",
1314 "v1",
1315 "a b.incoming",
1316 "v1.k3.x.incoming",
1317 "v1..incoming",
1318 "v1.incoming.bak",
1319 ];
1320 for name in directories {
1321 fs::create_dir_all(parent.join(name)).unwrap();
1322 fs::write(parent.join(name).join(assets::MAIN_DICTIONARY), b"partial").unwrap();
1323 }
1324 fs::write(parent.join("v3.incoming"), b"not a directory").unwrap();
1325 let outside = tempfile::tempdir().unwrap();
1326 fs::write(outside.path().join("keep"), b"keep").unwrap();
1327 #[cfg(unix)]
1328 std::os::unix::fs::symlink(outside.path(), parent.join("v4.incoming")).unwrap();
1329 let names = || {
1330 let mut names: Vec<_> = fs::read_dir(&parent)
1331 .unwrap()
1332 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
1333 .collect();
1334 names.sort();
1335 names
1336 };
1337 let mut untouched: Vec<String> = [
1338 "a b.incoming",
1339 "v1",
1340 "v1..incoming",
1341 "v1.incoming.bak",
1342 "v1.k3.x.incoming",
1343 "v3.incoming",
1344 ]
1345 .map(String::from)
1346 .to_vec();
1347 if cfg!(unix) {
1348 untouched.push("v4.incoming".into());
1349 }
1350 untouched.sort();
1351
1352 sweep_abandoned_stages(&parent, SystemTime::now());
1354 let mut fresh = untouched.clone();
1355 fresh.extend(["v1.incoming".into(), "v1.k3Xq9z.incoming".into()]);
1356 fresh.sort();
1357 assert_eq!(names(), fresh);
1358
1359 sweep_abandoned_stages(
1360 &parent,
1361 SystemTime::now() + ABANDONED_STAGE_AGE + Duration::from_secs(60),
1362 );
1363 assert_eq!(names(), untouched);
1364 assert_eq!(fs::read(outside.path().join("keep")).unwrap(), b"keep");
1365 }
1366
1367 #[cfg(unix)]
1369 #[test]
1370 fn preparation_sweeps_stages_and_table_copies_abandoned_an_hour_ago() {
1371 let root = tempfile::tempdir().unwrap();
1372 let resources = resources(root.path());
1373 let user = root.path().join("user");
1374 let cache = root.path().join("cache");
1375 prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1376 let parent = user.join("dictionaries");
1377 let abandoned = parent.join("v1.k3Xq9z.incoming");
1378 fs::create_dir_all(&abandoned).unwrap();
1379 let partial = abandoned.join(assets::MAIN_DICTIONARY);
1380 fs::write(&partial, b"partial").unwrap();
1381 let long_ago = SystemTime::now() - ABANDONED_STAGE_AGE - Duration::from_secs(60);
1382 let backdate = |path: &Path| {
1383 fs::File::open(path)
1384 .unwrap()
1385 .set_modified(long_ago)
1386 .unwrap()
1387 };
1388 backdate(&abandoned);
1390 prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1391 assert_eq!(fs::read(&partial).unwrap(), b"partial");
1392
1393 let copy = parent.join("v1").join("msime-trigram.bin.k3Xq9z.incoming");
1395 fs::write(©, b"partial").unwrap();
1396 prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1397 assert!(copy.is_file());
1398
1399 backdate(&partial);
1400 backdate(&abandoned);
1401 backdate(©);
1402 prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1403 assert!(!abandoned.exists());
1404 assert!(!copy.exists());
1405 assert_eq!(fs::read_dir(&parent).unwrap().count(), 1);
1406 }
1407
1408 #[cfg(unix)]
1409 #[test]
1410 fn a_symlinked_generation_marker_is_not_treated_as_ready() {
1411 use std::os::unix::fs::symlink;
1412
1413 let root = tempfile::tempdir().unwrap();
1414 let resources = resources(root.path());
1415 let user = root.path().join("user");
1416 let cache = root.path().join("cache");
1417 let generation = user.join("dictionaries/v1");
1418 fs::create_dir_all(&generation).unwrap();
1419 for name in generation_dictionaries(true) {
1420 fs::copy(resources.join(name), generation.join(name)).unwrap();
1421 }
1422 let outside = tempfile::tempdir().unwrap();
1423 let marker_target = outside.path().join("marker");
1424 fs::write(&marker_target, b"v1\n").unwrap();
1425 symlink(&marker_target, generation.join(assets::GENERATION_READY)).unwrap();
1426
1427 assert_eq!(
1428 prepare_runtime_paths(&resources, &user, &cache, "v1")
1429 .unwrap_err()
1430 .to_string(),
1431 diagnostics::INCOMPLETE_RUNTIME_GENERATION
1432 );
1433 }
1434
1435 #[test]
1436 fn the_backup_copy_includes_committed_wal_content() {
1437 let root = tempfile::tempdir().unwrap();
1438 let resources = resources(root.path());
1439 let live = Connection::open(resources.join(assets::MAIN_DICTIONARY)).unwrap();
1440 live.query_row("PRAGMA journal_mode=WAL", [], |_| Ok(()))
1441 .unwrap();
1442 live.execute_batch(
1443 "PRAGMA wal_autocheckpoint=0; INSERT INTO tbl_1_n VALUES('ni','n','妮',70);",
1444 )
1445 .unwrap();
1446 let paths = prepare_runtime_paths(
1447 &resources,
1448 &root.path().join("user"),
1449 &root.path().join("cache"),
1450 "v1",
1451 )
1452 .unwrap();
1453 assert_eq!(
1454 weight(
1455 &paths.dictionary(assets::MAIN_DICTIONARY),
1456 "SELECT weight FROM tbl_1_n WHERE value='妮'"
1457 ),
1458 Some(70)
1459 );
1460 }
1461
1462 fn reverse_indexes(database: &Path) -> Vec<String> {
1463 let connection = Connection::open(database).unwrap();
1464 let mut statement = connection
1465 .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_wubi%_value' ORDER BY name")
1466 .unwrap();
1467 statement
1468 .query_map([], |row| row.get(0))
1469 .unwrap()
1470 .map(Result::unwrap)
1471 .collect()
1472 }
1473
1474 #[test]
1475 fn generations_get_the_wubi_reverse_index_and_resources_stay_untouched() {
1476 let root = tempfile::tempdir().unwrap();
1477 let resources = resources(root.path());
1478 sql(
1480 &resources.join(assets::MAIN_DICTIONARY),
1481 "CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER,UNIQUE(key,value));
1482 INSERT INTO wubi86 VALUES('wqvb','你好',300);",
1483 );
1484 let original = fs::read(resources.join(assets::MAIN_DICTIONARY)).unwrap();
1485 let user = root.path().join("user");
1486 let cache = root.path().join("cache");
1487
1488 let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1489 let main = paths.dictionary(assets::MAIN_DICTIONARY);
1490 assert_eq!(reverse_indexes(&main), ["idx_wubi86_value"]);
1491 assert!(reverse_indexes(&paths.dictionary(assets::ENGLISH_DICTIONARY)).is_empty());
1492 assert_eq!(
1493 fs::read(resources.join(assets::MAIN_DICTIONARY)).unwrap(),
1494 original
1495 );
1496
1497 sql(&main, "DROP INDEX idx_wubi86_value;");
1498 #[cfg(unix)]
1500 {
1501 use std::os::unix::fs::PermissionsExt;
1502 fs::set_permissions(&main, fs::Permissions::from_mode(0o444)).unwrap();
1503 let reopened = prepare_runtime_paths(&resources, &user, &cache, "v1");
1504 fs::set_permissions(&main, fs::Permissions::from_mode(0o644)).unwrap();
1505 reopened.unwrap();
1506 assert!(reverse_indexes(&main).is_empty());
1507 }
1508 prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1510 assert_eq!(reverse_indexes(&main), ["idx_wubi86_value"]);
1511 let indexed = fs::read(&main).unwrap();
1513 prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1514 assert_eq!(reverse_indexes(&main), ["idx_wubi86_value"]);
1515 assert_eq!(fs::read(&main).unwrap(), indexed);
1516 assert_eq!(
1517 weight(&main, "SELECT weight FROM wubi86 WHERE value='你好'"),
1518 Some(300)
1519 );
1520 }
1521
1522 #[test]
1524 fn generation_tables_are_staged_and_later_ones_added_without_replacing() {
1525 let root = tempfile::tempdir().unwrap();
1526 let resources = resources(root.path());
1527 fs::write(resources.join(assets::BIGRAM_TABLE), "first").unwrap();
1528 let user = root.path().join("user");
1529 let cache = root.path().join("cache");
1530 let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1531 assert_eq!(
1532 fs::read(paths.dictionary(assets::BIGRAM_TABLE)).unwrap(),
1533 b"first"
1534 );
1535 assert!(!paths.dictionary(assets::TRIGRAM_TABLE).exists());
1536
1537 fs::write(resources.join(assets::TRIGRAM_TABLE), "third").unwrap();
1538 fs::write(resources.join(assets::BIGRAM_TABLE), "second").unwrap();
1539 let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1540 assert_eq!(
1541 fs::read(paths.dictionary(assets::TRIGRAM_TABLE)).unwrap(),
1542 b"third"
1543 );
1544 assert_eq!(
1545 fs::read(paths.dictionary(assets::BIGRAM_TABLE)).unwrap(),
1546 b"first"
1547 );
1548 assert!(incoming_entries(&paths.dictionaries).is_empty());
1549 let fresh = prepare_runtime_paths(&resources, &user, &cache, "v2").unwrap();
1550 assert_eq!(
1551 fs::read(fresh.dictionary(assets::BIGRAM_TABLE)).unwrap(),
1552 b"second"
1553 );
1554 }
1555
1556 #[test]
1558 fn table_copies_left_by_a_killed_preparation_do_not_block_and_are_swept_later() {
1559 let root = tempfile::tempdir().unwrap();
1560 let resources = resources(root.path());
1561 let user = root.path().join("user");
1562 let cache = root.path().join("cache");
1563 let generation = prepare_runtime_paths(&resources, &user, &cache, "v1")
1564 .unwrap()
1565 .dictionaries;
1566 let leftovers = [
1567 "msime-trigram.bin.incoming",
1568 "msime-trigram.bin.k3Xq9z.incoming",
1569 ];
1570 for leftover in leftovers {
1571 fs::write(generation.join(leftover), b"partial").unwrap();
1572 }
1573 let unrelated = [
1574 "other.incoming",
1575 "msime-trigram.bin.k3.x.incoming",
1576 "msime-trigram.bin..incoming",
1577 "msime-trigram.bin.incoming.bak",
1578 ];
1579 for name in unrelated {
1580 fs::write(generation.join(name), b"keep").unwrap();
1581 }
1582 fs::create_dir(generation.join("msime-bigram.bin.Ab12cd.incoming")).unwrap();
1583 fs::write(resources.join(assets::TRIGRAM_TABLE), "third").unwrap();
1584
1585 prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1586 assert_eq!(
1587 fs::read(generation.join(assets::TRIGRAM_TABLE)).unwrap(),
1588 b"third"
1589 );
1590 for leftover in leftovers {
1591 assert_eq!(fs::read(generation.join(leftover)).unwrap(), b"partial");
1592 }
1593
1594 sweep_abandoned_copies(
1595 &generation,
1596 SystemTime::now() + ABANDONED_STAGE_AGE + Duration::from_secs(60),
1597 );
1598 for leftover in leftovers {
1599 assert!(!generation.join(leftover).exists(), "{leftover}");
1600 }
1601 for name in unrelated {
1602 assert_eq!(fs::read(generation.join(name)).unwrap(), b"keep", "{name}");
1603 }
1604 assert!(generation.join("msime-bigram.bin.Ab12cd.incoming").is_dir());
1605 for name in [
1606 assets::MAIN_DICTIONARY,
1607 assets::ENGLISH_DICTIONARY,
1608 assets::GENERATION_READY,
1609 assets::TRIGRAM_TABLE,
1610 ] {
1611 assert!(generation.join(name).is_file(), "{name}");
1612 }
1613 }
1614
1615 #[test]
1617 fn concurrent_preparations_add_a_newly_shipped_table_once() {
1618 const PREPARERS: usize = 8;
1619 let root = tempfile::tempdir().unwrap();
1620 let resources = resources(root.path());
1621 let user = root.path().join("user");
1622 let cache = root.path().join("cache");
1623 let generation = prepare_runtime_paths(&resources, &user, &cache, "v1")
1624 .unwrap()
1625 .dictionaries;
1626 let table: Vec<u8> = (0..8 * 1024 * 1024u32).map(|index| index as u8).collect();
1628 fs::write(resources.join(assets::TRIGRAM_TABLE), &table).unwrap();
1629
1630 let barrier = std::sync::Barrier::new(PREPARERS);
1631 let results: Vec<_> = std::thread::scope(|scope| {
1632 let preparers: Vec<_> = (0..PREPARERS)
1633 .map(|_| {
1634 scope.spawn(|| {
1635 barrier.wait();
1636 prepare_runtime_paths(&resources, &user, &cache, "v1")
1637 .map_err(|error| error.to_string())
1638 })
1639 })
1640 .collect();
1641 preparers
1642 .into_iter()
1643 .map(|preparer| preparer.join().unwrap())
1644 .collect()
1645 });
1646 for result in &results {
1647 assert_eq!(result.as_ref().unwrap().dictionaries, generation);
1648 }
1649 assert!(fs::read(generation.join(assets::TRIGRAM_TABLE)).unwrap() == table);
1650 assert!(incoming_entries(&generation).is_empty());
1651 }
1652
1653 #[test]
1655 fn split_wubi_tables_are_merged_into_the_generation_before_replay() {
1656 let root = tempfile::tempdir().unwrap();
1657 let resources = resources(root.path());
1658 sql(
1659 &resources.join(assets::WUBI_DICTIONARY),
1660 "CREATE TABLE wubi86(\"key\" TEXT NOT NULL,\"value\" TEXT NOT NULL,\"weight\" INTEGER NOT NULL DEFAULT 0,UNIQUE(\"key\",\"value\"));
1661 CREATE INDEX idx_wubi86_key_weight ON wubi86(\"key\",\"weight\" DESC);
1662 CREATE TABLE wubi98(\"key\" TEXT NOT NULL,\"value\" TEXT NOT NULL,\"weight\" INTEGER NOT NULL DEFAULT 0,UNIQUE(\"key\",\"value\"));
1663 INSERT INTO wubi86(rowid,\"key\",\"value\",weight) VALUES(7,'wqvb','你好',100),(3,'aaaa','工',90);
1664 INSERT INTO wubi98 VALUES('wqvb','你好',80);",
1665 );
1666 let user = root.path().join("user");
1667 let journal = user.join(assets::USER_JOURNAL);
1668 fs::create_dir_all(&user).unwrap();
1669 sql(&journal, OPERATIONS_DDL);
1670 sql(
1671 &journal,
1672 "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,user_inserted,updated_at) VALUES
1673 ('wubi','wqvb','合成五笔','upsert',9,1,1),
1674 ('wubi98','wqvb','你好','upsert',99,1,2)",
1675 );
1676 let cache = root.path().join("cache");
1677 let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1678 let main = paths.dictionary(assets::MAIN_DICTIONARY);
1679 assert_eq!(
1680 weight(&main, "SELECT weight FROM wubi86 WHERE value='合成五笔'"),
1681 Some(9)
1682 );
1683 assert_eq!(
1684 weight(&main, "SELECT weight FROM wubi98 WHERE value='你好'"),
1685 Some(99)
1686 );
1687 assert_eq!(
1688 weight(&main, "SELECT rowid FROM wubi86 WHERE value='你好'"),
1689 Some(7)
1690 );
1691 assert_eq!(
1692 weight(
1693 &main,
1694 "SELECT count(*) FROM sqlite_master WHERE name='idx_wubi86_key_weight'"
1695 ),
1696 Some(1)
1697 );
1698 assert_eq!(
1700 weight(
1701 &resources.join(assets::WUBI_DICTIONARY),
1702 "SELECT count(*) FROM wubi86"
1703 ),
1704 Some(2)
1705 );
1706 assert!(!paths.dictionary(assets::WUBI_DICTIONARY).exists());
1707
1708 let again = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1709 assert_eq!(
1710 weight(
1711 &again.dictionary(assets::MAIN_DICTIONARY),
1712 "SELECT count(*) FROM wubi86"
1713 ),
1714 Some(3)
1715 );
1716 }
1717
1718 #[test]
1720 fn a_downloaded_wubi_dictionary_is_merged_into_a_generation_prepared_without_one() {
1721 let root = tempfile::tempdir().unwrap();
1722 let resources = resources(root.path());
1723 assert!(!resources.join(assets::WUBI_DICTIONARY).exists());
1724 let user = root.path().join("user");
1725 fs::create_dir_all(&user).unwrap();
1726 let journal = user.join(assets::USER_JOURNAL);
1727 sql(&journal, OPERATIONS_DDL);
1728 sql(
1729 &journal,
1730 "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,user_inserted,updated_at) VALUES
1731 ('wubi','wqvb','合成五笔','upsert',9,1,1),
1732 ('wubi98','wqvb','你好','upsert',99,1,2)",
1733 );
1734 let cache = root.path().join("cache");
1735 let paths = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1736 let main = paths.dictionary(assets::MAIN_DICTIONARY);
1737 assert_eq!(
1738 weight(
1739 &main,
1740 "SELECT count(*) FROM sqlite_master WHERE name IN ('wubi86','wubi98')"
1741 ),
1742 Some(0)
1743 );
1744
1745 let pack = root.path().join("resource-packs").join("wubi");
1746 fs::create_dir_all(&pack).unwrap();
1747 let downloaded = pack.join(assets::WUBI_DICTIONARY);
1748 sql(
1749 &downloaded,
1750 "CREATE TABLE wubi86(\"key\" TEXT NOT NULL,\"value\" TEXT NOT NULL,\"weight\" INTEGER NOT NULL DEFAULT 0,UNIQUE(\"key\",\"value\"));
1751 CREATE TABLE wubi98(\"key\" TEXT NOT NULL,\"value\" TEXT NOT NULL,\"weight\" INTEGER NOT NULL DEFAULT 0,UNIQUE(\"key\",\"value\"));
1752 INSERT INTO wubi86 VALUES('aaaa','工',90);
1753 INSERT INTO wubi98 VALUES('wqvb','你好',80);",
1754 );
1755 let renamed = pack.join("other.db");
1757 fs::copy(&downloaded, &renamed).unwrap();
1758 assert!(!merge_downloaded_wubi(&paths, &renamed).unwrap());
1759 assert!(!merge_downloaded_wubi(
1760 &paths,
1761 &pack.join("missing").join(assets::WUBI_DICTIONARY)
1762 )
1763 .unwrap());
1764
1765 assert!(merge_downloaded_wubi(&paths, &downloaded).unwrap());
1766 assert_eq!(
1767 weight(&main, "SELECT weight FROM wubi86 WHERE value='合成五笔'"),
1768 Some(9)
1769 );
1770 assert_eq!(
1771 weight(&main, "SELECT weight FROM wubi98 WHERE value='你好'"),
1772 Some(99)
1773 );
1774 assert_eq!(
1775 weight(&main, "SELECT weight FROM wubi86 WHERE value='工'"),
1776 Some(90)
1777 );
1778 assert_eq!(weight(&downloaded, "SELECT count(*) FROM wubi86"), Some(1));
1780 assert!(!merge_downloaded_wubi(&paths, &downloaded).unwrap());
1781 assert_eq!(weight(&main, "SELECT count(*) FROM wubi86"), Some(2));
1782 let read_only = RuntimePaths {
1784 dictionaries: paths.resources.clone(),
1785 ..paths.clone()
1786 };
1787 assert!(!merge_downloaded_wubi(&read_only, &downloaded).unwrap());
1788 }
1789
1790 #[test]
1791 fn weakly_canonical_resolves_the_existing_prefix() {
1792 let root = tempfile::tempdir().unwrap();
1793 let base = root.path().canonicalize().unwrap();
1794 assert_eq!(
1795 weakly_canonical(&root.path().join("a/./b/../c")).unwrap(),
1796 base.join("a/c")
1797 );
1798 assert_eq!(weakly_canonical(root.path()).unwrap(), base);
1799 assert!(roots_overlap(Path::new("/a/b"), Path::new("/a")));
1800 assert!(!roots_overlap(Path::new("/a/bc"), Path::new("/a/b")));
1801 }
1802
1803 #[test]
1805 fn the_real_dictionary_set_stages_and_replays() {
1806 let Some(resources) = std::env::var_os("MSIME_EVAL_RESOURCES") else {
1807 eprintln!(
1808 "skipped: MSIME_EVAL_RESOURCES is not set to the dict-v2.0.5 resource directory"
1809 );
1810 return;
1811 };
1812 let resources = PathBuf::from(resources);
1813 let root = tempfile::tempdir().unwrap();
1814 let user = root.path().join("user");
1815 fs::create_dir_all(&user).unwrap();
1816 let journal = user.join(assets::USER_JOURNAL);
1817 sql(&journal, OPERATIONS_DDL);
1818 sql(
1819 &journal,
1820 "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,display,user_inserted) VALUES
1821 ('pinyin','ni''hao','拟好蒿','upsert',12345,'',1),
1822 ('pinyin','ni''hao''ni''hao''ni''hao''ni''hao','你好你好你好你好','upsert',7,'',1),
1823 ('wubi','wqvb','合成五笔','upsert',9,'',1),
1824 ('quick','zzfixture','合成短语','upsert',10,'',1),
1825 ('english','zzfixture','Zzfixture','upsert',11,'Zzfixture',1)",
1826 );
1827 let paths =
1828 prepare_runtime_paths(&resources, &user, &root.path().join("cache"), "real").unwrap();
1829 for name in [assets::BIGRAM_TABLE, assets::TRIGRAM_TABLE] {
1830 assert_eq!(
1831 fs::read(paths.dictionary(name)).unwrap(),
1832 fs::read(resources.join(name)).unwrap(),
1833 "{name}"
1834 );
1835 }
1836 let main = paths.dictionary(assets::MAIN_DICTIONARY);
1837 assert_eq!(
1838 weight(
1839 &main,
1840 "SELECT weight FROM tbl_2_n WHERE key='ni''hao' AND value='拟好蒿'"
1841 ),
1842 Some(12345)
1843 );
1844 assert_eq!(
1845 weight(
1846 &main,
1847 "SELECT weight FROM tbl_others_n WHERE value='你好你好你好你好'"
1848 ),
1849 Some(7)
1850 );
1851 assert_eq!(
1852 weight(&main, "SELECT weight FROM wubi86 WHERE value='合成五笔'"),
1853 Some(9)
1854 );
1855 assert_eq!(
1856 weight(
1857 &main,
1858 "SELECT weight FROM quick_parases WHERE value='合成短语'"
1859 ),
1860 Some(10)
1861 );
1862 assert_eq!(
1863 weight(
1864 &paths.dictionary(assets::ENGLISH_DICTIONARY),
1865 "SELECT weight FROM english_words WHERE word='zzfixture' AND display='Zzfixture'"
1866 ),
1867 Some(11)
1868 );
1869 let shipped =
1870 |path: &Path| weight(path, "SELECT count(*) FROM tbl_2_n WHERE key='ni''hao'").unwrap();
1871 assert_eq!(
1872 shipped(&main),
1873 shipped(&resources.join(assets::MAIN_DICTIONARY)) + 1
1874 );
1875 }
1876
1877 fn japanese_only() -> SchemeSet {
1878 SchemeSet::of(&[crate::types::SchemeType::JapaneseRomaji])
1879 }
1880
1881 #[test]
1883 fn a_generation_without_the_main_dictionary_holds_only_english() {
1884 let root = tempfile::tempdir().unwrap();
1885 let resources = resources(root.path());
1886 fs::remove_file(resources.join(assets::MAIN_DICTIONARY)).unwrap();
1887 let user = root.path().join("user");
1888 let cache = root.path().join("cache");
1889 let journal = user.join(assets::USER_JOURNAL);
1890 fs::create_dir_all(&user).unwrap();
1891 learn(&journal, "你", 1);
1892 sql(
1893 &journal,
1894 "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,display,user_inserted,updated_at) VALUES('english','zzfixture','Zzfixture','upsert',11,'Zzfixture',1,2)",
1895 );
1896
1897 let paths =
1898 prepare_runtime_paths_for(&resources, &user, &cache, "v1", japanese_only()).unwrap();
1899 assert!(paths.dictionaries.join(assets::GENERATION_READY).is_file());
1900 assert!(!paths.dictionary(assets::MAIN_DICTIONARY).exists());
1901 assert!(!resources.join(assets::MAIN_DICTIONARY).exists());
1902 assert_eq!(
1903 weight(
1904 &paths.dictionary(assets::ENGLISH_DICTIONARY),
1905 "SELECT weight FROM english_words WHERE word='zzfixture' AND display='Zzfixture'"
1906 ),
1907 Some(11)
1908 );
1909
1910 let again =
1912 prepare_runtime_paths_for(&resources, &user, &cache, "v1", japanese_only()).unwrap();
1913 assert_eq!(again, paths);
1914 assert!(!again.dictionary(assets::MAIN_DICTIONARY).exists());
1915
1916 let error = prepare_runtime_paths(&resources, &user, &cache, "v2").unwrap_err();
1918 assert_eq!(
1919 error.to_string(),
1920 format!(
1921 "{}{}",
1922 diagnostics::RUNTIME_COPY_FAILED,
1923 resources.join(assets::MAIN_DICTIONARY).display()
1924 )
1925 );
1926 assert!(!user.join("dictionaries/v2").exists());
1927 let error = prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap_err();
1929 assert_eq!(
1930 error.to_string(),
1931 diagnostics::INCOMPLETE_RUNTIME_GENERATION
1932 );
1933 }
1934
1935 #[test]
1937 fn a_generation_without_the_main_dictionary_still_needs_english() {
1938 let root = tempfile::tempdir().unwrap();
1939 let missing = root.path().join("missing-resources");
1940 fs::create_dir_all(&missing).unwrap();
1941 let user = root.path().join("user");
1942 let error = prepare_runtime_paths_for(
1943 &missing,
1944 &user,
1945 &root.path().join("cache"),
1946 "v1",
1947 japanese_only(),
1948 )
1949 .unwrap_err();
1950 assert_eq!(
1951 error.to_string(),
1952 format!(
1953 "{}{}",
1954 diagnostics::RUNTIME_COPY_FAILED,
1955 missing.join(assets::ENGLISH_DICTIONARY).display()
1956 )
1957 );
1958 assert!(stages(&user).is_empty());
1959 assert!(!user.join("dictionaries/v1").exists());
1960 }
1961
1962 #[cfg(unix)]
1963 #[test]
1964 fn generation_copy_rejects_a_symlinked_source() {
1965 use std::os::unix::fs::symlink;
1966
1967 let directory = tempfile::tempdir().unwrap();
1968 let outside = tempfile::tempdir().unwrap();
1969 let source = outside.path().join("source.bin");
1970 let target = directory.path().join("target.bin");
1971 std::fs::write(&source, b"outside").unwrap();
1972 symlink(&source, directory.path().join("linked.bin")).unwrap();
1973
1974 assert!(copy_private_file(&directory.path().join("linked.bin"), &target).is_err());
1975 assert!(!target.exists());
1976 }
1977
1978 #[cfg(unix)]
1979 #[test]
1980 fn generation_marker_writing_rejects_a_symlink() {
1981 use std::os::unix::fs::symlink;
1982
1983 let directory = tempfile::tempdir().unwrap();
1984 let outside = tempfile::tempdir().unwrap();
1985 let target = outside.path().join("outside.txt");
1986 std::fs::write(&target, b"keep").unwrap();
1987 let marker = directory.path().join(".ready");
1988 symlink(&target, &marker).unwrap();
1989
1990 assert!(write_private_file(&marker, b"ready\n").is_err());
1991 assert_eq!(std::fs::read(&target).unwrap(), b"keep");
1992 }
1993
1994 fn stamp(path: &Path, version: i32) {
1995 sql(
1996 path,
1997 &format!(
1998 "{} INSERT OR REPLACE INTO metadata VALUES('format_version', '{version}');",
1999 crate::format::METADATA_SCHEMA
2000 ),
2001 );
2002 }
2003
2004 fn assert_unsupported(error: &EngineError, file: &str) {
2005 let text = error.to_string();
2006 assert!(
2007 text.starts_with(diagnostics::DICTIONARY_FORMAT_UNSUPPORTED),
2008 "{text}"
2009 );
2010 assert!(text.contains(file), "{text}");
2011 }
2012
2013 #[test]
2015 fn preparation_checks_the_main_dictionary_format_version() {
2016 let root = tempfile::tempdir().unwrap();
2017 let resources = resources(root.path());
2018 let user = root.path().join("user");
2019 let cache = root.path().join("cache");
2020 let main = resources.join(assets::MAIN_DICTIONARY);
2021
2022 stamp(&main, crate::format::FORMAT_VERSION);
2023 let prepared = prepare_runtime_paths(&resources, &user, &cache, "current").unwrap();
2024 let copy = Connection::open(prepared.dictionary(assets::MAIN_DICTIONARY)).unwrap();
2025 assert_eq!(
2026 crate::format::check_format_version(©, assets::MAIN_DICTIONARY).unwrap(),
2027 crate::format::FORMAT_VERSION
2028 );
2029
2030 stamp(&main, crate::format::FORMAT_VERSION + 1);
2031 let error = prepare_runtime_paths(&resources, &user, &cache, "newer").unwrap_err();
2032 assert_unsupported(&error, assets::MAIN_DICTIONARY);
2033 assert!(!user.join("dictionaries").join("newer").exists());
2034 assert!(stages(&user).is_empty());
2035 }
2036
2037 #[test]
2039 fn reopening_a_ready_generation_checks_the_main_dictionary_format_version() {
2040 let root = tempfile::tempdir().unwrap();
2041 let resources = resources(root.path());
2042 let user = root.path().join("user");
2043 let cache = root.path().join("cache");
2044 let prepared = prepare_runtime_paths(&resources, &user, &cache, "current").unwrap();
2045 let copy = prepared.dictionary(assets::MAIN_DICTIONARY);
2046 assert!(prepared
2047 .dictionaries
2048 .join(assets::GENERATION_READY)
2049 .is_file());
2050
2051 stamp(©, crate::format::FORMAT_VERSION);
2052 prepare_runtime_paths(&resources, &user, &cache, "current").unwrap();
2053
2054 stamp(©, crate::format::FORMAT_VERSION + 1);
2055 let error = prepare_runtime_paths(&resources, &user, &cache, "current").unwrap_err();
2056 assert_unsupported(&error, assets::MAIN_DICTIONARY);
2057 }
2058
2059 #[test]
2061 fn preparation_checks_the_wubi_dictionary_format_version() {
2062 let root = tempfile::tempdir().unwrap();
2063 let resources = resources(root.path());
2064 let user = root.path().join("user");
2065 let cache = root.path().join("cache");
2066 let wubi = resources.join(assets::WUBI_DICTIONARY);
2067 sql(
2068 &wubi,
2069 "CREATE TABLE wubi86(key TEXT NOT NULL,value TEXT NOT NULL,weight INTEGER NOT NULL DEFAULT 0,UNIQUE(key,value));
2070 INSERT INTO wubi86 VALUES('wq','你',10);",
2071 );
2072 stamp(&wubi, crate::format::FORMAT_VERSION + 1);
2073 let error = prepare_runtime_paths(&resources, &user, &cache, "newer").unwrap_err();
2074 assert_unsupported(&error, assets::WUBI_DICTIONARY);
2075 assert!(!user.join("dictionaries").join("newer").exists());
2076
2077 stamp(&wubi, crate::format::FORMAT_VERSION);
2078 let prepared = prepare_runtime_paths(&resources, &user, &cache, "current").unwrap();
2079 assert_eq!(
2080 weight(
2081 &prepared.dictionary(assets::MAIN_DICTIONARY),
2082 "SELECT weight FROM wubi86 WHERE value='你'"
2083 ),
2084 Some(10)
2085 );
2086 }
2087
2088 #[test]
2090 fn the_read_only_layout_checks_the_format_version() {
2091 let root = tempfile::tempdir().unwrap();
2092 let resources = resources(root.path());
2093 let paths = RuntimePaths {
2094 resources: resources.clone(),
2095 user_data: root.path().join("user"),
2096 cache: root.path().join("cache"),
2097 dictionaries: resources.clone(),
2098 };
2099 check_read_only_layout(&paths).unwrap();
2100 stamp(
2101 &resources.join(assets::MAIN_DICTIONARY),
2102 crate::format::FORMAT_VERSION + 1,
2103 );
2104 let error = check_read_only_layout(&paths).unwrap_err();
2105 assert_unsupported(&error, assets::MAIN_DICTIONARY);
2106 let error = crate::Session::new(crate::SessionOptions::new(paths.clone()))
2107 .err()
2108 .expect("a newer dictionary format refuses the session");
2109 assert_unsupported(&error, assets::MAIN_DICTIONARY);
2110 let generation = RuntimePaths {
2111 dictionaries: root.path().join("user/dictionaries/v1"),
2112 ..paths
2113 };
2114 check_read_only_layout(&generation).unwrap();
2115 }
2116}