//! Deterministic and bounded tar/OAR archive handling. // Public signatures mirror the fixed C# compatibility surface. Several own // inputs intentionally and RegionSettings intentionally exposes boolean fields. #![allow(clippy::pedantic)] use std::fs::{self, File}; use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Component, Path, PathBuf}; use std::sync::Mutex; use flate2::{Compression, GzBuilder, read::GzDecoder}; use libremetaverse_types::compat::ReadWrite; use libremetaverse_types::{AssetType, Error, UUID}; use crate::assets::TarArchiveReaderTarEntryType; const BLOCK: usize = 512; const MAX_ENTRY_BYTES: usize = 64 * 1024 * 1024; const MAX_TOTAL_BYTES: usize = 1024 * 1024 * 1024; const MAX_ENTRIES: usize = 100_000; const MAX_PATH_BYTES: usize = 4096; const MAX_PATH_DEPTH: usize = 64; const ARCHIVE_XML: &str = "\n"; const OAR_DIRECTORIES: [&str; 5] = ["assets", "objects", "terrains", "landdata", "settings"]; type AsyncCallback = Box; type TerrainCallback = Box>, i64, i64) + Send + Sync>; fn archive_error(context: &'static str) -> Error { Error::Parse { position: 0, context, } } fn io_error(_: std::io::Error) -> Error { Error::InvalidOperation } fn validate_archive_path(path: &str) -> Result<(), Error> { if path.is_empty() || path.len() > MAX_PATH_BYTES || path.contains('\0') || path.contains('\\') || path.starts_with('/') { return Err(archive_error("unsafe archive path")); } let mut depth = 0usize; for component in Path::new(path).components() { match component { Component::Normal(_) => depth += 1, Component::CurDir => {} Component::ParentDir | Component::RootDir | Component::Prefix(_) => { return Err(archive_error("archive path traversal")); } } } if depth == 0 || depth > MAX_PATH_DEPTH { return Err(archive_error("archive path depth limit")); } Ok(()) } fn octal(bytes: &[u8]) -> Result { let text = std::str::from_utf8(bytes) .map_err(|_| archive_error("tar octal UTF-8"))? .trim_matches(['\0', ' ']); if text.is_empty() { return Ok(0); } if !text.bytes().all(|byte| matches!(byte, b'0'..=b'7')) { return Err(archive_error("tar octal field")); } u64::from_str_radix(text, 8).map_err(|_| archive_error("tar octal overflow")) } fn padded_octal(value: u64, width: usize) -> Result, Error> { if width < 2 { return Err(Error::Argument); } let digits = format!("{value:o}"); if digits.len() + 1 > width { return Err(Error::Argument); } let mut result = vec![b'0'; width]; let start = width - 1 - digits.len(); result[start..width - 1].copy_from_slice(digits.as_bytes()); result[width - 1] = 0; Ok(result) } fn split_ustar_path(path: &str) -> Result<(&str, &str), Error> { validate_archive_path(path)?; if path.len() <= 100 { return Ok(("", path)); } for (index, _) in path.match_indices('/').rev() { let prefix = &path[..index]; let name = &path[index + 1..]; if prefix.len() <= 155 && name.len() <= 100 && !name.is_empty() { return Ok((prefix, name)); } } Err(archive_error("tar path exceeds ustar limits")) } fn write_header( writer: &mut dyn Write, path: &str, size: usize, entry_type: u8, ) -> Result<(), Error> { if size > MAX_ENTRY_BYTES { return Err(archive_error("tar entry size limit")); } let (prefix, name) = split_ustar_path(path)?; let mut header = [0u8; BLOCK]; header[..name.len()].copy_from_slice(name.as_bytes()); header[100..108].copy_from_slice(&padded_octal( if entry_type == b'5' { 0o755 } else { 0o644 }, 8, )?); header[108..116].copy_from_slice(&padded_octal(0, 8)?); header[116..124].copy_from_slice(&padded_octal(0, 8)?); header[124..136].copy_from_slice(&padded_octal(size as u64, 12)?); header[136..148].copy_from_slice(&padded_octal(0, 12)?); header[148..156].fill(b' '); header[156] = entry_type; header[257..263].copy_from_slice(b"ustar\0"); header[263..265].copy_from_slice(b"00"); header[345..345 + prefix.len()].copy_from_slice(prefix.as_bytes()); let checksum = header.iter().map(|byte| u64::from(*byte)).sum(); header[148..156].copy_from_slice(&padded_octal(checksum, 8)?); writer.write_all(&header).map_err(io_error) } fn write_entry( writer: &mut dyn Write, path: &str, data: &[u8], entry_type: u8, ) -> Result<(), Error> { write_header(writer, path, data.len(), entry_type)?; writer.write_all(data).map_err(io_error)?; let padding = (BLOCK - data.len() % BLOCK) % BLOCK; if padding != 0 { writer.write_all(&vec![0; padding]).map_err(io_error)?; } Ok(()) } struct ReaderState { stream: Box, entries: usize, total_bytes: usize, finished: bool, } pub struct TarArchiveReader { state: Mutex, } impl std::fmt::Debug for TarArchiveReader { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("TarArchiveReader") .finish_non_exhaustive() } } impl TarArchiveReader { pub fn new(stream: Box) -> Result { Ok(Self { state: Mutex::new(ReaderState { stream, entries: 0, total_bytes: 0, finished: false, }), }) } pub fn close(&self) -> Result<(), Error> { self.state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .finished = true; Ok(()) } pub fn convert_octal_bytes_to_decimal( bytes: Vec, start_index: i32, count: i32, ) -> Result { let start = usize::try_from(start_index).map_err(|_| Error::IndexOutOfRange)?; let count = usize::try_from(count).map_err(|_| Error::IndexOutOfRange)?; let end = start.checked_add(count).ok_or(Error::IndexOutOfRange)?; let value = octal(bytes.get(start..end).ok_or(Error::IndexOutOfRange)?)?; i32::try_from(value).map_err(|_| Error::IndexOutOfRange) } pub fn read_entry( &self, file_path: &mut String, entry_type: &mut TarArchiveReaderTarEntryType, ) -> Result>, Error> { let mut state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if state.finished { return Ok(None); } let mut header = [0u8; BLOCK]; state.stream.read_exact(&mut header).map_err(io_error)?; if header.iter().all(|byte| *byte == 0) { state.finished = true; return Ok(None); } let recorded = octal(&header[148..156])?; let mut checksum_header = header; checksum_header[148..156].fill(b' '); let computed: u64 = checksum_header.iter().map(|byte| u64::from(*byte)).sum(); if recorded != computed { return Err(archive_error("tar checksum")); } let name = nul_string(&header[..100])?; let prefix = nul_string(&header[345..500])?; let path = if prefix.is_empty() { name } else { format!("{prefix}/{name}") }; validate_archive_path(&path)?; let size = usize::try_from(octal(&header[124..136])?) .map_err(|_| archive_error("tar size overflow"))?; if size > MAX_ENTRY_BYTES { return Err(archive_error("tar entry size limit")); } state.entries = state .entries .checked_add(1) .ok_or_else(|| archive_error("tar entry count overflow"))?; state.total_bytes = state .total_bytes .checked_add(size) .ok_or_else(|| archive_error("tar total size overflow"))?; if state.entries > MAX_ENTRIES || state.total_bytes > MAX_TOTAL_BYTES { return Err(archive_error("tar archive limits")); } let kind = match header[156] { 0 | b'0' => TarArchiveReaderTarEntryType::TYPE_NORMAL_FILE, b'1' => TarArchiveReaderTarEntryType::TYPE_HARD_LINK, b'2' => TarArchiveReaderTarEntryType::TYPE_SYMBOLIC_LINK, b'3' => TarArchiveReaderTarEntryType::TYPE_CHAR_SPECIAL, b'4' => TarArchiveReaderTarEntryType::TYPE_BLOCK_SPECIAL, b'5' => TarArchiveReaderTarEntryType::TYPE_DIRECTORY, b'6' => TarArchiveReaderTarEntryType::TYPE_FIFO, b'7' => TarArchiveReaderTarEntryType::TYPE_CONTIGUOUS_FILE, _ => TarArchiveReaderTarEntryType::TYPE_UNKNOWN, }; if matches!( kind, TarArchiveReaderTarEntryType::TYPE_SYMBOLIC_LINK | TarArchiveReaderTarEntryType::TYPE_HARD_LINK ) { return Err(archive_error("tar links are not permitted")); } let mut data = vec![0u8; size]; state.stream.read_exact(&mut data).map_err(io_error)?; let padding = (BLOCK - size % BLOCK) % BLOCK; if padding != 0 { state .stream .seek(SeekFrom::Current(padding as i64)) .map_err(io_error)?; } *file_path = path; *entry_type = kind; Ok(Some(data)) } } fn nul_string(bytes: &[u8]) -> Result { let end = bytes .iter() .position(|byte| *byte == 0) .unwrap_or(bytes.len()); std::str::from_utf8(&bytes[..end]) .map(str::to_owned) .map_err(|_| archive_error("tar path UTF-8")) } struct WriterState { stream: Box, entries: usize, total_bytes: usize, closed: bool, } pub struct TarArchiveWriter { state: Mutex, } #[derive(Clone)] pub struct OarFileAssetLoadedCallback( std::sync::Arc, ); impl OarFileAssetLoadedCallback { pub fn from_callback( callback: impl Fn(crate::assets::Asset, i64, i64) + Send + Sync + 'static, ) -> Self { Self(std::sync::Arc::new(callback)) } pub fn new( _object: libremetaverse_types::compat::Object, _method: isize, ) -> Result { Err(Error::Argument) } pub fn invoke( &self, asset: crate::assets::Asset, bytes_read: i64, total_bytes: i64, ) -> Result<(), Error> { (self.0)(asset, bytes_read, total_bytes); Ok(()) } pub fn begin_invoke( &self, asset: crate::assets::Asset, bytes_read: i64, total_bytes: i64, callback: AsyncCallback, _object: libremetaverse_types::compat::Object, ) -> Result, Error> { self.invoke(asset, bytes_read, total_bytes)?; callback(&()); Ok(Box::new(())) } pub fn end_invoke(&self, _result: Box) -> Result<(), Error> { Ok(()) } } #[derive(Clone)] pub struct OarFileTerrainLoadedCallback(std::sync::Arc, i64, i64) + Send + Sync>); impl OarFileTerrainLoadedCallback { pub fn from_callback(callback: impl Fn(Vec, i64, i64) + Send + Sync + 'static) -> Self { Self(std::sync::Arc::new(callback)) } pub fn new( _object: libremetaverse_types::compat::Object, _method: isize, ) -> Result { Err(Error::Argument) } pub fn invoke( &self, terrain: Vec, bytes_read: i64, total_bytes: i64, ) -> Result<(), Error> { (self.0)(terrain, bytes_read, total_bytes); Ok(()) } pub fn begin_invoke( &self, terrain: Vec, bytes_read: i64, total_bytes: i64, callback: AsyncCallback, _object: libremetaverse_types::compat::Object, ) -> Result, Error> { self.invoke(terrain, bytes_read, total_bytes)?; callback(&()); Ok(Box::new(())) } pub fn end_invoke(&self, _result: Box) -> Result<(), Error> { Ok(()) } } #[derive(Clone)] pub struct OarFileSettingsLoadedCallback( std::sync::Arc, ); impl OarFileSettingsLoadedCallback { pub fn from_callback( callback: impl Fn(String, RegionSettings) + Send + Sync + 'static, ) -> Self { Self(std::sync::Arc::new(callback)) } pub fn new( _object: libremetaverse_types::compat::Object, _method: isize, ) -> Result { Err(Error::Argument) } pub fn invoke(&self, region_name: String, settings: RegionSettings) -> Result<(), Error> { (self.0)(region_name, settings); Ok(()) } pub fn begin_invoke( &self, region_name: String, settings: RegionSettings, callback: AsyncCallback, _object: libremetaverse_types::compat::Object, ) -> Result, Error> { self.invoke(region_name, settings)?; callback(&()); Ok(Box::new(())) } pub fn end_invoke(&self, _result: Box) -> Result<(), Error> { Ok(()) } } #[derive(Clone)] pub struct OarFileSceneObjectLoadedCallback( std::sync::Arc, ); impl OarFileSceneObjectLoadedCallback { pub fn from_callback( callback: impl Fn(crate::assets::AssetPrim, i64, i64) + Send + Sync + 'static, ) -> Self { Self(std::sync::Arc::new(callback)) } pub fn new( _object: libremetaverse_types::compat::Object, _method: isize, ) -> Result { Err(Error::Argument) } pub fn invoke( &self, linkset: crate::assets::AssetPrim, bytes_read: i64, total_bytes: i64, ) -> Result<(), Error> { (self.0)(linkset, bytes_read, total_bytes); Ok(()) } pub fn begin_invoke( &self, linkset: crate::assets::AssetPrim, bytes_read: i64, total_bytes: i64, callback: AsyncCallback, _object: libremetaverse_types::compat::Object, ) -> Result, Error> { self.invoke(linkset, bytes_read, total_bytes)?; callback(&()); Ok(Box::new(())) } pub fn end_invoke(&self, _result: Box) -> Result<(), Error> { Ok(()) } } #[derive(Clone, Debug)] pub struct RegionSettings { pub agent_limit: i32, pub allow_damage: bool, pub allow_land_join_divide: bool, pub allow_land_resell: bool, pub block_fly: bool, pub block_land_show_in_search: bool, pub block_terraform: bool, pub disable_collisions: bool, pub disable_physics: bool, pub disable_scripts: bool, pub fixed_sun: bool, pub maturity_rating: i32, pub object_bonus: f32, pub restrict_pushing: bool, pub terrain_detail0: UUID, pub terrain_detail1: UUID, pub terrain_detail2: UUID, pub terrain_detail3: UUID, pub terrain_height_range00: f32, pub terrain_height_range01: f32, pub terrain_height_range10: f32, pub terrain_height_range11: f32, pub terrain_lower_limit: f32, pub terrain_raise_limit: f32, pub terrain_start_height00: f32, pub terrain_start_height01: f32, pub terrain_start_height10: f32, pub terrain_start_height11: f32, pub use_estate_sun: bool, pub water_height: f32, } impl RegionSettings { pub fn new() -> Result { Ok(Self::default()) } pub fn from_stream(mut stream: Box) -> Result { stream.seek(SeekFrom::Start(0)).map_err(io_error)?; let mut bytes = Vec::new(); stream .take((MAX_ENTRY_BYTES + 1) as u64) .read_to_end(&mut bytes) .map_err(io_error)?; if bytes.len() > MAX_ENTRY_BYTES { return Err(Error::Argument); } let text = std::str::from_utf8(&bytes).map_err(|_| archive_error("region settings UTF-8"))?; let document = roxmltree::Document::parse(text).map_err(|_| archive_error("region settings XML"))?; if !document.root_element().has_tag_name("RegionSettings") { return Err(archive_error("region settings root")); } let mut settings = Self::default(); for node in document.descendants().filter(|node| node.is_element()) { let text = node.text().unwrap_or_default().trim(); match node.tag_name().name() { "AllowDamage" => settings.allow_damage = parse_bool(text)?, "AllowLandResell" => settings.allow_land_resell = parse_bool(text)?, "AllowLandJoinDivide" => settings.allow_land_join_divide = parse_bool(text)?, "BlockFly" => settings.block_fly = parse_bool(text)?, "BlockLandShowInSearch" => settings.block_land_show_in_search = parse_bool(text)?, "BlockTerraform" => settings.block_terraform = parse_bool(text)?, "DisableCollisions" => settings.disable_collisions = parse_bool(text)?, "DisablePhysics" => settings.disable_physics = parse_bool(text)?, "DisableScripts" => settings.disable_scripts = parse_bool(text)?, "FixedSun" => settings.fixed_sun = parse_bool(text)?, "RestrictPushing" => settings.restrict_pushing = parse_bool(text)?, "UseEstateSun" => settings.use_estate_sun = parse_bool(text)?, "MaturityRating" => settings.maturity_rating = parse_i32(text)?, "AgentLimit" => settings.agent_limit = parse_i32(text)?, "ObjectBonus" => settings.object_bonus = parse_f32(text)?, "WaterHeight" => settings.water_height = parse_f32(text)?, "TerrainRaiseLimit" => settings.terrain_raise_limit = parse_f32(text)?, "TerrainLowerLimit" => settings.terrain_lower_limit = parse_f32(text)?, "Texture1" => settings.terrain_detail0 = UUID::new_with_string(text.to_owned())?, "Texture2" => settings.terrain_detail1 = UUID::new_with_string(text.to_owned())?, "Texture3" => settings.terrain_detail2 = UUID::new_with_string(text.to_owned())?, "Texture4" => settings.terrain_detail3 = UUID::new_with_string(text.to_owned())?, "ElevationLowSW" => settings.terrain_start_height00 = parse_f32(text)?, "ElevationLowNW" => settings.terrain_start_height01 = parse_f32(text)?, "ElevationLowSE" => settings.terrain_start_height10 = parse_f32(text)?, "ElevationLowNE" => settings.terrain_start_height11 = parse_f32(text)?, "ElevationHighSW" => settings.terrain_height_range00 = parse_f32(text)?, "ElevationHighNW" => settings.terrain_height_range01 = parse_f32(text)?, "ElevationHighSE" => settings.terrain_height_range10 = parse_f32(text)?, "ElevationHighNE" => settings.terrain_height_range11 = parse_f32(text)?, _ => {} } } Ok(settings) } pub fn to_xml(&self, filename: String) -> Result<(), Error> { fs::write(filename, self.xml()).map_err(io_error) } fn xml(&self) -> String { format!( "\n{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}\n", self.allow_damage, self.allow_land_resell, self.allow_land_join_divide, self.block_fly, self.block_land_show_in_search, self.block_terraform, self.disable_collisions, self.disable_physics, self.disable_scripts, self.maturity_rating, self.restrict_pushing, self.agent_limit, self.object_bonus, self.terrain_detail0.to_string(), self.terrain_detail1.to_string(), self.terrain_detail2.to_string(), self.terrain_detail3.to_string(), self.terrain_start_height00, self.terrain_start_height01, self.terrain_start_height10, self.terrain_start_height11, self.terrain_height_range00, self.terrain_height_range01, self.terrain_height_range10, self.terrain_height_range11, self.water_height, self.terrain_raise_limit, self.terrain_lower_limit, self.use_estate_sun, self.fixed_sun ) } } impl Default for RegionSettings { fn default() -> Self { Self { agent_limit: 0, allow_damage: false, allow_land_join_divide: false, allow_land_resell: false, block_fly: false, block_land_show_in_search: false, block_terraform: false, disable_collisions: false, disable_physics: false, disable_scripts: false, fixed_sun: false, maturity_rating: 0, object_bonus: 0.0, restrict_pushing: false, terrain_detail0: UUID::zero(), terrain_detail1: UUID::zero(), terrain_detail2: UUID::zero(), terrain_detail3: UUID::zero(), terrain_height_range00: 0.0, terrain_height_range01: 0.0, terrain_height_range10: 0.0, terrain_height_range11: 0.0, terrain_lower_limit: 0.0, terrain_raise_limit: 0.0, terrain_start_height00: 0.0, terrain_start_height01: 0.0, terrain_start_height10: 0.0, terrain_start_height11: 0.0, use_estate_sun: false, water_height: 0.0, } } } fn parse_bool(value: &str) -> Result { match value.to_ascii_lowercase().as_str() { "true" => Ok(true), "false" => Ok(false), _ => Err(archive_error("region settings boolean")), } } fn parse_i32(value: &str) -> Result { value .parse() .map_err(|_| archive_error("region settings integer")) } fn parse_f32(value: &str) -> Result { value .parse::() .ok() .filter(|value| value.is_finite()) .ok_or_else(|| archive_error("region settings number")) } impl std::fmt::Debug for TarArchiveWriter { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("TarArchiveWriter") .finish_non_exhaustive() } } impl TarArchiveWriter { pub fn new(stream: Box) -> Result { Ok(Self { state: Mutex::new(WriterState { stream, entries: 0, total_bytes: 0, closed: false, }), }) } pub fn convert_decimal_to_padded_octal_bytes( value: i32, padding: i32, ) -> Result, Error> { padded_octal( u64::try_from(value).map_err(|_| Error::Argument)?, usize::try_from(padding).map_err(|_| Error::Argument)?, ) } pub fn write_dir(&self, dir_name: String) -> Result<(), Error> { self.write(&dir_name, &[], b'5') } pub fn write_file_with_string_bytes( &self, file_path: String, data: Vec, ) -> Result<(), Error> { self.write(&file_path, &data, b'0') } pub fn write_file_with_string_string( &self, file_path: String, data: String, ) -> Result<(), Error> { self.write(&file_path, data.as_bytes(), b'0') } fn write(&self, path: &str, data: &[u8], kind: u8) -> Result<(), Error> { let mut state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if state.closed { return Err(Error::InvalidOperation); } state.entries = state .entries .checked_add(1) .ok_or(Error::InvalidOperation)?; state.total_bytes = state .total_bytes .checked_add(data.len()) .ok_or(Error::InvalidOperation)?; if state.entries > MAX_ENTRIES || state.total_bytes > MAX_TOTAL_BYTES { return Err(archive_error("tar archive limits")); } write_entry(&mut state.stream, path, data, kind) } pub fn close(&self) -> Result<(), Error> { let mut state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if !state.closed { state .stream .write_all(&[0u8; BLOCK * 2]) .map_err(io_error)?; state.stream.flush().map_err(io_error)?; state.closed = true; } Ok(()) } } pub(crate) fn clear_asset_folder(assets_path: String) -> Result<(), Error> { let root = PathBuf::from(assets_path); if !root.is_dir() { return fs::create_dir_all(root).map_err(io_error); } for entry in fs::read_dir(&root).map_err(io_error)? { let entry = entry.map_err(io_error)?; let metadata = fs::symlink_metadata(entry.path()).map_err(io_error)?; if metadata.file_type().is_symlink() || metadata.is_file() { fs::remove_file(entry.path()).map_err(io_error)?; } else if metadata.is_dir() { fs::remove_dir_all(entry.path()).map_err(io_error)?; } } Ok(()) } fn asset_extension(asset_type: AssetType) -> Option<&'static str> { match asset_type { AssetType::Animation => Some("_animation.bvh"), AssetType::Bodypart => Some("_bodypart.txt"), AssetType::CallingCard => Some("_callingcard.txt"), AssetType::Clothing => Some("_clothing.txt"), AssetType::Folder => Some("_folder.txt"), AssetType::Gesture => Some("_gesture.txt"), AssetType::ImageJPEG => Some("_image.jpg"), AssetType::ImageTGA => Some("_image.tga"), AssetType::Landmark => Some("_landmark.txt"), AssetType::LSLBytecode => Some("_bytecode.lso"), AssetType::LSLText => Some("_script.lsl"), AssetType::Notecard => Some("_notecard.txt"), AssetType::Object => Some("_object.xml"), AssetType::Simstate => Some("_simstate.bin"), AssetType::Sound => Some("_sound.ogg"), AssetType::SoundWAV => Some("_sound.wav"), AssetType::Texture => Some("_texture.jp2"), AssetType::TextureTGA => Some("_texture.tga"), AssetType::Mesh => Some("_mesh.llmesh"), AssetType::Material => Some("_material.llsd"), AssetType::Settings => Some("_settings.llsd"), _ => None, } } pub(crate) fn asset_type_to_extension() -> libremetaverse_types::compat::FrozenDictionary { let types = [ AssetType::Animation, AssetType::Bodypart, AssetType::CallingCard, AssetType::Clothing, AssetType::Folder, AssetType::Gesture, AssetType::ImageJPEG, AssetType::ImageTGA, AssetType::Landmark, AssetType::LSLBytecode, AssetType::LSLText, AssetType::Notecard, AssetType::Object, AssetType::Simstate, AssetType::Sound, AssetType::SoundWAV, AssetType::Texture, AssetType::TextureTGA, AssetType::Mesh, AssetType::Material, AssetType::Settings, ]; libremetaverse_types::compat::FrozenDictionary( types .into_iter() .filter_map(|kind| asset_extension(kind).map(|suffix| (kind, suffix.to_owned()))) .collect(), ) } pub(crate) fn extension_to_asset_type() -> libremetaverse_types::compat::FrozenDictionary { libremetaverse_types::compat::FrozenDictionary( asset_type_to_extension() .0 .into_iter() .map(|(kind, suffix)| (suffix, kind)) .collect(), ) } fn prepare_output_directory(path: &str) -> Result { let root = PathBuf::from(path); if root.exists() { let metadata = fs::symlink_metadata(&root).map_err(io_error)?; if metadata.file_type().is_symlink() || !metadata.is_dir() { return Err(archive_error("archive output root")); } } else { fs::create_dir_all(&root).map_err(io_error)?; } Ok(root) } fn write_asset(root: &Path, id: UUID, extension: &str, data: &[u8]) -> Result<(), Error> { if data.len() > MAX_ENTRY_BYTES { return Err(archive_error("asset size limit")); } let name = format!("{}{extension}", id); validate_archive_path(&name)?; fs::write(root.join(name), data).map_err(io_error) } pub(crate) async fn save_assets( asset_manager: crate::AssetManager, asset_type: AssetType, mut assets: Vec, assets_path: String, ) -> Result<(), Error> { if assets.len() > MAX_ENTRIES { return Err(archive_error("asset count limit")); } let extension = asset_extension(asset_type).ok_or(Error::Argument)?; let root = prepare_output_directory(&assets_path)?; assets.sort_by_key(UUID::to_string); assets.dedup(); let mut total = 0usize; for id in assets { if id == UUID::zero() { continue; } let bytes = if asset_type == AssetType::Texture { asset_manager .request_image(id, None, None) .await? .map(|asset| asset.native_bytes()) } else { asset_manager .request_asset_with_uuid_asset_type_boolean_cancellation_token( id, asset_type, false, None, ) .await? .map(|asset| asset.asset_data) }; if let Some(bytes) = bytes { total = total .checked_add(bytes.len()) .ok_or_else(|| archive_error("asset total size overflow"))?; if total > MAX_TOTAL_BYTES { return Err(archive_error("asset total size limit")); } write_asset(&root, id, extension, &bytes)?; } } Ok(()) } pub(crate) async fn save_sim_asset( asset_manager: crate::AssetManager, asset_type: AssetType, asset_id: UUID, item_id: UUID, prim_id: UUID, assets_path: String, ) -> Result<(), Error> { let extension = asset_extension(asset_type).ok_or(Error::Argument)?; let root = prepare_output_directory(&assets_path)?; let asset = asset_manager .request_asset_with_uuid_uuid_uuid_asset_type_boolean_source_type_uuid_cancellation_token( asset_id, item_id, prim_id, asset_type, false, crate::SourceType::SimInventoryItem, UUID::random()?, None, ) .await?; if let Some(asset) = asset { write_asset(&root, asset_id, extension, &asset.asset_data)?; } Ok(()) } pub(crate) fn package_archive(directory_name: String, filename: String) -> Result<(), Error> { let root = fs::canonicalize(&directory_name).map_err(io_error)?; if !root.is_dir() { return Err(Error::Argument); } let output = File::create(filename).map_err(io_error)?; let mut gzip = GzBuilder::new() .mtime(0) .write(output, Compression::default()); write_entry(&mut gzip, "archive.xml", ARCHIVE_XML.as_bytes(), b'0')?; let mut entries = Vec::new(); for directory in OAR_DIRECTORIES { let path = root.join(directory); if path.is_dir() { collect_files(&root, &path, 1, &mut entries)?; } } entries.sort_by(|left, right| left.0.cmp(&right.0)); let mut total = ARCHIVE_XML.len(); if entries.len() + 1 > MAX_ENTRIES { return Err(archive_error("OAR entry count limit")); } for (archive_path, disk_path) in entries { let data = fs::read(disk_path).map_err(io_error)?; total = total .checked_add(data.len()) .ok_or_else(|| archive_error("OAR total size overflow"))?; if data.len() > MAX_ENTRY_BYTES || total > MAX_TOTAL_BYTES { return Err(archive_error("OAR size limit")); } write_entry(&mut gzip, &archive_path, &data, b'0')?; } gzip.write_all(&[0u8; BLOCK * 2]).map_err(io_error)?; gzip.finish().map_err(io_error)?; Ok(()) } fn collect_files( root: &Path, directory: &Path, depth: usize, result: &mut Vec<(String, PathBuf)>, ) -> Result<(), Error> { if depth > MAX_PATH_DEPTH { return Err(archive_error("OAR directory depth limit")); } let mut children = fs::read_dir(directory) .map_err(io_error)? .collect::, _>>() .map_err(io_error)?; children.sort_by_key(std::fs::DirEntry::file_name); for child in children { let metadata = fs::symlink_metadata(child.path()).map_err(io_error)?; if metadata.file_type().is_symlink() { return Err(archive_error("OAR symlink rejected")); } if metadata.is_dir() { collect_files(root, &child.path(), depth + 1, result)?; } else if metadata.is_file() { let relative = child .path() .strip_prefix(root) .map_err(|_| archive_error("OAR extraction root"))? .to_string_lossy() .replace('\\', "/"); validate_archive_path(&relative)?; result.push((relative, child.path())); } } Ok(()) } pub(crate) fn unpackage_archive( filename: String, asset_callback: OarFileAssetLoadedCallback, terrain_callback: OarFileTerrainLoadedCallback, _object_callback: OarFileSceneObjectLoadedCallback, settings_callback: OarFileSettingsLoadedCallback, ) -> Result<(), Error> { let file = File::open(filename).map_err(io_error)?; let total = i64::try_from(file.metadata().map_err(io_error)?.len()) .map_err(|_| Error::InvalidOperation)?; let decoder = GzDecoder::new(file); let mut archive = tar::Archive::new(decoder); let mut count = 0usize; let mut expanded = 0usize; for item in archive.entries().map_err(io_error)? { let entry = item.map_err(io_error)?; count += 1; if count > MAX_ENTRIES { return Err(archive_error("OAR entry count limit")); } let path = entry .path() .map_err(io_error)? .to_string_lossy() .replace('\\', "/"); validate_archive_path(&path)?; let size = usize::try_from(entry.size()).map_err(|_| archive_error("OAR entry size"))?; expanded = expanded .checked_add(size) .ok_or_else(|| archive_error("OAR expanded size overflow"))?; if size > MAX_ENTRY_BYTES || expanded > MAX_TOTAL_BYTES { return Err(archive_error("OAR expanded size limit")); } if entry.header().entry_type().is_symlink() || entry.header().entry_type().is_hard_link() { return Err(archive_error("OAR links are not permitted")); } if !entry.header().entry_type().is_file() { continue; } let mut data = Vec::with_capacity(size); entry .take((MAX_ENTRY_BYTES + 1) as u64) .read_to_end(&mut data) .map_err(io_error)?; if data.len() != size { return Err(archive_error("OAR entry length")); } if path.starts_with("terrains/") { let flattened = decode_terrain(&path, &data)?.unwrap_or_default(); if !flattened.is_empty() { terrain_callback.invoke(flattened, expanded as i64, total)?; } } else if path.starts_with("assets/") { if let Some(asset) = load_generic_asset(&path, data)? { asset_callback.invoke(asset, expanded as i64, total)?; } } else if path.starts_with("settings/") && path.ends_with(".xml") { let settings = RegionSettings::from_stream(Box::new(std::io::Cursor::new(data)))?; let name = Path::new(&path) .file_stem() .and_then(|value| value.to_str()) .ok_or_else(|| archive_error("OAR settings name"))? .to_owned(); settings_callback.invoke(name, settings)?; } } Ok(()) } fn load_generic_asset(path: &str, data: Vec) -> Result, Error> { let filename = path .rsplit('/') .next() .ok_or_else(|| archive_error("OAR asset name"))?; let Some((suffix, asset_type)) = extension_asset_type(filename) else { return Ok(None); }; let Some(id_text) = filename.strip_suffix(suffix) else { return Ok(None); }; let id = UUID::new_with_string(id_text.to_owned())?; crate::asset_models::Asset::native_new(asset_type, id, data).map(Some) } fn extension_asset_type(filename: &str) -> Option<(&'static str, AssetType)> { asset_type_to_extension() .0 .into_iter() .find_map(|(kind, _)| { asset_extension(kind) .filter(|suffix| filename.ends_with(suffix)) .map(|suffix| (suffix, kind)) }) } fn decode_terrain(path: &str, data: &[u8]) -> Result>, Error> { if !path.ends_with(".r32") && !path.ends_with(".f32") { return Ok(None); } if data.is_empty() || !data.len().is_multiple_of(4) { return Ok(None); } let posts = data.len() / 4; let side = (posts as f64).sqrt() as usize; if side == 0 || side.checked_mul(side) != Some(posts) { return Ok(None); } let mut terrain = Vec::with_capacity(posts); for bytes in data.chunks_exact(4) { let value = f32::from_le_bytes( bytes .try_into() .map_err(|_| archive_error("terrain float"))?, ); if !value.is_finite() { return Err(archive_error("terrain non-finite height")); } terrain.push(value.clamp(0.0, 255.0)); } Ok(Some(terrain)) } pub(crate) fn load_terrain( path: &str, data: &[u8], callback: TerrainCallback, bytes_read: i64, total_bytes: i64, ) -> Result { let Some(flat) = decode_terrain(path, data)? else { return Ok(false); }; let side = (flat.len() as f64).sqrt() as usize; let rows = flat.chunks_exact(side).map(<[f32]>::to_vec).collect(); callback(rows, bytes_read, total_bytes); Ok(true) } pub(crate) fn save_terrain(sim: crate::Simulator, terrain_path: String) -> Result<(), Error> { let root = PathBuf::from(terrain_path); if root.exists() { fs::remove_dir_all(&root).map_err(io_error)?; } fs::create_dir_all(&root).map_err(io_error)?; let name = sim.name.clone(); let safe_name = name.replace(['/', '\\'], "_"); let mut file = File::create(root.join(format!("{safe_name}.r32"))).map_err(io_error)?; for y in 0..sim.size_y { for x in 0..sim.size_x { let mut height = 0.0; if !sim.terrain_height_at_point(x as i32, y as i32, &mut height)? { height = 0.0; } file.write_all(&height.to_le_bytes()).map_err(io_error)?; } } file.flush().map_err(io_error) } fn has_region_flag(flags: crate::RegionFlags, flag: crate::RegionFlags) -> bool { flags.0 & flag.0 == flag.0 } pub(crate) fn save_region_settings( sim: crate::Simulator, settings_path: String, ) -> Result<(), Error> { let root = PathBuf::from(&settings_path); if root.exists() { let metadata = fs::symlink_metadata(&root).map_err(io_error)?; if metadata.file_type().is_symlink() || !metadata.is_dir() { return Err(archive_error("settings output root")); } fs::remove_dir_all(&root).map_err(io_error)?; } fs::create_dir_all(&root).map_err(io_error)?; let flags = sim.flags; let settings = RegionSettings { allow_damage: has_region_flag(flags, crate::RegionFlags::ALLOW_DAMAGE), allow_land_resell: !has_region_flag(flags, crate::RegionFlags::BLOCK_LAND_RESELL), block_fly: has_region_flag(flags, crate::RegionFlags::NO_FLY), block_land_show_in_search: has_region_flag(flags, crate::RegionFlags::BLOCK_PARCEL_SEARCH), block_terraform: has_region_flag(flags, crate::RegionFlags::BLOCK_TERRAFORM), disable_collisions: has_region_flag(flags, crate::RegionFlags::SKIP_COLLISIONS), disable_physics: has_region_flag(flags, crate::RegionFlags::SKIP_PHYSICS), disable_scripts: has_region_flag(flags, crate::RegionFlags::SKIP_SCRIPTS), fixed_sun: has_region_flag(flags, crate::RegionFlags::SUN_FIXED), maturity_rating: i32::from(sim.access.0), restrict_pushing: has_region_flag(flags, crate::RegionFlags::RESTRICT_PUSH_OBJECT), terrain_detail0: sim.terrain_detail0, terrain_detail1: sim.terrain_detail1, terrain_detail2: sim.terrain_detail2, terrain_detail3: sim.terrain_detail3, terrain_height_range00: sim.terrain_height_range00, terrain_height_range01: sim.terrain_height_range01, terrain_height_range10: sim.terrain_height_range10, terrain_height_range11: sim.terrain_height_range11, terrain_start_height00: sim.terrain_start_height00, terrain_start_height01: sim.terrain_start_height01, terrain_start_height10: sim.terrain_start_height10, terrain_start_height11: sim.terrain_start_height11, water_height: sim.water_height, ..RegionSettings::default() }; let safe_name = sim.name.replace(['/', '\\'], "_"); settings.to_xml( root.join(format!("{safe_name}.xml")) .to_string_lossy() .into_owned(), ) } #[cfg(test)] mod tests { use super::{ BLOCK, MAX_ENTRY_BYTES, MAX_PATH_DEPTH, TarArchiveReader, padded_octal, validate_archive_path, }; use crate::assets::TarArchiveReaderTarEntryType; #[test] fn rejects_absolute_parent_and_windows_style_archive_paths() { for path in [ "/etc/passwd", "../escape", "assets/../../escape", "C:\\escape", "assets\\file", ] { assert!( validate_archive_path(path).is_err(), "accepted unsafe path {path}" ); } } #[test] fn rejects_archive_paths_over_depth_limit() { let path = std::iter::repeat_n("directory", MAX_PATH_DEPTH + 1) .collect::>() .join("/"); assert!(validate_archive_path(&path).is_err()); } #[test] fn octal_fields_are_fixed_width_and_nul_terminated() { let encoded = padded_octal(0o1234, 8).expect("octal field"); assert_eq!(encoded.len(), 8); assert_eq!(encoded.last(), Some(&0)); assert_eq!(&encoded[3..7], b"1234"); } #[test] fn rejects_declared_entry_over_size_limit_before_reading_payload() { let mut header = [0u8; BLOCK]; header[..9].copy_from_slice(b"large.bin"); header[100..108].copy_from_slice(&padded_octal(0o644, 8).expect("mode")); header[124..136].copy_from_slice( &padded_octal((MAX_ENTRY_BYTES + 1) as u64, 12).expect("oversized length"), ); header[148..156].fill(b' '); header[156] = b'0'; header[257..263].copy_from_slice(b"ustar\0"); header[263..265].copy_from_slice(b"00"); let checksum = header.iter().map(|byte| u64::from(*byte)).sum(); header[148..156].copy_from_slice(&padded_octal(checksum, 8).expect("checksum")); let reader = TarArchiveReader::new(Box::new(std::io::Cursor::new(header.to_vec()))) .expect("tar reader"); let mut path = String::new(); let mut kind = TarArchiveReaderTarEntryType::TYPE_UNKNOWN; assert!(reader.read_entry(&mut path, &mut kind).is_err()); } }