diff --git a/Engine/Resource/Test/oodle_path_tests.cpp b/Engine/Resource/Test/oodle_path_tests.cpp new file mode 100644 index 0000000..9d55a8d --- /dev/null +++ b/Engine/Resource/Test/oodle_path_tests.cpp @@ -0,0 +1,26 @@ +// A test codec exercises DLL discovery without the game's proprietary Oodle DLL. +#include "Engine/Resource/cas_codec.h" +#include +#include +#include +#include + +int wmain(int argc, wchar_t** argv) { + try { + if (argc != 2) throw std::runtime_error("Pass the directory containing the test Oodle DLL"); + if (GetModuleHandleW(L"oo2core_9_win64.dll")) + throw std::runtime_error("The fixture must exercise discovery before Oodle is loaded"); + // One byte in an Oodle CAS block. The fixture codec returns that byte unchanged. + constexpr std::array encoded{ + std::byte{0}, std::byte{0}, std::byte{0}, std::byte{1}, + std::byte{0x11}, std::byte{0}, std::byte{0}, std::byte{1}, std::byte{0x2a}}; + const auto decoded = dingosdk::frostbite::decode_cas(encoded, {std::filesystem::path(argv[1])}); + if (decoded.size() != 1 || decoded.front() != std::byte{0x2a}) + throw std::runtime_error("The test codec did not decode the CAS byte"); + std::cout << "Root Oodle fixture loaded and CAS byte decoded\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/Engine/Resource/Test/oodle_test_codec.cpp b/Engine/Resource/Test/oodle_test_codec.cpp new file mode 100644 index 0000000..9c794ac --- /dev/null +++ b/Engine/Resource/Test/oodle_test_codec.cpp @@ -0,0 +1,10 @@ +// Test-only DLL: never package this as the game's actual codec. +#include + +extern "C" __declspec(dllexport) std::int64_t __cdecl OodleLZ_Decompress( + const void* input, std::int64_t inputSize, void* output, std::int64_t outputSize, + int, int, int, void*, std::int64_t, void*, void*, void*, std::int64_t, int) { + if (!input || !output || inputSize != 1 || outputSize != 1) return -1; + *static_cast(output) = *static_cast(input); + return 1; +} diff --git a/Engine/Resource/cas_codec.cpp b/Engine/Resource/cas_codec.cpp index 9149e1f..83b4570 100644 --- a/Engine/Resource/cas_codec.cpp +++ b/Engine/Resource/cas_codec.cpp @@ -63,7 +63,10 @@ HMODULE oodle(const std::filesystem::path& gameRoot) { if (error || !entry.is_regular_file(error)) { error.clear(); continue; } auto name = entry.path().filename().wstring(); std::ranges::transform(name, name.begin(), ::towlower); - if (name.starts_with(L"oo2core_") && name.ends_with(L"_win64.dll")) matches.push_back(entry.path()); + // Under Proton, a Unix filename containing '\\' can leave extra directories + // in MSVC's iterator path. Keep the candidate anchored to the original root. + if (name.starts_with(L"oo2core_") && name.ends_with(L"_win64.dll")) + matches.push_back(gameRoot / entry.path().filename()); } if (matches.empty()) throw std::runtime_error("The game's Oodle codec was not found"); std::ranges::sort(matches); diff --git a/README.md b/README.md index fd4c5d0..f002662 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,39 @@ ctest --test-dir build/vs2022-x64 -C Release The project builds with `/W4 /WX`: warnings are errors. +### Packaging release ZIPs + +Stage the complete release contents, including a real `licenses/` directory, then create the ZIP +with Python 3.9 or newer (no extra packages): + +```sh +python3 contrib/package_release_zip.py path/to/staged-release path/to/ReSkate-release.zip +python3 contrib/package_release_zip.py --check path/to/ReSkate-release.zip +``` + +The output must be outside the staging directory and must not already exist. The packager stores +forward slashes on Windows as well as Linux, checks the completed ZIP, and rejects unsafe entries +and symlinks. Use `--check` on the actual upload artifact before publishing: backslashes in ZIP entry +names can become literal Unix filenames and break Oodle lookup under Proton +([issue 124](https://github.com/Dingo-Shenanigans/ReSkate/issues/124)). These commands do not change +the staged binaries or an installed game. Run the packaging tests with +`python3 -m unittest discover -s contrib/Test -p '*_tests.py'`. + +The `oodle_path` CTest (with `DINGOSDK_BUILD_LAUNCHER_TESTS=ON` on Windows) loads a test-only +codec DLL and decodes a CAS byte without game files. To reproduce the literal-backslash bug on +Proton, build that test once with the original `cas_codec.cpp` and once with the fix, then run: + +```sh +python3 contrib/Test/run_oodle_proton_test.py --wine path/to/Proton/files/bin/wine \ + --baseline path/to/original/dingosdk_oodle_path_tests.exe \ + --patched path/to/patched/dingosdk_oodle_path_tests.exe \ + --codec path/to/test-fixture/oo2core_9_win64.dll +``` + +This creates a temporary fixture and an isolated Wine prefix, requires the original repeated-path +failure, and checks that the patched loader works both before and after normalizing the filenames. +It never uses the installed game or its DLL. **Never ship the test codec DLL as a game/release file.** + ## Project layout The source tree loosely follows Frostbite's own layout. diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 85c0675..84b447b 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -13,6 +13,15 @@ endif() option(DINGOSDK_BUILD_LAUNCHER_TESTS "Build launcher mod manager regression tests" OFF) if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) enable_testing() + add_library(dingosdk_test_oodle_codec SHARED Engine/Resource/Test/oodle_test_codec.cpp) + set_target_properties(dingosdk_test_oodle_codec PROPERTIES + OUTPUT_NAME "oo2core_9_win64" PREFIX "" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/oodle-fixture/$") + add_executable(dingosdk_oodle_path_tests Engine/Resource/Test/oodle_path_tests.cpp) + target_link_libraries(dingosdk_oodle_path_tests PRIVATE dingosdk_frostbite) + target_include_directories(dingosdk_oodle_path_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_dependencies(dingosdk_oodle_path_tests dingosdk_test_oodle_codec) + add_test(NAME oodle_path COMMAND dingosdk_oodle_path_tests "$") add_executable(dingosdk_mod_manager_tests Launcher/Test/mod_manager_tests.cpp Launcher/mod_manager.cpp) target_link_libraries(dingosdk_mod_manager_tests PRIVATE dingosdk_mod_list dingosdk_launcher_support dingosdk_miniz shell32) add_test(NAME launcher_mod_manager COMMAND dingosdk_mod_manager_tests) diff --git a/contrib/Test/package_release_zip_tests.py b/contrib/Test/package_release_zip_tests.py new file mode 100644 index 0000000..2bcd03b --- /dev/null +++ b/contrib/Test/package_release_zip_tests.py @@ -0,0 +1,90 @@ +import importlib.util +from pathlib import Path +import tempfile +import unittest +import zipfile + +script = Path(__file__).resolve().parents[1] / "package_release_zip.py" +spec = importlib.util.spec_from_file_location("package_release_zip", script) +release = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release) + + +class ReleaseZipTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="release paths ") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.source = self.root / "staged release" + (self.source / "licenses").mkdir(parents=True) + (self.source / "licenses/imgui-LICENSE.txt").write_bytes(b"license contents\n") + (self.source / "ReSkate.dll").write_bytes(b"fixture binary") + self.output = self.root / "release.zip" + + def test_nested_files_extract_to_directories_with_contents_preserved(self): + self.assertEqual(release.package(self.source, self.output), 2) + release.check_archive(self.output) + with zipfile.ZipFile(self.output) as archive: + self.assertIn("licenses/imgui-LICENSE.txt", archive.namelist()) + self.assertFalse(any("\\" in name for name in archive.namelist())) + archive.extractall(self.root / "extracted") + self.assertEqual((self.root / "extracted/licenses/imgui-LICENSE.txt").read_bytes(), b"license contents\n") + self.assertEqual((self.root / "extracted/ReSkate.dll").read_bytes(), b"fixture binary") + + def test_backslash_archive_is_rejected_without_changes(self): + with zipfile.ZipFile(self.output, "w") as archive: + archive.writestr("licenses\\imgui-LICENSE.txt", b"license contents\n") + before = self.output.read_bytes() + with self.assertRaises(ValueError): + release.check_archive(self.output) + self.assertEqual(self.output.read_bytes(), before) + + def test_unsafe_and_duplicate_archive_names_are_rejected(self): + for names in [("../escape.txt",), ("/absolute.txt",), ("C:/drive.txt",), + ("licenses//file.txt",), ("file.txt", "FILE.TXT")]: + with self.subTest(names=names): + with zipfile.ZipFile(self.output, "w") as archive: + for name in names: + archive.writestr(name, b"fixture") + with self.assertRaises(ValueError): + release.check_archive(self.output) + + def test_symlink_archive_and_staging_are_refused(self): + entry = zipfile.ZipInfo("linked.txt") + entry.create_system = 3 + entry.external_attr = 0o120777 << 16 + with zipfile.ZipFile(self.output, "w") as archive: + archive.writestr(entry, "outside.txt") + with self.assertRaises(ValueError): + release.check_archive(self.output) + self.output.unlink() + try: + (self.source / "link").symlink_to(self.root / "missing") + except OSError: + self.skipTest("Creating symlinks is unavailable on this host") + with self.assertRaises(ValueError): + release.package(self.source, self.output) + self.assertFalse(self.output.exists()) + + def test_existing_output_is_preserved(self): + self.output.write_bytes(b"existing release") + with self.assertRaises(FileExistsError): + release.package(self.source, self.output) + self.assertEqual(self.output.read_bytes(), b"existing release") + + def test_output_inside_staging_is_refused(self): + with self.assertRaises(ValueError): + release.package(self.source, self.source / "release.zip") + self.assertFalse((self.source / "release.zip").exists()) + + def test_malformed_unix_staging_name_is_refused(self): + if "\\" == __import__("os").sep: + self.skipTest("Literal backslash filenames require a Unix filesystem") + (self.source / "licenses\\lz4-LICENSE.txt").write_bytes(b"malformed name") + with self.assertRaises(ValueError): + release.package(self.source, self.output) + self.assertFalse(self.output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/contrib/Test/run_oodle_proton_test.py b/contrib/Test/run_oodle_proton_test.py new file mode 100644 index 0000000..2b15c59 --- /dev/null +++ b/contrib/Test/run_oodle_proton_test.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Compare baseline/patched Windows codec tests in an isolated Proton Wine prefix.""" +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--wine", required=True, type=Path, help="wine executable from an installed Proton") + parser.add_argument("--baseline", required=True, type=Path, help="oodle_path test built with the original cas_codec.cpp") + parser.add_argument("--patched", required=True, type=Path, help="oodle_path test built with the patched cas_codec.cpp") + parser.add_argument("--codec", required=True, type=Path, help="test-only oo2core_9_win64.dll fixture, never the game's DLL") + args = parser.parse_args() + if os.name != "posix": + parser.error("The malformed-filename reproduction requires a Unix host") + for path in (args.wine, args.baseline, args.patched, args.codec): + if not path.is_file(): + parser.error(f"Missing fixture executable/library: {path}") + scratch = Path(tempfile.mkdtemp(prefix="reskate-oodle-proton-")) + root = scratch / "Game root" + root.mkdir() + libraries = ["bcdec", "detours", "imgui", "lz4", "miniz", "Montserrat", + "PermanentMarker", "rapidjson", "spdlog", "sqlite", "steam_networking", "zstd"] + malformed = [] + for library in libraries: + path = root / ("licenses\\" + library + "-LICENSE.txt") + path.write_text("Test filename only\n", encoding="utf-8") + malformed.append(path) + shutil.copy2(args.codec, root / "oo2core_9_win64.dll") + environment = os.environ.copy() + environment.update(WINEPREFIX=str(scratch / "prefix"), WINEARCH="win64", WINEDEBUG="-all", + WINEDLLOVERRIDES="mscoree,mshtml=") + game_path = "Z:" + str(root).replace("/", "\\") + results = [] + + def run(label, executable): + process = subprocess.run([str(args.wine.resolve()), str(executable.resolve()), game_path], + env=environment, text=True, capture_output=True, timeout=120) + row = {"test": label, "returncode": process.returncode, + "stdout": process.stdout, "stderr": process.stderr} + results.append(row) + (scratch / "results.json").write_text(json.dumps(results, indent=2) + "\n", encoding="utf-8") + return row + + print(f"Isolated fixture and results: {scratch}") + original_bad = run("baseline with malformed names", args.baseline) + if (original_bad["returncode"] == 0 or "Cannot load " not in original_bad["stderr"] or + "licenses\\licenses" not in original_bad["stderr"]): + parser.exit(1, "The original repeated-path failure was not reproduced; inspect results.json.\n") + print("PASS: original loader reproduces the repeated-license-path error") + fixed_bad = run("patched with malformed names", args.patched) + if fixed_bad["returncode"] != 0 or "CAS byte decoded" not in fixed_bad["stdout"]: + parser.exit(1, "Patched loader failed on malformed names; inspect results.json.\n") + print("PASS: patched loader loads the root fixture and decodes with malformed names present") + (root / "licenses").mkdir() + for path in malformed: + path.rename(root / "licenses" / path.name.split("\\", 1)[1]) + for label, executable in [("baseline with normalized names", args.baseline), + ("patched with normalized names", args.patched)]: + result = run(label, executable) + if result["returncode"] != 0 or "CAS byte decoded" not in result["stdout"]: + parser.exit(1, f"{label} failed; inspect results.json.\n") + print(f"PASS: {label}") + print("No installed game, real Oodle DLL, mod, save, setting, or existing Wine prefix was used or changed.") + + +if __name__ == "__main__": + main() diff --git a/contrib/package_release_zip.py b/contrib/package_release_zip.py new file mode 100644 index 0000000..de26746 --- /dev/null +++ b/contrib/package_release_zip.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Create or check a release ZIP with portable, forward-slash entry names.""" +import argparse +from pathlib import Path +import shutil +import stat +import tempfile +import zipfile + + +def check_name(name): + parts = name.rstrip("/").split("/") + if "\\" in name or ":" in name or any(part in ("", ".", "..") for part in parts): + raise ValueError(f"Non-portable ZIP entry: {name!r}") + + +def check_archive(path): + with zipfile.ZipFile(path) as archive: + seen = set() + for entry in archive.infolist(): + check_name(entry.filename) + key = entry.filename.rstrip("/").casefold() + if key in seen or stat.S_ISLNK(entry.external_attr >> 16): + raise ValueError(f"Duplicate or symlink ZIP entry: {entry.filename!r}") + seen.add(key) + damaged = archive.testzip() + if damaged is not None: + raise ValueError(f"Damaged ZIP entry: {damaged!r}") + + +def package(source, output): + source = Path(source).resolve(strict=True) + output = Path(output).absolute() + if not source.is_dir(): + raise ValueError("The staged release must be a directory") + if output.exists() or output.is_symlink(): + raise FileExistsError(f"Archive already exists: {output}") + if output.resolve().is_relative_to(source): + raise ValueError("Write the archive outside the staged release directory") + files = [] + for path in sorted(source.rglob("*")): + if path.is_symlink(): + raise ValueError(f"Symlinks are not release files: {path}") + if path.is_dir(): + continue + if not path.is_file(): + raise ValueError(f"Not a regular release file: {path}") + name = path.relative_to(source).as_posix() + check_name(name) + files.append((path, name)) + if not files: + raise ValueError("The staged release is empty") + # Validate the completed archive before exposing the requested output name. + with tempfile.TemporaryDirectory(prefix="reskate-release-", dir=output.parent) as temporary: + staged = Path(temporary) / "release.zip" + with zipfile.ZipFile(staged, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for path, name in files: + archive.write(path, arcname=name) + check_archive(staged) + created = False + try: + with output.open("xb") as target, staged.open("rb") as ready: + created = True + shutil.copyfileobj(ready, target) + except BaseException: + if created: + output.unlink() + raise + return len(files) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("paths", nargs="*", metavar="PATH", help="STAGED_FOLDER OUTPUT.zip") + parser.add_argument("--check", type=Path, metavar="ARCHIVE.zip", help="Validate an existing ZIP without changing it") + args = parser.parse_args() + try: + if args.check is not None: + if args.paths: + parser.error("--check accepts no staging/output paths") + check_archive(args.check) + print(f"Portable release ZIP: {args.check}") + else: + if len(args.paths) != 2: + parser.error("Provide STAGED_FOLDER OUTPUT.zip, or --check ARCHIVE.zip") + count = package(*args.paths) + print(f"Packaged {count} file(s): {args.paths[1]}") + except (OSError, ValueError, zipfile.BadZipFile) as error: + parser.exit(1, f"Release ZIP refused: {error}\n") + + +if __name__ == "__main__": + main()