From 6a2584e7cf4724ecc4bf2044fbd2ebcaca057ead Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 11 Jun 2026 10:15:15 +0000 Subject: [PATCH 1/4] test(path-safety): add extract path traversal regression tests Co-authored-by: PuritanWizard --- Libraries/PyKotor/tests/test_path_safety.py | 110 ++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 Libraries/PyKotor/tests/test_path_safety.py diff --git a/Libraries/PyKotor/tests/test_path_safety.py b/Libraries/PyKotor/tests/test_path_safety.py new file mode 100644 index 000000000..c8005d8c7 --- /dev/null +++ b/Libraries/PyKotor/tests/test_path_safety.py @@ -0,0 +1,110 @@ +"""Unit tests for extract/write path safety (path_safety.py). + +Guards against path traversal and writes outside allowed bases for CLI get and MCP extract. +""" + +from __future__ import annotations + +import os + +from pathlib import Path + +import pytest + +from pykotor.tools.path_safety import ( + MAX_PATH_LENGTH, + get_extract_base, + resolve_and_validate_under_base, + validate_extract_output_path, +) + + +def test_resolve_accepts_path_under_base(tmp_path: Path) -> None: + base = tmp_path / "extract" + base.mkdir() + target = base / "nested" / "out.txt" + + resolved = resolve_and_validate_under_base(target, base) + + assert resolved == target.resolve() + assert resolved.relative_to(base.resolve()) + + +def test_resolve_accepts_nonexistent_output_under_base(tmp_path: Path) -> None: + base = tmp_path / "extract" + base.mkdir() + target = base / "new" / "resource.tlk" + + resolved = resolve_and_validate_under_base(target, base, allow_nonexistent=True) + + assert resolved == target.resolve() + + +def test_resolve_rejects_path_outside_base(tmp_path: Path) -> None: + base = tmp_path / "allowed" + base.mkdir() + outside = tmp_path.parent / "escape.txt" + + with pytest.raises(ValueError, match="outside allowed base"): + resolve_and_validate_under_base(outside, base) + + +def test_resolve_rejects_traversal_via_parent_segments(tmp_path: Path) -> None: + base = tmp_path / "allowed" + base.mkdir() + traversal = base / ".." / ".." / "etc" / "passwd" + + with pytest.raises(ValueError, match="outside allowed base"): + resolve_and_validate_under_base(traversal, base) + + +def test_resolve_rejects_overlong_path(tmp_path: Path) -> None: + base = tmp_path / "allowed" + base.mkdir() + long_name = "a" * (MAX_PATH_LENGTH + 1) + long_path = base / long_name + + with pytest.raises(ValueError, match="Path length exceeds maximum"): + resolve_and_validate_under_base(long_path, base) + + +def test_resolve_strict_requires_existing_path(tmp_path: Path) -> None: + base = tmp_path / "allowed" + base.mkdir() + missing = base / "does_not_exist.txt" + + with pytest.raises(FileNotFoundError): + resolve_and_validate_under_base(missing, base, allow_nonexistent=False) + + +def test_get_extract_base_uses_env_when_set( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + extract_dir = tmp_path / "custom_extract" + extract_dir.mkdir() + monkeypatch.setenv("PYKOTOR_EXTRACT_DIR", str(extract_dir)) + + assert get_extract_base() == extract_dir.resolve() + + +def test_get_extract_base_falls_back_to_cwd(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PYKOTOR_EXTRACT_DIR", raising=False) + assert get_extract_base() == Path.cwd() + + +def test_validate_extract_output_path_uses_provided_base(tmp_path: Path) -> None: + base = tmp_path / "base" + base.mkdir() + target = base / "out" / "file.gff" + + resolved = validate_extract_output_path(target, base=base) + + assert resolved == target.resolve() + + +def test_validate_extract_output_path_rejects_escape(tmp_path: Path) -> None: + base = tmp_path / "base" + base.mkdir() + + with pytest.raises(ValueError, match="outside allowed base"): + validate_extract_output_path(tmp_path.parent / "outside.txt", base=base) From 7acee729fb455b68d870b3a7ff0aac661932791a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 11 Jun 2026 10:15:24 +0000 Subject: [PATCH 2/4] test(validation): add installation validation utility tests Co-authored-by: PuritanWizard --- Libraries/PyKotor/tests/test_validation.py | 197 +++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 Libraries/PyKotor/tests/test_validation.py diff --git a/Libraries/PyKotor/tests/test_validation.py b/Libraries/PyKotor/tests/test_validation.py new file mode 100644 index 000000000..b83516153 --- /dev/null +++ b/Libraries/PyKotor/tests/test_validation.py @@ -0,0 +1,197 @@ +"""Unit tests for installation validation utilities (validation.py).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from pykotor.common.misc import Game +from pykotor.extract.installation import SearchLocation +from pykotor.resource.type import ResourceType +from pykotor.tools.validation import ( + check_2da_file, + check_missing_resources_referenced, + check_txi_files, + get_installation_summary, + validate_installation, +) + + +class _FakeLocation: + def __init__(self, filepath: Path) -> None: + self.filepath = filepath + + +class FakeInstallation: + def __init__(self, path: Path, *, locations_map: dict | None = None) -> None: + self._path = path + self._locations_map = locations_map or {} + + def path(self) -> Path: + return self._path + + def locations(self, identifiers, search_locations): # noqa: ANN001, ARG002 + results: dict = {} + for ident in identifiers: + key = (ident.resname, ident.restype) + if key in self._locations_map: + results[ident] = self._locations_map[key] + return results + + def module_path(self) -> Path: + return self._path / "modules" + + def override_path(self) -> Path: + return self._path / "override" + + +class FakeModule: + def __init__(self, textures: set[str], lightmaps: set[str]) -> None: + self._textures = textures + self._lightmaps = lightmaps + + def models(self): + return [] + + +def test_validate_installation_reports_missing_essential_2das(tmp_path: Path) -> None: + install = FakeInstallation(tmp_path) + (tmp_path / "chitin.key").write_bytes(b"") + + result = validate_installation(install) + + assert result["valid"] is False + assert "appearance.2da" in result["missing_files"] + assert "baseitems.2da" in result["missing_files"] + assert result["errors"] == [] + + +def test_validate_installation_passes_when_essential_2das_present(tmp_path: Path) -> None: + appearance = tmp_path / "appearance.2da" + baseitems = tmp_path / "baseitems.2da" + classes = tmp_path / "classes.2da" + genericdoors = tmp_path / "genericdoors.2da" + locations_map = { + ("appearance", ResourceType.TwoDA): [_FakeLocation(appearance)], + ("baseitems", ResourceType.TwoDA): [_FakeLocation(baseitems)], + ("classes", ResourceType.TwoDA): [_FakeLocation(classes)], + ("genericdoors", ResourceType.TwoDA): [_FakeLocation(genericdoors)], + } + install = FakeInstallation(tmp_path, locations_map=locations_map) + + result = validate_installation(install) + + assert result["valid"] is True + assert result["missing_files"] == [] + assert result["errors"] == [] + + +def test_validate_installation_reports_missing_install_path(tmp_path: Path) -> None: + missing = tmp_path / "gone" + install = FakeInstallation(missing) + + result = validate_installation(install, check_essential_files=False) + + assert result["valid"] is False + assert any("does not exist" in err for err in result["errors"]) + + +def test_check_txi_files_returns_paths_per_texture(tmp_path: Path) -> None: + txi_path = tmp_path / "override" / "lda_bark04.txi" + txi_path.parent.mkdir(parents=True) + txi_path.write_text("upperleft 0 0", encoding="utf-8") + locations_map = { + ("lda_bark04", ResourceType.TXI): [_FakeLocation(txi_path)], + ("missing_tex", ResourceType.TXI): [], + } + install = FakeInstallation(tmp_path, locations_map=locations_map) + + results = check_txi_files( + install, + ["lda_bark04", "missing_tex"], + search_locations=[SearchLocation.OVERRIDE], + ) + + assert results["lda_bark04"] == [txi_path] + assert results["missing_tex"] == [] + + +def test_check_2da_file_found_and_not_found(tmp_path: Path) -> None: + twoda_path = tmp_path / "genericdoors.2da" + twoda_path.write_text("2DA V2.0\n", encoding="utf-8") + locations_map = { + ("genericdoors", ResourceType.TwoDA): [_FakeLocation(twoda_path)], + ("missing", ResourceType.TwoDA): [], + } + install = FakeInstallation(tmp_path, locations_map=locations_map) + + found, paths = check_2da_file(install, "genericdoors") + missing_found, missing_paths = check_2da_file(install, "missing") + + assert found is True + assert paths == [twoda_path] + assert missing_found is False + assert missing_paths == [] + + +def test_check_missing_resources_referenced(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + module = FakeModule(textures={"lda_bark04"}, lightmaps={"m03af_01a_lm13"}) + + def fake_get_module_referenced_resources(mod): # noqa: ANN001 + assert mod is module + return module._textures, module._lightmaps + + monkeypatch.setattr( + "pykotor.tools.validation.get_module_referenced_resources", + fake_get_module_referenced_resources, + ) + + results = check_missing_resources_referenced( + module, + missing_textures=["lda_bark04", "not_used"], + missing_lightmaps=["m03af_01a_lm13", "absent_lm"], + ) + + assert results["lda_bark04"] is True + assert results["not_used"] is False + assert results["m03af_01a_lm13"] is True + assert results["absent_lm"] is False + + +def test_get_installation_summary_counts_modules_and_override( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + modules = tmp_path / "modules" + override = tmp_path / "override" + modules.mkdir() + override.mkdir() + (modules / "danm13.rim").write_bytes(b"rim") + (modules / "danm13.mod").write_bytes(b"mod") + (override / "foo.uti").write_bytes(b"uti") + + appearance = tmp_path / "appearance.2da" + baseitems = tmp_path / "baseitems.2da" + classes = tmp_path / "classes.2da" + genericdoors = tmp_path / "genericdoors.2da" + locations_map = { + ("appearance", ResourceType.TwoDA): [_FakeLocation(appearance)], + ("baseitems", ResourceType.TwoDA): [_FakeLocation(baseitems)], + ("classes", ResourceType.TwoDA): [_FakeLocation(classes)], + ("genericdoors", ResourceType.TwoDA): [_FakeLocation(genericdoors)], + } + install = FakeInstallation(tmp_path, locations_map=locations_map) + + monkeypatch.setattr( + "pykotor.extract.installation.Installation.determine_game", + lambda _path: Game.K1, + ) + + summary = get_installation_summary(install) + + assert summary["path"] == str(tmp_path) + assert summary["game"] == "K1" + assert summary["valid"] is True + assert summary["module_count"] == 2 + assert summary["override_file_count"] == 1 + assert summary["missing"] == [] From 8bd1e46c062592f1a37eaee31fe9a9e114d4db1c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 11 Jun 2026 10:15:24 +0000 Subject: [PATCH 3/4] test(json): add deserialize and archive round-trip regression tests Co-authored-by: PuritanWizard --- .../PyKotor/tests/cli/test_json_commands.py | 31 ++++++++ .../test_archive_serializer_regressions.py | 37 +++++++++- .../tests/test_resource_json_deserialize.py | 71 +++++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 Libraries/PyKotor/tests/test_resource_json_deserialize.py diff --git a/Libraries/PyKotor/tests/cli/test_json_commands.py b/Libraries/PyKotor/tests/cli/test_json_commands.py index 67a02752f..1c0cda807 100644 --- a/Libraries/PyKotor/tests/cli/test_json_commands.py +++ b/Libraries/PyKotor/tests/cli/test_json_commands.py @@ -151,6 +151,37 @@ def test_cmd_get_can_extract_from_folder_source(tmp_path: Path) -> None: output_path.unlink() +def test_cmd_get_rejects_output_path_outside_cwd( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + workdir = tmp_path / "work" + workdir.mkdir() + source_dir = workdir / "source" + source_dir.mkdir() + (source_dir / "notes.txt").write_text("folder source", encoding="utf-8") + + monkeypatch.chdir(workdir) + escape_path = workdir.parent / "escaped.txt" + + args = Namespace( + resref="notes.txt", + path=str(source_dir), + game=None, + path_index=0, + source=None, + order=None, + output=str(escape_path), + format="binary", + ) + + with caplog.at_level(logging.ERROR): + assert cmd_get(args, logging.getLogger("test_cmd_get_path_safety")) == 1 + assert "Output path rejected" in caplog.text + assert not escape_path.exists() + + def test_cmd_find_can_search_archive_source( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/Libraries/PyKotor/tests/resource/formats/test_archive_serializer_regressions.py b/Libraries/PyKotor/tests/resource/formats/test_archive_serializer_regressions.py index de1278f6e..082b34cb3 100644 --- a/Libraries/PyKotor/tests/resource/formats/test_archive_serializer_regressions.py +++ b/Libraries/PyKotor/tests/resource/formats/test_archive_serializer_regressions.py @@ -14,7 +14,12 @@ from pykotor.resource.formats.gff.gff_data import GFF, GFFFieldType from pykotor.resource.formats.gff.io_gff_json import GFFJSONWriter from pykotor.resource.type import ResourceType -from pykotor.tools.archive_serializer import _resource_bytes_to_plaintext, archive_to_dict +from pykotor.resource.formats.erf import read_erf +from pykotor.tools.archive_serializer import ( + _resource_bytes_to_plaintext, + archive_to_dict, + dict_to_archive, +) def test_archive_to_dict_mod_extension_uses_erf_path() -> None: @@ -67,3 +72,33 @@ def test_read_gff_accepts_bytes_for_plaintext_pipeline() -> None: _tag, ftype, value = next(iter(loaded.root)) assert ftype == GFFFieldType.ResRef assert str(value) == "abc" + + +def test_erf_base64_roundtrip_preserves_gff_resource() -> None: + """archive_to_dict -> dict_to_archive must preserve ERF resources (base64 encoding).""" + gff = GFF() + gff.root.set_uint32("Count", 7) + original_gff = bytes_gff(gff) + + erf = ERF(ERFType.ERF) + erf.set_data(ResRef("testitem"), ResourceType.UTI, original_gff) + with tempfile.TemporaryDirectory() as tmp: + erf_path = Path(tmp) / "test.erf" + write_erf(erf, erf_path) + archive_dict = archive_to_dict(erf_path, embed_plaintext=False) + + assert archive_dict["format"] == "erf" + assert len(archive_dict["resources"]) == 1 + assert archive_dict["resources"][0]["data_encoding"] == "base64" + + rebuilt_bytes, extension = dict_to_archive(archive_dict) + assert extension == "erf" + + rebuilt_erf = read_erf(rebuilt_bytes) + resources = { + (str(res.resref).lower(), res.restype): bytes(res.data) + for res in rebuilt_erf + } + key = (str(ResRef("testitem")).lower(), ResourceType.UTI) + assert key in resources + assert resources[key] == original_gff diff --git a/Libraries/PyKotor/tests/test_resource_json_deserialize.py b/Libraries/PyKotor/tests/test_resource_json_deserialize.py new file mode 100644 index 000000000..0d10ba677 --- /dev/null +++ b/Libraries/PyKotor/tests/test_resource_json_deserialize.py @@ -0,0 +1,71 @@ +"""Unit tests for embedded resource JSON deserialization (resource_json.py).""" + +from __future__ import annotations + +import base64 + +import pytest + +from pykotor.resource.formats.gff import bytes_gff, read_gff +from pykotor.resource.formats.gff.gff_data import GFF, GFFFieldType +from pykotor.resource.type import ResourceType +from pykotor.tools.resource_json import ( + deserialize_embedded_resource_payload, + direct_json_document_to_resource_bytes, +) + + +def test_deserialize_base64_roundtrip() -> None: + payload = b"hello archive" + encoded = base64.b64encode(payload).decode("ascii") + + result = deserialize_embedded_resource_payload("base64", encoded, ResourceType.TXT) + + assert result == payload + + +def test_deserialize_base64_rejects_non_string_payload() -> None: + with pytest.raises(ValueError, match="Base64 payload must be a string"): + deserialize_embedded_resource_payload("base64", 123, ResourceType.TXT) + + +def test_deserialize_gff_json_roundtrip() -> None: + import json + + from pykotor.resource.formats.gff.io_gff_json import GFFJSONWriter + + gff = GFF() + gff.root.set_uint32("Value", 99) + out = bytearray() + GFFJSONWriter(gff, out).write() + payload = json.loads(bytes(out).decode("utf-8")) + + result = deserialize_embedded_resource_payload("gff_json", payload, ResourceType.GFF) + loaded = read_gff(result) + _label, ftype, value = next(iter(loaded.root)) + assert ftype == GFFFieldType.UInt32 + assert value == 99 + + +def test_deserialize_unknown_encoding_raises() -> None: + with pytest.raises(ValueError, match="Unknown embedded JSON encoding"): + deserialize_embedded_resource_payload("not_a_codec", "data", ResourceType.TXT) + + +def test_direct_json_document_binary_wrapper() -> None: + payload = b"\xde\xad\xbe\xef" + document = { + "format": "binary", + "restype": "TXT", + "encoding": "base64", + "data_base64": base64.b64encode(payload).decode("ascii"), + } + + result = direct_json_document_to_resource_bytes(document, ResourceType.TXT) + + assert result == payload + + +def test_direct_json_document_rejects_unsupported_format() -> None: + with pytest.raises(ValueError, match="does not use a direct-wrapper format"): + direct_json_document_to_resource_bytes({"format": "unknown"}, ResourceType.TXT) From 947c4e8ed7b6ffadc1854db2132a0649b7c75922 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 11 Jun 2026 10:15:25 +0000 Subject: [PATCH 4/4] fix(tools): repair json deserialize and archive rebuild stream handling Co-authored-by: PuritanWizard --- .../PyKotor/src/pykotor/tools/archive_serializer.py | 8 ++++---- Libraries/PyKotor/src/pykotor/tools/resource_json.py | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Libraries/PyKotor/src/pykotor/tools/archive_serializer.py b/Libraries/PyKotor/src/pykotor/tools/archive_serializer.py index d7685793d..c2b777820 100644 --- a/Libraries/PyKotor/src/pykotor/tools/archive_serializer.py +++ b/Libraries/PyKotor/src/pykotor/tools/archive_serializer.py @@ -175,9 +175,9 @@ def _dict_to_erf(data: dict[str, Any]) -> bytes: payload = r["data"] raw = deserialize_embedded_resource_payload(enc, payload, restype) erf.set_data(ResRef(resref), restype, raw) - out = BytesIO() + out: bytearray = bytearray() write_erf(erf, out) - return out.getvalue() + return bytes(out) def _dict_to_rim(data: dict[str, Any]) -> bytes: @@ -189,9 +189,9 @@ def _dict_to_rim(data: dict[str, Any]) -> bytes: payload = r["data"] raw = deserialize_embedded_resource_payload(enc, payload, restype) rim.set_data(ResRef(resref), restype, raw) - out = BytesIO() + out: bytearray = bytearray() write_rim(rim, out) - return out.getvalue() + return bytes(out) def _dict_to_bif(data: dict[str, Any]) -> bytes: diff --git a/Libraries/PyKotor/src/pykotor/tools/resource_json.py b/Libraries/PyKotor/src/pykotor/tools/resource_json.py index 61d46a162..59a562d36 100644 --- a/Libraries/PyKotor/src/pykotor/tools/resource_json.py +++ b/Libraries/PyKotor/src/pykotor/tools/resource_json.py @@ -778,7 +778,7 @@ def deserialize_embedded_resource_payload( return base64.b64decode(payload) if encoding == "gff_json": gff = read_gff( - BytesIO(json.dumps(payload, ensure_ascii=False).encode("utf-8")), + json.dumps(payload, ensure_ascii=False).encode("utf-8"), file_format=ToolsetFormat.GFF_JSON, ) output = bytearray() @@ -786,7 +786,7 @@ def deserialize_embedded_resource_payload( return bytes(output) if encoding == "tlk_json": tlk = read_tlk( - BytesIO(json.dumps(payload, ensure_ascii=False).encode("utf-8")), + json.dumps(payload, ensure_ascii=False).encode("utf-8"), file_format=ToolsetFormat.TLK_JSON, ) output = bytearray() @@ -794,7 +794,7 @@ def deserialize_embedded_resource_payload( return bytes(output) if encoding == "2da_json": twoda = read_2da( - BytesIO(json.dumps(payload, ensure_ascii=False).encode("utf-8")), + json.dumps(payload, ensure_ascii=False).encode("utf-8"), file_format=ToolsetFormat.TwoDA_JSON, ) output = bytearray() @@ -802,7 +802,7 @@ def deserialize_embedded_resource_payload( return bytes(output) if encoding == "lip_json": lip = read_lip( - BytesIO(json.dumps(payload, ensure_ascii=False).encode("utf-8")), + json.dumps(payload, ensure_ascii=False).encode("utf-8"), file_format=ToolsetFormat.LIP_JSON, ) output = bytearray() @@ -810,7 +810,7 @@ def deserialize_embedded_resource_payload( return bytes(output) if encoding == "ssf_json": ssf = read_ssf( - BytesIO(json.dumps(payload, ensure_ascii=False).encode("utf-8")), + json.dumps(payload, ensure_ascii=False).encode("utf-8"), file_format=ToolsetFormat.SSF_JSON, ) output = bytearray()