feat: add validated launcher manifest v1

This commit is contained in:
emil28092005
2026-09-06 00:32:30 +03:00
parent 1f0283e173
commit a573b0f503
5 changed files with 209 additions and 2 deletions
+30
View File
@@ -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-адреса и относительные пути без
`..`, обратных слешей и пустых сегментов.
+1
View File
@@ -2798,6 +2798,7 @@ name = "shacraft-launcher"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-build",
]
+1 -1
View File
@@ -14,5 +14,5 @@ tauri-build = { version = "2", features = [] }
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
+9 -1
View File
@@ -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");
}
+168
View File
@@ -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());
}
}