All checks were successful
Native Rust workspace compile / compile (push) Successful in 21m44s
1060 lines
35 KiB
Rust
1060 lines
35 KiB
Rust
//! Bounded native inventory browsing, statistics, search, and text export.
|
|
|
|
use clap::Parser;
|
|
use clap::error::ErrorKind;
|
|
use libremetaverse::structured_data::{OSD, OSDParser};
|
|
use libremetaverse::types::compat::{CancellationTokenSource, Subscription};
|
|
use libremetaverse::types::{AssetType, UUID};
|
|
use libremetaverse::{
|
|
DisconnectedEventArgs, GridClient, Inventory, InventoryAISClient, InventoryFolder,
|
|
InventoryManager, InventoryObjectClass, LoginProgressEventArgs, NetworkManager,
|
|
};
|
|
use std::cmp::Ordering;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::fmt;
|
|
use std::fs;
|
|
use std::io::{self, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::ExitCode;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tokio::sync::mpsc;
|
|
|
|
pub const EXIT_SUCCESS: u8 = 0;
|
|
pub const EXIT_USAGE: u8 = 2;
|
|
pub const EXIT_INPUT: u8 = 3;
|
|
pub const EXIT_CLIENT: u8 = 4;
|
|
pub const EXIT_OUTPUT: u8 = 5;
|
|
|
|
const DEFAULT_LOGIN_TIMEOUT_SECONDS: u64 = 30;
|
|
const DEFAULT_INVENTORY_TIMEOUT_SECONDS: u64 = 30;
|
|
const DEFAULT_MAX_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
|
|
const DEFAULT_MAX_EXPORT_BYTES: usize = 16 * 1024 * 1024;
|
|
const DEFAULT_MAX_ENTRIES: usize = 100_000;
|
|
const MAX_FIXTURE_BYTES: u64 = 8 * 1024 * 1024;
|
|
const MAX_FIXTURE_ALLOC_BYTES: usize = 8 * 1024 * 1024;
|
|
const EVENT_QUEUE_CAPACITY: usize = 256;
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "inventory-explorer",
|
|
version,
|
|
about = "Explore, search, summarize, and export native LibreMetaverse inventory",
|
|
long_about = None,
|
|
arg_required_else_help = true
|
|
)]
|
|
struct Cli {
|
|
/// Avatar first name. May also be supplied as `GRID_FIRST_NAME`.
|
|
#[arg(value_name = "FIRSTNAME")]
|
|
first_name: Option<String>,
|
|
|
|
/// Avatar last name. May also be supplied as `GRID_LAST_NAME`.
|
|
#[arg(value_name = "LASTNAME")]
|
|
last_name: Option<String>,
|
|
|
|
/// Avatar password. May also be supplied as `GRID_PASSWORD`.
|
|
#[arg(value_name = "PASSWORD")]
|
|
password: Option<String>,
|
|
|
|
/// Search folders and items by case-insensitive name substring.
|
|
#[arg(long, value_name = "TERM")]
|
|
search: Option<String>,
|
|
|
|
/// Restrict search results to this item asset type.
|
|
#[arg(long = "type", value_name = "TYPE", value_parser = parse_asset_type)]
|
|
asset_type: Option<AssetType>,
|
|
|
|
/// Write a deterministic hierarchical inventory export.
|
|
#[arg(long, value_name = "FILE")]
|
|
export: Option<PathBuf>,
|
|
|
|
/// Print inventory statistics.
|
|
#[arg(long)]
|
|
stats: bool,
|
|
|
|
/// Parse a bounded AIS LLSD JSON response instead of logging in.
|
|
#[arg(long, value_name = "FILE")]
|
|
fake_ais: Option<PathBuf>,
|
|
|
|
/// Override the login endpoint. `GRID_LOGIN_URL` is used when absent.
|
|
#[arg(long, value_name = "URL")]
|
|
login_uri: Option<String>,
|
|
|
|
/// Maximum time allowed for login.
|
|
#[arg(long, default_value_t = DEFAULT_LOGIN_TIMEOUT_SECONDS, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))]
|
|
login_timeout_seconds: u64,
|
|
|
|
/// Maximum time to wait for a populated inventory root.
|
|
#[arg(long, default_value_t = DEFAULT_INVENTORY_TIMEOUT_SECONDS, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))]
|
|
inventory_timeout_seconds: u64,
|
|
|
|
/// Maximum search results to display.
|
|
#[arg(long, default_value_t = 50, value_name = "COUNT")]
|
|
max_results: usize,
|
|
|
|
/// Maximum hierarchy entries to traverse.
|
|
#[arg(long, default_value_t = DEFAULT_MAX_ENTRIES, value_name = "COUNT")]
|
|
max_entries: usize,
|
|
|
|
/// Maximum folder depth to traverse.
|
|
#[arg(long, default_value_t = 64, value_name = "DEPTH")]
|
|
max_depth: usize,
|
|
|
|
/// Refuse to emit more than this many stdout bytes.
|
|
#[arg(long, default_value_t = DEFAULT_MAX_OUTPUT_BYTES, value_name = "BYTES")]
|
|
max_output_bytes: usize,
|
|
|
|
/// Refuse to write a larger export file.
|
|
#[arg(long, default_value_t = DEFAULT_MAX_EXPORT_BYTES, value_name = "BYTES")]
|
|
max_export_bytes: usize,
|
|
}
|
|
|
|
struct LiveArguments {
|
|
first_name: String,
|
|
last_name: String,
|
|
password: String,
|
|
login_uri: Option<String>,
|
|
login_timeout: Duration,
|
|
inventory_timeout: Duration,
|
|
}
|
|
|
|
struct OperationOptions {
|
|
stats: bool,
|
|
search: Option<String>,
|
|
asset_type: Option<AssetType>,
|
|
export: Option<PathBuf>,
|
|
max_results: usize,
|
|
max_entries: usize,
|
|
max_depth: usize,
|
|
max_export_bytes: usize,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum ProgramError {
|
|
Usage(String),
|
|
Input { action: String, source: io::Error },
|
|
InvalidFixture(&'static str),
|
|
Client(&'static str),
|
|
LoginFailed,
|
|
LoginTimedOut,
|
|
InventoryUnavailable,
|
|
Disconnected,
|
|
Cancelled,
|
|
HierarchyCycle(UUID),
|
|
HierarchyDepth,
|
|
EntryLimit,
|
|
Output(io::Error),
|
|
OutputLimit,
|
|
Export { action: String, source: io::Error },
|
|
ExportLimit,
|
|
}
|
|
|
|
impl ProgramError {
|
|
const fn exit_code(&self) -> u8 {
|
|
match self {
|
|
Self::Usage(_) => EXIT_USAGE,
|
|
Self::Input { .. } | Self::InvalidFixture(_) => EXIT_INPUT,
|
|
Self::Client(_)
|
|
| Self::LoginFailed
|
|
| Self::LoginTimedOut
|
|
| Self::InventoryUnavailable
|
|
| Self::Disconnected
|
|
| Self::Cancelled
|
|
| Self::HierarchyCycle(_)
|
|
| Self::HierarchyDepth
|
|
| Self::EntryLimit => EXIT_CLIENT,
|
|
Self::Output(_) | Self::OutputLimit | Self::Export { .. } | Self::ExportLimit => {
|
|
EXIT_OUTPUT
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for ProgramError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Usage(message) => formatter.write_str(message),
|
|
Self::Input { action, source } | Self::Export { action, source } => {
|
|
write!(formatter, "{action}: {source}")
|
|
}
|
|
Self::InvalidFixture(reason) => write!(formatter, "invalid fake AIS fixture: {reason}"),
|
|
Self::Client(operation) => {
|
|
write!(formatter, "native client operation failed: {operation}")
|
|
}
|
|
Self::LoginFailed => formatter.write_str("login failed"),
|
|
Self::LoginTimedOut => formatter.write_str("login timed out"),
|
|
Self::InventoryUnavailable => {
|
|
formatter.write_str("inventory root did not become available")
|
|
}
|
|
Self::Disconnected => formatter.write_str("grid disconnected while loading inventory"),
|
|
Self::Cancelled => formatter.write_str("inventory operation cancelled"),
|
|
Self::HierarchyCycle(uuid) => {
|
|
write!(formatter, "inventory folder cycle detected at {uuid}")
|
|
}
|
|
Self::HierarchyDepth => {
|
|
formatter.write_str("inventory exceeds the hierarchy depth limit")
|
|
}
|
|
Self::EntryLimit => formatter.write_str("inventory exceeds the entry traversal limit"),
|
|
Self::Output(source) => write!(formatter, "writing standard output: {source}"),
|
|
Self::OutputLimit => formatter.write_str("inventory output exceeds the byte limit"),
|
|
Self::ExportLimit => formatter.write_str("inventory export exceeds the byte limit"),
|
|
}
|
|
}
|
|
}
|
|
|
|
struct BoundedOutput {
|
|
bytes: Vec<u8>,
|
|
maximum: usize,
|
|
}
|
|
|
|
impl BoundedOutput {
|
|
fn new(maximum: usize) -> Self {
|
|
Self {
|
|
bytes: Vec::new(),
|
|
maximum,
|
|
}
|
|
}
|
|
|
|
fn line(&mut self, value: impl AsRef<str>) -> Result<(), ProgramError> {
|
|
let value = safe_text(value.as_ref());
|
|
let required = value
|
|
.len()
|
|
.checked_add(1)
|
|
.ok_or(ProgramError::OutputLimit)?;
|
|
if self
|
|
.bytes
|
|
.len()
|
|
.checked_add(required)
|
|
.is_none_or(|total| total > self.maximum)
|
|
{
|
|
return Err(ProgramError::OutputLimit);
|
|
}
|
|
self.bytes.extend_from_slice(value.as_bytes());
|
|
self.bytes.push(b'\n');
|
|
Ok(())
|
|
}
|
|
|
|
fn flush(self) -> Result<(), ProgramError> {
|
|
let mut stdout = io::stdout().lock();
|
|
stdout
|
|
.write_all(&self.bytes)
|
|
.map_err(ProgramError::Output)?;
|
|
stdout.flush().map_err(ProgramError::Output)
|
|
}
|
|
}
|
|
|
|
enum LiveEvent {
|
|
Status(String),
|
|
Disconnected,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct SnapshotEntry {
|
|
uuid: UUID,
|
|
parent_uuid: UUID,
|
|
parent_name: String,
|
|
name: String,
|
|
kind: EntryKind,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum EntryKind {
|
|
Folder,
|
|
Item {
|
|
asset_type: AssetType,
|
|
target: Option<UUID>,
|
|
},
|
|
Other,
|
|
}
|
|
|
|
impl SnapshotEntry {
|
|
const fn is_folder(&self) -> bool {
|
|
matches!(self.kind, EntryKind::Folder)
|
|
}
|
|
}
|
|
|
|
struct InventorySnapshot {
|
|
root: InventoryFolder,
|
|
entries: Vec<SnapshotEntry>,
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn main_entry() -> ExitCode {
|
|
match run() {
|
|
Ok(()) => ExitCode::SUCCESS,
|
|
Err(error) => {
|
|
eprintln!("inventory-explorer: {error}");
|
|
ExitCode::from(error.exit_code())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run() -> Result<(), ProgramError> {
|
|
let cli = match Cli::try_parse() {
|
|
Ok(cli) => cli,
|
|
Err(error)
|
|
if matches!(
|
|
error.kind(),
|
|
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
|
|
) =>
|
|
{
|
|
error.print().map_err(ProgramError::Output)?;
|
|
return Ok(());
|
|
}
|
|
Err(error) => return Err(ProgramError::Usage(error.to_string())),
|
|
};
|
|
validate_cli(&cli)?;
|
|
let options = OperationOptions {
|
|
stats: cli.stats,
|
|
search: cli.search.clone(),
|
|
asset_type: cli.asset_type,
|
|
export: cli.export.clone(),
|
|
max_results: cli.max_results,
|
|
max_entries: cli.max_entries,
|
|
max_depth: cli.max_depth,
|
|
max_export_bytes: cli.max_export_bytes,
|
|
};
|
|
let mut output = BoundedOutput::new(cli.max_output_bytes);
|
|
if let Some(path) = &cli.fake_ais {
|
|
run_fake(path, &options, &mut output)?;
|
|
} else {
|
|
let arguments = resolve_live_arguments(&cli)?;
|
|
let runtime =
|
|
tokio::runtime::Runtime::new().map_err(|_| ProgramError::Client("start runtime"))?;
|
|
runtime.block_on(run_live(arguments, &options, &mut output))?;
|
|
}
|
|
output.flush()
|
|
}
|
|
|
|
fn validate_cli(cli: &Cli) -> Result<(), ProgramError> {
|
|
if cli
|
|
.search
|
|
.as_ref()
|
|
.is_some_and(|value| value.is_empty() || value.chars().count() > 256)
|
|
{
|
|
return Err(ProgramError::Usage(
|
|
"--search must contain 1 to 256 characters".into(),
|
|
));
|
|
}
|
|
if cli.fake_ais.is_some()
|
|
&& (cli.first_name.is_some()
|
|
|| cli.last_name.is_some()
|
|
|| cli.password.is_some()
|
|
|| cli.login_uri.is_some())
|
|
{
|
|
return Err(ProgramError::Usage(
|
|
"live credentials and --login-uri cannot be combined with --fake-ais".into(),
|
|
));
|
|
}
|
|
if !(1..=1_000).contains(&cli.max_results) {
|
|
return Err(ProgramError::Usage(
|
|
"--max-results must be between 1 and 1000".into(),
|
|
));
|
|
}
|
|
if !(1..=1_000_000).contains(&cli.max_entries) {
|
|
return Err(ProgramError::Usage(
|
|
"--max-entries must be between 1 and 1000000".into(),
|
|
));
|
|
}
|
|
if !(1..=512).contains(&cli.max_depth) {
|
|
return Err(ProgramError::Usage(
|
|
"--max-depth must be between 1 and 512".into(),
|
|
));
|
|
}
|
|
if cli.max_output_bytes == 0 || cli.max_export_bytes == 0 {
|
|
return Err(ProgramError::Usage(
|
|
"output byte limits must be greater than zero".into(),
|
|
));
|
|
}
|
|
if cli.asset_type.is_some() && cli.search.is_none() {
|
|
return Err(ProgramError::Usage("--type requires --search".into()));
|
|
}
|
|
if let Some(path) = &cli.export {
|
|
validate_export_path(path)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_export_path(path: &Path) -> Result<(), ProgramError> {
|
|
if path.as_os_str().is_empty() || path.to_string_lossy().chars().count() > 4_096 {
|
|
return Err(ProgramError::Usage(
|
|
"--export path must contain 1 to 4096 characters".into(),
|
|
));
|
|
}
|
|
if fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
|
|
return Err(ProgramError::Usage(
|
|
"--export refuses to follow a symbolic link".into(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn resolve_live_arguments(cli: &Cli) -> Result<LiveArguments, ProgramError> {
|
|
fn required(
|
|
value: Option<&String>,
|
|
variable: &str,
|
|
label: &str,
|
|
) -> Result<String, ProgramError> {
|
|
value
|
|
.cloned()
|
|
.or_else(|| std::env::var(variable).ok())
|
|
.filter(|value| !value.is_empty())
|
|
.ok_or_else(|| ProgramError::Usage(format!("{label} is required (or set {variable})")))
|
|
}
|
|
Ok(LiveArguments {
|
|
first_name: required(cli.first_name.as_ref(), "GRID_FIRST_NAME", "FIRSTNAME")?,
|
|
last_name: required(cli.last_name.as_ref(), "GRID_LAST_NAME", "LASTNAME")?,
|
|
password: required(cli.password.as_ref(), "GRID_PASSWORD", "PASSWORD")?,
|
|
login_uri: cli
|
|
.login_uri
|
|
.clone()
|
|
.or_else(|| std::env::var("GRID_LOGIN_URL").ok())
|
|
.filter(|value| !value.is_empty()),
|
|
login_timeout: Duration::from_secs(cli.login_timeout_seconds),
|
|
inventory_timeout: Duration::from_secs(cli.inventory_timeout_seconds),
|
|
})
|
|
}
|
|
|
|
async fn run_live(
|
|
mut arguments: LiveArguments,
|
|
options: &OperationOptions,
|
|
output: &mut BoundedOutput,
|
|
) -> Result<(), ProgramError> {
|
|
let client = GridClient::new().map_err(|_| ProgramError::Client("construct GridClient"))?;
|
|
let network = client.network();
|
|
let inventory = client.inventory();
|
|
let mut login = network
|
|
.default_login_params(
|
|
std::mem::take(&mut arguments.first_name),
|
|
std::mem::take(&mut arguments.last_name),
|
|
std::mem::take(&mut arguments.password),
|
|
"InventoryExplorer".into(),
|
|
env!("CARGO_PKG_VERSION").into(),
|
|
)
|
|
.map_err(|_| ProgramError::Client("build login parameters"))?;
|
|
if let Some(uri) = arguments.login_uri.take() {
|
|
login.uri = uri;
|
|
}
|
|
let (sender, mut receiver) = mpsc::channel(EVENT_QUEUE_CAPACITY);
|
|
let subscriptions = install_subscriptions(&network, &sender);
|
|
if let Err(error) = output.line("Logging in...") {
|
|
shutdown(&client, &network, subscriptions);
|
|
return Err(error);
|
|
}
|
|
let cancellation = CancellationTokenSource::new();
|
|
let login_result = tokio::select! {
|
|
result = network.login_with_login_params_cancellation_token(login, Some(cancellation.token())) => {
|
|
result.map_err(|_| ProgramError::LoginFailed)
|
|
}
|
|
() = tokio::time::sleep(arguments.login_timeout) => {
|
|
cancellation.cancel();
|
|
let _ = network.abort_login();
|
|
Err(ProgramError::LoginTimedOut)
|
|
}
|
|
signal = tokio::signal::ctrl_c() => {
|
|
cancellation.cancel();
|
|
let _ = network.abort_login();
|
|
signal.map_err(|_| ProgramError::Cancelled)?;
|
|
Err(ProgramError::Cancelled)
|
|
}
|
|
};
|
|
let result = async {
|
|
if !login_result? {
|
|
return Err(ProgramError::LoginFailed);
|
|
}
|
|
output.line("Logged in successfully")?;
|
|
output.line("Downloading inventory...")?;
|
|
let store = wait_for_inventory(
|
|
&inventory,
|
|
arguments.inventory_timeout,
|
|
&mut receiver,
|
|
output,
|
|
)
|
|
.await?;
|
|
output.line(format!("Inventory loaded: {} entries", store.count()))?;
|
|
process_store(&store, options, output)
|
|
}
|
|
.await;
|
|
shutdown(&client, &network, subscriptions);
|
|
result
|
|
}
|
|
|
|
fn install_subscriptions(
|
|
network: &NetworkManager,
|
|
sender: &mpsc::Sender<LiveEvent>,
|
|
) -> Vec<Subscription> {
|
|
let status = sender.clone();
|
|
let disconnected = sender.clone();
|
|
vec![
|
|
network.subscribe_login_progress(Arc::new(move |event: LoginProgressEventArgs| {
|
|
let _ = status.try_send(LiveEvent::Status(format!(
|
|
"Login {:?}: {}",
|
|
event.status(),
|
|
event.message()
|
|
)));
|
|
})),
|
|
network.subscribe_disconnected(Arc::new(move |_event: DisconnectedEventArgs| {
|
|
let _ = disconnected.try_send(LiveEvent::Disconnected);
|
|
})),
|
|
]
|
|
}
|
|
|
|
async fn wait_for_inventory(
|
|
manager: &InventoryManager,
|
|
timeout: Duration,
|
|
receiver: &mut mpsc::Receiver<LiveEvent>,
|
|
output: &mut BoundedOutput,
|
|
) -> Result<Inventory, ProgramError> {
|
|
let deadline = tokio::time::Instant::now() + timeout;
|
|
loop {
|
|
if let Some(store) = manager.store()
|
|
&& store.count() > 0
|
|
&& store.root_folder().is_some()
|
|
{
|
|
return Ok(store);
|
|
}
|
|
tokio::select! {
|
|
() = tokio::time::sleep(Duration::from_millis(100)) => {},
|
|
() = tokio::time::sleep_until(deadline) => return Err(ProgramError::InventoryUnavailable),
|
|
signal = tokio::signal::ctrl_c() => {
|
|
signal.map_err(|_| ProgramError::Cancelled)?;
|
|
return Err(ProgramError::Cancelled);
|
|
}
|
|
event = receiver.recv() => match event {
|
|
Some(LiveEvent::Status(status)) => output.line(status)?,
|
|
Some(LiveEvent::Disconnected) => return Err(ProgramError::Disconnected),
|
|
None => return Err(ProgramError::Client("inventory event queue closed")),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn shutdown(client: &GridClient, network: &NetworkManager, subscriptions: Vec<Subscription>) {
|
|
drop(subscriptions);
|
|
let _ = network.logout_with_method();
|
|
let _ = client.dispose_with_method();
|
|
}
|
|
|
|
fn run_fake(
|
|
path: &Path,
|
|
options: &OperationOptions,
|
|
output: &mut BoundedOutput,
|
|
) -> Result<(), ProgramError> {
|
|
let root = read_fixture(path)?;
|
|
let client =
|
|
Arc::new(GridClient::new().map_err(|_| ProgramError::Client("construct fake GridClient"))?);
|
|
let result = (|| {
|
|
let manager = client.inventory();
|
|
let mut store = manager
|
|
.store()
|
|
.ok_or(ProgramError::Client("resolve fake inventory store"))?;
|
|
let ais = InventoryAISClient::new(Some(Arc::clone(&client)))
|
|
.map_err(|_| ProgramError::Client("construct AIS parser"))?;
|
|
let root_id = match &root {
|
|
OSD::Map(map) => map.get("root_id").and_then(|value| value.as_uuid().ok()),
|
|
_ => None,
|
|
}
|
|
.filter(|uuid| *uuid != UUID::zero())
|
|
.ok_or(ProgramError::InvalidFixture(
|
|
"root_id must be a non-zero UUID",
|
|
))?;
|
|
let mut folders = None;
|
|
let mut items = None;
|
|
let mut links = None;
|
|
ais.parse_embedded(root, &mut folders, &mut items, &mut links)
|
|
.map_err(|_| ProgramError::InvalidFixture("AIS embedded inventory is invalid"))?;
|
|
let folders = folders.unwrap_or_default();
|
|
let items = items.unwrap_or_default();
|
|
let links = links.unwrap_or_default();
|
|
if folders
|
|
.len()
|
|
.saturating_add(items.len())
|
|
.saturating_add(links.len())
|
|
> options.max_entries
|
|
{
|
|
return Err(ProgramError::EntryLimit);
|
|
}
|
|
for folder in &folders {
|
|
store
|
|
.update_node_for(folder)
|
|
.map_err(|_| ProgramError::InvalidFixture("could not add AIS folder to store"))?;
|
|
}
|
|
for item in items.iter().chain(&links) {
|
|
store
|
|
.update_node_for(item)
|
|
.map_err(|_| ProgramError::InvalidFixture("could not add AIS item to store"))?;
|
|
}
|
|
let root_folder = folders
|
|
.iter()
|
|
.find(|folder| folder.base.uuid() == root_id)
|
|
.cloned()
|
|
.ok_or(ProgramError::InvalidFixture(
|
|
"root_id does not identify a parsed folder",
|
|
))?;
|
|
store.set_root_folder(Some(root_folder));
|
|
output.line(format!("CALL AIS fetch root={root_id}"))?;
|
|
output.line(format!(
|
|
"Fake AIS inventory loaded: {} entries",
|
|
store.count()
|
|
))?;
|
|
process_store(&store, options, output)
|
|
})();
|
|
let _ = client.dispose_with_method();
|
|
result?;
|
|
output.line("Fake grid logout complete; active_tasks=0 open_sockets=0")
|
|
}
|
|
|
|
fn read_fixture(path: &Path) -> Result<OSD, ProgramError> {
|
|
let metadata = fs::metadata(path).map_err(|source| ProgramError::Input {
|
|
action: format!("reading fixture metadata {}", path.display()),
|
|
source,
|
|
})?;
|
|
if metadata.len() > MAX_FIXTURE_BYTES {
|
|
return Err(ProgramError::InvalidFixture("file exceeds 8 MiB limit"));
|
|
}
|
|
let bytes = fs::read(path).map_err(|source| ProgramError::Input {
|
|
action: format!("reading fixture {}", path.display()),
|
|
source,
|
|
})?;
|
|
if bytes.len() > MAX_FIXTURE_ALLOC_BYTES {
|
|
return Err(ProgramError::InvalidFixture("file exceeds 8 MiB limit"));
|
|
}
|
|
let text =
|
|
String::from_utf8(bytes).map_err(|_| ProgramError::InvalidFixture("JSON must be UTF-8"))?;
|
|
let root = OSDParser::deserialize_json_with_string(text)
|
|
.map_err(|_| ProgramError::InvalidFixture("could not parse LLSD JSON"))?;
|
|
root.validate_limits(64, 1_000_000, MAX_FIXTURE_ALLOC_BYTES)
|
|
.map_err(|_| ProgramError::InvalidFixture("LLSD limits exceeded"))?;
|
|
if !matches!(root, OSD::Map(_)) {
|
|
return Err(ProgramError::InvalidFixture("root must be a map"));
|
|
}
|
|
Ok(root)
|
|
}
|
|
|
|
fn process_store(
|
|
store: &Inventory,
|
|
options: &OperationOptions,
|
|
output: &mut BoundedOutput,
|
|
) -> Result<(), ProgramError> {
|
|
let snapshot = snapshot(store, options.max_entries, options.max_depth)?;
|
|
let no_explicit_operation =
|
|
!options.stats && options.search.is_none() && options.export.is_none();
|
|
if options.stats {
|
|
show_statistics(store, &snapshot, output)?;
|
|
}
|
|
if let Some(search) = &options.search {
|
|
show_search(
|
|
&snapshot,
|
|
search,
|
|
options.asset_type,
|
|
options.max_results,
|
|
output,
|
|
)?;
|
|
}
|
|
if let Some(path) = &options.export {
|
|
export_inventory(
|
|
&snapshot,
|
|
path,
|
|
options.max_export_bytes,
|
|
options.max_depth,
|
|
output,
|
|
)?;
|
|
}
|
|
if no_explicit_operation {
|
|
show_top_level(store, &snapshot.root, output)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn snapshot(
|
|
store: &Inventory,
|
|
max_entries: usize,
|
|
max_depth: usize,
|
|
) -> Result<InventorySnapshot, ProgramError> {
|
|
let root = store
|
|
.root_folder()
|
|
.ok_or(ProgramError::InventoryUnavailable)?;
|
|
let mut entries = Vec::new();
|
|
let mut visited = HashSet::new();
|
|
let mut stack = vec![(root.clone(), 0_usize)];
|
|
while let Some((folder, depth)) = stack.pop() {
|
|
let folder_id = folder.base.uuid();
|
|
if !visited.insert(folder_id) {
|
|
return Err(ProgramError::HierarchyCycle(folder_id));
|
|
}
|
|
if depth > max_depth {
|
|
return Err(ProgramError::HierarchyDepth);
|
|
}
|
|
let mut contents = store
|
|
.get_contents_with_uuid(folder_id)
|
|
.map_err(|_| ProgramError::Client("read inventory folder contents"))?;
|
|
contents.sort_by(|left, right| compare_objects(left.as_ref(), right.as_ref()));
|
|
for object in contents.into_iter().rev() {
|
|
if entries.len() >= max_entries {
|
|
return Err(ProgramError::EntryLimit);
|
|
}
|
|
let entry = snapshot_entry(object.as_ref(), &folder.base.name());
|
|
if entry.is_folder() {
|
|
let child = object
|
|
.as_any()
|
|
.downcast_ref::<InventoryFolder>()
|
|
.cloned()
|
|
.ok_or(ProgramError::Client("downcast inventory folder"))?;
|
|
stack.push((child, depth + 1));
|
|
}
|
|
entries.push(entry);
|
|
}
|
|
}
|
|
Ok(InventorySnapshot { root, entries })
|
|
}
|
|
|
|
fn snapshot_entry(object: &dyn InventoryObjectClass, parent_name: &str) -> SnapshotEntry {
|
|
let base = object.inventory_base();
|
|
let kind = if object.as_any().is::<InventoryFolder>() {
|
|
EntryKind::Folder
|
|
} else if let Some(item) = object.inventory_item() {
|
|
let target = matches!(item.asset_type(), AssetType::Link | AssetType::LinkFolder)
|
|
.then(|| item.asset_uuid());
|
|
EntryKind::Item {
|
|
asset_type: item.asset_type(),
|
|
target,
|
|
}
|
|
} else {
|
|
EntryKind::Other
|
|
};
|
|
SnapshotEntry {
|
|
uuid: base.uuid(),
|
|
parent_uuid: base.parent_uuid(),
|
|
parent_name: parent_name.into(),
|
|
name: base.name(),
|
|
kind,
|
|
}
|
|
}
|
|
|
|
fn compare_objects(left: &dyn InventoryObjectClass, right: &dyn InventoryObjectClass) -> Ordering {
|
|
let left_folder = left.as_any().is::<InventoryFolder>();
|
|
let right_folder = right.as_any().is::<InventoryFolder>();
|
|
right_folder
|
|
.cmp(&left_folder)
|
|
.then_with(|| {
|
|
left.inventory_base()
|
|
.name()
|
|
.to_lowercase()
|
|
.cmp(&right.inventory_base().name().to_lowercase())
|
|
})
|
|
.then_with(|| {
|
|
left.inventory_base()
|
|
.uuid()
|
|
.cmp(&right.inventory_base().uuid())
|
|
})
|
|
}
|
|
|
|
fn compare_entries(left: &SnapshotEntry, right: &SnapshotEntry) -> Ordering {
|
|
right
|
|
.is_folder()
|
|
.cmp(&left.is_folder())
|
|
.then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
|
|
.then_with(|| left.uuid.cmp(&right.uuid))
|
|
}
|
|
|
|
fn show_statistics(
|
|
store: &Inventory,
|
|
snapshot: &InventorySnapshot,
|
|
output: &mut BoundedOutput,
|
|
) -> Result<(), ProgramError> {
|
|
let item_count = snapshot
|
|
.entries
|
|
.iter()
|
|
.filter(|entry| matches!(entry.kind, EntryKind::Item { .. }))
|
|
.count();
|
|
let root_folders = store
|
|
.get_contents_with_uuid(snapshot.root.base.uuid())
|
|
.map_err(|_| ProgramError::Client("read root inventory contents"))?
|
|
.into_iter()
|
|
.filter(|entry| entry.as_any().is::<InventoryFolder>())
|
|
.count();
|
|
output.line("=== Inventory Statistics ===")?;
|
|
output.line(format!("Total Items: {item_count}"))?;
|
|
output.line(format!("Root Folders: {root_folders}"))?;
|
|
output.line("")?;
|
|
let mut counts = HashMap::<AssetType, usize>::new();
|
|
for entry in &snapshot.entries {
|
|
if let EntryKind::Item { asset_type, .. } = entry.kind {
|
|
*counts.entry(asset_type).or_default() += 1;
|
|
}
|
|
}
|
|
let mut counts = counts.into_iter().collect::<Vec<_>>();
|
|
counts.sort_by(|(left_type, left_count), (right_type, right_count)| {
|
|
right_count
|
|
.cmp(left_count)
|
|
.then_with(|| format!("{left_type:?}").cmp(&format!("{right_type:?}")))
|
|
});
|
|
output.line("Top Item Types:")?;
|
|
for (asset_type, count) in counts.into_iter().take(10) {
|
|
output.line(format!("{asset_type:?}: {count} items"))?;
|
|
}
|
|
output.line("")
|
|
}
|
|
|
|
fn show_search(
|
|
snapshot: &InventorySnapshot,
|
|
search: &str,
|
|
filter: Option<AssetType>,
|
|
max_results: usize,
|
|
output: &mut BoundedOutput,
|
|
) -> Result<(), ProgramError> {
|
|
let needle = search.to_lowercase();
|
|
let mut results = snapshot.entries.iter().filter(|entry| entry.name.to_lowercase().contains(&needle))
|
|
.filter(|entry| filter.is_none_or(|filter| matches!(entry.kind, EntryKind::Item { asset_type, .. } if asset_type == filter)))
|
|
.collect::<Vec<_>>();
|
|
results.sort_by(|left, right| {
|
|
left.name
|
|
.to_lowercase()
|
|
.cmp(&right.name.to_lowercase())
|
|
.then_with(|| left.uuid.cmp(&right.uuid))
|
|
});
|
|
output.line(format!("=== Search Results for '{search}' ==="))?;
|
|
output.line(format!("Found {} matching entries", results.len()))?;
|
|
output.line("")?;
|
|
for entry in results.iter().take(max_results) {
|
|
output.line(&entry.name)?;
|
|
match entry.kind {
|
|
EntryKind::Folder => output.line("Type: Folder")?,
|
|
EntryKind::Item { asset_type, target } => {
|
|
output.line(format!("Type: {asset_type:?}"))?;
|
|
if let Some(target) = target {
|
|
output.line(format!("Link Target: {target}"))?;
|
|
}
|
|
}
|
|
EntryKind::Other => output.line("Type: Unknown")?,
|
|
}
|
|
output.line(format!("Folder: {}", entry.parent_name))?;
|
|
output.line(format!("UUID: {}", entry.uuid))?;
|
|
output.line("")?;
|
|
}
|
|
if results.len() > max_results {
|
|
output.line(format!(
|
|
"... and {} more results",
|
|
results.len() - max_results
|
|
))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn show_top_level(
|
|
store: &Inventory,
|
|
root: &InventoryFolder,
|
|
output: &mut BoundedOutput,
|
|
) -> Result<(), ProgramError> {
|
|
output.line("=== Inventory Tree (Top Level) ===")?;
|
|
output.line("")?;
|
|
let mut contents = store
|
|
.get_contents_with_uuid(root.base.uuid())
|
|
.map_err(|_| ProgramError::Client("read root inventory contents"))?;
|
|
contents.sort_by(|left, right| compare_objects(left.as_ref(), right.as_ref()));
|
|
for object in contents {
|
|
let name = object.inventory_base().name();
|
|
if let Some(folder) = object.as_any().downcast_ref::<InventoryFolder>() {
|
|
let count = store
|
|
.get_contents_with_uuid(folder.base.uuid())
|
|
.map_err(|_| ProgramError::Client("read top-level folder contents"))?
|
|
.len();
|
|
output.line(format!("Folder: {name} ({count} entries)"))?;
|
|
} else if let Some(item) = object.inventory_item() {
|
|
if matches!(item.asset_type(), AssetType::Link | AssetType::LinkFolder) {
|
|
output.line(format!(
|
|
"Link: {name} ({:?} -> {})",
|
|
item.asset_type(),
|
|
item.asset_uuid()
|
|
))?;
|
|
} else {
|
|
output.line(format!("Item: {name} ({:?})", item.asset_type()))?;
|
|
}
|
|
} else {
|
|
output.line(format!("Entry: {name}"))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn export_inventory(
|
|
snapshot: &InventorySnapshot,
|
|
path: &Path,
|
|
maximum: usize,
|
|
max_depth: usize,
|
|
output: &mut BoundedOutput,
|
|
) -> Result<(), ProgramError> {
|
|
let mut children = HashMap::<UUID, Vec<&SnapshotEntry>>::new();
|
|
for entry in &snapshot.entries {
|
|
children.entry(entry.parent_uuid).or_default().push(entry);
|
|
}
|
|
for values in children.values_mut() {
|
|
values.sort_by(|left, right| compare_entries(left, right));
|
|
}
|
|
let mut bytes = Vec::new();
|
|
append_export(&mut bytes, "LibreMetaverse Inventory Export\n", maximum)?;
|
|
append_export(&mut bytes, "Format: deterministic-v1\n", maximum)?;
|
|
append_export(
|
|
&mut bytes,
|
|
&format!("Entries: {}\n\n", snapshot.entries.len()),
|
|
maximum,
|
|
)?;
|
|
let mut visited = HashSet::new();
|
|
export_children(
|
|
snapshot.root.base.uuid(),
|
|
0,
|
|
&children,
|
|
&mut visited,
|
|
&mut bytes,
|
|
maximum,
|
|
max_depth,
|
|
)?;
|
|
validate_export_path(path)?;
|
|
fs::write(path, &bytes).map_err(|source| ProgramError::Export {
|
|
action: format!("writing inventory export {}", path.display()),
|
|
source,
|
|
})?;
|
|
output.line(format!(
|
|
"Exported {} entries to {}",
|
|
snapshot.entries.len(),
|
|
path.display()
|
|
))
|
|
}
|
|
|
|
fn export_children(
|
|
folder: UUID,
|
|
depth: usize,
|
|
children: &HashMap<UUID, Vec<&SnapshotEntry>>,
|
|
visited: &mut HashSet<UUID>,
|
|
bytes: &mut Vec<u8>,
|
|
maximum: usize,
|
|
max_depth: usize,
|
|
) -> Result<(), ProgramError> {
|
|
if depth > max_depth {
|
|
return Err(ProgramError::HierarchyDepth);
|
|
}
|
|
if !visited.insert(folder) {
|
|
return Err(ProgramError::HierarchyCycle(folder));
|
|
}
|
|
if let Some(entries) = children.get(&folder) {
|
|
for entry in entries {
|
|
let indent = " ".repeat(depth);
|
|
let name = safe_text(&entry.name);
|
|
let line = match entry.kind {
|
|
EntryKind::Folder => format!("{indent}[Folder] {name}\n"),
|
|
EntryKind::Item {
|
|
asset_type,
|
|
target: Some(target),
|
|
} => format!(
|
|
"{indent}{name} ({asset_type:?} -> {target}) - {}\n",
|
|
entry.uuid
|
|
),
|
|
EntryKind::Item {
|
|
asset_type,
|
|
target: None,
|
|
} => format!("{indent}{name} ({asset_type:?}) - {}\n", entry.uuid),
|
|
EntryKind::Other => format!("{indent}{name} - {}\n", entry.uuid),
|
|
};
|
|
append_export(bytes, &line, maximum)?;
|
|
if entry.is_folder() {
|
|
export_children(
|
|
entry.uuid,
|
|
depth + 1,
|
|
children,
|
|
visited,
|
|
bytes,
|
|
maximum,
|
|
max_depth,
|
|
)?;
|
|
}
|
|
}
|
|
}
|
|
visited.remove(&folder);
|
|
Ok(())
|
|
}
|
|
|
|
fn append_export(bytes: &mut Vec<u8>, value: &str, maximum: usize) -> Result<(), ProgramError> {
|
|
if bytes
|
|
.len()
|
|
.checked_add(value.len())
|
|
.is_none_or(|total| total > maximum)
|
|
{
|
|
return Err(ProgramError::ExportLimit);
|
|
}
|
|
bytes.extend_from_slice(value.as_bytes());
|
|
Ok(())
|
|
}
|
|
|
|
fn parse_asset_type(value: &str) -> Result<AssetType, String> {
|
|
match value.to_ascii_lowercase().as_str() {
|
|
"texture" => Ok(AssetType::Texture),
|
|
"sound" => Ok(AssetType::Sound),
|
|
"callingcard" | "calling-card" => Ok(AssetType::CallingCard),
|
|
"landmark" => Ok(AssetType::Landmark),
|
|
"script" => Ok(AssetType::Script),
|
|
"clothing" => Ok(AssetType::Clothing),
|
|
"object" => Ok(AssetType::Object),
|
|
"notecard" => Ok(AssetType::Notecard),
|
|
"folder" => Ok(AssetType::Folder),
|
|
"lsltext" | "lsl-text" => Ok(AssetType::LSLText),
|
|
"lslbytecode" | "lsl-bytecode" => Ok(AssetType::LSLBytecode),
|
|
"texturetga" | "texture-tga" => Ok(AssetType::TextureTGA),
|
|
"bodypart" | "body-part" => Ok(AssetType::Bodypart),
|
|
"soundwav" | "sound-wav" => Ok(AssetType::SoundWAV),
|
|
"imagetga" | "image-tga" => Ok(AssetType::ImageTGA),
|
|
"imagejpeg" | "image-jpeg" => Ok(AssetType::ImageJPEG),
|
|
"animation" => Ok(AssetType::Animation),
|
|
"gesture" => Ok(AssetType::Gesture),
|
|
"simstate" => Ok(AssetType::Simstate),
|
|
"link" => Ok(AssetType::Link),
|
|
"linkfolder" | "link-folder" => Ok(AssetType::LinkFolder),
|
|
"mesh" => Ok(AssetType::Mesh),
|
|
"widget" => Ok(AssetType::Widget),
|
|
"person" => Ok(AssetType::Person),
|
|
"settings" => Ok(AssetType::Settings),
|
|
"material" => Ok(AssetType::Material),
|
|
"unknown" => Ok(AssetType::Unknown),
|
|
_ => Err(format!("unknown asset type '{value}'")),
|
|
}
|
|
}
|
|
|
|
fn safe_text(value: &str) -> String {
|
|
value
|
|
.replace(['\r', '\n', '\t'], " ")
|
|
.split_whitespace()
|
|
.map(|word| {
|
|
let lower = word.to_ascii_lowercase();
|
|
if lower.starts_with("http://")
|
|
|| lower.starts_with("https://")
|
|
|| lower.contains("password=")
|
|
|| lower.contains("token=")
|
|
|| lower.contains("authorization=")
|
|
{
|
|
"[redacted]"
|
|
} else {
|
|
word
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn asset_types_are_case_insensitive_and_reject_unknown_names() {
|
|
assert_eq!(parse_asset_type("LsL-TeXt").unwrap(), AssetType::LSLText);
|
|
assert!(parse_asset_type("money").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn status_and_inventory_text_are_sanitized() {
|
|
assert_eq!(
|
|
safe_text("open https://grid.invalid/cap?token=x now"),
|
|
"open [redacted] now"
|
|
);
|
|
}
|
|
}
|