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:
parent
bf3b3d84c8
commit
abff33ba67
5 changed files with 157 additions and 37 deletions
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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,8 +26,12 @@ impl AssetFile {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_buffer(&self) -> *const c_void {
|
pub fn get_buffer(&self) -> *const c_void {
|
||||||
|
if self.data.is_empty() {
|
||||||
|
std::ptr::null()
|
||||||
|
} else {
|
||||||
self.data.as_ptr() as *const c_void
|
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 {
|
||||||
let available = self.data.len().saturating_sub(self.cursor);
|
let available = self.data.len().saturating_sub(self.cursor);
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(_) => return -1,
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(mutex) = table.get(&id) {
|
if let Some(mutex) = table.get(&id) {
|
||||||
if let Ok(mut file) = mutex.lock() {
|
if let Ok(mut file) = mutex.lock() {
|
||||||
let slice = std::slice::from_raw_parts_mut(buf as *mut u8, count);
|
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;
|
return file.read(slice) as i32;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
-1
|
||||||
0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
61
crates/molotov-core/tests/asset_fixes_test.rs
Normal file
61
crates/molotov-core/tests/asset_fixes_test.rs
Normal 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");
|
||||||
|
}
|
||||||
Reference in a new issue