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 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 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 restore_interrupted_reset(&result.user(assets::USER_JOURNAL));
88 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 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 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 fs::rename(stage.path(), &result.dictionaries)?;
139 Ok(())
140 })();
141 match staged {
142 Ok(()) => {
143 let _ = stage.keep();
145 Ok(result)
146 }
147 Err(error) => {
148 drop(stage);
149 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
158fn 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
170fn open_ready_generation(
172 result: RuntimePaths,
173 resources: &Path,
174 main_dictionary: bool,
175 english_required: bool,
176) -> Result<RuntimePaths> {
177 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 if main_dictionary {
199 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 replay_into(&result, &result.dictionaries, dictionaries)?;
208 index_reverse_lookup(&result.dictionaries, dictionaries);
210 stage_generation_copies(resources, &result.dictionaries, false)?;
211 Ok(result)
212}
213
214fn 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 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 if fs::rename(&path, &claimed).is_ok() {
242 let _ = fs::remove_dir_all(&claimed);
243 }
244 }
245}
246
247fn 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
262fn 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#[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
315fn 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
344fn 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
361fn 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 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
436pub(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 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
465pub(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
487pub(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 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 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
567pub(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 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
590pub(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
601pub(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 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 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
643fn 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
667fn 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
689pub(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
746pub(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 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 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 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]
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 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 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]
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 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 #[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 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 #[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(¤t, &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 #[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 #[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 #[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 #[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 #[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 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 #[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 backdate(&abandoned);
1429 prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1430 assert_eq!(fs::read(&partial).unwrap(), b"partial");
1431
1432 let copy = parent.join("v1").join("msime-trigram.bin.k3Xq9z.incoming");
1434 fs::write(©, 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(©);
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 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 #[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 prepare_runtime_paths(&resources, &user, &cache, "v1").unwrap();
1549 assert_eq!(reverse_indexes(&main), ["idx_wubi86_value"]);
1550 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]
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 #[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 #[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 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 #[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 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 #[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 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 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 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 #[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 #[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 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 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 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 #[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 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 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 #[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(©, 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 #[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(©, crate::format::FORMAT_VERSION);
2139 prepare_runtime_paths(&resources, &user, &cache, "current").unwrap();
2140
2141 stamp(©, 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 #[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 #[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}