diff --git a/ftl/core/errors.ftl b/ftl/core/errors.ftl index f4afca53a..8f122dfde 100644 --- a/ftl/core/errors.ftl +++ b/ftl/core/errors.ftl @@ -9,6 +9,7 @@ errors-100-tags-max = is no need to select child tags if you have selected a parent tag. errors-multiple-notetypes-selected = Please select notes from only one notetype. errors-please-check-database = Please use the Check Database action, then try again. +errors-please-check-media = Please use the Check Media action, then try again. errors-collection-too-new = This collection requires a newer version of Anki to open. ## Card Rendering diff --git a/rslib/src/backend/error.rs b/rslib/src/backend/error.rs index 116878c2f..165e1c7fb 100644 --- a/rslib/src/backend/error.rs +++ b/rslib/src/backend/error.rs @@ -36,6 +36,7 @@ impl AnkiError { AnkiError::CustomStudyError(_) => Kind::CustomStudyError, AnkiError::ImportError(_) => Kind::ImportError, AnkiError::FileIoError(_) => Kind::IoError, + AnkiError::MediaCheckRequired => Kind::InvalidInput, }; pb::BackendError { diff --git a/rslib/src/error/mod.rs b/rslib/src/error/mod.rs index 27a17cdb0..1f1464013 100644 --- a/rslib/src/error/mod.rs +++ b/rslib/src/error/mod.rs @@ -42,6 +42,7 @@ pub enum AnkiError { UndoEmpty, MultipleNotetypesSelected, DatabaseCheckRequired, + MediaCheckRequired, CustomStudyError(CustomStudyError), ImportError(ImportError), } @@ -97,6 +98,7 @@ impl AnkiError { AnkiError::InvalidRegex(err) => format!("
{}
", err), AnkiError::MultipleNotetypesSelected => tr.errors_multiple_notetypes_selected().into(), AnkiError::DatabaseCheckRequired => tr.errors_please_check_database().into(), + AnkiError::MediaCheckRequired => tr.errors_please_check_media().into(), AnkiError::CustomStudyError(err) => err.localized_description(tr), AnkiError::ImportError(err) => err.localized_description(tr), AnkiError::IoError(_) diff --git a/rslib/src/import_export/package/colpkg/export.rs b/rslib/src/import_export/package/colpkg/export.rs index 491c63df8..63ea482d6 100644 --- a/rslib/src/import_export/package/colpkg/export.rs +++ b/rslib/src/import_export/package/colpkg/export.rs @@ -18,7 +18,9 @@ use zstd::{ use super::super::{MediaEntries, MediaEntry, Meta, Version}; use crate::{ - collection::CollectionBuilder, media::files::sha1_of_data, prelude::*, text::normalize_to_nfc, + collection::CollectionBuilder, + media::files::{filename_if_normalized, sha1_of_data}, + prelude::*, }; /// Enable multithreaded compression if over this size. For smaller files, @@ -279,16 +281,18 @@ fn make_media_entry(data: &[u8], name: String) -> MediaEntry { } fn normalized_unicode_file_name(entry: &DirEntry) -> Result { - entry - .file_name() - .to_str() - .map(|name| normalize_to_nfc(name).into()) - .ok_or_else(|| { - AnkiError::IoError(format!( - "non-unicode file name: {}", - entry.file_name().to_string_lossy() - )) - }) + let filename = entry.file_name(); + let filename = filename.to_str().ok_or_else(|| { + AnkiError::IoError(format!( + "non-unicode file name: {}", + entry.file_name().to_string_lossy() + )) + })?; + if let Some(filename) = filename_if_normalized(filename) { + Ok(filename.into_owned()) + } else { + Err(AnkiError::MediaCheckRequired) + } } /// Writes media files while compressing according to the targeted version. diff --git a/rslib/src/import_export/package/colpkg/import.rs b/rslib/src/import_export/package/colpkg/import.rs index 99506f929..efd820e54 100644 --- a/rslib/src/import_export/package/colpkg/import.rs +++ b/rslib/src/import_export/package/colpkg/import.rs @@ -2,6 +2,7 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::{ + borrow::Cow, collections::HashMap, fs::{self, File}, io::{self, Read, Write}, @@ -21,8 +22,8 @@ use crate::{ package::{MediaEntries, MediaEntry, Meta}, ImportProgress, }, + media::files::normalize_filename, prelude::*, - text::normalize_to_nfc, }; impl Meta { @@ -119,8 +120,8 @@ fn restore_media( if let Ok(mut zip_file) = archive.by_name(&archive_file_name.to_string()) { check_filename_safe(&entry.name)?; - let nfc_name = normalize_to_nfc(&entry.name); - let file_path = media_folder.join(nfc_name.as_ref()); + let normalized = maybe_normalizing(&entry.name, meta.strict_media_checks())?; + let file_path = media_folder.join(normalized.as_ref()); let size_in_colpkg = if meta.media_list_is_hashmap() { zip_file.size() } else { @@ -151,6 +152,18 @@ fn restore_media( Ok(()) } +/// - If strict is true, return an error if not normalized. +/// - If false, return the normalized version. +fn maybe_normalizing(name: &str, strict: bool) -> Result> { + let normalized = normalize_filename(name); + if strict && matches!(normalized, Cow::Owned(_)) { + // exporting code should have checked this + Err(AnkiError::ImportError(ImportError::Corrupt)) + } else { + Ok(normalized) + } +} + /// Return an error if name contains any path separators. fn check_filename_safe(name: &str) -> Result<()> { let mut components = Path::new(name).components(); @@ -238,4 +251,10 @@ mod test { assert!(check_filename_safe("\\foo").is_err()); } } + + #[test] + fn normalization() { + assert_eq!(&maybe_normalizing("con", false).unwrap(), "con_"); + assert!(&maybe_normalizing("con", true).is_err()); + } } diff --git a/rslib/src/import_export/package/colpkg/tests.rs b/rslib/src/import_export/package/colpkg/tests.rs index 50cec595a..37da08c41 100644 --- a/rslib/src/import_export/package/colpkg/tests.rs +++ b/rslib/src/import_export/package/colpkg/tests.rs @@ -68,3 +68,24 @@ fn roundtrip() -> Result<()> { Ok(()) } + +/// Files with an invalid encoding should prevent export, except +/// on Apple platforms where the encoding is transparently changed. +#[test] +#[cfg(not(target_vendor = "apple"))] +fn normalization_check_on_export() -> Result<()> { + let _dir = tempdir()?; + let dir = _dir.path(); + + let col = collection_with_media(dir, "normalize")?; + let colpkg_name = dir.join("normalize.colpkg"); + // manually write a file in the wrong encoding. + std::fs::write(col.media_folder.join("ぱぱ.jpg"), "nfd encoding")?; + assert_eq!( + col.export_colpkg(&colpkg_name, true, false, |_| ()) + .unwrap_err(), + AnkiError::MediaCheckRequired + ); + + Ok(()) +} diff --git a/rslib/src/import_export/package/meta.rs b/rslib/src/import_export/package/meta.rs index df164918c..c2ac4e80c 100644 --- a/rslib/src/import_export/package/meta.rs +++ b/rslib/src/import_export/package/meta.rs @@ -39,6 +39,10 @@ impl Meta { self.is_legacy() } + pub(super) fn strict_media_checks(&self) -> bool { + !self.is_legacy() + } + fn is_legacy(&self) -> bool { matches!(self.version(), Version::Legacy1 | Version::Legacy2) } diff --git a/rslib/src/media/files.rs b/rslib/src/media/files.rs index 031b1bb39..1d3f51da3 100644 --- a/rslib/src/media/files.rs +++ b/rslib/src/media/files.rs @@ -133,7 +133,7 @@ pub(crate) fn normalize_nfc_filename(mut fname: Cow) -> Cow { /// but can be accessed as NFC. On these devices, if the filename /// is otherwise valid, the filename is returned as NFC. #[allow(clippy::collapsible_else_if)] -pub(super) fn filename_if_normalized(fname: &str) -> Option> { +pub(crate) fn filename_if_normalized(fname: &str) -> Option> { if cfg!(target_vendor = "apple") { if !is_nfc(fname) { let as_nfc = fname.chars().nfc().collect::();