diff --git a/book/src/packages.md b/book/src/packages.md index 167fb0a1..5db0c120 100644 --- a/book/src/packages.md +++ b/book/src/packages.md @@ -131,7 +131,8 @@ This ensures that file paths assembled from path components work on Windows and Unix and cannot escape the package root. Symbolic links are not supported. The filenames `manifest.filepack` and `.filepack`, with any case, are reserved -in the package root. +in the package root. Case variations of `metadata.filemeta`, other than +lowercase, are reserved in the package root. Signatures ---------- diff --git a/src/component.rs b/src/component.rs index 9e9ba465..109402d3 100644 --- a/src/component.rs +++ b/src/component.rs @@ -27,9 +27,21 @@ impl Component { } pub(crate) fn is_reserved(&self) -> bool { - Self::RESERVED - .iter() - .any(|name| self.0.eq_ignore_ascii_case(name)) + let component = self.as_str(); + + for reserved in Self::RESERVED { + if component.eq_ignore_ascii_case(reserved) { + return true; + } + } + + if component.eq_ignore_ascii_case(Metadata::DECO_FILENAME) + && component != Metadata::DECO_FILENAME + { + return true; + } + + false } pub(crate) fn new(s: &str) -> Result<&Component, ComponentError> { @@ -148,6 +160,8 @@ mod tests { case("manifest.filepack", true); case(".filepack", true); case("MANIFEST.FILEPACK", true); + case("METADATA.FILEMETA", true); + case("metadata.filemeta", false); case("foo", false); } diff --git a/src/error.rs b/src/error.rs index da6faa0a..0d25f7a8 100644 --- a/src/error.rs +++ b/src/error.rs @@ -209,6 +209,12 @@ pub enum Error { backtrace: Option, hash: Hash, }, + #[snafu(display("expected `{expected}` but found case mismatch `{actual}`"))] + FilenameCase { + actual: String, + backtrace: Option, + expected: &'static str, + }, #[snafu(transparent)] Filesystem { backtrace: Option, diff --git a/src/functions.rs b/src/functions.rs index 6cd4af7d..6bca9101 100644 --- a/src/functions.rs +++ b/src/functions.rs @@ -3,6 +3,26 @@ use { ::image::{DynamicImage, ImageBuffer, Rgb, Rgba}, }; +pub(crate) fn check_case(dir: &Utf8Path, names: &[&'static str]) -> Result { + for entry in fs::read_dir(dir).context(filesystem_error::Io { path: dir })? { + let actual = entry + .context(filesystem_error::Io { path: dir })? + .file_name(); + + for &expected in names { + ensure! { + !actual.as_encoded_bytes().eq_ignore_ascii_case(expected.as_bytes()) || actual == expected, + error::FilenameCase { + actual: actual.to_str().unwrap(), + expected, + } + } + } + } + + Ok(()) +} + pub(crate) fn current_dir() -> Result { Utf8PathBuf::from_path_buf(env::current_dir().context(error::CurrentDir)?) .map_err(|path| error::PathUnicode { path }.build()) diff --git a/src/lib.rs b/src/lib.rs index e5e898b7..650fb54d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -81,7 +81,8 @@ use { float_ext::FloatExt, format::Format, functions::{ - current_dir, decode_path, default, format_size, ignore, now, parse_number, transfer_tempfile, + check_case, current_dir, decode_path, default, format_size, ignore, now, parse_number, + transfer_tempfile, }, hashing_writer::HashingWriter, head::Head, diff --git a/src/loader.rs b/src/loader.rs index 4dceabdd..f7b3b799 100644 --- a/src/loader.rs +++ b/src/loader.rs @@ -25,11 +25,12 @@ impl Loader { pub fn load_with_options(options: DecodeOptions, path: Option<&Utf8Path>) -> Result { let path = if let Some(path) = path { if path.is_dir() { - path.join(Manifest::FILENAME) + path.join_case_sensitive(Manifest::FILENAME)? } else { path.into() } } else { + Utf8Path::new(".").join_case_sensitive(Manifest::FILENAME)?; Manifest::FILENAME.into() }; @@ -58,7 +59,13 @@ impl Loader { } pub(crate) fn root(&self) -> &Utf8Path { - self.path.parent().unwrap() + let parent = self.path.parent().unwrap(); + + if parent.is_empty() { + ".".into() + } else { + parent + } } pub(crate) fn unpack(&self) -> Result { diff --git a/src/state.rs b/src/state.rs index e429c55e..75641694 100644 --- a/src/state.rs +++ b/src/state.rs @@ -11,7 +11,7 @@ impl State { const FILENAME: &'static str = "state.json"; pub fn load(root: &Utf8Path) -> Result { - let path = Self::path(root); + let path = root.join_case_sensitive(Self::DIR)?.join(Self::FILENAME); let Some(json) = filesystem::read_to_string_opt(&path)? else { return Ok(Self::default()); @@ -20,16 +20,12 @@ impl State { serde_json::from_str(&json).context(error::DeserializeState { path }) } - fn path(root: &Utf8Path) -> Utf8PathBuf { - root.join(Self::DIR).join(Self::FILENAME) - } - pub(crate) fn save(&self, root: &Utf8Path) -> Result { - let dir = root.join(Self::DIR); + let dir = root.join_case_sensitive(Self::DIR)?; filesystem::create_dir_all(&dir)?; - let path = Self::path(root); + let path = dir.join(Self::FILENAME); let mut json = serde_json::to_string_pretty(self).unwrap(); diff --git a/src/subcommand/create.rs b/src/subcommand/create.rs index 7912fcaa..0f0b7500 100644 --- a/src/subcommand/create.rs +++ b/src/subcommand/create.rs @@ -59,6 +59,16 @@ impl Create { error::PackageRootDirectory { path: root }, } + check_case( + &root, + &[ + Manifest::FILENAME, + Metadata::DECO_FILENAME, + Metadata::YAML_FILENAME, + State::DIR, + ], + )?; + let manifest_path = if let Some(path) = self.manifest { path } else { diff --git a/src/subcommand/metadata.rs b/src/subcommand/metadata.rs index 22b13d45..1bf59d36 100644 --- a/src/subcommand/metadata.rs +++ b/src/subcommand/metadata.rs @@ -21,11 +21,12 @@ impl Metadata { pub(crate) fn run(self) -> Result { let path = if let Some(path) = self.path { if path.is_dir() { - path.join(crate::Metadata::DECO_FILENAME) + path.join_case_sensitive(crate::Metadata::DECO_FILENAME)? } else { path } } else { + Utf8Path::new(".").join_case_sensitive(crate::Metadata::DECO_FILENAME)?; crate::Metadata::DECO_FILENAME.into() }; diff --git a/src/subcommand/verify.rs b/src/subcommand/verify.rs index 12a75913..3347dedf 100644 --- a/src/subcommand/verify.rs +++ b/src/subcommand/verify.rs @@ -67,6 +67,11 @@ impl Verify { error::PackageRootDirectory { path: root }, } + check_case( + &root, + &[Manifest::FILENAME, Metadata::DECO_FILENAME, State::DIR], + )?; + if self.manifest.is_some() { ensure! { !filesystem::exists(&root.join(Manifest::FILENAME))?, diff --git a/src/utf8_path_ext.rs b/src/utf8_path_ext.rs index b7a35e2f..cc0fce2c 100644 --- a/src/utf8_path_ext.rs +++ b/src/utf8_path_ext.rs @@ -1,10 +1,23 @@ use super::*; pub(crate) trait Utf8PathExt { + fn is_empty(&self) -> bool; + + fn join_case_sensitive(&self, name: &'static str) -> Result; + fn lexiclean(&self) -> Utf8PathBuf; } impl Utf8PathExt for Utf8Path { + fn is_empty(&self) -> bool { + self.as_str().is_empty() + } + + fn join_case_sensitive(&self, name: &'static str) -> Result { + check_case(self, &[name])?; + Ok(self.join(name)) + } + fn lexiclean(&self) -> Utf8PathBuf { Utf8PathBuf::from_path_buf(self.as_std_path().lexiclean()).unwrap() } diff --git a/tests/create.rs b/tests/create.rs index b234ab9b..772e7fdf 100644 --- a/tests/create.rs +++ b/tests/create.rs @@ -19,6 +19,24 @@ fn backslash_error() { .failure(); } +#[test] +fn case_variant_error() { + for (actual, expected) in [ + ("MANIFEST.FILEPACK", "manifest.filepack"), + ("METADATA.FILEMETA", "metadata.filemeta"), + ("METADATA.YAML", "metadata.yaml"), + (".FILEPACK", ".filepack"), + ] { + Test::new() + .touch(actual) + .arg("create") + .stderr(&format!( + "error: expected `{expected}` but found case mismatch `{actual}`\n" + )) + .failure(); + } +} + #[test] fn create_rejects_invalid_artwork() { Test::new() diff --git a/tests/fingerprint.rs b/tests/fingerprint.rs index b395414e..d3e029c0 100644 --- a/tests/fingerprint.rs +++ b/tests/fingerprint.rs @@ -1,5 +1,17 @@ use super::*; +#[test] +fn case_variant_error() { + Test::new() + .touch("MANIFEST.FILEPACK") + .arg("fingerprint") + .stderr("error: expected `manifest.filepack` but found case mismatch `MANIFEST.FILEPACK`\n") + .failure() + .args(["fingerprint", "."]) + .stderr("error: expected `manifest.filepack` but found case mismatch `MANIFEST.FILEPACK`\n") + .failure(); +} + #[test] fn fingerprint() { let test = Test::new() diff --git a/tests/metadata.rs b/tests/metadata.rs index 385af397..e3633523 100644 --- a/tests/metadata.rs +++ b/tests/metadata.rs @@ -1167,6 +1167,18 @@ fn metadata_deco_force() { .success(); } +#[test] +fn metadata_subcommand_case_variant_error() { + Test::new() + .touch("METADATA.FILEMETA") + .arg("metadata") + .stderr("error: expected `metadata.filemeta` but found case mismatch `METADATA.FILEMETA`\n") + .failure() + .args(["metadata", "."]) + .stderr("error: expected `metadata.filemeta` but found case mismatch `METADATA.FILEMETA`\n") + .failure(); +} + #[test] fn metadata_subcommand_default() { Test::new() diff --git a/tests/upload.rs b/tests/upload.rs index 570f001a..81fda6af 100644 --- a/tests/upload.rs +++ b/tests/upload.rs @@ -526,6 +526,17 @@ fn upload_package_skips_files_already_on_server() { server.terminate().success(); } +#[test] +fn upload_package_state_case_variant_error() { + Test::new() + .arg("create") + .success() + .create_dir(".FILEPACK") + .args(["upload", "--server", "http://127.0.0.1:1"]) + .stderr("error: expected `.filepack` but found case mismatch `.FILEPACK`\n") + .failure(); +} + #[test] fn upload_package_uploads_files() { let server = Test::new() diff --git a/tests/verify.rs b/tests/verify.rs index c211f447..8ca1f749 100644 --- a/tests/verify.rs +++ b/tests/verify.rs @@ -1,5 +1,22 @@ use super::*; +#[test] +fn case_variant_error() { + for (actual, expected) in [ + ("MANIFEST.FILEPACK", "manifest.filepack"), + ("METADATA.FILEMETA", "metadata.filemeta"), + (".FILEPACK", ".filepack"), + ] { + Test::new() + .touch(actual) + .arg("verify") + .stderr(&format!( + "error: expected `{expected}` but found case mismatch `{actual}`\n" + )) + .failure(); + } +} + #[test] fn duplicate_key_literal() { Test::new()