Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ pbkdf2 = "0.12"
rand = "0.8"
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
sha2 = "0.10"
xxhash-rust = { version = "0.8", features = ["xxh64"] }
url = "2"
webpki-roots = "1"
whoami = "1"
Expand Down
149 changes: 149 additions & 0 deletions src-tauri/src/archive_hash.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
//! xxHash64 of Mod archives.
//!
//! Everest (and therefore Olympus) decides whether an installed Mod still
//! matches the newest released file by comparing the xxHash64 of the installed
//! archive with the hashes listed in the Mod update database. Comparing the
//! `everest.yaml` version instead misses releases whose archive keeps the old
//! or a wrong version number (aonHelper 0.19.8 still declares 0.19.7), so
//! CeleMod uses the same hash as well.
//!
//! Hashing reads the whole archive, so results are cached and reused while the
//! file keeps its size and modification time.

use std::{
collections::HashMap,
fs::{self, File},
io::Read,
path::{Path, PathBuf},
};

use anyhow::Context;
use lazy_static::lazy_static;
use parking_lot::Mutex;

/// Hashes are streamed so that large Mod archives never sit in memory.
const BUFFER_SIZE: usize = 1 << 20;

#[derive(Clone, PartialEq, Eq)]
struct CachedHash {
size: u64,
modified_at: u64,
hash: String,
}

lazy_static! {
static ref HASH_CACHE: Mutex<HashMap<PathBuf, CachedHash>> = Mutex::new(HashMap::new());
}

/// Lowercase hex xxHash64 of a file, matching `Everest.GetChecksum` and the
/// `xxHash` entries of the Mod update database.
pub fn hash_file(path: &Path) -> anyhow::Result<String> {
let mut hasher = xxhash_rust::xxh64::Xxh64::new(0);
let mut file =
File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
let mut buffer = vec![0u8; BUFFER_SIZE];
loop {
let read = file
.read(&mut buffer)
.with_context(|| format!("Failed to read {}", path.display()))?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(format!("{:016x}", hasher.digest()))
}

/// The cached hash of `path`, or `None` when it was never hashed or the file
/// changed since (a replaced Mod archive changes size and modification time).
pub fn cached_hash(path: &Path, size: u64, modified_at: u64) -> Option<String> {
let key = cache_key(path);
let cache = HASH_CACHE.lock();
let entry = cache.get(&key)?;
(entry.size == size && entry.modified_at == modified_at).then(|| entry.hash.clone())
}

/// The cached hash of `path`, hashing and caching it when needed.
pub fn ensure_hash(path: &Path, size: u64, modified_at: u64) -> anyhow::Result<String> {
if let Some(hash) = cached_hash(path, size, modified_at) {
return Ok(hash);
}
let hash = hash_file(path)?;
HASH_CACHE.lock().insert(
cache_key(path),
CachedHash {
size,
modified_at,
hash: hash.clone(),
},
);
Ok(hash)
}

/// Mod archives are addressed by path, but the same file can be reached
/// through a symlinked `Mods` folder, so cache by the resolved path.
fn cache_key(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}

#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};

fn test_dir(name: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path =
std::env::temp_dir().join(format!("celemod-{name}-{}-{unique}", std::process::id()));
fs::create_dir_all(&path).unwrap();
path
}

/// Reference values of the xxHash64 specification, so the implementation
/// stays compatible with Everest's.
#[test]
fn hashes_known_content() {
let root = test_dir("archive-hash");
for (contents, expected) in [
(&b""[..], "ef46db3751d8e999"),
(&b"abc"[..], "44bc2cf5ad770999"),
(&b"xxhash"[..], "32dd38952c4bc720"),
] {
let path = root.join("archive.zip");
fs::write(&path, contents).unwrap();
assert_eq!(hash_file(&path).unwrap(), expected);
}
}

#[test]
fn reuses_the_cached_hash_until_the_file_changes() {
let root = test_dir("archive-hash-cache");
let path = root.join("archive.zip");
fs::write(&path, b"first").unwrap();
let metadata = fs::metadata(&path).unwrap();
let size = metadata.len();
let modified_at = metadata
.modified()
.unwrap()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;

let first = ensure_hash(&path, size, modified_at).unwrap();
assert_eq!(
cached_hash(&path, size, modified_at).as_deref(),
Some(first.as_str())
);
assert_eq!(ensure_hash(&path, size, modified_at).unwrap(), first);

// A replaced archive keeps neither its size nor its timestamp.
fs::write(&path, b"second").unwrap();
let metadata = fs::metadata(&path).unwrap();
let changed = ensure_hash(&path, metadata.len(), modified_at + 1).unwrap();
assert_ne!(changed, first);
assert_eq!(hash_file(&path).unwrap(), changed);
}
}
84 changes: 83 additions & 1 deletion src-tauri/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ lazy_static::lazy_static! {
static ref MIAONET_AUTHORIZATION: ParkingMutex<Option<(PathBuf, MiaoNetAuthorization)>> = ParkingMutex::new(None);
}

#[path = "archive_hash.rs"]
mod archive_hash;
#[path = "blacklist.rs"]
mod blacklist;
#[path = "crash_analysis.rs"]
Expand Down Expand Up @@ -3233,6 +3235,37 @@ mod local_package_tests {
assert!(is_newly_installed_mod("New.Mod", &installed_before));
}

#[test]
fn hashes_installed_mod_archives_for_the_update_check() {
let root = test_dir("mod-archive-hashes");
let mods = root.join("Mods");
fs::create_dir_all(&mods).unwrap();
let archive = mods.join("HashCheck.zip");
write_zip(
&archive,
&[("everest.yaml", b"- Name: HashCheck\n Version: 0.19.7\n")],
);
// Unpacked Mods and non-archive files have no hash to compare.
fs::create_dir_all(mods.join("UnpackedMod")).unwrap();
fs::write(mods.join("notes.txt"), b"not a Mod").unwrap();

let hashes = mod_archive_hashes_sync(mods.to_string_lossy().as_ref());
assert_eq!(hashes.len(), 1);
let hash = hashes.get("HashCheck.zip").unwrap();
assert_eq!(hash, &archive_hash::hash_file(&archive).unwrap());

// Replacing the archive changes the hash instead of reusing the cache.
write_zip(
&archive,
&[(
"everest.yaml",
b"- Name: HashCheck\n Version: 0.19.8\n Dependencies:\n - Name: EverestCore\n Version: 1.6531.0\n",
)],
);
let replaced = mod_archive_hashes_sync(mods.to_string_lossy().as_ref());
assert_ne!(replaced.get("HashCheck.zip"), Some(hash));
}

#[test]
fn identifies_and_installs_mod_archives() {
let root = test_dir("drop-mod");
Expand Down Expand Up @@ -5244,7 +5277,8 @@ fn get_mod_update(name: String, on_event: Channel<IpcEvent>) {
#[tauri::command]
fn get_mod_latest_info(on_event: Channel<IpcEvent>) {
std::thread::spawn(move || {
let values: Vec<(String, String, String, String)> = match get_mod_cached_new() {
let values: Vec<(String, String, String, String, Vec<String>)> = match get_mod_cached_new()
{
Ok(mods) => mods
.iter()
.map(|(name, item)| {
Expand All @@ -5253,6 +5287,7 @@ fn get_mod_latest_info(on_event: Channel<IpcEvent>) {
item.version.clone(),
item.game_banana_file_id.to_string(),
item.download_url.clone(),
item.xx_hash.clone(),
)
})
.collect(),
Expand All @@ -5266,6 +5301,52 @@ fn get_mod_latest_info(on_event: Channel<IpcEvent>) {
});
}

/// Hashes the archives of a `Mods` folder, keyed by file name.
///
/// The UI compares these with the catalog's `xxHash` entries to find Mods whose
/// newest release does not bump the version in `everest.yaml`. Results are
/// cached per file, so calling this repeatedly only stats the archives.
fn mod_archive_hashes_sync(mods_folder_path: &str) -> HashMap<String, String> {
let mut hashes = HashMap::new();
let Ok(entries) = fs::read_dir(mods_folder_path) else {
return hashes;
};
for entry in entries.flatten() {
let path = entry.path();
if !path
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("zip"))
{
continue;
}
let Ok(metadata) = fs::metadata(&path) else {
continue;
};
if !metadata.is_file() {
continue;
}
match archive_hash::ensure_hash(&path, metadata.len(), modified_at_millis(&metadata)) {
Ok(hash) => {
hashes.insert(entry.file_name().to_string_lossy().to_string(), hash);
}
Err(error) => crate::logging::warn(format_args!(
"[ WARNING ] Failed to hash {:?}: {error:#}",
entry.file_name()
)),
}
}
hashes
}

#[tauri::command]
fn get_mod_archive_hashes(mods_folder_path: String, on_event: Channel<IpcEvent>) {
std::thread::spawn(move || {
let hashes = mod_archive_hashes_sync(&mods_folder_path);
let data = serde_json::to_string(&hashes).unwrap_or_else(|_| "{}".to_string());
send_event(&on_event, vec![serde_json::json!(data)]);
});
}

#[tauri::command]
fn rm_mod(mods_folder_path: String, mod_name: String) -> Result<(), String> {
rm_mod_sync(&mods_folder_path, &mod_name).map_err(|error| format!("{error:#}"))
Expand Down Expand Up @@ -6461,6 +6542,7 @@ pub fn run() {
verify_celeste_install,
normalize_game_path,
get_mod_latest_info,
get_mod_archive_hashes,
show_log_window,
write_frontend_log,
set_catalog_offline,
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/everest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ pub struct ModInfoCached {
pub game_banana_id: i64,
pub game_banana_file_id: i64,
pub download_url: String,
/// xxHash64 of the newest file, the check Everest and Olympus use.
pub xx_hash: Vec<String>,
}

static CATALOG_OFFLINE: AtomicBool = AtomicBool::new(false);
Expand Down Expand Up @@ -119,6 +121,11 @@ fn compact_catalog(mods: &[wegfan::Mod]) -> HashMap<String, ModInfoCached> {
download_url: item.submission_file.url.clone(),
name: item.name.clone(),
version: item.version.clone(),
xx_hash: item
.xx_hash
.iter()
.map(|hash| hash.trim().to_ascii_lowercase())
.collect(),
};
(compact.name.clone(), compact)
})
Expand Down
1 change: 1 addition & 0 deletions src/celemod-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"test:catalog-download": "node --import tsx --test src/catalogDownload.test.ts",
"test:crash-trace": "node --import tsx --test src/components/crashTrace.test.ts",
"test:deep-link": "node --import tsx --test src/deepLink.test.ts",
"test:mod-updates": "node --import tsx --test src/modUpdates.test.ts",
"test:update-notes": "node --import tsx --test src/components/UpdateNotes.test.tsx"
},
"dependencies": {
Expand Down
Loading
Loading