//! Integration tests for the `unityasset` command. use assert_cmd::Command; use predicates::prelude::*; use serde::de::DeserializeOwned; use serde_json::Value; use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; fn cargo_command() -> Command { Command::cargo_bin("unityasset").expect("binary") } const ALIGN_BYTES_FLAG: i32 = 0x4000; #[derive(Clone, Copy)] struct PPtrValue { file_id: i32, path_id: i64, } #[derive(Clone)] enum FixtureObject { GameObject { name: String, }, TextAsset { name: String, script: String, }, MonoScript { name: String, class_name: String, namespace: String, assembly_name: String, }, MonoBehaviour { name: String, script: PPtrValue, text_asset: PPtrValue, }, StrippedMonoBehaviour { game_object: PPtrValue, script: PPtrValue, extra_refs: Vec, }, } #[derive(Clone)] struct FixtureEntry { path_id: i64, class_id: i32, object: FixtureObject, } fn unique_temp_dir(label: &str) -> PathBuf { let suffix = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("clock") .as_nanos(); let path = std::env::temp_dir().join(format!("unityasset-{label}-{suffix}")); fs::create_dir_all(&path).expect("temp dir"); path } fn to_i32(value: usize) -> i32 { i32::try_from(value).expect("fixture value fits in i32") } fn to_u32(value: usize) -> u32 { u32::try_from(value).expect("fixture value fits in u32") } fn to_i64(value: usize) -> i64 { i64::try_from(value).expect("fixture value fits in i64") } fn standalone_entries() -> Vec { vec![ FixtureEntry { path_id: 1001, class_id: 49, object: FixtureObject::TextAsset { name: "BinaryText".to_string(), script: "Hello from binary text asset\n".to_string(), }, }, FixtureEntry { path_id: 1002, class_id: 115, object: FixtureObject::MonoScript { name: "SampleBehaviour".to_string(), class_name: "SampleBehaviour".to_string(), namespace: "Mercury.Sample".to_string(), assembly_name: "Assembly-CSharp".to_string(), }, }, FixtureEntry { path_id: 1003, class_id: 114, object: FixtureObject::MonoBehaviour { name: "BinaryBehaviour".to_string(), script: PPtrValue { file_id: 0, path_id: 1002, }, text_asset: PPtrValue { file_id: 0, path_id: 1001, }, }, }, ] } fn bundle_entries() -> Vec { vec![ FixtureEntry { path_id: 2001, class_id: 49, object: FixtureObject::TextAsset { name: "BundledText".to_string(), script: "Hello from bundled text asset\n".to_string(), }, }, FixtureEntry { path_id: 2002, class_id: 115, object: FixtureObject::MonoScript { name: "BundledBehaviour".to_string(), class_name: "BundledBehaviour".to_string(), namespace: "Mercury.Bundle".to_string(), assembly_name: "Assembly-CSharp".to_string(), }, }, FixtureEntry { path_id: 2003, class_id: 114, object: FixtureObject::MonoBehaviour { name: "BundledMonoBehaviour".to_string(), script: PPtrValue { file_id: 0, path_id: 2002, }, text_asset: PPtrValue { file_id: 0, path_id: 2001, }, }, }, ] } fn stripped_entries() -> Vec { vec![ FixtureEntry { path_id: 29, class_id: 1, object: FixtureObject::GameObject { name: "StrippedWindow".to_string(), }, }, FixtureEntry { path_id: 30, class_id: 1, object: FixtureObject::GameObject { name: "LinkedChild".to_string(), }, }, FixtureEntry { path_id: 1182, class_id: 115, object: FixtureObject::MonoScript { name: "StrippedWindowBehaviour".to_string(), class_name: "StrippedWindowBehaviour".to_string(), namespace: "Mercury.Stripped".to_string(), assembly_name: "Assembly-CSharp".to_string(), }, }, FixtureEntry { path_id: 2000, class_id: 114, object: FixtureObject::StrippedMonoBehaviour { game_object: PPtrValue { file_id: 0, path_id: 29, }, script: PPtrValue { file_id: 0, path_id: 1182, }, extra_refs: vec![PPtrValue { file_id: 0, path_id: 30, }], }, }, ] } fn write_fixture_workspace(root: &Path) { fs::write( root.join("sample.assets"), build_serialized_file(&standalone_entries()), ) .expect("standalone asset"); fs::write( root.join("sample.bundle"), build_unityfs_bundle("bundled.assets", &build_serialized_file(&bundle_entries())), ) .expect("bundle"); fs::write( root.join("stripped.assets"), build_serialized_file(&stripped_entries()), ) .expect("stripped asset"); fs::write(root.join("scene.unity"), sample_yaml_fixture()).expect("yaml fixture"); } fn sample_yaml_fixture() -> String { r"--- !u!1 &1000 GameObject: m_Name: SampleYamlGameObject m_Component: - component: {fileID: 2000} --- !u!114 &2000 MonoBehaviour: m_Name: SampleYamlBehaviour m_GameObject: {fileID: 1000} m_Script: {fileID: 11500000, guid: 1234567890abcdef1234567890abcdef, type: 3} m_TextAsset: {fileID: 3000} --- !u!49 &3000 TextAsset: m_Name: YamlText m_Script: Hello from YAML text asset " .to_string() } fn build_serialized_file(entries: &[FixtureEntry]) -> Vec { let type_descriptors = entries .iter() .map(|entry| (entry.class_id, type_tree_blob_for(&entry.object))) .collect::>(); let object_payloads = entries .iter() .map(|entry| object_bytes(&entry.object)) .collect::>(); let mut metadata = Vec::new(); metadata.extend_from_slice(b"2019.4.0f1\0"); metadata.extend_from_slice(&0i32.to_le_bytes()); metadata.push(1u8); metadata.extend_from_slice(&to_i32(type_descriptors.len()).to_le_bytes()); for (class_id, tree_blob) in &type_descriptors { metadata.extend_from_slice(&class_id.to_le_bytes()); metadata.push(0u8); metadata.extend_from_slice(&(-1i16).to_le_bytes()); if *class_id == 114 { metadata.extend_from_slice(&[0xAB; 16]); } metadata.extend_from_slice(&[0xCD; 16]); metadata.extend_from_slice(tree_blob); } metadata.extend_from_slice(&to_i32(entries.len()).to_le_bytes()); let mut relative_offset = 0u32; for (index, entry) in entries.iter().enumerate() { while metadata.len() % 4 != 0 { metadata.push(0u8); } metadata.extend_from_slice(&entry.path_id.to_le_bytes()); metadata.extend_from_slice(&relative_offset.to_le_bytes()); metadata.extend_from_slice(&to_u32(object_payloads[index].len()).to_le_bytes()); metadata.extend_from_slice(&to_i32(index).to_le_bytes()); relative_offset = relative_offset .checked_add(to_u32(object_payloads[index].len())) .expect("relative offset overflow"); } metadata.extend_from_slice(&0i32.to_le_bytes()); metadata.extend_from_slice(&0i32.to_le_bytes()); metadata.push(0u8); let header_size = 20u32; let data_offset = header_size .checked_add(to_u32(metadata.len())) .expect("data offset overflow"); let file_size = data_offset .checked_add( object_payloads .iter() .map(|bytes| to_u32(bytes.len())) .sum::(), ) .expect("file size overflow"); let mut bytes = Vec::new(); bytes.extend_from_slice(&data_offset.to_be_bytes()); bytes.extend_from_slice(&file_size.to_be_bytes()); bytes.extend_from_slice(&19u32.to_be_bytes()); bytes.extend_from_slice(&data_offset.to_be_bytes()); bytes.push(0u8); bytes.extend_from_slice(&[0u8; 3]); bytes.extend_from_slice(&metadata); for payload in object_payloads { bytes.extend_from_slice(&payload); } bytes } fn build_unityfs_bundle(entry_name: &str, file_bytes: &[u8]) -> Vec { let mut blocks_info = vec![0u8; 16]; blocks_info.extend_from_slice(&1i32.to_be_bytes()); blocks_info.extend_from_slice(&to_u32(file_bytes.len()).to_be_bytes()); blocks_info.extend_from_slice(&to_u32(file_bytes.len()).to_be_bytes()); blocks_info.extend_from_slice(&0u16.to_be_bytes()); blocks_info.extend_from_slice(&1i32.to_be_bytes()); blocks_info.extend_from_slice(&0i64.to_be_bytes()); blocks_info.extend_from_slice(&to_i64(file_bytes.len()).to_be_bytes()); blocks_info.extend_from_slice(&0x4u32.to_be_bytes()); blocks_info.extend_from_slice(entry_name.as_bytes()); blocks_info.push(0u8); let mut bytes = Vec::new(); bytes.extend_from_slice(b"UnityFS\0"); bytes.extend_from_slice(&7u32.to_be_bytes()); bytes.extend_from_slice(b"2019.4.0f1\0"); bytes.extend_from_slice(b"2019.4.0f1\0"); let size_offset = bytes.len(); bytes.extend_from_slice(&0i64.to_be_bytes()); bytes.extend_from_slice(&to_u32(blocks_info.len()).to_be_bytes()); bytes.extend_from_slice(&to_u32(blocks_info.len()).to_be_bytes()); bytes.extend_from_slice(&0u32.to_be_bytes()); while bytes.len() % 16 != 0 { bytes.push(0u8); } bytes.extend_from_slice(&blocks_info); bytes.extend_from_slice(file_bytes); let total_size = to_i64(bytes.len()); bytes[size_offset..size_offset + 8].copy_from_slice(&total_size.to_be_bytes()); bytes } fn type_tree_blob_for(object: &FixtureObject) -> Vec { let nodes = match object { FixtureObject::GameObject { .. } => vec![ node(0, "GameObject", "Base", -1, 0), node(1, "string", "m_Name", -1, ALIGN_BYTES_FLAG), ], FixtureObject::TextAsset { .. } => vec![ node(0, "TextAsset", "Base", -1, 0), node(1, "string", "m_Name", -1, ALIGN_BYTES_FLAG), node(1, "string", "m_Script", -1, ALIGN_BYTES_FLAG), ], FixtureObject::MonoScript { .. } => vec![ node(0, "MonoScript", "Base", -1, 0), node(1, "string", "m_Name", -1, ALIGN_BYTES_FLAG), node(1, "string", "m_ClassName", -1, ALIGN_BYTES_FLAG), node(1, "string", "m_Namespace", -1, ALIGN_BYTES_FLAG), node(1, "string", "m_AssemblyName", -1, ALIGN_BYTES_FLAG), ], FixtureObject::MonoBehaviour { .. } => vec![ node(0, "MonoBehaviour", "Base", -1, 0), node(1, "string", "m_Name", -1, ALIGN_BYTES_FLAG), node(1, "PPtr", "m_Script", -1, 0), node(2, "int", "m_FileID", 4, 0), node(2, "long long", "m_PathID", 8, 0), node(1, "PPtr", "m_TextAsset", -1, 0), node(2, "int", "m_FileID", 4, 0), node(2, "long long", "m_PathID", 8, 0), ], FixtureObject::StrippedMonoBehaviour { .. } => return empty_type_tree_blob(), }; let mut string_buffer = Vec::new(); let mut records = Vec::new(); for (index, (level, type_name, field_name, byte_size, meta_flags)) in nodes.iter().enumerate() { let type_offset = push_cstring(&mut string_buffer, type_name); let name_offset = push_cstring(&mut string_buffer, field_name); records.push(( *level, type_offset, name_offset, *byte_size, to_i32(index), *meta_flags, )); } let mut blob = Vec::new(); blob.extend_from_slice(&to_i32(records.len()).to_le_bytes()); blob.extend_from_slice(&to_i32(string_buffer.len()).to_le_bytes()); for (level, type_offset, name_offset, byte_size, index, meta_flags) in records { blob.extend_from_slice(&1u16.to_le_bytes()); blob.push(level); blob.push(0u8); blob.extend_from_slice(&type_offset.to_le_bytes()); blob.extend_from_slice(&name_offset.to_le_bytes()); blob.extend_from_slice(&byte_size.to_le_bytes()); blob.extend_from_slice(&index.to_le_bytes()); blob.extend_from_slice(&meta_flags.to_le_bytes()); blob.extend_from_slice(&0u64.to_le_bytes()); } blob.extend_from_slice(&string_buffer); blob } fn empty_type_tree_blob() -> Vec { let mut blob = Vec::new(); blob.extend_from_slice(&0i32.to_le_bytes()); blob.extend_from_slice(&0i32.to_le_bytes()); blob } const fn node( level: u8, type_name: &'static str, field_name: &'static str, byte_size: i32, meta_flags: i32, ) -> (u8, &'static str, &'static str, i32, i32) { (level, type_name, field_name, byte_size, meta_flags) } fn push_cstring(buffer: &mut Vec, value: &str) -> u32 { let offset = to_u32(buffer.len()); buffer.extend_from_slice(value.as_bytes()); buffer.push(0u8); offset } fn object_bytes(object: &FixtureObject) -> Vec { let mut bytes = Vec::new(); match object { FixtureObject::GameObject { name } => { push_aligned_string(&mut bytes, name); } FixtureObject::TextAsset { name, script } => { push_aligned_string(&mut bytes, name); push_aligned_string(&mut bytes, script); } FixtureObject::MonoScript { name, class_name, namespace, assembly_name, } => { push_aligned_string(&mut bytes, name); push_aligned_string(&mut bytes, class_name); push_aligned_string(&mut bytes, namespace); push_aligned_string(&mut bytes, assembly_name); } FixtureObject::MonoBehaviour { name, script, text_asset, } => { push_aligned_string(&mut bytes, name); push_pptr(&mut bytes, *script); push_pptr(&mut bytes, *text_asset); } FixtureObject::StrippedMonoBehaviour { game_object, script, extra_refs, } => { push_pptr(&mut bytes, *game_object); bytes.extend_from_slice(&1i32.to_le_bytes()); push_pptr(&mut bytes, *script); for value in extra_refs { push_pptr(&mut bytes, *value); } } } bytes } fn push_aligned_string(bytes: &mut Vec, value: &str) { bytes.extend_from_slice(&to_i32(value.len()).to_le_bytes()); bytes.extend_from_slice(value.as_bytes()); while bytes.len() % 4 != 0 { bytes.push(0u8); } } fn push_pptr(bytes: &mut Vec, value: PPtrValue) { bytes.extend_from_slice(&value.file_id.to_le_bytes()); bytes.extend_from_slice(&value.path_id.to_le_bytes()); } fn assert_json(assert: &assert_cmd::assert::Assert, label: &str) -> T { serde_json::from_slice(&assert.get_output().stdout).expect(label) } #[test] fn help_includes_subcommands_and_examples() { let mut command = cargo_command(); command .arg("--help") .assert() .success() .stdout(predicate::str::contains("unityasset index")) .stdout(predicate::str::contains("unityasset refs")) .stdout(predicate::str::contains("--direction ")) .stdout(predicate::str::contains("--max-refs ")) .stdout(predicate::str::contains("unityasset dump")) .stdout(predicate::str::contains("unityasset extract")); } #[test] fn indexes_serialized_bundle_and_yaml_objects_as_json() { let root = unique_temp_dir("index"); write_fixture_workspace(&root); let mut command = cargo_command(); let assert = command .arg("--json") .arg("index") .arg(&root) .assert() .success(); let rows = assert_json::>(&assert, "json"); assert!(rows.iter().any(|row| { row.get("id").and_then(Value::as_str) == Some("sample.assets#1001") && row.get("class_name").and_then(Value::as_str) == Some("TextAsset") })); assert!(rows.iter().any(|row| { row.get("id").and_then(Value::as_str) == Some("sample.assets#1002") && row.get("script_class").and_then(Value::as_str) == Some("SampleBehaviour") && row.get("script_namespace").and_then(Value::as_str) == Some("Mercury.Sample") && row.get("script_assembly").and_then(Value::as_str) == Some("Assembly-CSharp") })); assert!(rows.iter().any(|row| { row.get("id").and_then(Value::as_str) == Some("sample.bundle::bundled.assets#2001") && row.get("source_kind").and_then(Value::as_str) == Some("asset_bundle") })); assert!(rows.iter().any(|row| { row.get("id").and_then(Value::as_str) == Some("scene.unity#3000") && row.get("format").and_then(Value::as_str) == Some("yaml") })); fs::remove_dir_all(root).expect("cleanup"); } #[test] fn index_filters_by_script_metadata_for_mono_behaviour_instances() { let root = unique_temp_dir("index-script-filter"); write_fixture_workspace(&root); let mut command = cargo_command(); let assert = command .args([ "--json", "index", "--class", "MonoBehaviour", "--script", "SampleBehaviour", &root.display().to_string(), ]) .assert() .success(); let rows = assert_json::>(&assert, "json"); assert_eq!(rows.len(), 1); assert_eq!( rows[0].get("id").and_then(Value::as_str), Some("sample.assets#1003") ); assert_eq!( rows[0].get("script_class").and_then(Value::as_str), Some("SampleBehaviour") ); fs::remove_dir_all(root).expect("cleanup"); } #[test] fn index_empty_matches_still_exit_success_with_json_output() { let root = unique_temp_dir("index-empty-success"); write_fixture_workspace(&root); let mut command = cargo_command(); let assert = command .args([ "--json", "index", "--class", "^VideoClip$", &root.display().to_string(), ]) .assert() .success(); let rows = assert_json::>(&assert, "json"); assert!(rows.is_empty()); fs::remove_dir_all(root).expect("cleanup"); } #[test] fn reports_binary_incoming_and_outgoing_references_as_json() { let root = unique_temp_dir("refs-bin"); write_fixture_workspace(&root); let mut command = cargo_command(); let assert = command .args(["--json", "refs", "sample.assets#1001"]) .arg(&root) .assert() .success(); let refs = assert_json::(&assert, "json"); assert_eq!( refs.get("target") .and_then(|target| target.get("id")) .and_then(Value::as_str), Some("sample.assets#1001") ); assert_eq!( refs.get("incoming") .and_then(Value::as_array) .expect("incoming") .iter() .filter_map(|entry| entry.get("id").and_then(Value::as_str)) .collect::>(), vec!["sample.assets#1003"] ); assert!( refs.get("outgoing") .and_then(Value::as_array) .expect("outgoing") .is_empty() ); fs::remove_dir_all(root).expect("cleanup"); } #[test] fn reports_yaml_references_as_json() { let root = unique_temp_dir("refs-yaml"); write_fixture_workspace(&root); let mut command = cargo_command(); let assert = command .args(["--json", "refs", "scene.unity#1000"]) .arg(&root) .assert() .success(); let refs = assert_json::(&assert, "json"); assert_eq!( refs.get("outgoing") .and_then(Value::as_array) .expect("outgoing") .iter() .filter_map(|entry| entry.get("id").and_then(Value::as_str)) .collect::>(), vec!["scene.unity#2000"] ); assert_eq!( refs.get("incoming") .and_then(Value::as_array) .expect("incoming") .iter() .filter_map(|entry| entry.get("id").and_then(Value::as_str)) .collect::>(), vec!["scene.unity#2000"] ); fs::remove_dir_all(root).expect("cleanup"); } #[test] fn reports_stripped_mono_behaviour_refs_and_metadata_only_dump() { let root = unique_temp_dir("refs-stripped"); write_fixture_workspace(&root); let mut refs_command = cargo_command(); let refs_assert = refs_command .args(["--json", "refs", "stripped.assets#2000"]) .arg(&root) .assert() .success(); let refs = assert_json::(&refs_assert, "json"); let outgoing = refs .get("outgoing") .and_then(Value::as_array) .expect("outgoing"); assert!(outgoing.iter().any(|entry| { entry.get("path").and_then(Value::as_str) == Some("m_GameObject") && entry.get("id").and_then(Value::as_str) == Some("stripped.assets#29") })); assert!(outgoing.iter().any(|entry| { entry.get("path").and_then(Value::as_str) == Some("m_Script") && entry.get("id").and_then(Value::as_str) == Some("stripped.assets#1182") })); assert!(outgoing.iter().any(|entry| { entry .get("path") .and_then(Value::as_str) .is_some_and(|path| path.starts_with("_raw_pptr@")) && entry.get("id").and_then(Value::as_str) == Some("stripped.assets#30") })); let mut dump_command = cargo_command(); let dump_assert = dump_command .args(["--json", "dump", "stripped.assets#2000"]) .arg(&root) .assert() .success(); let dump = assert_json::(&dump_assert, "json"); assert_eq!( dump.get("field_mode").and_then(Value::as_str), Some("metadata_only") ); assert_eq!( dump.get("properties") .and_then(|value| value.get("m_GameObject")) .and_then(|value| value.get("resolved_id")) .and_then(Value::as_str), Some("stripped.assets#29") ); assert_eq!( dump.get("properties") .and_then(|value| value.get("m_Script")) .and_then(|value| value.get("resolved_id")) .and_then(Value::as_str), Some("stripped.assets#1182") ); assert!( dump.get("properties") .and_then(|value| value.get("_raw_resolved_references")) .and_then(Value::as_array) .is_some_and(|items| !items.is_empty()) ); fs::remove_dir_all(root).expect("cleanup"); } #[test] fn refs_json_supports_direction_and_max_refs() { let root = unique_temp_dir("refs-direction"); write_fixture_workspace(&root); let mut command = cargo_command(); let assert = command .args([ "--json", "refs", "--direction", "outgoing", "--max-refs", "2", "stripped.assets#2000", ]) .arg(&root) .assert() .success(); let refs = assert_json::(&assert, "json"); assert_eq!( refs.get("direction").and_then(Value::as_str), Some("outgoing") ); assert_eq!(refs.get("max_refs").and_then(Value::as_u64), Some(2)); assert_eq!(refs.get("outgoing_total").and_then(Value::as_u64), Some(3)); assert_eq!(refs.get("outgoing_shown").and_then(Value::as_u64), Some(2)); assert_eq!( refs.get("outgoing_truncated").and_then(Value::as_bool), Some(true) ); assert_eq!( refs.get("incoming_filter_reason").and_then(Value::as_str), Some("excluded_by_direction") ); assert!( refs.get("incoming") .and_then(Value::as_array) .expect("incoming") .is_empty() ); assert_eq!( refs.get("outgoing") .and_then(Value::as_array) .expect("outgoing") .len(), 2 ); fs::remove_dir_all(root).expect("cleanup"); } #[test] fn dumps_bundle_objects_and_extracts_text_payloads() { let root = unique_temp_dir("dump-extract"); write_fixture_workspace(&root); let mut dump_command = cargo_command(); let dump_assert = dump_command .args(["--json", "dump", "sample.bundle::bundled.assets#2002"]) .arg(&root) .assert() .success(); let dump = assert_json::(&dump_assert, "dump json"); assert_eq!( dump.get("properties") .and_then(|properties| properties.get("m_ClassName")) .and_then(Value::as_str), Some("BundledBehaviour") ); let out_dir = root.join("out"); fs::create_dir_all(&out_dir).expect("out dir"); let mut extract_command = cargo_command(); extract_command .args([ "extract", "sample.bundle::bundled.assets#2001", &root.display().to_string(), "--output-dir", &out_dir.display().to_string(), ]) .assert() .success() .stdout(predicate::str::contains("BundledText")); let bundled_text = fs::read_to_string(out_dir.join("BundledText.txt")).expect("bundled text"); assert_eq!(bundled_text, "Hello from bundled text asset\n"); let mut yaml_extract = cargo_command(); yaml_extract .args([ "extract", "scene.unity#3000", &root.display().to_string(), "--output-dir", &out_dir.display().to_string(), ]) .assert() .success() .stdout(predicate::str::contains("YamlText")); let yaml_text = fs::read_to_string(out_dir.join("YamlText.txt")).expect("yaml text"); assert_eq!(yaml_text, "Hello from YAML text asset"); fs::remove_dir_all(root).expect("cleanup"); }