use libremetaverse_imaging::{J2kCodec, J2kEncodeOptions, ManagedImage, ManagedImageImageChannels}; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; const CLIENT: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; const MASTER: &str = "11111111-2222-3333-4444-555555555555"; const RECIPIENT: &str = "22222222-3333-4444-5555-666666666666"; const ROOT: &str = "10000000-0000-0000-0000-000000000001"; const OUTFITS: &str = "10000000-0000-0000-0000-000000000002"; const TRASH: &str = "10000000-0000-0000-0000-000000000003"; const LOST: &str = "10000000-0000-0000-0000-000000000004"; const NOTES: &str = "10000000-0000-0000-0000-000000000005"; const SCRIPTS: &str = "10000000-0000-0000-0000-000000000006"; const TEXTURES: &str = "10000000-0000-0000-0000-000000000007"; const OBJECTS: &str = "10000000-0000-0000-0000-000000000008"; const DELETE_ME: &str = "10000000-0000-0000-0000-000000000009"; const GIVE_ITEM: &str = "20000000-0000-0000-0000-000000000001"; const NOTE_ITEM: &str = "20000000-0000-0000-0000-000000000002"; const SCRIPT_ITEM: &str = "20000000-0000-0000-0000-000000000003"; const OUTFIT_ITEM: &str = "20000000-0000-0000-0000-000000000004"; const NOTE_ASSET: &str = "30000000-0000-0000-0000-000000000001"; const SCRIPT_ASSET: &str = "30000000-0000-0000-0000-000000000002"; const OBJECT_ASSET: &str = "30000000-0000-0000-0000-000000000003"; const TEXTURE_ASSET: &str = "30000000-0000-0000-0000-000000000004"; const AVATAR: &str = "40000000-0000-0000-0000-000000000001"; const ATTACHMENT: &str = "40000000-0000-0000-0000-000000000002"; const TASK: &str = "50000000-0000-0000-0000-000000000001"; const TASK_SCRIPT: &str = "50000000-0000-0000-0000-000000000002"; const SESSION: &str = "60000000-0000-0000-0000-000000000001"; const CREATED_NOTE_ITEM: &str = "00000000-0000-0000-0200-00000000e01e"; const UPLOADED_TEXTURE_ASSET: &str = "00000000-0000-0000-0500-0000000050a5"; 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-test-client-inventory-{name}-{}-{id}", std::process::id() )); fs::create_dir(&path).expect("create test directory"); Self(path) } fn write(&self, name: &str, contents: impl AsRef<[u8]>) -> PathBuf { let path = self.0.join(name); fs::write(&path, contents).expect("write fixture"); 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 path_text(path: &Path) -> &str { path.to_str().expect("UTF-8 test path") } fn run(args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_test-client")) .args(args) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .expect("run test-client") } fn stdout(output: &Output) -> &str { std::str::from_utf8(&output.stdout).expect("UTF-8 stdout") } fn stderr(output: &Output) -> &str { std::str::from_utf8(&output.stderr).expect("UTF-8 stderr") } fn hex(bytes: &[u8]) -> String { const DIGITS: &[u8; 16] = b"0123456789abcdef"; let mut output = String::with_capacity(bytes.len() * 2); for byte in bytes { output.push(char::from(DIGITS[usize::from(byte >> 4)])); output.push(char::from(DIGITS[usize::from(byte & 0x0f)])); } output } fn notecard(body: &str) -> Vec { format!( "Linden text version 2\n{{\nLLEmbeddedItems version 1\n{{\ncount 0\n}}\nText length {}\n{}\n}}\n", body.len(), body ) .into_bytes() } fn one_pixel_jp2() -> Vec { let mut image = ManagedImage::new(1, 1, ManagedImageImageChannels::COLOR).expect("create managed image"); image.red[0] = 10; image.green[0] = 20; image.blue[0] = 30; J2kCodec::encode(&image, J2kEncodeOptions::default()).expect("encode JPEG2000 fixture") } fn one_pixel_tga() -> Vec { let mut bytes = vec![0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 32, 0x28]; bytes.extend_from_slice(&[30, 20, 10, 255]); bytes } #[test] #[allow(clippy::too_many_lines)] fn fake_grid_exercises_every_owned_inventory_appearance_and_asset_command() { let directory = TestDir::new("commands"); let note_source = directory.write("new note.txt", "created note body"); let script_source = directory.write("new script.lsl", "default { state_entry() {} }"); let image_source = directory.write("image.tga", one_pixel_tga()); let nested_script = directory.write("nested.tc", "balance\nls -l\n"); let download = directory.path("downloaded-note.asset"); let xfer = directory.path("object.xfer"); let backup = directory.path("backup"); let outfit = directory.path("outfit"); let jp2 = one_pixel_jp2(); let script = directory.write( "terminal.tsv", format!( "!client\t{CLIENT}\tAlice\tBot\tMaster Resident\t{MASTER}\tfalse\n\ !inventory-root\t{CLIENT}\t{ROOT}\t{CLIENT}\tMy Inventory\n\ !inventory-folder\t{CLIENT}\t{OUTFITS}\t{ROOT}\toutfit\tOutfits\n\ !inventory-folder\t{CLIENT}\t{TRASH}\t{ROOT}\ttrash\tTrash\n\ !inventory-folder\t{CLIENT}\t{LOST}\t{ROOT}\tlostandfound\tLost And Found\n\ !inventory-folder\t{CLIENT}\t{NOTES}\t{ROOT}\tnotecard\tNotecards\n\ !inventory-folder\t{CLIENT}\t{SCRIPTS}\t{ROOT}\tscript\tScripts\n\ !inventory-folder\t{CLIENT}\t{TEXTURES}\t{ROOT}\ttexture\tTextures\n\ !inventory-folder\t{CLIENT}\t{OBJECTS}\t{ROOT}\tobject\tObjects\n\ !inventory-folder\t{CLIENT}\t{DELETE_ME}\t{ROOT}\tnone\tDelete Me\n\ !inventory-item\t{CLIENT}\t{GIVE_ITEM}\t{ROOT}\t{OBJECT_ASSET}\tobject\tobject\t0008e000\tGiveable\tTransfer me\n\ !inventory-item\t{CLIENT}\t{NOTE_ITEM}\t{NOTES}\t{NOTE_ASSET}\tnotecard\tnotecard\t0008e000\tFixture Note\tA note\n\ !inventory-item\t{CLIENT}\t{SCRIPT_ITEM}\t{SCRIPTS}\t{SCRIPT_ASSET}\tlsltext\tlsl\t0008e000\tFixture Script\tA script\n\ !inventory-item\t{CLIENT}\t{OUTFIT_ITEM}\t{OUTFITS}\t{TEXTURE_ASSET}\tclothing\twearable\t0008e000\tShirt\tOutfit shirt\n\ !asset\t{CLIENT}\t{NOTE_ASSET}\tnotecard\t{}\n\ !asset\t{CLIENT}\t{SCRIPT_ASSET}\tlsltext\t{}\n\ !asset\t{CLIENT}\t{OBJECT_ASSET}\tobject\t{}\n\ !asset\t{CLIENT}\t{TEXTURE_ASSET}\ttexture\t{}\n\ !balance\t{CLIENT}\t75\n\ !avatar\t{CLIENT}\t{AVATAR}\tTarget Resident\tHead={TEXTURE_ASSET}\n\ !appearance-cache\t{CLIENT}\t{AVATAR}\n\ !attachment\t{CLIENT}\tChest\t42\t{ATTACHMENT}\t<1,2,3>\n\ !task\t{CLIENT}\t{TASK}\t99\n\ !task-item\t{CLIENT}\t{TASK}\t{TASK_SCRIPT}\tlsltext\ttrue\tRunning Script\tTask script\n\ appearance rebake --confirm\n\ attachments\n\ avatarinfo Target Resident\n\ clone Target Resident --confirm\n\ wear /Outfits --confirm\n\ backuptext to \"{}\"\n\ backuptext status\n\ backuptext abort\n\ balance\n\ cd Outfits\n\ ls\n\ cd /\n\ ls -l\n\ i\n\ give {RECIPIENT} Giveable --confirm\n\ download {NOTE_ASSET} notecard \"{}\"\n\ xfer {OBJECT_ASSET} \"{}\"\n\ viewnote {NOTE_ITEM}\n\ objectinventory {TASK}\n\ taskrunning {TASK}\n\ taskrunning {TASK} \"Running Script\" false --confirm\n\ taskrunning {TASK}\n\ createnotecard \"{}\" {GIVE_ITEM} --confirm\n\ viewnote {CREATED_NOTE_ITEM}\n\ uploadscript \"{}\" --confirm\n\ uploadimage TestTexture 30000 \"{}\" --confirm\n\ !avatar\t{CLIENT}\t{AVATAR}\tTarget Resident\tUploaded={UPLOADED_TEXTURE_ASSET}\n\ dumpoutfit {AVATAR} \"{}\"\n\ script \"{}\"\n\ deletefolder \"Delete Me\" --confirm\n\ emptylostandfound --confirm\n\ emptytrash --confirm\n\ i\n\ !im\t{CLIENT}\t{MASTER}\tMaster Resident\tagent\tfalse\tgiveall --confirm\t{SESSION}\n\ balance\n\ quit\n", hex(¬ecard("fixture note body")), hex(b"default { state_entry() {} }"), hex(b"fake object asset"), hex(&jp2), path_text(&backup), path_text(&download), path_text(&xfer), path_text(¬e_source), path_text(&script_source), path_text(&image_source), path_text(&outfit), path_text(&nested_script), ), ); let output = run(&[ "--allow-live-mutations", "--allow-spending", "--fake-script", path_text(&script), ]); assert!(output.status.success(), "{}", stderr(&output)); assert!(output.stderr.is_empty(), "{}", stderr(&output)); let text = stdout(&output); for expected in [ "Appearance sequence started", "Found 1 attachments", "Head: 30000000-0000-0000-0000-000000000004", "Cloned Target Resident", "Starting to change outfit to /Outfits", "backuptext completed: found 2, transferred 2, errors 0", "Last backup: found 2, transferred 2, errors 0", "No backup is currently running", "Balance is L$: 75", "Current folder: Outfits", "Shirt", "Returned 12 items.", "Gave Giveable (Object)", "Raw Notecard Data: fixture note body", "[Item] Name: Running Script Desc: Task script Type: LSLText", "IsRunning: true", "Setting true => false", "IsRunning: false", "Notecard successfully created", "Raw Notecard Data: created note body", "compilation requested", "Texture upload succeeded", "Downloaded Uploaded", "Finished executing 2 commands", "Moved folder Delete Me to Trash", "Lost And Found Emptied", "Trash Emptied", "Returned 14 items.", "Gave $75 to 11111111-2222-3333-4444-555555555555", "Balance is L$: 0", "CALL appearance-set rebake=true", "CALL appearance-clone 40000000-0000-0000-0000-000000000001", "CALL appearance-wear 20000000-0000-0000-0000-000000000004", "CALL inventory-give 20000000-0000-0000-0000-000000000001", "CALL xfer 30000000-0000-0000-0000-000000000003 Object", "CALL task-script-running 50000000-0000-0000-0000-000000000001 50000000-0000-0000-0000-000000000002 false", "CALL inventory-empty LostAndFound", "CALL inventory-empty Trash", "active-tasks=0 shutdown=true", ] { assert!(text.contains(expected), "missing {expected}:\n{text}"); } assert_eq!( fs::read(&download).expect("download file"), notecard("fixture note body") ); assert_eq!(fs::read(&xfer).expect("xfer file"), b"fake object asset"); assert_eq!( fs::read(backup.join("Notecards/Fixture Note.txt")).expect("backup notecard"), notecard("fixture note body") ); assert_eq!( fs::read(backup.join("Scripts/Fixture Script.lsl")).expect("backup script"), b"default { state_entry() {} }" ); assert_eq!( J2kCodec::decode_bytes( &fs::read(outfit.join(format!("{UPLOADED_TEXTURE_ASSET}.jp2"))) .expect("uploaded outfit jp2"), libremetaverse_imaging::J2kDecodeOptions::default(), ) .expect("decode uploaded outfit texture") .width, 1 ); assert!( outfit .join(format!("{UPLOADED_TEXTURE_ASSET}.tga")) .exists() ); } #[test] fn mutations_require_global_opt_in_and_per_command_confirmation() { let directory = TestDir::new("guards"); let script = directory.write( "terminal.tsv", format!( "!client\t{CLIENT}\tAlice\tBot\n\ !inventory-root\t{CLIENT}\t{ROOT}\t{CLIENT}\tMy Inventory\n\ !inventory-folder\t{CLIENT}\t{TRASH}\t{ROOT}\ttrash\tTrash\n\ emptytrash --confirm\n\ quit\n" ), ); let output = run(&["--fake-script", path_text(&script)]); assert!(output.status.success()); assert!(stdout(&output).contains("Live mutation blocked; restart with --allow-live-mutations")); assert!(!stdout(&output).contains("CALL inventory-empty")); let no_confirm = directory.write( "no-confirm.tsv", format!( "!client\t{CLIENT}\tAlice\tBot\n\ !inventory-root\t{CLIENT}\t{ROOT}\t{CLIENT}\tMy Inventory\n\ !inventory-folder\t{CLIENT}\t{TRASH}\t{ROOT}\ttrash\tTrash\n\ emptytrash\n\ quit\n" ), ); let output = run(&[ "--allow-live-mutations", "--fake-script", path_text(&no_confirm), ]); assert!(output.status.success()); assert!(stdout(&output).contains("Operation requires an explicit --confirm argument")); assert!(!stdout(&output).contains("CALL inventory-empty")); }