Implement native InventoryExplorer
All checks were successful
Native Rust workspace compile / compile (push) Successful in 21m44s

This commit is contained in:
2026-08-11 09:01:42 +00:00
parent 4504abcc64
commit bb7f419ca0
7 changed files with 1456 additions and 21 deletions

View File

@@ -485,8 +485,8 @@ documented in the
### Milestone 11 ### Milestone 11
The native `osd-inspector`, `simple-bot`, `packet-dump`, and `prim-inspector` The native `osd-inspector`, `simple-bot`, `packet-dump`, `prim-inspector`, and
programs are complete. `inventory-explorer` programs are complete.
`osd-inspector` validates and converts bounded LLSD and performs deterministic `osd-inspector` validates and converts bounded LLSD and performs deterministic
Primitive-to-OSD round trips. `simple-bot` provides native async login, IM and Primitive-to-OSD round trips. `simple-bot` provides native async login, IM and
local-chat commands, movement and animation calls, bounded fake-grid local-chat commands, movement and animation calls, bounded fake-grid
@@ -495,6 +495,8 @@ adds filtered, bounded incoming/outgoing capture with native wire validation,
sanitized optional raw bytes, and deterministic fake datagrams. `prim-inspector` sanitized optional raw bytes, and deterministic fake datagrams. `prim-inspector`
discovers real simulator primitives, hydrates missing object properties, orders discovers real simulator primitives, hydrates missing object properties, orders
nearest matches, and formats construction, feature, and ownership details with nearest matches, and formats construction, feature, and ownership details with
bounded deterministic fake-grid coverage. Their command bounded deterministic fake-grid coverage. `inventory-explorer` uses the native
manager/store and AIS parser for bounded browsing, statistics, typed search,
link-aware hierarchy traversal, and deterministic export. Their command
surfaces, limits, isolated CLI tests, and the status of every remaining program surfaces, limits, isolated CLI tests, and the status of every remaining program
are documented in the [`native programs guide`](programs/README.md). are documented in the [`native programs guide`](programs/README.md).

View File

@@ -214,6 +214,11 @@ pub trait InventoryObjectClass: std::any::Any {
/// Returns the shared `InventoryBase` portion of the concrete object. /// Returns the shared `InventoryBase` portion of the concrete object.
fn inventory_base(&self) -> &InventoryBase; fn inventory_base(&self) -> &InventoryBase;
/// Returns the shared `InventoryItem` portion for item-derived objects.
fn inventory_item(&self) -> Option<&InventoryItem> {
None
}
} }
/// Rust object-safe view of concrete classes derived from C# `InventoryItem`. /// Rust object-safe view of concrete classes derived from C# `InventoryItem`.
@@ -233,6 +238,24 @@ macro_rules! inventory_object_class {
}; };
} }
macro_rules! inventory_item_class {
($type:ty, $item:ident $(.$nested:ident)*) => {
impl InventoryObjectClass for $type {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn inventory_base(&self) -> &InventoryBase {
&self.$item$(.$nested)*.base
}
fn inventory_item(&self) -> Option<&InventoryItem> {
Some(&self.$item$(.$nested)*)
}
}
};
}
impl InventoryObjectClass for InventoryBase { impl InventoryObjectClass for InventoryBase {
fn as_any(&self) -> &dyn std::any::Any { fn as_any(&self) -> &dyn std::any::Any {
self self
@@ -244,22 +267,34 @@ impl InventoryObjectClass for InventoryBase {
} }
inventory_object_class!(InventoryFolder, base); inventory_object_class!(InventoryFolder, base);
inventory_object_class!(InventoryItem, base); impl InventoryObjectClass for InventoryItem {
inventory_object_class!(InventoryAnimation, base.base); fn as_any(&self) -> &dyn std::any::Any {
inventory_object_class!(InventoryAttachment, base.base); self
inventory_object_class!(InventoryCallingCard, base.base); }
inventory_object_class!(InventoryCategory, base.base);
inventory_object_class!(InventoryGesture, base.base); fn inventory_base(&self) -> &InventoryBase {
inventory_object_class!(InventoryLSL, base.base); &self.base
inventory_object_class!(InventoryLandmark, base.base); }
inventory_object_class!(InventoryMaterial, base.base);
inventory_object_class!(InventoryNotecard, base.base); fn inventory_item(&self) -> Option<&InventoryItem> {
inventory_object_class!(InventoryObject, base.base); Some(self)
inventory_object_class!(InventorySettings, base.base); }
inventory_object_class!(InventorySnapshot, base.base); }
inventory_object_class!(InventorySound, base.base); inventory_item_class!(InventoryAnimation, base);
inventory_object_class!(InventoryTexture, base.base); inventory_item_class!(InventoryAttachment, base);
inventory_object_class!(InventoryWearable, base.base); inventory_item_class!(InventoryCallingCard, base);
inventory_item_class!(InventoryCategory, base);
inventory_item_class!(InventoryGesture, base);
inventory_item_class!(InventoryLSL, base);
inventory_item_class!(InventoryLandmark, base);
inventory_item_class!(InventoryMaterial, base);
inventory_item_class!(InventoryNotecard, base);
inventory_item_class!(InventoryObject, base);
inventory_item_class!(InventorySettings, base);
inventory_item_class!(InventorySnapshot, base);
inventory_item_class!(InventorySound, base);
inventory_item_class!(InventoryTexture, base);
inventory_item_class!(InventoryWearable, base);
impl InventoryItemClass for InventoryItem {} impl InventoryItemClass for InventoryItem {}
impl InventoryItemClass for InventoryAnimation {} impl InventoryItemClass for InventoryAnimation {}

View File

@@ -11,7 +11,7 @@ here so a source entry is never mistaken for a completed port.
| `simple-bot` | SimpleBot | Implemented with live and deterministic fake-grid modes | | `simple-bot` | SimpleBot | Implemented with live and deterministic fake-grid modes |
| `packet-dump` | PacketDump | Implemented with live and deterministic fake-grid capture | | `packet-dump` | PacketDump | Implemented with live and deterministic fake-grid capture |
| `prim-inspector` | PrimInspector | Implemented with live and deterministic fake-grid discovery | | `prim-inspector` | PrimInspector | Implemented with live and deterministic fake-grid discovery |
| `inventory-explorer` | InventoryExplorer | Pending milestone 11 issue #89 | | `inventory-explorer` | InventoryExplorer | Implemented with live inventory and deterministic AIS fixtures |
| `irc-gateway` | IRCGateway | Pending milestone 11 issue #90 | | `irc-gateway` | IRCGateway | Pending milestone 11 issue #90 |
| `test-client` | TestClient | Pending milestone 11 issues #91#94 | | `test-client` | TestClient | Pending milestone 11 issues #91#94 |
| `vivox-test` | VivoxTest | Pending milestone 11 issue #95 | | `vivox-test` | VivoxTest | Pending milestone 11 issue #95 |
@@ -189,3 +189,52 @@ Run the isolated CLI golden tests and the related translated primitive cases:
cargo test -p libremetaverse-programs --test prim_inspector_cli --locked cargo test -p libremetaverse-programs --test prim_inspector_cli --locked
cargo test --manifest-path tests/compat/Cargo.toml --test world_object_semantics --locked cargo test --manifest-path tests/compat/Cargo.toml --test world_object_semantics --locked
``` ```
## InventoryExplorer
`inventory-explorer` preserves the source program's live login and operation
options:
```text
inventory-explorer FIRSTNAME LASTNAME PASSWORD [--stats]
[--search TERM [--type TYPE]] [--export FILE]
```
Credentials may instead come from `GRID_FIRST_NAME`, `GRID_LAST_NAME`, and
`GRID_PASSWORD`; `GRID_LOGIN_URL` or `--login-uri` selects the endpoint. Login
and inventory readiness each have a 30-second deadline. The native client waits
for a populated root in the real `InventoryManager::store`, handles Ctrl-C and
disconnects, removes event handlers, logs out, and disposes the client on every
post-construction path.
With no operation option, the program prints the sorted top-level folders and
items. `--stats` traverses the hierarchy and reports item and root-folder counts
plus the ten most common asset types. `--search` performs a case-insensitive
name search across folders and items, reports parent folders and UUIDs, and
shows link targets. `--type` accepts every native `AssetType` name and narrows
item results. Search output defaults to 50 results and is capped at 1,000.
`--export FILE` writes a deterministic `deterministic-v1` hierarchy with
folders before items, stable case-insensitive name and UUID ordering, asset
types, link targets, and UUIDs. The destination must be an explicit non-symlink
path of at most 4,096 characters. The in-memory export is validated before the
file is written and defaults to a 16 MiB limit. Traversal defaults to 100,000
entries and 64 folder levels, detects cycles, and exposes configurable bounded
limits. Standard output is buffered and limited to 4 MiB, and inventory/status
text redacts URLs and credential-like assignments.
Offline validation uses `--fake-ais FILE`. The file is an LLSD JSON AIS response
with a nonzero `root_id` and ordinary `_embedded.categories`, `.items`, and
`.links` maps. It is capped at 8 MiB and parsed by the public native
`InventoryAISClient`; parsed folders, items, and links are then installed
through the real manager-owned `Inventory` store. This exercises AIS typing,
derived inventory items, link targets, parent indexes, and folder contents
without contacting a grid or embedding credentials.
Run the isolated CLI/golden suite and its related translated inventory gates:
```sh
cargo test -p libremetaverse-programs --test inventory_explorer_cli --locked
cargo test --manifest-path tests/compat/Cargo.toml --test inventory_store_semantics --locked
cargo test --manifest-path tests/compat/Cargo.toml --test inventory_ais_semantics --locked
```

View File

@@ -1,3 +1,3 @@
fn main() -> std::process::ExitCode { fn main() -> std::process::ExitCode {
libremetaverse_programs::pending_program("InventoryExplorer") libremetaverse_programs::inventory_explorer::main_entry()
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
//! Rust targets corresponding to the upstream example and tool projects. //! Rust targets corresponding to the upstream example and tool projects.
pub mod commands; pub mod commands;
pub mod inventory_explorer;
pub mod osd_inspector; pub mod osd_inspector;
pub mod packet_dump; pub mod packet_dump;
pub mod prim_inspector; pub mod prim_inspector;

View File

@@ -0,0 +1,289 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
const EXIT_USAGE: i32 = 2;
const EXIT_INPUT: i32 = 3;
const EXIT_OUTPUT: i32 = 5;
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
struct TestDir(PathBuf);
impl TestDir {
fn new(name: &str) -> Self {
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"metacrate-inventory-explorer-{name}-{}-{id}",
std::process::id()
));
fs::create_dir(&path).expect("create test directory");
Self(path)
}
fn path(&self, name: &str) -> PathBuf {
self.0.join(name)
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn run(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_inventory-explorer"))
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("run inventory-explorer")
}
fn text(bytes: &[u8]) -> &str {
std::str::from_utf8(bytes).expect("UTF-8 output")
}
fn path_text(path: &Path) -> &str {
path.to_str().expect("UTF-8 path")
}
fn assert_exit(output: &Output, expected: i32) {
assert_eq!(
output.status.code(),
Some(expected),
"stdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn fixture(directory: &TestDir) -> PathBuf {
let path = directory.path("inventory.json");
fs::write(
&path,
r#"{
"root_id":"10000000-0000-0000-0000-000000000000",
"_embedded":{
"categories":{
"root":{"category_id":"10000000-0000-0000-0000-000000000000","parent_id":"00000000-0000-0000-0000-000000000000","name":"My Inventory","type_default":8,"version":4,"descendents":5},
"objects":{"category_id":"20000000-0000-0000-0000-000000000000","parent_id":"10000000-0000-0000-0000-000000000000","name":"Objects","type_default":6,"version":2,"descendents":2},
"scripts":{"category_id":"30000000-0000-0000-0000-000000000000","parent_id":"10000000-0000-0000-0000-000000000000","name":"Scripts","type_default":10,"version":2,"descendents":1},
"empty":{"category_id":"40000000-0000-0000-0000-000000000000","parent_id":"10000000-0000-0000-0000-000000000000","name":"Empty","type_default":-1,"version":1,"descendents":0}
},
"items":{
"sword":{"item_id":"50000000-0000-0000-0000-000000000000","parent_id":"20000000-0000-0000-0000-000000000000","agent_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","asset_id":"51000000-0000-0000-0000-000000000000","name":"Sword","desc":"A test object","type":"object","inv_type":"object","created_at":1},
"texture":{"item_id":"60000000-0000-0000-0000-000000000000","parent_id":"20000000-0000-0000-0000-000000000000","agent_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","asset_id":"61000000-0000-0000-0000-000000000000","name":"Sword Texture","desc":"Texture","type":"texture","inv_type":"texture","created_at":2},
"script":{"item_id":"70000000-0000-0000-0000-000000000000","parent_id":"30000000-0000-0000-0000-000000000000","agent_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","asset_id":"71000000-0000-0000-0000-000000000000","name":"Sword Script","desc":"Script","type":"lsltext","inv_type":"script","created_at":3},
"readme":{"item_id":"80000000-0000-0000-0000-000000000000","parent_id":"10000000-0000-0000-0000-000000000000","agent_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","asset_id":"81000000-0000-0000-0000-000000000000","name":"Read Me","desc":"Notes","type":"notecard","inv_type":"notecard","created_at":4}
},
"links":{
"shortcut":{"item_id":"90000000-0000-0000-0000-000000000000","parent_id":"10000000-0000-0000-0000-000000000000","agent_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","linked_id":"50000000-0000-0000-0000-000000000000","name":"Sword Shortcut","desc":"Link","type":"object","inv_type":"texture","created_at":5}
}
}
}"#,
)
.expect("write AIS fixture");
path
}
#[test]
fn help_preserves_upstream_options_and_documents_bounded_fake_mode() {
let output = run(&["--help"]);
assert!(output.status.success());
let help = text(&output.stdout);
for marker in [
"[FIRSTNAME]",
"[LASTNAME]",
"[PASSWORD]",
"--stats",
"--search <TERM>",
"--type <TYPE>",
"--export <FILE>",
"--fake-ais <FILE>",
"--max-entries",
"--max-export-bytes",
] {
assert!(help.contains(marker), "help omitted {marker}:\n{help}");
}
let output = run(&[]);
assert_exit(&output, EXIT_USAGE);
assert!(text(&output.stderr).contains("Usage: inventory-explorer"));
}
#[test]
fn fake_ais_top_level_and_statistics_use_real_hierarchical_store() {
let directory = TestDir::new("browse-stats");
let fixture = fixture(&directory);
let output = run(&["--fake-ais", path_text(&fixture)]);
assert!(output.status.success(), "{}", text(&output.stderr));
let output = text(&output.stdout);
for expected in [
"CALL AIS fetch root=10000000-0000-0000-0000-000000000000",
"Fake AIS inventory loaded: 9 entries",
"=== Inventory Tree (Top Level) ===",
"Folder: Empty (0 entries)",
"Folder: Objects (2 entries)",
"Folder: Scripts (1 entries)",
"Item: Read Me (Notecard)",
"Link: Sword Shortcut (Link -> 50000000-0000-0000-0000-000000000000)",
"Fake grid logout complete; active_tasks=0 open_sockets=0",
] {
assert!(
output.contains(expected),
"output omitted {expected}:\n{output}"
);
}
assert!(output.find("Folder: Empty").unwrap() < output.find("Folder: Objects").unwrap());
let output = run(&["--fake-ais", path_text(&fixture), "--stats"]);
assert!(output.status.success(), "{}", text(&output.stderr));
let output = text(&output.stdout);
for expected in [
"=== Inventory Statistics ===",
"Total Items: 5",
"Root Folders: 3",
"Link: 1 items",
"LSLText: 1 items",
"Notecard: 1 items",
"Object: 1 items",
"Texture: 1 items",
] {
assert!(
output.contains(expected),
"stats omitted {expected}:\n{output}"
);
}
}
#[test]
fn search_filter_limit_parent_and_link_details_are_deterministic() {
let directory = TestDir::new("search");
let fixture = fixture(&directory);
let output = run(&[
"--fake-ais",
path_text(&fixture),
"--search",
"sWoRd",
"--type",
"TeXtUrE",
]);
assert!(output.status.success(), "{}", text(&output.stderr));
let output = text(&output.stdout);
assert!(output.contains("Found 1 matching entries"));
assert!(output.contains("Sword Texture\nType: Texture\nFolder: Objects"));
assert!(!output.contains("Sword Script\n"));
assert!(!output.contains("Sword Shortcut\n"));
let output = run(&[
"--fake-ais",
path_text(&fixture),
"--search",
"Sword",
"--max-results",
"2",
]);
assert!(output.status.success());
let output = text(&output.stdout);
assert!(output.contains("Found 4 matching entries"));
assert!(output.contains("... and 2 more results"));
assert!(output.contains("Sword\nType: Object"));
assert!(output.contains("Sword Script\nType: LSLText"));
}
#[test]
fn export_is_hierarchical_deterministic_and_bounded() {
let directory = TestDir::new("export");
let fixture = fixture(&directory);
let first = directory.path("first.txt");
let second = directory.path("second.txt");
for destination in [&first, &second] {
let output = run(&[
"--fake-ais",
path_text(&fixture),
"--export",
path_text(destination),
]);
assert!(output.status.success(), "{}", text(&output.stderr));
assert!(text(&output.stdout).contains("Exported 8 entries"));
}
let first_bytes = fs::read(&first).expect("read first export");
let second_bytes = fs::read(&second).expect("read second export");
assert_eq!(first_bytes, second_bytes);
let export = text(&first_bytes);
assert_eq!(
export,
concat!(
"LibreMetaverse Inventory Export\n",
"Format: deterministic-v1\n",
"Entries: 8\n",
"\n",
"[Folder] Empty\n",
"[Folder] Objects\n",
" Sword (Object) - 50000000-0000-0000-0000-000000000000\n",
" Sword Texture (Texture) - 60000000-0000-0000-0000-000000000000\n",
"[Folder] Scripts\n",
" Sword Script (LSLText) - 70000000-0000-0000-0000-000000000000\n",
"Read Me (Notecard) - 80000000-0000-0000-0000-000000000000\n",
"Sword Shortcut (Link -> 50000000-0000-0000-0000-000000000000) - ",
"90000000-0000-0000-0000-000000000000\n",
)
);
let limited = directory.path("limited.txt");
let output = run(&[
"--fake-ais",
path_text(&fixture),
"--export",
path_text(&limited),
"--max-export-bytes",
"10",
]);
assert_exit(&output, EXIT_OUTPUT);
assert!(!limited.exists());
assert!(text(&output.stderr).contains("export exceeds the byte limit"));
}
#[test]
fn invalid_fixtures_options_filesystem_and_credentials_fail_explicitly() {
let directory = TestDir::new("invalid");
let malformed = directory.path("malformed.json");
fs::write(&malformed, "{bad-json}").expect("write malformed fixture");
let output = run(&["--fake-ais", path_text(&malformed)]);
assert_exit(&output, EXIT_INPUT);
assert!(output.stdout.is_empty());
assert!(text(&output.stderr).contains("could not parse LLSD JSON"));
let fixture = fixture(&directory);
let output = run(&["--fake-ais", path_text(&fixture), "--type", "Texture"]);
assert_exit(&output, EXIT_USAGE);
assert!(text(&output.stderr).contains("--type requires --search"));
let output = run(&[
"--fake-ais",
path_text(&fixture),
"--search",
"Sword",
"--type",
"Money",
]);
assert_exit(&output, EXIT_USAGE);
assert!(text(&output.stderr).contains("unknown asset type"));
let password = "do-not-print-this-password";
let output = run(&["First", "Last", password, "--fake-ais", path_text(&fixture)]);
assert_exit(&output, EXIT_USAGE);
assert!(!text(&output.stdout).contains(password));
assert!(!text(&output.stderr).contains(password));
let output = run(&[
"--fake-ais",
path_text(&fixture),
"--export",
path_text(&directory.0),
]);
assert_exit(&output, EXIT_OUTPUT);
assert!(text(&output.stderr).contains("writing inventory export"));
}