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
This commit is contained in:
Alejandro González
2025-06-16 18:30:01 +02:00
committed by GitHub
parent 97e4d8e132
commit fb30c0ba2b
5 changed files with 147 additions and 48 deletions

View File

@@ -32,11 +32,13 @@ pub async fn read_from_field(
) -> Result<BytesMut, CreateError> { ) -> Result<BytesMut, CreateError> {
let mut bytes = BytesMut::new(); let mut bytes = BytesMut::new();
while let Some(chunk) = field.next().await { 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))); return Err(CreateError::InvalidInput(String::from(err_msg)));
} else {
bytes.extend_from_slice(&chunk?);
} }
bytes.extend_from_slice(&chunk);
} }
Ok(bytes) Ok(bytes)
} }

View File

@@ -1,8 +1,8 @@
use crate::validate::{ use crate::validate::{
SupportedGameVersions, ValidationError, ValidationResult, MaybeProtectedZipFile, PLAUSIBLE_PACK_REGEX, SupportedGameVersions,
ValidationError, ValidationResult,
}; };
use std::io::Cursor; use chrono::DateTime;
use zip::ZipArchive;
pub struct DataPackValidator; pub struct DataPackValidator;
@@ -16,19 +16,29 @@ impl super::Validator for DataPackValidator {
} }
fn get_supported_game_versions(&self) -> SupportedGameVersions { 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, &self,
archive: &mut ZipArchive<Cursor<bytes::Bytes>>, file: &mut MaybeProtectedZipFile,
) -> Result<ValidationResult, ValidationError> { ) -> Result<ValidationResult, ValidationError> {
if archive.by_name("pack.mcmeta").is_err() { if match file {
return Ok(ValidationResult::Warning( 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!", "No pack.mcmeta present for datapack file. Tip: Make sure pack.mcmeta is in the root directory of your datapack!",
)); ))
} }
Ok(ValidationResult::Pass)
} }
} }

View File

@@ -17,10 +17,14 @@ use crate::validate::rift::RiftValidator;
use crate::validate::shader::{ use crate::validate::shader::{
CanvasShaderValidator, CoreShaderValidator, ShaderValidator, CanvasShaderValidator, CoreShaderValidator, ShaderValidator,
}; };
use bytes::Bytes;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use std::io::Cursor; use std::io::{self, Cursor};
use std::mem;
use std::sync::LazyLock;
use thiserror::Error; use thiserror::Error;
use zip::ZipArchive; use zip::ZipArchive;
use zip::result::ZipError;
mod datapack; mod datapack;
mod fabric; mod fabric;
@@ -80,14 +84,43 @@ pub enum SupportedGameVersions {
Custom(Vec<MinecraftGameVersion>), Custom(Vec<MinecraftGameVersion>),
} }
pub enum MaybeProtectedZipFile {
Unprotected(ZipArchive<Cursor<Bytes>>),
MaybeProtected { read_error: ZipError, data: Bytes },
}
pub trait Validator: Sync { pub trait Validator: Sync {
fn get_file_extensions(&self) -> &[&str]; fn get_file_extensions(&self) -> &[&str];
fn get_supported_loaders(&self) -> &[&str]; fn get_supported_loaders(&self) -> &[&str];
fn get_supported_game_versions(&self) -> SupportedGameVersions; fn get_supported_game_versions(&self) -> SupportedGameVersions;
fn validate( fn validate(
&self, &self,
archive: &mut ZipArchive<Cursor<bytes::Bytes>>, archive: &mut ZipArchive<Cursor<bytes::Bytes>>,
) -> Result<ValidationResult, ValidationError>; ) -> Result<ValidationResult, ValidationError> {
// By default, any non-protected ZIP archive is valid
let _ = archive;
Ok(ValidationResult::Pass)
}
fn validate_maybe_protected_zip(
&self,
file: &mut MaybeProtectedZipFile,
) -> Result<ValidationResult, ValidationError> {
// 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"]; static ALWAYS_ALLOWED_EXT: &[&str] = &["zip", "txt"];
@@ -113,6 +146,29 @@ static VALIDATORS: &[&dyn Validator] = &[
&NeoForgeValidator, &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<regex::bytes::Regex> =
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 /// 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)] #[allow(clippy::too_many_arguments)]
pub async fn validate_file( pub async fn validate_file(
@@ -144,7 +200,7 @@ pub async fn validate_file(
} }
async fn validate_minecraft_file( async fn validate_minecraft_file(
data: bytes::Bytes, data: Bytes,
file_extension: String, file_extension: String,
loaders: Vec<Loader>, loaders: Vec<Loader>,
game_versions: Vec<MinecraftGameVersion>, game_versions: Vec<MinecraftGameVersion>,
@@ -152,13 +208,18 @@ async fn validate_minecraft_file(
file_type: Option<FileType>, file_type: Option<FileType>,
) -> Result<ValidationResult, ValidationError> { ) -> Result<ValidationResult, ValidationError> {
actix_web::web::block(move || { actix_web::web::block(move || {
let reader = Cursor::new(data); let mut zip = match ZipArchive::new(Cursor::new(Bytes::clone(&data))) {
let mut zip = ZipArchive::new(reader)?; Ok(zip) => MaybeProtectedZipFile::Unprotected(zip),
Err(read_error) => MaybeProtectedZipFile::MaybeProtected {
read_error,
data,
},
};
if let Some(file_type) = file_type { if let Some(file_type) = file_type {
match file_type { match file_type {
FileType::RequiredResourcePack | FileType::OptionalResourcePack => { FileType::RequiredResourcePack | FileType::OptionalResourcePack => {
return PackValidator.validate(&mut zip); return PackValidator.validate_maybe_protected_zip(&mut zip);
} }
FileType::Unknown => {} FileType::Unknown => {}
} }
@@ -177,7 +238,7 @@ async fn validate_minecraft_file(
) )
{ {
if validator.get_file_extensions().contains(&&*file_extension) { 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 { match result {
ValidationResult::PassWithPackDataAndFiles { .. } => { ValidationResult::PassWithPackDataAndFiles { .. } => {
saved_result = Some(result); saved_result = Some(result);

View File

@@ -1,5 +1,6 @@
use crate::validate::{ use crate::validate::{
SupportedGameVersions, ValidationError, ValidationResult, MaybeProtectedZipFile, PLAUSIBLE_PACK_REGEX, SupportedGameVersions,
ValidationError, ValidationResult,
}; };
use chrono::DateTime; use chrono::DateTime;
use std::io::Cursor; use std::io::Cursor;
@@ -23,17 +24,24 @@ impl super::Validator for PackValidator {
) )
} }
fn validate( fn validate_maybe_protected_zip(
&self, &self,
archive: &mut ZipArchive<Cursor<bytes::Bytes>>, file: &mut MaybeProtectedZipFile,
) -> Result<ValidationResult, ValidationError> { ) -> Result<ValidationResult, ValidationError> {
if archive.by_name("pack.mcmeta").is_err() { if match file {
return Ok(ValidationResult::Warning( MaybeProtectedZipFile::Unprotected(archive) => {
"No pack.mcmeta present for pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!", 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)
} }
} }

View File

@@ -1,7 +1,8 @@
use crate::validate::{ 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; use zip::ZipArchive;
pub struct ShaderValidator; pub struct ShaderValidator;
@@ -83,25 +84,42 @@ impl super::Validator for CoreShaderValidator {
SupportedGameVersions::All SupportedGameVersions::All
} }
fn validate( fn validate_maybe_protected_zip(
&self, &self,
archive: &mut ZipArchive<Cursor<bytes::Bytes>>, file: &mut MaybeProtectedZipFile,
) -> Result<ValidationResult, ValidationError> { ) -> Result<ValidationResult, ValidationError> {
if archive.by_name("pack.mcmeta").is_err() { static VANILLA_SHADER_CEN_ENTRY_REGEX: LazyLock<regex::bytes::Regex> =
return Ok(ValidationResult::Warning( LazyLock::new(|| {
"No pack.mcmeta present for pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!", 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 if match file {
.file_names() MaybeProtectedZipFile::Unprotected(archive) => {
.any(|x| x.starts_with("assets/minecraft/shaders/")) archive.by_name("pack.mcmeta").is_ok()
{ && archive
return Ok(ValidationResult::Warning( .file_names()
"No shaders folder present for vanilla shaders.", .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)
} }
} }