diff --git a/docs/manifest-v1.md b/docs/manifest-v1.md new file mode 100644 index 0000000..3ebc2a7 --- /dev/null +++ b/docs/manifest-v1.md @@ -0,0 +1,30 @@ +# ShaCraft manifest v1 + +Каждая игровая сборка описывается одним JSON-документом. Лаунчер принимает +документ только после проверки подписи релизного ключа и затем валидирует эту +схему до начала загрузки. + +```json +{ + "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" + }] +} +``` + +`managed` — файл контролирует лаунчер: при несовпадении SHA-256 он заменяется. +`seed` — файл создаётся только при первом запуске и затем сохраняет изменения +игрока. В manifest v1 допускаются только HTTPS-адреса и относительные пути без +`..`, обратных слешей и пустых сегментов. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6569f97..26e953c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2798,6 +2798,7 @@ name = "shacraft-launcher" version = "0.1.0" dependencies = [ "serde", + "serde_json", "tauri", "tauri-build", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index eaf56fc..491935d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,5 +14,5 @@ tauri-build = { version = "2", features = [] } [dependencies] serde = { version = "1", features = ["derive"] } +serde_json = "1" tauri = { version = "2", features = [] } - diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 95109c0..a49c5ea 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,5 @@ +mod manifest; + use serde::Serialize; use tauri::{AppHandle, Manager}; @@ -25,9 +27,15 @@ fn native_host(app: AppHandle) -> Result { }) } +/// Validates an untrusted profile manifest before any file is downloaded. +#[tauri::command] +fn validate_manifest(manifest_json: String) -> Result<(), String> { + manifest::validate_json(&manifest_json).map(|_| ()).map_err(|error| error.to_string()) +} + pub fn run() { tauri::Builder::default() - .invoke_handler(tauri::generate_handler![native_host]) + .invoke_handler(tauri::generate_handler![native_host, validate_manifest]) .run(tauri::generate_context!()) .expect("error while running ShaCraft Launcher"); } diff --git a/src-tauri/src/manifest.rs b/src-tauri/src/manifest.rs new file mode 100644 index 0000000..c96558b --- /dev/null +++ b/src-tauri/src/manifest.rs @@ -0,0 +1,168 @@ +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()); + } +}