Files
MetaCrate/crates/libremetaverse/src/asset_archive.rs
Chili Palmer 55424dbd7f
Some checks failed
Native code generation / deterministic (push) Failing after 1m48s
Imaging and meshing gate / native (push) Successful in 5m26s
JPEG 2000 feature / linux (push) Successful in 2m51s
Native Rust workspace compile / compile (push) Successful in 5m28s
Skia feature / linux (push) Successful in 31m53s
Implement native archive and model workflows (#74)
2026-08-10 20:31:34 +00:00

1262 lines
45 KiB
Rust

//! 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 = "<?xml version=\"1.0\" encoding=\"utf-16\"?>\n<archive major_version=\"0\" minor_version=\"1\" />";
const OAR_DIRECTORIES: [&str; 5] = ["assets", "objects", "terrains", "landdata", "settings"];
type AsyncCallback = Box<dyn Fn(&dyn std::any::Any) + Send + Sync>;
type TerrainCallback = Box<dyn Fn(Vec<Vec<f32>>, 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<u64, Error> {
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<Vec<u8>, 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<dyn ReadWrite + Send>,
entries: usize,
total_bytes: usize,
finished: bool,
}
pub struct TarArchiveReader {
state: Mutex<ReaderState>,
}
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<dyn ReadWrite + Send>) -> Result<Self, Error> {
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<u8>,
start_index: i32,
count: i32,
) -> Result<i32, Error> {
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<Option<Vec<u8>>, 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<String, Error> {
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<dyn ReadWrite + Send>,
entries: usize,
total_bytes: usize,
closed: bool,
}
pub struct TarArchiveWriter {
state: Mutex<WriterState>,
}
#[derive(Clone)]
pub struct OarFileAssetLoadedCallback(
std::sync::Arc<dyn Fn(crate::assets::Asset, i64, i64) + Send + Sync>,
);
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<Self, Error> {
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<Box<dyn std::any::Any + Send + Sync>, Error> {
self.invoke(asset, bytes_read, total_bytes)?;
callback(&());
Ok(Box::new(()))
}
pub fn end_invoke(&self, _result: Box<dyn std::any::Any + Send + Sync>) -> Result<(), Error> {
Ok(())
}
}
#[derive(Clone)]
pub struct OarFileTerrainLoadedCallback(std::sync::Arc<dyn Fn(Vec<f32>, i64, i64) + Send + Sync>);
impl OarFileTerrainLoadedCallback {
pub fn from_callback(callback: impl Fn(Vec<f32>, i64, i64) + Send + Sync + 'static) -> Self {
Self(std::sync::Arc::new(callback))
}
pub fn new(
_object: libremetaverse_types::compat::Object,
_method: isize,
) -> Result<Self, Error> {
Err(Error::Argument)
}
pub fn invoke(
&self,
terrain: Vec<f32>,
bytes_read: i64,
total_bytes: i64,
) -> Result<(), Error> {
(self.0)(terrain, bytes_read, total_bytes);
Ok(())
}
pub fn begin_invoke(
&self,
terrain: Vec<f32>,
bytes_read: i64,
total_bytes: i64,
callback: AsyncCallback,
_object: libremetaverse_types::compat::Object,
) -> Result<Box<dyn std::any::Any + Send + Sync>, Error> {
self.invoke(terrain, bytes_read, total_bytes)?;
callback(&());
Ok(Box::new(()))
}
pub fn end_invoke(&self, _result: Box<dyn std::any::Any + Send + Sync>) -> Result<(), Error> {
Ok(())
}
}
#[derive(Clone)]
pub struct OarFileSettingsLoadedCallback(
std::sync::Arc<dyn Fn(String, RegionSettings) + Send + Sync>,
);
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<Self, Error> {
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<Box<dyn std::any::Any + Send + Sync>, Error> {
self.invoke(region_name, settings)?;
callback(&());
Ok(Box::new(()))
}
pub fn end_invoke(&self, _result: Box<dyn std::any::Any + Send + Sync>) -> Result<(), Error> {
Ok(())
}
}
#[derive(Clone)]
pub struct OarFileSceneObjectLoadedCallback(
std::sync::Arc<dyn Fn(crate::assets::AssetPrim, i64, i64) + Send + Sync>,
);
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<Self, Error> {
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<Box<dyn std::any::Any + Send + Sync>, Error> {
self.invoke(linkset, bytes_read, total_bytes)?;
callback(&());
Ok(Box::new(()))
}
pub fn end_invoke(&self, _result: Box<dyn std::any::Any + Send + Sync>) -> 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<Self, Error> {
Ok(Self::default())
}
pub fn from_stream(mut stream: Box<dyn ReadWrite + Send>) -> Result<Self, Error> {
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!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<RegionSettings><General><AllowDamage>{}</AllowDamage><AllowLandResell>{}</AllowLandResell><AllowLandJoinDivide>{}</AllowLandJoinDivide><BlockFly>{}</BlockFly><BlockLandShowInSearch>{}</BlockLandShowInSearch><BlockTerraform>{}</BlockTerraform><DisableCollisions>{}</DisableCollisions><DisablePhysics>{}</DisablePhysics><DisableScripts>{}</DisableScripts><MaturityRating>{}</MaturityRating><RestrictPushing>{}</RestrictPushing><AgentLimit>{}</AgentLimit><ObjectBonus>{}</ObjectBonus></General><GroundTextures><Texture1>{}</Texture1><Texture2>{}</Texture2><Texture3>{}</Texture3><Texture4>{}</Texture4><ElevationLowSW>{}</ElevationLowSW><ElevationLowNW>{}</ElevationLowNW><ElevationLowSE>{}</ElevationLowSE><ElevationLowNE>{}</ElevationLowNE><ElevationHighSW>{}</ElevationHighSW><ElevationHighNW>{}</ElevationHighNW><ElevationHighSE>{}</ElevationHighSE><ElevationHighNE>{}</ElevationHighNE></GroundTextures><Terrain><WaterHeight>{}</WaterHeight><TerrainRaiseLimit>{}</TerrainRaiseLimit><TerrainLowerLimit>{}</TerrainLowerLimit><UseEstateSun>{}</UseEstateSun><FixedSun>{}</FixedSun></Terrain></RegionSettings>\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<bool, Error> {
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<i32, Error> {
value
.parse()
.map_err(|_| archive_error("region settings integer"))
}
fn parse_f32(value: &str) -> Result<f32, Error> {
value
.parse::<f32>()
.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<dyn ReadWrite + Send>) -> Result<Self, Error> {
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<Vec<u8>, 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<u8>,
) -> 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<AssetType, String> {
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<String, AssetType> {
libremetaverse_types::compat::FrozenDictionary(
asset_type_to_extension()
.0
.into_iter()
.map(|(kind, suffix)| (suffix, kind))
.collect(),
)
}
fn prepare_output_directory(path: &str) -> Result<PathBuf, Error> {
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<UUID>,
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::<Result<Vec<_>, _>>()
.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<u8>) -> Result<Option<crate::assets::Asset>, 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<Option<Vec<f32>>, 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<bool, Error> {
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::<Vec<_>>()
.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());
}
}