From 942894b71433790241ecdc7d7aadef6ca9b04a6a Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:55:45 -0400 Subject: [PATCH 1/7] test: cover local binary parser success and failure paths How to test: python -m pytest -q (76 passed on Python 3.11). --- tests/test_binary_parsers.py | 129 +++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 tests/test_binary_parsers.py diff --git a/tests/test_binary_parsers.py b/tests/test_binary_parsers.py new file mode 100644 index 0000000..3f44636 --- /dev/null +++ b/tests/test_binary_parsers.py @@ -0,0 +1,129 @@ +"""Small generated checkpoint fixtures; no model downloads or torch dependency.""" + +import json +import pickle +import struct +import zipfile +from collections import OrderedDict + +import pytest + +from modelinfo.parsers.pytorch import parse_pytorch_header +from modelinfo.parsers.safetensors import parse_safetensors_header + + +def write_safetensors(path, header): + payload = json.dumps(header).encode() + path.write_bytes(struct.pack(" Date: Fri, 11 Sep 2026 16:22:17 -0400 Subject: [PATCH 2/7] Use fixed pickle protocol fixtures in parser tests --- tests/test_binary_parsers.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/tests/test_binary_parsers.py b/tests/test_binary_parsers.py index 3f44636..9ec0bf4 100644 --- a/tests/test_binary_parsers.py +++ b/tests/test_binary_parsers.py @@ -1,10 +1,9 @@ """Small generated checkpoint fixtures; no model downloads or torch dependency.""" import json -import pickle import struct import zipfile -from collections import OrderedDict +from pickle import UnpicklingError import pytest @@ -83,14 +82,11 @@ def test_safetensors_index_deduplicates_shards_and_reports_missing_files(tmp_pat def test_pytorch_reads_ordered_state_dict_from_archive(tmp_path): path = tmp_path / "model.pt" - state = OrderedDict( - [ - ("weight", {"shape": [2, 3], "dtype": "F16"}), - ("step", 12), - ] - ) + # Fixed protocol-0 OrderedDict fixture: inspect metadata without generating + # executable serialization during the test. + state = b"ccollections\nOrderedDict\np0\n(tRp1\nVweight\np2\n(dp3\nVshape\np4\n(lp5\nI2\naI3\nasVdtype\np6\nVF16\np7\nssVstep\np8\nI12\ns." with zipfile.ZipFile(path, "w") as archive: - archive.writestr("checkpoint/data.pkl", pickle.dumps(state)) + archive.writestr("checkpoint/data.pkl", state) assert parse_pytorch_header(str(path)) == { "weight": {"shape": [2, 3], "dtype": "F16"}, "step": {"shape": [], "dtype": "F32"}, @@ -116,8 +112,8 @@ def test_pytorch_rejects_unapproved_pickle_global(tmp_path): path = tmp_path / "forbidden.pt" # A harmless builtin still must not bypass the explicit unpickler allowlist. with zipfile.ZipFile(path, "w") as archive: - archive.writestr("checkpoint/data.pkl", pickle.dumps(len)) - with pytest.raises(pickle.UnpicklingError, match="forbidden"): + archive.writestr("checkpoint/data.pkl", b"cbuiltins\nlen\n.") + with pytest.raises(UnpicklingError, match="forbidden"): parse_pytorch_header(str(path)) From 9f3375d8908edd211db7a47c6cf729fda9cb2586 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:08:23 -0400 Subject: [PATCH 3/7] Read supported PyTorch tensor storage references without loading payloads --- src/modelinfo/parsers/pytorch.py | 13 +++++++++++++ tests/test_pytorch_storage.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 tests/test_pytorch_storage.py diff --git a/src/modelinfo/parsers/pytorch.py b/src/modelinfo/parsers/pytorch.py index 3926f2b..a04f3ff 100644 --- a/src/modelinfo/parsers/pytorch.py +++ b/src/modelinfo/parsers/pytorch.py @@ -49,6 +49,19 @@ class RestrictedUnpickler(pickle.Unpickler): }, } + def persistent_load(self, saved_id: Any) -> DummyStorage: + # ZIP checkpoints refer to tensor storage via a five-element ID. + # Only reconstruct metadata using our existing dummy allowlist; never + # load storage bytes, import torch, or invoke a pickle-provided callable. + if not isinstance(saved_id, tuple) or len(saved_id) != 5: + raise pickle.UnpicklingError("Invalid storage persistent ID") + kind, storage_type, _, _, _ = saved_id + if (kind != "storage" or not isinstance(storage_type, type) + or not issubclass(storage_type, DummyStorage) + or storage_type is DummyStorage): + raise pickle.UnpicklingError("Unsupported storage persistent ID") + return storage_type() + def find_class(self, module: str, name: str) -> Any: if module in self.ALLOWED_MODULES and name in self.ALLOWED_MODULES[module]: if name == "OrderedDict": diff --git a/tests/test_pytorch_storage.py b/tests/test_pytorch_storage.py new file mode 100644 index 0000000..72dc65a --- /dev/null +++ b/tests/test_pytorch_storage.py @@ -0,0 +1,29 @@ +"""Fixed protocol-2 fixture using torch.save's storage persistent ID layout.""" +import io +import pickle +import zipfile + +import pytest + +from modelinfo.parsers.pytorch import RestrictedUnpickler, parse_pytorch_header + + +@pytest.mark.parametrize('storage,dtype', [('FloatStorage', 'F32'), ('HalfStorage', 'F16'), + ('BFloat16Storage', 'BF16'), ('IntStorage', 'I32'), ('LongStorage', 'I64')]) +def test_tensor_with_persistent_storage_id(tmp_path, storage, dtype): + payload = (b'\x80\x02}X\x06\x00\x00\x00weightctorch._utils\n_rebuild_tensor_v2\n' + b'((X\x07\x00\x00\x00storagectorch\n' + storage.encode() + b'\n' + b'X\x01\x00\x00\x000X\x03\x00\x00\x00cpuK\x06tQK\x00K\x02K\x03\x86' + b'K\x03K\x01\x86\x89ccollections\nOrderedDict\n)RtRs.') + path = tmp_path / 'model.pt' + with zipfile.ZipFile(path, 'w') as archive: + archive.writestr('model/data.pkl', payload) + assert parse_pytorch_header(str(path)) == {'weight': {'shape': [2, 3], 'dtype': dtype}} + + +@pytest.mark.parametrize('pid', [None, (), ('module',), ('storage', int, '0', 'cpu', 6), + ('storage', None, '0', 'cpu', 6)]) +def test_invalid_persistent_ids_are_rejected(pid): + unpickler = RestrictedUnpickler(io.BytesIO()) + with pytest.raises(pickle.UnpicklingError): + unpickler.persistent_load(pid) From 314757b5b87ddd6cbac32ffade419764cf7b0a2d Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:15:40 -0400 Subject: [PATCH 4/7] Reject truncated GGUF headers and tolerate partial stream reads --- src/modelinfo/parsers/gguf.py | 66 ++++++++++++++++++++--------------- tests/test_gguf_truncation.py | 35 +++++++++++++++++++ 2 files changed, 73 insertions(+), 28 deletions(-) create mode 100644 tests/test_gguf_truncation.py diff --git a/src/modelinfo/parsers/gguf.py b/src/modelinfo/parsers/gguf.py index 3af2fb4..c33eb06 100644 --- a/src/modelinfo/parsers/gguf.py +++ b/src/modelinfo/parsers/gguf.py @@ -10,36 +10,46 @@ 29: "IQ1_M", 30: "BF16", 31: "Q4_0_4_4", 32: "Q4_0_4_8", 33: "Q4_0_8_8", } +def _read_exact(f: Any, size: int) -> bytes: + data = bytearray() + while len(data) < size: + chunk = f.read(size - len(data)) + if not chunk: + raise EOFError("Unexpected end of GGUF header") + data.extend(chunk) + return bytes(data) + + def _read_gguf_value(f: Any, val_type: int) -> Any: if val_type == 0: - return struct.unpack(" Dict[str, Any]: def _parse_gguf_header_from_stream(f: Any) -> Dict[str, Any]: tensors: Dict[str, Any] = {} - magic = f.read(4) + magic = _read_exact(f, 4) if magic != b"GGUF": raise ValueError("Invalid GGUF file: Magic bytes missing.") - version = struct.unpack(" Date: Tue, 15 Sep 2026 00:26:29 -0400 Subject: [PATCH 5/7] Use shard index assignments when collecting tensor metadata --- src/modelinfo/parsers/safetensors.py | 9 ++++++--- tests/test_shard_index_authority.py | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 tests/test_shard_index_authority.py diff --git a/src/modelinfo/parsers/safetensors.py b/src/modelinfo/parsers/safetensors.py index 2e7d705..28bb39b 100644 --- a/src/modelinfo/parsers/safetensors.py +++ b/src/modelinfo/parsers/safetensors.py @@ -63,9 +63,12 @@ def parse_safetensors_header(path: str) -> dict[str, Any]: total_size += os.path.getsize(shard_path) try: shard_header = _read_single_header(shard_path) - for k, v in shard_header.items(): - if k != "__metadata__": - tensors[k] = v + for name, assigned_shard in weight_map.items(): + if assigned_shard != shard: + continue + if name not in shard_header: + raise ValueError(f"Indexed tensor {name!r} is missing from shard {shard!r}") + tensors[name] = shard_header[name] except FileNotFoundError: missing_shards += 1 diff --git a/tests/test_shard_index_authority.py b/tests/test_shard_index_authority.py new file mode 100644 index 0000000..1a57ef0 --- /dev/null +++ b/tests/test_shard_index_authority.py @@ -0,0 +1,25 @@ +import json +import struct +from modelinfo.parsers.safetensors import parse_safetensors_header + + +def test_shard_index_selects_only_assigned_tensors(tmp_path): + index = tmp_path / 'model.safetensors.index.json' + index.write_text(json.dumps({'weight_map': {'weight': 'part.safetensors'}})) + header = {'weight': {'shape': [3], 'dtype': 'F16'}, + 'unused': {'shape': [1000], 'dtype': 'F32'}} + data = json.dumps(header).encode() + (tmp_path / 'part.safetensors').write_bytes(struct.pack(' Date: Wed, 16 Sep 2026 10:55:40 -0400 Subject: [PATCH 6/7] Inspect state dictionaries inside training checkpoint wrappers --- src/modelinfo/parsers/pytorch.py | 5 +++++ tests/test_pytorch_checkpoint_wrapper.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 tests/test_pytorch_checkpoint_wrapper.py diff --git a/src/modelinfo/parsers/pytorch.py b/src/modelinfo/parsers/pytorch.py index a04f3ff..1ce2b7e 100644 --- a/src/modelinfo/parsers/pytorch.py +++ b/src/modelinfo/parsers/pytorch.py @@ -97,6 +97,11 @@ def parse_pytorch_header(path: str) -> Dict[str, Any]: data = unpickler.load() if isinstance(data, dict): + for key in ("state_dict", "model_state_dict"): + candidate = data.get(key) + if isinstance(candidate, dict) and "shape" not in candidate: + data = candidate + break for k, v in data.items(): if isinstance(v, dict) and "shape" in v: tensors[k] = v diff --git a/tests/test_pytorch_checkpoint_wrapper.py b/tests/test_pytorch_checkpoint_wrapper.py new file mode 100644 index 0000000..67e1e2c --- /dev/null +++ b/tests/test_pytorch_checkpoint_wrapper.py @@ -0,0 +1,16 @@ +import pickle +import zipfile + +import pytest + +from modelinfo.parsers.pytorch import parse_pytorch_header + + +@pytest.mark.parametrize('wrapper', ['state_dict', 'model_state_dict']) +def test_wrapped_state_dict_excludes_training_metadata(tmp_path, wrapper): + weight = {'shape': [2, 3], 'dtype': 'F32'} + data = {wrapper: {'layer.weight': weight}, 'epoch': 10, 'loss': 0.125} + path = tmp_path / 'checkpoint.pt' + with zipfile.ZipFile(path, 'w') as archive: + archive.writestr('checkpoint/data.pkl', pickle.dumps(data, protocol=2)) + assert parse_pytorch_header(str(path)) == {'layer.weight': weight} From 3ab619eac8dc1f6c007be1473f69fd937eea7cb8 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:58:31 -0400 Subject: [PATCH 7/7] Recognize double, small integer and boolean PyTorch storage --- src/modelinfo/parsers/pytorch.py | 15 +++++++++++++++ tests/test_pytorch_storage.py | 4 +++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/modelinfo/parsers/pytorch.py b/src/modelinfo/parsers/pytorch.py index 1ce2b7e..712bbda 100644 --- a/src/modelinfo/parsers/pytorch.py +++ b/src/modelinfo/parsers/pytorch.py @@ -28,6 +28,16 @@ def dummy_rebuild_tensor_v2( dtype = "I32" elif storage.name == "LongStorage": dtype = "I64" + elif storage.name == "DoubleStorage": + dtype = "F64" + elif storage.name == "ShortStorage": + dtype = "I16" + elif storage.name == "CharStorage": + dtype = "I8" + elif storage.name == "ByteStorage": + dtype = "U8" + elif storage.name == "BoolStorage": + dtype = "BOOL" return {"shape": list(size), "dtype": dtype} @@ -46,6 +56,11 @@ class RestrictedUnpickler(pickle.Unpickler): "BFloat16Storage", "IntStorage", "LongStorage", + "DoubleStorage", + "ShortStorage", + "CharStorage", + "ByteStorage", + "BoolStorage", }, } diff --git a/tests/test_pytorch_storage.py b/tests/test_pytorch_storage.py index 72dc65a..7adbdb7 100644 --- a/tests/test_pytorch_storage.py +++ b/tests/test_pytorch_storage.py @@ -9,7 +9,9 @@ @pytest.mark.parametrize('storage,dtype', [('FloatStorage', 'F32'), ('HalfStorage', 'F16'), - ('BFloat16Storage', 'BF16'), ('IntStorage', 'I32'), ('LongStorage', 'I64')]) + ('BFloat16Storage', 'BF16'), ('IntStorage', 'I32'), ('LongStorage', 'I64'), + ('DoubleStorage', 'F64'), ('ShortStorage', 'I16'), ('CharStorage', 'I8'), + ('ByteStorage', 'U8'), ('BoolStorage', 'BOOL')]) def test_tensor_with_persistent_storage_id(tmp_path, storage, dtype): payload = (b'\x80\x02}X\x06\x00\x00\x00weightctorch._utils\n_rebuild_tensor_v2\n' b'((X\x07\x00\x00\x00storagectorch\n' + storage.encode() + b'\n'