fix(assets): implement -1 error returns on invalid handles, null buffer check for empty assets, checked seek arithmetic, and path normalization

This commit is contained in:
katR 2026-10-01 19:42:19 +05:30
parent bf3b3d84c8
commit abff33ba67
5 changed files with 157 additions and 37 deletions

View file

@ -9,3 +9,6 @@ tracing = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
libc = { workspace = true } libc = { workspace = true }
lazy_static = "1.5" lazy_static = "1.5"
[dev-dependencies]
tempfile = { workspace = true }

View file

@ -1,14 +1,19 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
use super::types::{SEEK_CUR, SEEK_END, SEEK_SET}; use super::types::{SEEK_CUR, SEEK_END, SEEK_SET};
use std::ffi::c_void; use std::ffi::c_void;
use std::sync::Arc;
pub struct AssetFile { pub struct AssetFile {
data: Vec<u8>, data: Arc<Vec<u8>>,
cursor: usize, cursor: usize,
} }
impl AssetFile { impl AssetFile {
pub fn new(data: Vec<u8>) -> Self { pub fn new(data: Vec<u8>) -> Self {
Self::new_shared(Arc::new(data))
}
pub fn new_shared(data: Arc<Vec<u8>>) -> Self {
Self { data, cursor: 0 } Self { data, cursor: 0 }
} }
@ -21,7 +26,11 @@ impl AssetFile {
} }
pub fn get_buffer(&self) -> *const c_void { pub fn get_buffer(&self) -> *const c_void {
self.data.as_ptr() as *const c_void if self.data.is_empty() {
std::ptr::null()
} else {
self.data.as_ptr() as *const c_void
}
} }
pub fn read(&mut self, buf: &mut [u8]) -> usize { pub fn read(&mut self, buf: &mut [u8]) -> usize {
@ -35,18 +44,19 @@ impl AssetFile {
} }
pub fn seek(&mut self, offset: i64, whence: i32) -> i64 { pub fn seek(&mut self, offset: i64, whence: i32) -> i64 {
let new_pos: i64 = match whence { let new_pos: Option<i64> = match whence {
SEEK_SET => offset, SEEK_SET => Some(offset),
SEEK_CUR => self.cursor as i64 + offset, SEEK_CUR => (self.cursor as i64).checked_add(offset),
SEEK_END => self.data.len() as i64 + offset, SEEK_END => (self.data.len() as i64).checked_add(offset),
_ => return -1, _ => return -1,
}; };
if new_pos >= 0 && (new_pos as usize) <= self.data.len() { if let Some(pos) = new_pos {
self.cursor = new_pos as usize; if pos >= 0 && (pos as usize) <= self.data.len() {
self.cursor as i64 self.cursor = pos as usize;
} else { return self.cursor as i64;
-1 }
} }
-1
} }
} }

View file

@ -80,19 +80,27 @@ pub unsafe extern "C" fn AAssetManager_openDir(_mgr: *mut (), dirName: *const li
#[no_mangle] #[no_mangle]
pub unsafe extern "C" fn AAsset_read(asset: *mut (), buf: *mut c_void, count: usize) -> i32 { pub unsafe extern "C" fn AAsset_read(asset: *mut (), buf: *mut c_void, count: usize) -> i32 {
if asset.is_null() || buf.is_null() || count == 0 { if asset.is_null() || buf.is_null() {
return -1;
}
if count == 0 {
return 0; return 0;
} }
let id = asset as usize; let id = asset as usize;
if let Ok(table) = ASSET_TABLE.read() { let table = match ASSET_TABLE.read() {
if let Some(mutex) = table.get(&id) { Ok(t) => t,
if let Ok(mut file) = mutex.lock() { Err(_) => return -1,
let slice = std::slice::from_raw_parts_mut(buf as *mut u8, count); };
return file.read(slice) as i32;
} if let Some(mutex) = table.get(&id) {
if let Ok(mut file) = mutex.lock() {
let clamped_count = count.min(isize::MAX as usize);
let slice = std::slice::from_raw_parts_mut(buf as *mut u8, clamped_count);
return file.read(slice) as i32;
} }
} }
0 -1
} }
#[no_mangle] #[no_mangle]

View file

@ -2,10 +2,13 @@
use super::asset::AssetFile; use super::asset::AssetFile;
use super::dir::AssetDir; use super::dir::AssetDir;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
pub struct AssetManager { pub struct AssetManager {
base_assets: HashMap<String, Vec<u8>>, base_assets: HashMap<String, Arc<Vec<u8>>>,
mod_overrides: HashMap<String, Vec<u8>>, mod_overrides: HashMap<String, Arc<Vec<u8>>>,
mods_dir: Option<PathBuf>,
} }
impl Default for AssetManager { impl Default for AssetManager {
@ -16,51 +19,86 @@ impl Default for AssetManager {
impl AssetManager { impl AssetManager {
pub fn new() -> Self { pub fn new() -> Self {
Self::new_with_mods_dir(None)
}
pub fn new_with_mods_dir(mods_dir: Option<PathBuf>) -> Self {
Self { Self {
base_assets: HashMap::new(), base_assets: HashMap::new(),
mod_overrides: HashMap::new(), mod_overrides: HashMap::new(),
mods_dir,
} }
} }
pub fn normalize_path(path: &str) -> String {
let mut clean = path.replace('\\', "/");
while clean.starts_with('/') || clean.starts_with("./") {
if clean.starts_with('/') {
clean.remove(0);
} else if clean.starts_with("./") {
clean.replace_range(0..2, "");
}
}
clean
}
pub fn add_asset(&mut self, path: &str, data: Vec<u8>) { pub fn add_asset(&mut self, path: &str, data: Vec<u8>) {
self.base_assets.insert(path.to_string(), data); let norm = Self::normalize_path(path);
self.base_assets.insert(norm, Arc::new(data));
} }
pub fn add_mod_override(&mut self, path: &str, data: Vec<u8>) { pub fn add_mod_override(&mut self, path: &str, data: Vec<u8>) {
self.mod_overrides.insert(path.to_string(), data); let norm = Self::normalize_path(path);
self.mod_overrides.insert(norm, Arc::new(data));
} }
pub fn open(&self, filename: &str) -> Option<AssetFile> { pub fn open(&self, filename: &str) -> Option<AssetFile> {
// 1. Check user mod overrides first (non-destructive mod engine) let norm = Self::normalize_path(filename);
if let Some(data) = self.mod_overrides.get(filename) {
tracing::info!("AssetManager: redirected '{}' to active user mod", filename); // 1. Check in-memory user mod overrides first
return Some(AssetFile::new(data.clone())); if let Some(data) = self.mod_overrides.get(&norm) {
tracing::info!("AssetManager: redirected '{}' to active in-memory mod", norm);
return Some(AssetFile::new_shared(data.clone()));
} }
// 2. Fallback to base asset // 2. Check disk mod overrides in ~/.config/molotov/mods/
if let Some(data) = self.base_assets.get(filename) { if let Some(dir) = &self.mods_dir {
return Some(AssetFile::new(data.clone())); let disk_file = dir.join(&norm);
if disk_file.is_file() {
if let Ok(bytes) = std::fs::read(&disk_file) {
tracing::info!("AssetManager: loaded custom mod from disk: {}", disk_file.display());
return Some(AssetFile::new(bytes));
}
}
} }
tracing::warn!("AssetManager: asset '{}' not found", filename); // 3. Fallback to base asset
if let Some(data) = self.base_assets.get(&norm) {
return Some(AssetFile::new_shared(data.clone()));
}
tracing::warn!("AssetManager: asset '{}' not found", norm);
None None
} }
pub fn open_dir(&self, dir_name: &str) -> AssetDir { pub fn open_dir(&self, dir_name: &str) -> AssetDir {
let prefix = if dir_name.is_empty() || dir_name == "." { let norm = Self::normalize_path(dir_name);
let prefix = if norm.is_empty() {
"".to_string() "".to_string()
} else if !dir_name.ends_with('/') {
format!("{}/", dir_name)
} else { } else {
dir_name.to_string() format!("{}/", norm)
}; };
let mut files = Vec::new(); let mut files = Vec::new();
for key in self.base_assets.keys().chain(self.mod_overrides.keys()) { for key in self.base_assets.keys().chain(self.mod_overrides.keys()) {
if key.starts_with(&prefix) { if key.starts_with(&prefix) {
let rest = &key[prefix.len()..]; let rest = &key[prefix.len()..];
if !rest.contains('/') { if !rest.is_empty() {
files.push(rest.to_string()); let entry = match rest.find('/') {
Some(idx) => &rest[..idx],
None => rest,
};
files.push(entry.to_string());
} }
} }
} }

View file

@ -0,0 +1,61 @@
// SPDX-License-Identifier: GPL-3.0-or-later
use molotov_core::assets::asset::AssetFile;
use molotov_core::assets::c_api::*;
use molotov_core::assets::manager::AssetManager;
use tempfile::tempdir;
#[test]
fn test_aasset_read_invalid_handle_returns_error_negative_one() {
unsafe {
// Invalid or null pointer must return -1, never fake 0
let ret = AAsset_read(std::ptr::null_mut(), std::ptr::null_mut(), 16);
assert_eq!(ret, -1, "Invalid handle or null buffer must return -1");
let bogus_handle = 0x9999 as *mut ();
let mut buf = [0u8; 16];
let ret2 = AAsset_read(bogus_handle, buf.as_mut_ptr() as *mut _, 16);
assert_eq!(ret2, -1, "Stale or closed handle must return -1");
}
}
#[test]
fn test_empty_asset_get_buffer_returns_null() {
let empty_asset = AssetFile::new(vec![]);
assert_eq!(empty_asset.length(), 0);
assert!(
empty_asset.get_buffer().is_null(),
"Empty asset get_buffer must return NULL, never dangling pointer 0x1"
);
}
#[test]
fn test_seek_overflow_does_not_panic() {
let mut asset = AssetFile::new(b"SAMPLE".to_vec());
// Seeking with i64::MAX from SEEK_CUR must return -1 using checked arithmetic
let ret = asset.seek(i64::MAX, 1);
assert_eq!(ret, -1);
}
#[test]
fn test_path_normalization_and_disk_mod_resolution() {
let temp = tempdir().unwrap();
let mods_dir = temp.path().join("mods");
std::fs::create_dir_all(&mods_dir).unwrap();
let custom_sound = mods_dir.join("content/sounds/ouch.ogg");
if let Some(parent) = custom_sound.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&custom_sound, b"CUSTOM_DISK_MOD_DATA").unwrap();
let mut mgr = AssetManager::new_with_mods_dir(Some(mods_dir));
mgr.add_asset("content/sounds/ouch.ogg", b"BASE_DATA".to_vec());
// Should resolve path with Windows backslashes and leading slashes
let asset = mgr.open(r"\content\sounds\ouch.ogg").expect("Should open with backslash normalization");
let mut out = vec![0u8; asset.length() as usize];
let mut asset_mut = asset;
asset_mut.read(&mut out);
assert_eq!(&out, b"CUSTOM_DISK_MOD_DATA", "Must resolve disk mod overlay with normalized path");
}