use nox_roblox::manifest::{package_dir, Manifest}; use nox_roblox::md5::{md5_hex, to_hex, Md5}; use std::process::Command; const REAL_MANIFEST: &str = "v0\nRobloxApp.zip\naeaa4432a6005a7cead977db05447511\n134901428\n173551147\ncontent-avatar.zip\n961cd6b55ab23d2efcc9a7319191aa37\n293687\n1020627\nRobloxPlayerInstaller.exe\nd41d8cd98f00b204e9800998ecf8427e\n100\n100\n"; #[test] fn parses_a_real_manifest() { let m = Manifest::parse("version-abc", REAL_MANIFEST).unwrap(); assert_eq!(m.packages.len(), 3); assert_eq!(m.packages[0].name, "RobloxApp.zip"); assert_eq!(m.packages[0].md5, "aeaa4432a6005a7cead977db05447511"); assert_eq!(m.packages[0].packed, 134901428); assert_eq!(m.packages[0].unpacked, 173551147); // The installer executable has no destination, so it is not installable. assert_eq!(m.installable().len(), 2); assert_eq!(m.total_packed(), 134901428 + 293687); } #[test] fn rejects_broken_manifests() { assert!(Manifest::parse("v", "").is_err()); assert!(Manifest::parse("v", "v1\na\nb\n1\n2\n").is_err()); assert!(Manifest::parse("v", "v0\na\nb\n1\n").is_err()); assert!(Manifest::parse("v", "v0\na\nb\nxx\n2\n").is_err()); } #[test] fn every_package_in_a_live_manifest_has_a_destination() { // Captured from the live WindowsPlayer manifest; if Roblox adds a package we // do not know where to put, this is where it should show up. let live = [ "RobloxApp.zip", "WebView2.zip", "WebView2RuntimeInstaller.zip", "content-avatar.zip", "content-configs.zip", "content-fonts.zip", "content-models.zip", "content-platform-dictionaries.zip", "content-platform-fonts.zip", "content-sky.zip", "content-sounds.zip", "content-terrain.zip", "content-textures2.zip", "content-textures3.zip", "extracontent-luapackages.zip", "extracontent-models.zip", "extracontent-places.zip", "extracontent-textures.zip", "extracontent-translations.zip", "shaders.zip", "ssl.zip", ]; for p in live { assert!(package_dir(p).is_some(), "no destination mapped for {p}"); } assert!(package_dir("RobloxPlayerInstaller.exe").is_none()); assert!(package_dir("something-new.zip").is_none()); } #[test] fn md5_matches_python() { if Command::new("python3").arg("--version").output().is_err() { return; } let mut cases: Vec> = vec![ Vec::new(), b"a".to_vec(), b"abc".to_vec(), b"message digest".to_vec(), b"abcdefghijklmnopqrstuvwxyz".to_vec(), b"12345678901234567890123456789012345678901234567890123456789012345678901234567890".to_vec(), ]; // Every length across a block boundary, where padding logic tends to break. for n in 0..200usize { cases.push((0..n).map(|i| (i * 7 % 251) as u8).collect()); } cases.push(vec![0xab; 100_000]); let script = std::env::temp_dir().join("nox-md5.py"); std::fs::write(&script, "import sys,hashlib\nfor line in sys.stdin:\n line=line.strip()\n print(hashlib.md5(bytes.fromhex(line)).hexdigest())\n").unwrap(); let input: String = cases.iter().map(|c| format!("{}\n", to_hex(c))).collect(); let mut child = Command::new("python3") .arg(&script) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .spawn() .unwrap(); use std::io::Write; child.stdin.as_mut().unwrap().write_all(input.as_bytes()).unwrap(); let out = child.wait_with_output().unwrap(); let want: Vec<&str> = std::str::from_utf8(&out.stdout).unwrap().lines().collect(); assert_eq!(want.len(), cases.len()); for (i, c) in cases.iter().enumerate() { assert_eq!(md5_hex(c), want[i], "md5 differs for case {i} (len {})", c.len()); } // Streaming in odd-sized chunks must equal hashing in one go. let big = &cases[cases.len() - 1]; let mut h = Md5::new(); for chunk in big.chunks(7) { h.update(chunk); } assert_eq!(to_hex(&h.finish()), md5_hex(big)); } #[test] fn launch_passes_the_uri_through_untouched() { use nox_roblox::launch::plan; use nox_roblox::Binary; let root = std::path::Path::new("/tmp/nox-root"); let uri = "roblox-player:1+launchmode:play+gameinfo:TICKET+placelauncherurl:https%3A%2F%2Fx.com%2Fy%3Fa%3D1%26b%3D2+launchtime:123"; let l = plan(root, "version-abc", Binary::Player, Some(uri)); assert_eq!(l.args, vec![uri.to_string()], "the uri must not be split or re-encoded"); assert!(l.exe.ends_with("RobloxPlayerBeta.exe")); assert!(l.working_dir.ends_with("version-abc")); let none = plan(root, "version-abc", Binary::Player, None); assert_eq!(none.args, vec!["--app".to_string()]); } #[test] fn install_state_round_trips_on_disk() { let root = std::env::temp_dir().join("nox-roblox-state"); let _ = std::fs::remove_dir_all(&root); assert!(!nox_roblox::is_installed(&root, "version-x")); assert!(nox_roblox::installed_versions(&root).is_empty()); let dir = nox_roblox::version_dir(&root, "version-x"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join(".noxstrap-installed"), "version-x").unwrap(); // Marker alone is not enough; the executable has to be there too. assert!(!nox_roblox::is_installed(&root, "version-x")); std::fs::write(dir.join("RobloxPlayerBeta.exe"), b"stub").unwrap(); assert!(nox_roblox::is_installed(&root, "version-x")); assert_eq!(nox_roblox::installed_versions(&root), vec!["version-x".to_string()]); nox_roblox::remove_version(&root, "version-x").unwrap(); assert!(!nox_roblox::is_installed(&root, "version-x")); }