use libremetaverse_imaging::{ J2kEncodeOptions, ManagedImage, ManagedImageImageChannels, RustJ2kCodec, }; 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 TARGET: &str = "11111111-2222-3333-4444-555555555555"; const ROOT: &str = "90000000-0000-0000-0000-000000000001"; const CHILD: &str = "90000000-0000-0000-0000-000000000002"; const REMOVE: &str = "90000000-0000-0000-0000-000000000003"; const TEXTURE: &str = "80000000-0000-0000-0000-000000000001"; const COVENANT: &str = "70000000-0000-0000-0000-000000000001"; const LAYER: &str = "60000000-0000-0000-0000-000000000001"; const LANDMARK: &str = "50000000-0000-0000-0000-000000000001"; const HANDLE: &str = "4294967298000"; 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-world-{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) } fn write(&self, name: &str, contents: impl AsRef<[u8]>) -> PathBuf { let path = self.path(name); fs::write(&path, contents).expect("write fixture"); path } } 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 one_pixel_jp2() -> Vec { let mut image = ManagedImage::new(1, 1, ManagedImageImageChannels::COLOR).expect("create image"); image.red[0] = 10; image.green[0] = 20; image.blue[0] = 30; RustJ2kCodec::encode(&image, J2kEncodeOptions::default()).expect("encode JPEG2000") } #[test] #[allow(clippy::too_many_lines)] fn fake_grid_exercises_every_owned_world_command() { let directory = TestDir::new("commands"); let terrain_source = directory.write("terrain.raw", [9, 8, 7, 6]); let terrain_download = directory.path("downloaded.raw"); let texture_download = directory.path("download.jp2"); let export_one = directory.path("export-one.xml"); let export_two = directory.path("export-two.xml"); let texture = one_pixel_jp2(); let script = directory.write( "terminal.tsv", format!( "!client\t{CLIENT}\tAlice\tBot\n\ !person\tTarget Resident\t{TARGET}\n\ !world-region\t{CLIENT}\t{HANDLE}\tTest Region\t128\t128\t25\t1.5\t-2.5\n\ !world-avatar\t{CLIENT}\t{TARGET}\tTarget Resident\t132\t132\t25\n\ !world-prim\t{CLIENT}\t{ROOT}\t100\t0\t{CLIENT}\tCube Root\tExport root\t130\t130\t25\t{TEXTURE}\ttrue\n\ !world-prim\t{CLIENT}\t{CHILD}\t101\t100\t{CLIENT}\tCube Child\tExport child\t1\t0\t0\t{TEXTURE}\tfalse\n\ !world-prim\t{CLIENT}\t{REMOVE}\t102\t0\t{CLIENT}\tRemove Me\tDerez target\t131\t130\t25\t{TEXTURE}\tfalse\n\ !world-parcel\t{CLIENT}\t1\t{CLIENT}\tHome Parcel\tFixture parcel\t2.5\t4096\t3\t117\n\ !world-parcel-owner\t{CLIENT}\t1\t{CLIENT}\t3\n\ !world-parcel-object\t{CLIENT}\t1\t{CLIENT}\t100,101,102\n\ !world-grid-region\t{CLIENT}\tTest Region\t{HANDLE}\t1000\t2000\t3\n\ !world-layer\t{CLIENT}\t{LAYER}\t0\t0\t1024\t1024\n\ !world-agent-location\t{CLIENT}\t{HANDLE}\t3\t128\t128\n\ !world-estate\t{CLIENT}\tFixture Estate\t{CLIENT}\t{COVENANT}\t12345\tBe kind.\t0102\n\ !world-asset\t{CLIENT}\t{TEXTURE}\t{}\n\ !world-syntax\t{CLIENT}\tdefault,state_entry,llSay\n\ downloadterrain 1000 \"{}\" --confirm\n\ getestatecovenant 1\n\ uploadterrain \"{}\" --confirm\n\ agentlocations\n\ findsim Test Region\n\ gridlayer\n\ gridmap\n\ parceldetails 1\n\ parcelinfo\n\ primowners 1\n\ selectobjects 1 {CLIENT}\n\ syntaxid\n\ wind\n\ back 0 --confirm\n\ crossregion north fly --confirm\n\ crouch start --confirm\n\ fly start --confirm\n\ flyto 130 130 25 1 --confirm\n\ follow Target Resident --confirm\n\ follow off --confirm\n\ forward 0 --confirm\n\ gohome --confirm\n\ goto Test Region/128/128/25 --confirm\n\ goto_landmark {LANDMARK} --confirm\n\ jump --confirm\n\ left 0 --confirm\n\ location\n\ moveto 10 11 12 --confirm\n\ right 0 --confirm\n\ sethome --confirm\n\ sit --confirm\n\ siton {ROOT} --confirm\n\ stand --confirm\n\ turnto 140 141 25 --confirm\n\ changeperms {ROOT} copy mod xfer --confirm\n\ derez {REMOVE} --confirm\n\ downloadtexture {TEXTURE} 0 \"{}\"\n\ export {ROOT} \"{}\"\n\ export {ROOT} \"{}\"\n\ exportparticles {ROOT}\n\ findobjects 4096 Cube\n\ findtexture 0 {TEXTURE}\n\ import \"{}\" usegroup --confirm\n\ primcount\n\ priminfo {ROOT}\n\ primregex Cube\n\ textures on\n\ textures off\n\ tree Oak --confirm\n\ quit\n", hex(&texture), path_text(&terrain_download), path_text(&terrain_source), path_text(&texture_download), path_text(&export_one), path_text(&export_two), path_text(&export_one), ), ); let output = run(&[ "--allow-live-mutations", "--allow-spending", "--allow-estate-actions", "--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 [ "Terrain file Test Region (2 bytes) downloaded successfully", "Estate name: Fixture Estate", "Terrain raw file uploaded and applied", "3 avatar(s) at 128,128", "Test Region: handle=4294967298000", "Received 1 layer chunks", "Received 1 grid regions", "Name = Home Parcel", "Downloaded 1 Parcels in Test Region", "Owner: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee Count: 3", "Found a total of 3 Objects", "llSay", "Local wind speed is Vector2", "Following Target Resident", "CurrentSim: 'Test Region'", "Set permissions to PermissionMask", "Removing Remove Me", "Saved", "Exported 2 prims", "llParticleSystem", "Done searching; found 2 objects", "Done searching; found 2 faces", "Import complete.", "Tracking a total of", "ID: 90000000-0000-0000-0000-000000000001", "Texture downloading is on", "Texture downloading is off", "CALL estate-upload-terrain terrain.raw", "CALL world-query download-terrain", "CALL world-query estate-covenant", "CALL world-query agent-locations", "CALL world-query find-region", "CALL world-query grid-layer", "CALL world-query grid-map", "CALL world-query parcel-owners", "CALL world-query parcel-objects", "CALL movement-back duration-ms=0", "CALL movement-cross Vector3", "CALL movement-crouch true", "CALL movement-fly true", "CALL movement-flyto Vector3", "CALL movement-follow 11111111-2222-3333-4444-555555555555", "CALL movement-follow off", "CALL movement-forward duration-ms=0", "CALL teleport-home", "CALL teleport-region Test Region", "CALL teleport-landmark 50000000-0000-0000-0000-000000000001", "CALL movement-jump", "CALL movement-left duration-ms=0", "CALL movement-autopilot [1010.0, 2011.0, 12.0]", "CALL movement-right duration-ms=0", "CALL movement-set-home", "CALL movement-sit 90000000-0000-0000-0000-000000000001", "CALL movement-stand", "CALL movement-turn Vector3", "CALL object-permissions 90000000-0000-0000-0000-000000000001", "CALL object-derez 90000000-0000-0000-0000-000000000003", "CALL texture-download 80000000-0000-0000-0000-000000000001", "CALL object-import count=2 use-group=true", "CALL texture-stream enabled=true", "CALL texture-stream enabled=false", "CALL object-tree Oak", ] { assert!(text.contains(expected), "missing {expected:?} in:\n{text}"); } assert_eq!( text.matches("CALL world-query parcels").count(), 2, "{text}" ); assert_eq!(text.matches("CALL movement-sit ").count(), 2, "{text}"); assert_eq!( text.matches("CALL texture-download 80000000-0000-0000-0000-000000000001") .count(), 3, "{text}" ); assert_eq!(fs::read(&terrain_download).expect("terrain output"), [1, 2]); assert_eq!( fs::read(&texture_download).expect("texture output"), texture ); assert_eq!( fs::read(&export_one).expect("first export"), fs::read(&export_two).expect("second export"), "exports must be deterministic" ); assert!(directory.path(&format!("{TEXTURE}.jp2")).is_file()); assert!(directory.path(&format!("{TEXTURE}.tga")).is_file()); } #[test] fn privileged_world_mutations_require_every_gate_and_confirmation() { let directory = TestDir::new("guards"); let terrain = directory.write("terrain.raw", [1, 2, 3]); let script = directory.write( "guards.tsv", format!( "!client\t{CLIENT}\tAlice\tBot\n\ !world-region\t{CLIENT}\t{HANDLE}\tTest Region\t128\t128\t25\t0\t0\n\ !world-estate\t{CLIENT}\tFixture Estate\t{CLIENT}\t{COVENANT}\t1\tCovenant\t0102\n\ forward 1 --confirm\n\ uploadterrain \"{}\" --confirm\n\ quit\n", path_text(&terrain), ), ); let output = run(&["--fake-script", path_text(&script)]); assert!(output.status.success(), "{}", stderr(&output)); let text = stdout(&output); assert!(text.contains("Live world mutation blocked"), "{text}"); assert!(!text.contains("CALL movement-forward"), "{text}"); assert!(!text.contains("CALL estate-upload-terrain"), "{text}"); let script = directory.write( "confirm.tsv", format!( "!client\t{CLIENT}\tAlice\tBot\n\ !world-region\t{CLIENT}\t{HANDLE}\tTest Region\t128\t128\t25\t0\t0\n\ !world-estate\t{CLIENT}\tFixture Estate\t{CLIENT}\t{COVENANT}\t1\tCovenant\t0102\n\ uploadterrain \"{}\"\n\ quit\n", path_text(&terrain), ), ); let output = run(&[ "--allow-live-mutations", "--allow-spending", "--allow-estate-actions", "--fake-script", path_text(&script), ]); assert!(output.status.success(), "{}", stderr(&output)); let text = stdout(&output); assert!(text.contains("explicit --confirm"), "{text}"); assert!(!text.contains("CALL estate-upload-terrain"), "{text}"); }