use serde::Deserialize; use std::{collections::HashSet, fmt}; const MAX_MANIFEST_BYTES: usize = 2 * 1024 * 1024; const CURRENT_SCHEMA_VERSION: u32 = 1; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Manifest { pub schema_version: u32, pub id: String, pub display_name: String, pub minecraft: Minecraft, pub files: Vec, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Minecraft { pub version: String, pub loader: Loader, pub java_major: u8, } #[derive(Debug, Deserialize)] pub struct Loader { pub kind: String, pub version: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ManagedFile { pub path: String, pub url: String, pub sha256: String, pub size: u64, pub policy: FilePolicy, } #[derive(Debug, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum FilePolicy { Managed, Seed, } #[derive(Debug)] pub enum ManifestError { TooLarge, InvalidJson(serde_json::Error), Invalid(String), } impl fmt::Display for ManifestError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::TooLarge => write!(formatter, "Manifest is larger than 2 MiB"), Self::InvalidJson(error) => write!(formatter, "Invalid manifest JSON: {error}"), Self::Invalid(message) => formatter.write_str(message), } } } pub fn validate_json(source: &str) -> Result { if source.len() > MAX_MANIFEST_BYTES { return Err(ManifestError::TooLarge); } let manifest = serde_json::from_str::(source).map_err(ManifestError::InvalidJson)?; validate(&manifest)?; Ok(manifest) } fn validate(manifest: &Manifest) -> Result<(), ManifestError> { if manifest.schema_version != CURRENT_SCHEMA_VERSION { return Err(ManifestError::Invalid(format!( "Unsupported schemaVersion {}; expected {CURRENT_SCHEMA_VERSION}", manifest.schema_version ))); } if !is_identifier(&manifest.id) { return Err(ManifestError::Invalid("Profile id must contain only lowercase letters, numbers and hyphens".into())); } if manifest.display_name.trim().is_empty() { return Err(ManifestError::Invalid("Profile displayName cannot be empty".into())); } if manifest.minecraft.version.trim().is_empty() || manifest.minecraft.loader.kind.trim().is_empty() || manifest.minecraft.loader.version.trim().is_empty() { return Err(ManifestError::Invalid("Minecraft version and loader must be specified".into())); } if !(8..=25).contains(&manifest.minecraft.java_major) { return Err(ManifestError::Invalid("Unsupported Java major version".into())); } let mut paths = HashSet::new(); for file in &manifest.files { match file.policy { FilePolicy::Managed | FilePolicy::Seed => {} } if !is_safe_relative_path(&file.path) { return Err(ManifestError::Invalid(format!("Unsafe file path: {}", file.path))); } if !paths.insert(&file.path) { return Err(ManifestError::Invalid(format!("Duplicate file path: {}", file.path))); } if !file.url.starts_with("https://") { return Err(ManifestError::Invalid(format!("File URL must use HTTPS: {}", file.path))); } if file.size == 0 { return Err(ManifestError::Invalid(format!("File has zero size: {}", file.path))); } if file.sha256.len() != 64 || !file.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err(ManifestError::Invalid(format!("Invalid SHA-256 for {}", file.path))); } } Ok(()) } fn is_identifier(value: &str) -> bool { !value.is_empty() && value.len() <= 48 && value.bytes().all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') } fn is_safe_relative_path(value: &str) -> bool { !value.is_empty() && !value.starts_with('/') && !value.starts_with('\\') && !value.contains('\\') && !value.split('/').any(|part| part.is_empty() || part == "." || part == "..") } #[cfg(test)] mod tests { use super::validate_json; const VALID: &str = r#"{ "schemaVersion": 1, "id": "aeronautics", "displayName": "All of Create Aeronautics", "minecraft": { "version": "1.21.1", "loader": { "kind": "neoforge", "version": "21.1.248" }, "javaMajor": 21 }, "files": [{ "path": "mods/example.jar", "url": "https://cdn.shacraft.ru/aeronautics/example.jar", "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "size": 42, "policy": "managed" }] }"#; #[test] fn accepts_a_safe_manifest() { assert!(validate_json(VALID).is_ok()); } #[test] fn rejects_path_traversal() { assert!(validate_json(&VALID.replace("mods/example.jar", "../secrets.txt")).is_err()); } #[test] fn rejects_insecure_downloads() { assert!(validate_json(&VALID.replace("https://", "http://")).is_err()); } }