feat: inspect local profile file integrity
This commit is contained in:
Generated
+1
@@ -2799,6 +2799,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
]
|
||||
|
||||
@@ -15,4 +15,5 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
tauri = { version = "2", features = [] }
|
||||
|
||||
+19
-1
@@ -1,4 +1,5 @@
|
||||
mod manifest;
|
||||
mod profile;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Manager};
|
||||
@@ -33,9 +34,26 @@ fn validate_manifest(manifest_json: String) -> Result<(), String> {
|
||||
manifest::validate_json(&manifest_json).map(|_| ()).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Inspects the local profile without changing player files.
|
||||
#[tauri::command]
|
||||
async fn inspect_profile(app: AppHandle, manifest_json: String) -> Result<profile::ProfileInspection, String> {
|
||||
let manifest = manifest::validate_json(&manifest_json).map_err(|error| error.to_string())?;
|
||||
let root = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?
|
||||
.join("profiles")
|
||||
.join(&manifest.id);
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || profile::inspect(&root, &manifest))
|
||||
.await
|
||||
.map_err(|error| format!("Profile inspection task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![native_host, validate_manifest])
|
||||
.invoke_handler(tauri::generate_handler![native_host, validate_manifest, inspect_profile])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running ShaCraft Launcher");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
use crate::manifest::Manifest;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{fmt, fs::File, io::{self, Read}, path::Path};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileInspection {
|
||||
pub root: String,
|
||||
pub managed_files: usize,
|
||||
pub missing_files: usize,
|
||||
pub mismatched_files: usize,
|
||||
pub up_to_date: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ProfileError {
|
||||
Io(io::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for ProfileError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => write!(formatter, "Cannot inspect profile: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inspect(root: &Path, manifest: &Manifest) -> Result<ProfileInspection, ProfileError> {
|
||||
let mut missing_files = 0;
|
||||
let mut mismatched_files = 0;
|
||||
|
||||
for expected in &manifest.files {
|
||||
let path = root.join(&expected.path);
|
||||
let metadata = match path.metadata() {
|
||||
Ok(metadata) if metadata.is_file() => metadata,
|
||||
Ok(_) => {
|
||||
mismatched_files += 1;
|
||||
continue;
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
missing_files += 1;
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(ProfileError::Io(error)),
|
||||
};
|
||||
|
||||
if metadata.len() != expected.size || sha256(&path)? != expected.sha256.to_ascii_lowercase() {
|
||||
mismatched_files += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ProfileInspection {
|
||||
root: root.display().to_string(),
|
||||
managed_files: manifest.files.len(),
|
||||
missing_files,
|
||||
mismatched_files,
|
||||
up_to_date: missing_files == 0 && mismatched_files == 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn sha256(path: &Path) -> Result<String, ProfileError> {
|
||||
let mut file = File::open(path).map_err(ProfileError::Io)?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
|
||||
loop {
|
||||
let read = file.read(&mut buffer).map_err(ProfileError::Io)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
digest.update(&buffer[..read]);
|
||||
}
|
||||
|
||||
Ok(format!("{:x}", digest.finalize()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::inspect;
|
||||
use crate::manifest::{FilePolicy, Loader, ManagedFile, Manifest, Minecraft};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{fs, process, time::{SystemTime, UNIX_EPOCH}};
|
||||
|
||||
fn manifest(hash: String, size: u64) -> Manifest {
|
||||
Manifest {
|
||||
schema_version: 1,
|
||||
id: "aeronautics".into(),
|
||||
display_name: "Aeronautics".into(),
|
||||
minecraft: Minecraft {
|
||||
version: "1.21.1".into(),
|
||||
loader: Loader { kind: "neoforge".into(), version: "21.1.248".into() },
|
||||
java_major: 21,
|
||||
},
|
||||
files: vec![ManagedFile {
|
||||
path: "mods/example.jar".into(),
|
||||
url: "https://cdn.shacraft.ru/example.jar".into(),
|
||||
sha256: hash,
|
||||
size,
|
||||
policy: FilePolicy::Managed,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_missing_and_matching_files() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-launcher-test-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
|
||||
));
|
||||
let bytes = b"ShaCraft test file";
|
||||
let digest = format!("{:x}", Sha256::digest(bytes));
|
||||
let expected = manifest(digest, bytes.len() as u64);
|
||||
|
||||
let missing = inspect(&root, &expected).unwrap();
|
||||
assert_eq!(missing.missing_files, 1);
|
||||
assert!(!missing.up_to_date);
|
||||
|
||||
fs::create_dir_all(root.join("mods")).unwrap();
|
||||
fs::write(root.join("mods/example.jar"), bytes).unwrap();
|
||||
let current = inspect(&root, &expected).unwrap();
|
||||
assert_eq!(current.missing_files, 0);
|
||||
assert_eq!(current.mismatched_files, 0);
|
||||
assert!(current.up_to_date);
|
||||
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user