1use std::ffi::OsString;
4use std::fs;
5use std::io::ErrorKind;
6use std::path::{Path, PathBuf};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use rusqlite::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::generation::{copy_private_file, merge_split_wubi, weakly_canonical};
17use crate::user_dictionary::journal::{
18 checkpoint_journal, close_cached_journals, ensure_schema, open_database,
19};
20
21const JOURNAL_SIDECARS: [&str; 3] = ["-wal", "-shm", "-journal"];
23
24struct Replacement {
26 target: PathBuf,
27 temporary: PathBuf,
28 backup: PathBuf,
29 had_original: bool,
30 published: bool,
31}
32
33pub fn reset_learned_data(paths: &RuntimePaths) -> Result<()> {
37 reset(paths, true, true, true)
38}
39
40pub fn reset_learned_data_for(paths: &RuntimePaths, schemes: SchemeSet) -> Result<()> {
42 let main_dictionary = schemes.reads_main_dictionary();
43 reset(
44 paths,
45 main_dictionary,
46 main_dictionary || schemes.requires_english_dictionary(),
47 true,
48 )
49}
50
51pub fn reset_learned_data_with(paths: &RuntimePaths, main_dictionary: bool) -> Result<()> {
53 reset(paths, main_dictionary, main_dictionary, false)
54}
55
56fn reset(
58 paths: &RuntimePaths,
59 main_dictionary: bool,
60 english_required: bool,
61 take_lease: bool,
62) -> Result<()> {
63 paths.validate()?;
64 if weakly_canonical(&paths.resources)? == weakly_canonical(&paths.dictionaries)? {
65 return Err(EngineError::invalid(diagnostics::RESET_IN_PLACE));
66 }
67 let english = english_required
69 || fs::symlink_metadata(paths.resource(assets::ENGLISH_DICTIONARY)).is_ok();
70 let names: Vec<&str> = [
71 (main_dictionary, assets::MAIN_DICTIONARY),
72 (english, assets::ENGLISH_DICTIONARY),
73 ]
74 .into_iter()
75 .filter_map(|(present, name)| present.then_some(name))
76 .collect();
77 let sources: Vec<PathBuf> = names.iter().map(|name| paths.resource(name)).collect();
78 if !sources.iter().all(|source| is_real_file(source)) {
79 return Err(EngineError::failed(
80 diagnostics::PACKAGED_DICTIONARY_UNAVAILABLE,
81 ));
82 }
83 reject_redirected_directory(&paths.user_data)?;
84 reject_redirected_directory(&paths.dictionaries)?;
85 fs::create_dir_all(&paths.user_data)?;
86 fs::create_dir_all(&paths.dictionaries)?;
87 reject_redirected_directory(&paths.user_data)?;
88 reject_redirected_directory(&paths.dictionaries)?;
89 let _access = if take_lease {
90 Some(crate::access::maintenance(paths)?)
91 } else {
92 None
93 };
94 close_cached_journals();
96
97 let stamp = SystemTime::now()
99 .duration_since(UNIX_EPOCH)
100 .map_or(0, |elapsed| elapsed.as_nanos())
101 .to_string();
102 let journal = paths.user(assets::USER_JOURNAL);
103 checkpoint_journal(&journal);
105 let mut replacements: Vec<Replacement> = names
107 .iter()
108 .map(|name| paths.dictionary(name))
109 .chain([journal.clone()])
110 .map(|target| Replacement {
111 temporary: affixed(&target, ".reset.", &stamp),
112 backup: affixed(&target, ".backup.", &stamp),
113 target,
114 had_original: false,
115 published: false,
116 })
117 .collect();
118 let journal_temporary = replacements[names.len()].temporary.clone();
119
120 let prepared = (|| -> Result<()> {
122 let connection = open_database(
123 &journal_temporary,
124 OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
125 )?;
126 ensure_schema(&connection)
127 })();
128 if prepared.is_err() {
129 remove_ignoring_errors(&journal_temporary);
130 return Err(EngineError::failed(diagnostics::RESET_JOURNAL_FAILED));
131 }
132
133 let swapped = swap(
134 &mut replacements,
135 &paths.resources,
136 main_dictionary,
137 &sources,
138 &journal,
139 );
140 match swapped {
141 Ok(()) => {
142 for replacement in &replacements {
144 remove_ignoring_errors(&replacement.backup);
145 }
146 Ok(())
147 }
148 Err(error) => {
149 restore(&replacements);
150 Err(error)
151 }
152 }
153}
154
155fn swap(
156 replacements: &mut [Replacement],
157 resources: &Path,
158 main_dictionary: bool,
159 sources: &[PathBuf],
160 journal: &Path,
161) -> Result<()> {
162 for (source, replacement) in sources.iter().zip(replacements.iter()) {
164 copy_private_file(source, &replacement.temporary)
165 .map_err(|_| EngineError::failed(diagnostics::RESET_STAGE_DICTIONARIES_FAILED))?;
166 }
167 if main_dictionary {
169 merge_split_wubi(resources, &replacements[0].temporary)
170 .map_err(|_| EngineError::failed(diagnostics::RESET_STAGE_DICTIONARIES_FAILED))?;
171 }
172 for replacement in replacements.iter_mut() {
173 replacement.had_original = replacement.target.exists();
174 if replacement.had_original {
175 if replacement.backup.exists() {
176 remove_ignoring_errors(&replacement.backup);
177 }
178 fs::rename(&replacement.target, &replacement.backup)
180 .map_err(|_| EngineError::failed(diagnostics::RESET_STAGE_FAILED))?;
181 }
182 fs::rename(&replacement.temporary, &replacement.target)
183 .map_err(|_| EngineError::failed(diagnostics::RESET_PUBLISH_FAILED))?;
184 replacement.published = true;
185 }
186 for suffix in JOURNAL_SIDECARS {
187 let mut sidecar = journal.as_os_str().to_owned();
188 sidecar.push(suffix);
189 match fs::remove_file(PathBuf::from(sidecar)) {
190 Ok(()) => {}
191 Err(error) if error.kind() == ErrorKind::NotFound => {}
192 Err(error) => return Err(error.into()),
193 }
194 }
195 Ok(())
196}
197
198fn restore(replacements: &[Replacement]) {
200 for replacement in replacements.iter().rev() {
201 if replacement.published {
202 remove_ignoring_errors(&replacement.target);
203 }
204 if replacement.had_original && replacement.backup.exists() {
205 let _ = fs::rename(&replacement.backup, &replacement.target);
206 }
207 remove_ignoring_errors(&replacement.temporary);
208 }
209}
210
211pub(crate) fn restore_interrupted_reset(target: &Path) {
215 match fs::symlink_metadata(target) {
216 Err(error) if error.kind() == ErrorKind::NotFound => {}
217 _ => return,
218 }
219 let backup = newest_reset_backup(target).or_else(|| {
221 let name = target.file_name()?.to_str()?;
222 let (_, legacy) = assets::LEGACY_NAMES
223 .iter()
224 .find(|(current, _)| *current == name)?;
225 let legacy = target.with_file_name(legacy);
226 match fs::symlink_metadata(&legacy) {
227 Err(error) if error.kind() == ErrorKind::NotFound => newest_reset_backup(&legacy),
228 _ => None,
229 }
230 });
231 if let Some(backup) = backup {
232 backup.publish(target);
233 }
234}
235
236struct ResetBackup {
237 path: PathBuf,
238 unpublished: PathBuf,
239}
240
241fn newest_reset_backup(target: &Path) -> Option<ResetBackup> {
243 let directory = target.parent()?;
244 let name = target.file_name()?.to_str()?;
245 let prefix = format!(".{name}.backup.");
246 fs::read_dir(directory)
247 .ok()?
248 .flatten()
249 .filter_map(|entry| {
250 let stamp = entry
251 .file_name()
252 .to_str()?
253 .strip_prefix(&prefix)?
254 .to_owned();
255 let order = stamp
256 .parse::<u128>()
257 .ok()
258 .filter(|_| stamp.bytes().all(|byte| byte.is_ascii_digit()))?;
259 is_real_file(&entry.path()).then_some((order, stamp, entry.path()))
260 })
261 .max_by_key(|(order, _, _)| *order)
262 .map(|(_, stamp, path)| ResetBackup {
263 path,
264 unpublished: affixed(target, ".reset.", &stamp),
265 })
266}
267
268impl ResetBackup {
269 fn publish(self, target: &Path) {
270 let Ok(mut backup) = tempfile::TempPath::try_from_path(self.path) else {
271 return;
272 };
273 backup.disable_cleanup(true);
275 if backup.persist_noclobber(target).is_ok() {
276 remove_ignoring_errors(&self.unpublished);
277 }
278 }
279}
280
281fn affixed(target: &Path, infix: &str, stamp: &str) -> PathBuf {
283 let mut name = OsString::from(".");
284 name.push(target.file_name().unwrap_or_default());
285 name.push(infix);
286 name.push(stamp);
287 target.with_file_name(name)
288}
289
290fn remove_ignoring_errors(path: &Path) {
291 if path.is_dir() {
292 let _ = fs::remove_dir_all(path);
293 } else {
294 let _ = fs::remove_file(path);
295 }
296}
297
298fn is_real_file(path: &Path) -> bool {
299 fs::symlink_metadata(path)
300 .map(|metadata| metadata.file_type().is_file())
301 .unwrap_or(false)
302}
303
304fn reject_redirected_directory(path: &Path) -> std::io::Result<()> {
305 for ancestor in path.ancestors() {
306 match fs::symlink_metadata(ancestor) {
307 Ok(metadata) if metadata.file_type().is_symlink() => {
308 if crate::paths::is_trusted_system_alias(ancestor) {
309 continue;
310 }
311 return Err(std::io::Error::new(
312 std::io::ErrorKind::InvalidInput,
313 "reset directory has a symbolic-link ancestor",
314 ));
315 }
316 Ok(metadata) if !metadata.is_dir() => {
317 return Err(std::io::Error::new(
318 std::io::ErrorKind::NotADirectory,
319 "reset directory parent is not a directory",
320 ));
321 }
322 Ok(_) => {}
323 Err(error) if error.kind() == ErrorKind::NotFound => {}
324 Err(error) => return Err(error),
325 }
326 }
327 Ok(())
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use crate::user_dictionary::replay::tests::{sql, weight};
334
335 #[test]
337 fn a_late_reset_recovery_preserves_the_winners_file_and_its_own_backup() {
338 let root = tempfile::tempdir().unwrap();
339 let target = root.path().join(assets::USER_JOURNAL);
340 let older = affixed(&target, ".backup.", "100");
341 let newest = affixed(&target, ".backup.", "200");
342 let older_stage = affixed(&target, ".reset.", "100");
343 fs::write(&older, b"older synthetic journal").unwrap();
344 fs::write(&newest, b"newest synthetic journal").unwrap();
345 fs::write(&older_stage, b"unpublished synthetic journal").unwrap();
346 assert_eq!(
347 fs::symlink_metadata(&target).unwrap_err().kind(),
348 ErrorKind::NotFound
349 );
350
351 newest_reset_backup(&target).unwrap().publish(&target);
352 newest_reset_backup(&target).unwrap().publish(&target);
354
355 assert_eq!(fs::read(&target).unwrap(), b"newest synthetic journal");
356 assert_eq!(fs::read(&older).unwrap(), b"older synthetic journal");
357 assert_eq!(
358 fs::read(&older_stage).unwrap(),
359 b"unpublished synthetic journal"
360 );
361 }
362
363 #[test]
365 fn reset_recovery_follows_current_and_legacy_name_priority() {
366 for (name, legacy) in [
367 (assets::MAIN_DICTIONARY, "msime.db"),
368 (assets::ENGLISH_DICTIONARY, "english.db"),
369 ] {
370 let root = tempfile::tempdir().unwrap();
371 let paths = RuntimePaths {
372 dictionaries: root.path().to_owned(),
373 ..RuntimePaths::default()
374 };
375 let current = root.path().join(name);
376 let old = root.path().join(legacy);
377 let current_backup = affixed(¤t, ".backup.", "100");
378 let old_backup = affixed(&old, ".backup.", "200");
379 fs::write(¤t, b"existing synthetic dictionary").unwrap();
380 fs::write(&old_backup, b"legacy synthetic backup").unwrap();
381 restore_interrupted_reset(&paths.dictionary(name));
382 assert_eq!(
383 fs::read(¤t).unwrap(),
384 b"existing synthetic dictionary"
385 );
386 assert!(old_backup.exists());
387
388 fs::rename(¤t, &old).unwrap();
389 fs::write(¤t_backup, b"current synthetic backup").unwrap();
390 restore_interrupted_reset(&paths.dictionary(name));
391 assert_eq!(fs::read(&old).unwrap(), b"existing synthetic dictionary");
392 assert!(current_backup.exists());
393 assert!(!current.exists());
394
395 fs::remove_file(&old).unwrap();
396 restore_interrupted_reset(&paths.dictionary(name));
397 assert_eq!(fs::read(¤t).unwrap(), b"current synthetic backup");
398 assert!(!current_backup.exists());
399 assert_eq!(fs::read(&old_backup).unwrap(), b"legacy synthetic backup");
400 }
401 }
402
403 fn paths(root: &Path) -> RuntimePaths {
404 let path = |name: &str| {
405 let path = root.join(name);
406 fs::create_dir_all(&path).unwrap();
407 path
408 };
409 RuntimePaths {
410 resources: path("resources"),
411 user_data: path("user"),
412 cache: path("cache"),
413 dictionaries: path("dictionaries"),
414 }
415 }
416
417 #[test]
419 fn reset_takes_the_maintenance_lease() {
420 let temporary = tempfile::tempdir().unwrap();
421 let paths = paths(temporary.path());
422 sql(
423 &paths.resource(assets::MAIN_DICTIONARY),
424 "CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);",
425 );
426 sql(
427 &paths.resource(assets::ENGLISH_DICTIONARY),
428 "CREATE TABLE english_words(word TEXT,display TEXT,weight INTEGER);",
429 );
430 fs::create_dir_all(&paths.user_data).unwrap();
431 fs::create_dir_all(&paths.dictionaries).unwrap();
432 let journal = paths.user(assets::USER_JOURNAL);
433 sql(&journal, "CREATE TABLE marker(value INTEGER);");
434 let session = crate::DictionaryAccess::try_session(&paths.user_data, &paths.dictionaries)
435 .unwrap()
436 .unwrap();
437
438 let error = reset_learned_data(&paths).unwrap_err();
439 assert_eq!(error.code(), crate::ErrorCode::Busy);
440 let marker = "SELECT count(*) FROM sqlite_master WHERE name='marker'";
441 assert_eq!(weight(&journal, marker), Some(1));
442 assert!(!paths.dictionary(assets::MAIN_DICTIONARY).exists());
443
444 drop(session);
445 reset_learned_data(&paths).unwrap();
446 assert!(paths.dictionary(assets::MAIN_DICTIONARY).exists());
447 assert_eq!(weight(&journal, marker), Some(0));
448 }
449
450 #[test]
452 fn reset_merges_the_split_wubi_tables_into_the_fresh_working_copy() {
453 let temporary = tempfile::tempdir().unwrap();
454 let paths = paths(temporary.path());
455 sql(
456 &paths.resource(assets::MAIN_DICTIONARY),
457 "CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);",
458 );
459 sql(
460 &paths.resource(assets::WUBI_DICTIONARY),
461 "CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);
462 INSERT INTO wubi86 VALUES('aaaa','工',200);
463 CREATE TABLE wubi98(key TEXT,value TEXT,weight INTEGER);",
464 );
465 sql(
466 &paths.resource(assets::ENGLISH_DICTIONARY),
467 "CREATE TABLE english_words(word TEXT,display TEXT,weight INTEGER);",
468 );
469
470 reset_learned_data(&paths).unwrap();
471
472 assert_eq!(
473 weight(
474 &paths.dictionary(assets::MAIN_DICTIONARY),
475 "SELECT weight FROM wubi86 WHERE key='aaaa'"
476 ),
477 Some(200)
478 );
479 assert_eq!(
480 weight(
481 &paths.dictionary(assets::MAIN_DICTIONARY),
482 "SELECT count(*) FROM wubi98"
483 ),
484 Some(0)
485 );
486 assert!(!paths.dictionary(assets::WUBI_DICTIONARY).exists());
487 }
488
489 #[test]
491 fn reset_restores_packaged_dictionaries_and_clears_the_journal() {
492 for component in ["ascii", "陆傲天"] {
493 let temporary = tempfile::tempdir().unwrap();
494 let paths = paths(&temporary.path().join(component));
495 sql(
496 &paths.resource(assets::MAIN_DICTIONARY),
497 "CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
498 INSERT INTO tbl_2_n VALUES('ni''hao','nh','你好',100);
499 CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);
500 CREATE TABLE quick_parases(key TEXT,value TEXT,weight INTEGER);",
501 );
502 sql(
503 &paths.resource(assets::ENGLISH_DICTIONARY),
504 "CREATE TABLE english_words(word TEXT,display TEXT,weight INTEGER);
505 CREATE TABLE en_zh_glosses(english TEXT PRIMARY KEY,chinese_gloss TEXT);
506 CREATE TABLE zh_en_glosses(chinese TEXT PRIMARY KEY,english TEXT);
507 INSERT INTO english_words VALUES('word','word',100);",
508 );
509 for name in [assets::MAIN_DICTIONARY, assets::ENGLISH_DICTIONARY] {
510 fs::copy(paths.resource(name), paths.dictionary(name)).unwrap();
511 }
512 let journal = paths.user(assets::USER_JOURNAL);
513 sql(
515 &journal,
516 "CREATE TABLE user_dictionary_operations(dictionary TEXT,key TEXT,value TEXT,operation TEXT,weight INTEGER,display TEXT,user_inserted INTEGER);
517 CREATE TABLE personal_dictionary_receipts(request_id TEXT PRIMARY KEY,payload TEXT);
518 CREATE TABLE candidate_selection_state(context_key TEXT,entry_key TEXT,value TEXT,selection_count INTEGER);
519 CREATE TABLE fixed_candidate_positions(context_key TEXT,entry_key TEXT,value TEXT,position INTEGER);
520 INSERT INTO user_dictionary_operations VALUES('pinyin','ni''hao','你好','upsert',1,'',1);
521 INSERT INTO candidate_selection_state VALUES('ni''hao','ni''hao','你好',7);",
522 );
523 sql(
524 &paths.dictionary(assets::MAIN_DICTIONARY),
525 "UPDATE tbl_2_n SET weight=1",
526 );
527 let mut wal = journal.as_os_str().to_owned();
528 wal.push("-wal");
529 fs::write(PathBuf::from(&wal), b"stale learned data").unwrap();
530
531 reset_learned_data(&paths).unwrap();
532
533 assert_eq!(
534 weight(
535 &paths.dictionary(assets::MAIN_DICTIONARY),
536 "SELECT weight FROM tbl_2_n WHERE key='ni''hao'"
537 ),
538 Some(100)
539 );
540 assert_eq!(
541 weight(&journal, "SELECT count(*) FROM user_dictionary_operations"),
542 Some(0)
543 );
544 assert_eq!(
545 weight(&journal, "SELECT count(*) FROM candidate_selection_state"),
546 Some(0)
547 );
548 assert_eq!(
550 weight(&journal, "SELECT count(*) FROM pinned_candidates"),
551 Some(0)
552 );
553 assert!(!PathBuf::from(&wal).exists());
554 let leftovers: Vec<_> = [&paths.user_data, &paths.dictionaries]
555 .iter()
556 .flat_map(|directory| fs::read_dir(directory).unwrap())
557 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
558 .filter(|name| name.starts_with('.') && name != crate::access::LOCK_FILE)
560 .collect();
561 assert!(leftovers.is_empty(), "{leftovers:?}");
562 }
563 }
564
565 #[test]
567 fn reset_clears_shuangpin_typo_learning() {
568 use crate::types::ShuangpinProfileKind;
569 use crate::user_dictionary::typo_profile::{PersonalTypoProfile, ShuangpinTypoOutcome};
570 let temporary = tempfile::tempdir().unwrap();
571 let paths = paths(temporary.path());
572 sql(
573 &paths.resource(assets::MAIN_DICTIONARY),
574 "CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);",
575 );
576 sql(
577 &paths.resource(assets::ENGLISH_DICTIONARY),
578 "CREATE TABLE english_words(word TEXT,display TEXT,weight INTEGER);",
579 );
580 let journal = paths.user(assets::USER_JOURNAL);
581 let profile = PersonalTypoProfile::shared(&journal);
582 profile
583 .record_shuangpin(
584 ShuangpinProfileKind::Xiaohe,
585 &[(*b"gf", *b"gr")],
586 ShuangpinTypoOutcome::Accepted,
587 )
588 .unwrap();
589 profile
590 .record_shuangpin_suppression(ShuangpinProfileKind::Xiaohe, "mwgfxi")
591 .unwrap();
592 assert!(profile.has_shuangpin_counts());
593
594 reset_learned_data(&paths).unwrap();
595
596 for table in [
597 "shuangpin_typo_counts",
598 "shuangpin_autocorrect_suppressions",
599 ] {
600 assert_eq!(
601 weight(&journal, &format!("SELECT count(*) FROM {table}")),
602 Some(0),
603 "{table}"
604 );
605 }
606 profile.refresh_if_changed();
607 assert!(!profile.has_shuangpin_counts());
608 assert!(!profile.shuangpin_suppressed(ShuangpinProfileKind::Xiaohe, "mwgfxi"));
609 }
610
611 #[test]
612 fn reset_refuses_the_bundle_in_place_and_a_missing_package() {
613 let root = tempfile::tempdir().unwrap();
614 let mut paths = paths(root.path());
615 let error = reset_learned_data(&paths).unwrap_err();
616 assert_eq!(
617 error.to_string(),
618 diagnostics::PACKAGED_DICTIONARY_UNAVAILABLE
619 );
620 paths.dictionaries = paths.resources.join(".");
621 let error = reset_learned_data(&paths).unwrap_err();
622 assert!(matches!(error, EngineError::InvalidArgument(_)));
623 assert_eq!(error.to_string(), diagnostics::RESET_IN_PLACE);
624 }
625
626 #[test]
628 fn reset_without_the_main_dictionary_restores_only_english() {
629 let root = tempfile::tempdir().unwrap();
630 let paths = paths(root.path());
631 sql(
632 &paths.resource(assets::ENGLISH_DICTIONARY),
633 "CREATE TABLE english_words(word TEXT,display TEXT,weight INTEGER);
634 INSERT INTO english_words VALUES('word','word',100);",
635 );
636 fs::copy(
637 paths.resource(assets::ENGLISH_DICTIONARY),
638 paths.dictionary(assets::ENGLISH_DICTIONARY),
639 )
640 .unwrap();
641 sql(
642 &paths.dictionary(assets::ENGLISH_DICTIONARY),
643 "UPDATE english_words SET weight=1",
644 );
645 let journal = paths.user(assets::USER_JOURNAL);
646 sql(
647 &journal,
648 "CREATE TABLE user_dictionary_operations(dictionary TEXT,key TEXT,value TEXT,operation TEXT,weight INTEGER,display TEXT,user_inserted INTEGER);
649 INSERT INTO user_dictionary_operations VALUES('english','word','word','upsert',1,'word',0);",
650 );
651
652 assert_eq!(
653 reset_learned_data(&paths).unwrap_err().to_string(),
654 diagnostics::PACKAGED_DICTIONARY_UNAVAILABLE
655 );
656 reset_learned_data_with(&paths, false).unwrap();
657
658 assert_eq!(
659 weight(
660 &paths.dictionary(assets::ENGLISH_DICTIONARY),
661 "SELECT weight FROM english_words WHERE word='word'"
662 ),
663 Some(100)
664 );
665 assert_eq!(
666 weight(&journal, "SELECT count(*) FROM user_dictionary_operations"),
667 Some(0)
668 );
669 assert!(!paths.dictionary(assets::MAIN_DICTIONARY).exists());
670 assert!(!paths.resource(assets::MAIN_DICTIONARY).exists());
671 let leftovers: Vec<_> = [&paths.user_data, &paths.dictionaries]
672 .iter()
673 .flat_map(|directory| fs::read_dir(directory).unwrap())
674 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
675 .filter(|name| name.starts_with('.') && name != crate::access::LOCK_FILE)
677 .collect();
678 assert!(leftovers.is_empty(), "{leftovers:?}");
679 }
680
681 #[cfg(unix)]
682 #[test]
683 fn reset_rejects_a_symlinked_packaged_dictionary() {
684 use std::os::unix::fs::symlink;
685
686 let root = tempfile::tempdir().unwrap();
687 let paths = paths(root.path());
688 sql(
689 &paths.resource(assets::ENGLISH_DICTIONARY),
690 "CREATE TABLE english_words(word TEXT,display TEXT,weight INTEGER);",
691 );
692 let external = root.path().join("external-msime-pinyin.db");
693 sql(
694 &external,
695 "CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);",
696 );
697 symlink(&external, paths.resource(assets::MAIN_DICTIONARY)).unwrap();
698
699 assert_eq!(
700 reset_learned_data(&paths).unwrap_err().to_string(),
701 diagnostics::PACKAGED_DICTIONARY_UNAVAILABLE
702 );
703 assert!(!paths.dictionary(assets::MAIN_DICTIONARY).exists());
704 }
705
706 #[cfg(unix)]
707 #[test]
708 fn reset_rejects_a_symlinked_dictionary_directory() {
709 use std::os::unix::fs::symlink;
710
711 let root = tempfile::tempdir().unwrap();
712 let outside = tempfile::tempdir().unwrap();
713 let paths = paths(root.path());
714 for name in [assets::MAIN_DICTIONARY, assets::ENGLISH_DICTIONARY] {
715 sql(&paths.resource(name), "CREATE TABLE packaged(x);");
716 }
717 fs::remove_dir(&paths.dictionaries).unwrap();
718 symlink(outside.path(), &paths.dictionaries).unwrap();
719
720 assert!(reset_learned_data(&paths).is_err());
721 assert!(fs::read_dir(outside.path()).unwrap().next().is_none());
722 }
723
724 #[test]
725 fn a_failed_publish_puts_every_original_back() {
726 let root = tempfile::tempdir().unwrap();
727 let paths = paths(root.path());
728 sql(
729 &paths.resource(assets::MAIN_DICTIONARY),
730 "CREATE TABLE fresh(x);",
731 );
732 sql(
733 &paths.resource(assets::ENGLISH_DICTIONARY),
734 "CREATE TABLE fresh(x);",
735 );
736 sql(
737 &paths.dictionary(assets::MAIN_DICTIONARY),
738 "CREATE TABLE learned(x);",
739 );
740 sql(
741 &paths.dictionary(assets::ENGLISH_DICTIONARY),
742 "CREATE TABLE learned(x);",
743 );
744 let journal = paths.user(assets::USER_JOURNAL);
745 sql(&journal, "CREATE TABLE learned(x);");
746 let mut wal = journal.as_os_str().to_owned();
748 wal.push("-wal");
749 let wal = PathBuf::from(wal);
750 fs::create_dir_all(wal.join("blocker")).unwrap();
751 let before: Vec<Vec<u8>> = [
752 paths.dictionary(assets::MAIN_DICTIONARY),
753 paths.dictionary(assets::ENGLISH_DICTIONARY),
754 journal.clone(),
755 ]
756 .iter()
757 .map(|path| fs::read(path).unwrap())
758 .collect();
759
760 assert!(reset_learned_data(&paths).is_err());
761
762 let after: Vec<Vec<u8>> = [
763 paths.dictionary(assets::MAIN_DICTIONARY),
764 paths.dictionary(assets::ENGLISH_DICTIONARY),
765 journal.clone(),
766 ]
767 .iter()
768 .map(|path| fs::read(path).unwrap())
769 .collect();
770 assert_eq!(after, before);
771 assert!(wal.join("blocker").is_dir());
772 let leftovers: Vec<_> = [&paths.user_data, &paths.dictionaries]
773 .iter()
774 .flat_map(|directory| fs::read_dir(directory).unwrap())
775 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
776 .filter(|name| name.starts_with('.') && name != crate::access::LOCK_FILE)
778 .collect();
779 assert!(leftovers.is_empty(), "{leftovers:?}");
780 }
781}