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