feat: add validated launcher manifest v1
This commit is contained in:
@@ -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-адреса и относительные пути без
|
||||
`..`, обратных слешей и пустых сегментов.
|
||||
Generated
+1
@@ -2798,6 +2798,7 @@ name = "shacraft-launcher"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
]
|
||||
|
||||
@@ -14,5 +14,5 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", features = [] }
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
mod manifest;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
@@ -25,9 +27,15 @@ fn native_host(app: AppHandle) -> Result<NativeHost, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
|
||||
@@ -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<ManagedFile>,
|
||||
}
|
||||
|
||||
#[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<Manifest, ManifestError> {
|
||||
if source.len() > MAX_MANIFEST_BYTES {
|
||||
return Err(ManifestError::TooLarge);
|
||||
}
|
||||
|
||||
let manifest = serde_json::from_str::<Manifest>(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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user