From fb30c0ba2b329e35ef99ed2a218ee7159ff32135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= <7822554+AlexTMjugador@users.noreply.github.com> Date: Mon, 16 Jun 2025 18:30:01 +0200 Subject: [PATCH] feat(labrinth): allow protected resource and data packs to pass validation (#3792) * fix(labrinth): return version artifact size exceeded error eagerly Now we don't wait until the result memory buffer has grown to a size greater than the maximum allowed, and instead we return such an error before the buffer is grown with the current chunk, which should reduce memory usage. * fix(labrinth): proper supported game versions range for datapacks * feat(labrinth): allow protected resource and data packs to pass validation --- apps/labrinth/src/util/routes.rs | 8 ++- apps/labrinth/src/validate/datapack.rs | 32 +++++---- apps/labrinth/src/validate/mod.rs | 75 ++++++++++++++++++++-- apps/labrinth/src/validate/resourcepack.rs | 26 +++++--- apps/labrinth/src/validate/shader.rs | 54 ++++++++++------ 5 files changed, 147 insertions(+), 48 deletions(-) diff --git a/apps/labrinth/src/util/routes.rs b/apps/labrinth/src/util/routes.rs index cad439c62..4766ef81f 100644 --- a/apps/labrinth/src/util/routes.rs +++ b/apps/labrinth/src/util/routes.rs @@ -32,11 +32,13 @@ pub async fn read_from_field( ) -> Result { let mut bytes = BytesMut::new(); while let Some(chunk) = field.next().await { - if bytes.len() >= cap { + let chunk = chunk?; + + if bytes.len().saturating_add(chunk.len()) > cap { return Err(CreateError::InvalidInput(String::from(err_msg))); - } else { - bytes.extend_from_slice(&chunk?); } + + bytes.extend_from_slice(&chunk); } Ok(bytes) } diff --git a/apps/labrinth/src/validate/datapack.rs b/apps/labrinth/src/validate/datapack.rs index 18cdd7e76..f152486d4 100644 --- a/apps/labrinth/src/validate/datapack.rs +++ b/apps/labrinth/src/validate/datapack.rs @@ -1,8 +1,8 @@ use crate::validate::{ - SupportedGameVersions, ValidationError, ValidationResult, + MaybeProtectedZipFile, PLAUSIBLE_PACK_REGEX, SupportedGameVersions, + ValidationError, ValidationResult, }; -use std::io::Cursor; -use zip::ZipArchive; +use chrono::DateTime; pub struct DataPackValidator; @@ -16,19 +16,29 @@ impl super::Validator for DataPackValidator { } fn get_supported_game_versions(&self) -> SupportedGameVersions { - SupportedGameVersions::All + // Time since release of 17w43a, 2017-10-25, which introduced datapacks + SupportedGameVersions::PastDate( + DateTime::from_timestamp(1508889600, 0).unwrap(), + ) } - fn validate( + fn validate_maybe_protected_zip( &self, - archive: &mut ZipArchive>, + file: &mut MaybeProtectedZipFile, ) -> Result { - if archive.by_name("pack.mcmeta").is_err() { - return Ok(ValidationResult::Warning( + if match file { + MaybeProtectedZipFile::Unprotected(archive) => { + archive.by_name("pack.mcmeta").is_ok() + } + MaybeProtectedZipFile::MaybeProtected { data, .. } => { + PLAUSIBLE_PACK_REGEX.is_match(data) + } + } { + Ok(ValidationResult::Pass) + } else { + Ok(ValidationResult::Warning( "No pack.mcmeta present for datapack file. Tip: Make sure pack.mcmeta is in the root directory of your datapack!", - )); + )) } - - Ok(ValidationResult::Pass) } } diff --git a/apps/labrinth/src/validate/mod.rs b/apps/labrinth/src/validate/mod.rs index b8a86f2d7..6304b61dd 100644 --- a/apps/labrinth/src/validate/mod.rs +++ b/apps/labrinth/src/validate/mod.rs @@ -17,10 +17,14 @@ use crate::validate::rift::RiftValidator; use crate::validate::shader::{ CanvasShaderValidator, CoreShaderValidator, ShaderValidator, }; +use bytes::Bytes; use chrono::{DateTime, Utc}; -use std::io::Cursor; +use std::io::{self, Cursor}; +use std::mem; +use std::sync::LazyLock; use thiserror::Error; use zip::ZipArchive; +use zip::result::ZipError; mod datapack; mod fabric; @@ -80,14 +84,43 @@ pub enum SupportedGameVersions { Custom(Vec), } +pub enum MaybeProtectedZipFile { + Unprotected(ZipArchive>), + MaybeProtected { read_error: ZipError, data: Bytes }, +} + pub trait Validator: Sync { fn get_file_extensions(&self) -> &[&str]; fn get_supported_loaders(&self) -> &[&str]; fn get_supported_game_versions(&self) -> SupportedGameVersions; + fn validate( &self, archive: &mut ZipArchive>, - ) -> Result; + ) -> Result { + // By default, any non-protected ZIP archive is valid + let _ = archive; + Ok(ValidationResult::Pass) + } + + fn validate_maybe_protected_zip( + &self, + file: &mut MaybeProtectedZipFile, + ) -> Result { + // By default, validate that the ZIP file is not protected, and if so, + // delegate to the inner validate method with a known good archive + match file { + MaybeProtectedZipFile::Unprotected(archive) => { + self.validate(archive) + } + MaybeProtectedZipFile::MaybeProtected { read_error, .. } => { + Err(ValidationError::Zip(mem::replace( + read_error, + ZipError::Io(io::Error::other("ZIP archive reading error")), + ))) + } + } + } } static ALWAYS_ALLOWED_EXT: &[&str] = &["zip", "txt"]; @@ -113,6 +146,29 @@ static VALIDATORS: &[&dyn Validator] = &[ &NeoForgeValidator, ]; +/// A regex that matches a potentially protected ZIP archive containing +/// a vanilla Minecraft pack, with a requisite `pack.mcmeta` file. +/// +/// Please note that this regex avoids false negatives at the cost of false +/// positives being possible, i.e. it may match files that are not actually +/// Minecraft packs, but it will not miss packs that the game can load. +static PLAUSIBLE_PACK_REGEX: LazyLock = + LazyLock::new(|| { + regex::bytes::RegexBuilder::new(concat!( + r"\x50\x4b\x01\x02", // CEN signature + r".{24}", // CEN fields + r"[\x0B\x0C]\x00", // CEN file name length + r".{16}", // More CEN fields + r"pack\.mcmeta/?", // CEN file name + r".*", // Rest of CEN entries and records + r"\x50\x4b\x05\x06", // EOCD signature + )) + .unicode(false) + .dot_matches_new_line(true) + .build() + .unwrap() + }); + /// The return value is whether this file should be marked as primary or not, based on the analysis of the file #[allow(clippy::too_many_arguments)] pub async fn validate_file( @@ -144,7 +200,7 @@ pub async fn validate_file( } async fn validate_minecraft_file( - data: bytes::Bytes, + data: Bytes, file_extension: String, loaders: Vec, game_versions: Vec, @@ -152,13 +208,18 @@ async fn validate_minecraft_file( file_type: Option, ) -> Result { actix_web::web::block(move || { - let reader = Cursor::new(data); - let mut zip = ZipArchive::new(reader)?; + let mut zip = match ZipArchive::new(Cursor::new(Bytes::clone(&data))) { + Ok(zip) => MaybeProtectedZipFile::Unprotected(zip), + Err(read_error) => MaybeProtectedZipFile::MaybeProtected { + read_error, + data, + }, + }; if let Some(file_type) = file_type { match file_type { FileType::RequiredResourcePack | FileType::OptionalResourcePack => { - return PackValidator.validate(&mut zip); + return PackValidator.validate_maybe_protected_zip(&mut zip); } FileType::Unknown => {} } @@ -177,7 +238,7 @@ async fn validate_minecraft_file( ) { if validator.get_file_extensions().contains(&&*file_extension) { - let result = validator.validate(&mut zip)?; + let result = validator.validate_maybe_protected_zip(&mut zip)?; match result { ValidationResult::PassWithPackDataAndFiles { .. } => { saved_result = Some(result); diff --git a/apps/labrinth/src/validate/resourcepack.rs b/apps/labrinth/src/validate/resourcepack.rs index 687c5b4e8..1d9d52c35 100644 --- a/apps/labrinth/src/validate/resourcepack.rs +++ b/apps/labrinth/src/validate/resourcepack.rs @@ -1,5 +1,6 @@ use crate::validate::{ - SupportedGameVersions, ValidationError, ValidationResult, + MaybeProtectedZipFile, PLAUSIBLE_PACK_REGEX, SupportedGameVersions, + ValidationError, ValidationResult, }; use chrono::DateTime; use std::io::Cursor; @@ -23,17 +24,24 @@ impl super::Validator for PackValidator { ) } - fn validate( + fn validate_maybe_protected_zip( &self, - archive: &mut ZipArchive>, + file: &mut MaybeProtectedZipFile, ) -> Result { - if archive.by_name("pack.mcmeta").is_err() { - return Ok(ValidationResult::Warning( - "No pack.mcmeta present for pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!", - )); + if match file { + MaybeProtectedZipFile::Unprotected(archive) => { + archive.by_name("pack.mcmeta").is_ok() + } + MaybeProtectedZipFile::MaybeProtected { data, .. } => { + PLAUSIBLE_PACK_REGEX.is_match(data) + } + } { + Ok(ValidationResult::Pass) + } else { + Ok(ValidationResult::Warning( + "No pack.mcmeta present for resourcepack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!", + )) } - - Ok(ValidationResult::Pass) } } diff --git a/apps/labrinth/src/validate/shader.rs b/apps/labrinth/src/validate/shader.rs index 6a83a8195..2ba7d7222 100644 --- a/apps/labrinth/src/validate/shader.rs +++ b/apps/labrinth/src/validate/shader.rs @@ -1,7 +1,8 @@ use crate::validate::{ - SupportedGameVersions, ValidationError, ValidationResult, + MaybeProtectedZipFile, PLAUSIBLE_PACK_REGEX, SupportedGameVersions, + ValidationError, ValidationResult, }; -use std::io::Cursor; +use std::{io::Cursor, sync::LazyLock}; use zip::ZipArchive; pub struct ShaderValidator; @@ -83,25 +84,42 @@ impl super::Validator for CoreShaderValidator { SupportedGameVersions::All } - fn validate( + fn validate_maybe_protected_zip( &self, - archive: &mut ZipArchive>, + file: &mut MaybeProtectedZipFile, ) -> Result { - if archive.by_name("pack.mcmeta").is_err() { - return Ok(ValidationResult::Warning( - "No pack.mcmeta present for pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!", - )); - }; + static VANILLA_SHADER_CEN_ENTRY_REGEX: LazyLock = + LazyLock::new(|| { + regex::bytes::RegexBuilder::new(concat!( + r"\x50\x4b\x01\x02", // CEN signature + r".{24}", // CEN fields + r".{2}", // CEN file name length + r".{16}", // More CEN fields + r"assets/minecraft/shaders/", // CEN file name + )) + .unicode(false) + .dot_matches_new_line(true) + .build() + .unwrap() + }); - if !archive - .file_names() - .any(|x| x.starts_with("assets/minecraft/shaders/")) - { - return Ok(ValidationResult::Warning( - "No shaders folder present for vanilla shaders.", - )); + if match file { + MaybeProtectedZipFile::Unprotected(archive) => { + archive.by_name("pack.mcmeta").is_ok() + && archive + .file_names() + .any(|x| x.starts_with("assets/minecraft/shaders/")) + } + MaybeProtectedZipFile::MaybeProtected { data, .. } => { + PLAUSIBLE_PACK_REGEX.is_match(data) + && VANILLA_SHADER_CEN_ENTRY_REGEX.is_match(data) + } + } { + Ok(ValidationResult::Pass) + } else { + Ok(ValidationResult::Warning( + "No pack.mcmeta or vanilla shaders folder present for pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!", + )) } - - Ok(ValidationResult::Pass) } }