From 00240005e64c5411b7d9c431d8f1e896c34f7589 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:57:51 -0400 Subject: [PATCH 01/12] test: exercise hugging face requests without network access How to test: python -m pytest -q (73 passed); ruff check tests/test_huggingface.py. --- tests/test_huggingface.py | 183 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 tests/test_huggingface.py diff --git a/tests/test_huggingface.py b/tests/test_huggingface.py new file mode 100644 index 0000000..304047b --- /dev/null +++ b/tests/test_huggingface.py @@ -0,0 +1,183 @@ +"""Exercise HTTP request construction and checkpoint parsing entirely offline.""" + +import io +import json +import struct +import urllib.error + +import pytest + +from modelinfo.parsers import huggingface as hf + + +@pytest.fixture(autouse=True) +def isolated_hub(monkeypatch): + monkeypatch.setenv("HF_ENDPOINT", "https://hub.example") + monkeypatch.setattr(hf, "_get_hf_token", lambda: "test-token") + + +def response(data, **headers): + stream = io.BytesIO(data) + stream.headers = headers + return stream + + +def test_request_sends_auth_range_timeout_and_limits_read(monkeypatch): + calls = [] + + def urlopen(request, timeout): + calls.append((request, timeout)) + return response(b"0123456789") + + monkeypatch.setattr(hf.urllib.request, "urlopen", urlopen) + assert ( + hf._make_request( + "https://hub.example/file", {"Range": "bytes=0-3"}, limit=4, timeout=2.5 + ) + == b"0123" + ) + request, timeout = calls[0] + assert request.get_header("Authorization") == "Bearer test-token" + assert request.get_header("Range") == "bytes=0-3" + assert timeout == 2.5 + + +@pytest.mark.parametrize( + "status,error", + [(401, PermissionError), (404, FileNotFoundError), (503, urllib.error.HTTPError)], +) +def test_request_translates_only_expected_http_errors(monkeypatch, status, error): + def urlopen(request, timeout): + raise urllib.error.HTTPError(request.full_url, status, "failure", {}, None) + + monkeypatch.setattr(hf.urllib.request, "urlopen", urlopen) + with pytest.raises(error): + hf._make_request("https://hub.example/file") + + +def test_single_checkpoint_uses_api_head_and_bounded_header_request(monkeypatch): + header = {"weight": {"shape": [2, 3], "dtype": "F16"}} + encoded = json.dumps(header).encode() + calls = [] + + def urlopen(request, timeout): + calls.append((request.get_method(), request.full_url, timeout)) + assert request.get_header("Authorization") == "Bearer test-token" + if "/api/models/" in request.full_url: + return response( + json.dumps({"siblings": [{"rfilename": "model.safetensors"}]}).encode() + ) + if request.get_method() == "HEAD": + return response(b"", **{"Content-Length": "128"}) + assert request.get_header("Range") == "bytes=0-500000" + return response(struct.pack("= len(payload): + raise urllib.error.HTTPError(request.full_url, 416, "range", {}, None) + return response(payload[start : end + 1]) + + monkeypatch.setattr(hf.urllib.request, "urlopen", urlopen) + stream = hf.RemoteFileStream("https://hub.example/file", chunk_size=4) + assert stream.read(3) == b"abc" + assert stream.seek(1) == 1 + assert stream.read(3) == b"bcd" + assert calls == [(0, 3)] + assert stream.read(20) == b"efghij" + assert stream.tell() == 10 + assert stream.read(1) == b"" From f11665090b774f36063c8ca9328d5287ec260c14 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:16:35 -0400 Subject: [PATCH 02/12] fix: bound remote safetensors headers before fetching How to test: PYTHONPATH=src pytest tests/test_hf_header_limit.py; PYTHONPATH=src pytest --- src/modelinfo/parsers/huggingface.py | 2 ++ tests/test_hf_header_limit.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 tests/test_hf_header_limit.py diff --git a/src/modelinfo/parsers/huggingface.py b/src/modelinfo/parsers/huggingface.py index b36dd7f..2fffd28 100644 --- a/src/modelinfo/parsers/huggingface.py +++ b/src/modelinfo/parsers/huggingface.py @@ -90,6 +90,8 @@ def _fetch_safetensors_header(repo_id: str, filename: str, timeout: float = 10.0 raise ValueError(f"File {filename} is too small to contain a SafeTensors header.") header_size = struct.unpack(" 100 * 1024 * 1024: + raise ValueError(f"Header length ({header_size} bytes) exceeds maximum allowed size.") # 2. Slice locally if it fits if 8 + header_size <= len(chunk): diff --git a/tests/test_hf_header_limit.py b/tests/test_hf_header_limit.py new file mode 100644 index 0000000..56d8d69 --- /dev/null +++ b/tests/test_hf_header_limit.py @@ -0,0 +1,18 @@ +import struct + +import pytest + +from modelinfo.parsers import huggingface as hf + + +def test_remote_safetensors_rejects_oversized_header_before_second_request(monkeypatch): + calls = [] + + def request(*args, **kwargs): + calls.append(kwargs.get("limit")) + return struct.pack(" Date: Sun, 13 Sep 2026 21:17:43 -0400 Subject: [PATCH 03/12] fix: reject ignored nonzero byte ranges from hub servers How to test: PYTHONPATH=src pytest tests/test_hf_range_response.py; PYTHONPATH=src pytest --- src/modelinfo/parsers/huggingface.py | 5 +++++ tests/test_hf_range_response.py | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 tests/test_hf_range_response.py diff --git a/src/modelinfo/parsers/huggingface.py b/src/modelinfo/parsers/huggingface.py index 2fffd28..36cc35d 100644 --- a/src/modelinfo/parsers/huggingface.py +++ b/src/modelinfo/parsers/huggingface.py @@ -63,6 +63,11 @@ def _make_request( req = urllib.request.Request(url, headers=headers) try: with urllib.request.urlopen(req, timeout=timeout) as response: + requested_range = req.get_header("Range", "") + if (requested_range.startswith("bytes=") + and requested_range.split("=", 1)[1].split("-", 1)[0] != "0" + and getattr(response, "status", None) == 200): + raise ValueError("Server ignored the requested byte range; refusing data from the wrong offset.") if limit is not None: return response.read(limit) return response.read() diff --git a/tests/test_hf_range_response.py b/tests/test_hf_range_response.py new file mode 100644 index 0000000..b9cca90 --- /dev/null +++ b/tests/test_hf_range_response.py @@ -0,0 +1,22 @@ +import io + +import pytest + +from modelinfo.parsers import huggingface as hf + + +def test_nonzero_range_rejects_full_response(monkeypatch): + monkeypatch.setattr(hf, "_get_hf_token", lambda: None) + response = io.BytesIO(b"wrong prefix") + response.status = 200 + monkeypatch.setattr(hf.urllib.request, "urlopen", lambda *a, **k: response) + with pytest.raises(ValueError, match="range"): + hf._make_request("https://hub.example/file", {"Range": "bytes=8-15"}, limit=8) + + +def test_nonzero_range_accepts_partial_response(monkeypatch): + monkeypatch.setattr(hf, "_get_hf_token", lambda: None) + response = io.BytesIO(b"expected") + response.status = 206 + monkeypatch.setattr(hf.urllib.request, "urlopen", lambda *a, **k: response) + assert hf._make_request("https://hub.example/file", {"Range": "bytes=8-15"}, limit=8) == b"expected" From 00c8e0fdf42e15d01894f00a9638f64683f0f3a1 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:18:29 -0400 Subject: [PATCH 04/12] fix: validate remote stream cursor and chunk arguments How to test: PYTHONPATH=src pytest tests/test_remote_stream_positions.py; PYTHONPATH=src pytest --- src/modelinfo/parsers/huggingface.py | 11 +++++++++-- tests/test_remote_stream_positions.py | 28 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 tests/test_remote_stream_positions.py diff --git a/src/modelinfo/parsers/huggingface.py b/src/modelinfo/parsers/huggingface.py index 36cc35d..4c2f263 100644 --- a/src/modelinfo/parsers/huggingface.py +++ b/src/modelinfo/parsers/huggingface.py @@ -122,6 +122,8 @@ def _get_remote_file_size_fallback(repo_id: str, filename: str, timeout: float = class RemoteFileStream: def __init__(self, url: str, chunk_size: int = 1024*1024, timeout: float = 10.0): + if chunk_size <= 0: + raise ValueError("chunk_size must be positive") self.url = url self.chunk_size = chunk_size self.timeout = timeout @@ -132,6 +134,8 @@ def read(self, size: int = -1) -> bytes: if size == -1: raise NotImplementedError("Unlimited remote read is not supported.") + if size < -1: + raise ValueError("read size must be nonnegative or -1") end_pos = self.position + size if end_pos > 50 * 1024 * 1024: raise ValueError("Remote header read limit exceeded (50MB). File might be invalid or too large.") @@ -164,11 +168,14 @@ def read(self, size: int = -1) -> bytes: def seek(self, offset: int, whence: int = 0) -> int: if whence == 0: - self.position = offset + position = offset elif whence == 1: - self.position += offset + position = self.position + offset else: raise NotImplementedError("Seek from end is not supported.") + if position < 0: + raise ValueError("negative seek position") + self.position = position return self.position def tell(self) -> int: diff --git a/tests/test_remote_stream_positions.py b/tests/test_remote_stream_positions.py new file mode 100644 index 0000000..2f158c9 --- /dev/null +++ b/tests/test_remote_stream_positions.py @@ -0,0 +1,28 @@ +import pytest + +from modelinfo.parsers import huggingface as hf + + +def test_negative_seek_does_not_corrupt_position(): + stream = hf.RemoteFileStream("https://hub.example/file") + assert stream.seek(3) == 3 + with pytest.raises(ValueError): + stream.seek(-4, 1) + assert stream.tell() == 3 + with pytest.raises(ValueError): + stream.seek(-1) + assert stream.tell() == 3 + + +def test_invalid_negative_read_does_not_move_cursor(): + stream = hf.RemoteFileStream("https://hub.example/file") + stream.buffer = b"abc" + with pytest.raises(ValueError): + stream.read(-2) + assert stream.tell() == 0 + + +@pytest.mark.parametrize("chunk_size", [0, -1]) +def test_invalid_chunk_size_rejected(chunk_size): + with pytest.raises(ValueError): + hf.RemoteFileStream("https://hub.example/file", chunk_size=chunk_size) From ab17dbe9a1675dd785bac6ca479f68dfb6344710 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:19:14 -0400 Subject: [PATCH 05/12] fix: encode hub filenames without losing path characters How to test: PYTHONPATH=src pytest tests/test_hf_filename_encoding.py; PYTHONPATH=src pytest --- src/modelinfo/parsers/huggingface.py | 20 +++++++++++++------- tests/test_hf_filename_encoding.py | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 tests/test_hf_filename_encoding.py diff --git a/src/modelinfo/parsers/huggingface.py b/src/modelinfo/parsers/huggingface.py index 4c2f263..0546955 100644 --- a/src/modelinfo/parsers/huggingface.py +++ b/src/modelinfo/parsers/huggingface.py @@ -24,6 +24,12 @@ def _get_hf_endpoint() -> str: return endpoint +def _hub_file_url(repo_id: str, filename: str) -> str: + repo_path = urllib.parse.quote(repo_id, safe="/") + file_path = urllib.parse.quote(filename, safe="/") + return f"{_get_hf_endpoint()}/{repo_path}/resolve/main/{file_path}" + + def _get_hf_token() -> str | None: token = os.environ.get("HF_TOKEN") if token: @@ -79,7 +85,7 @@ def _make_request( raise def _fetch_safetensors_header(repo_id: str, filename: str, timeout: float = 10.0) -> Dict[str, Any]: - url = f"{_get_hf_endpoint()}/{repo_id}/resolve/main/{filename}" + url = _hub_file_url(repo_id, filename) # 1. Fetch the first 500KB in a single roundtrip headers = {"Range": "bytes=0-500000"} @@ -109,7 +115,7 @@ def _fetch_safetensors_header(repo_id: str, filename: str, timeout: float = 10.0 return json.loads(json_bytes) def _get_remote_file_size_fallback(repo_id: str, filename: str, timeout: float = 10.0) -> float: - req = urllib.request.Request(f"{_get_hf_endpoint()}/{repo_id}/resolve/main/{filename}", method="HEAD") + req = urllib.request.Request(_hub_file_url(repo_id, filename), method="HEAD") token = _get_hf_token() if token: req.add_header("Authorization", f"Bearer {token}") @@ -186,7 +192,7 @@ def close(self) -> None: def _fetch_remote_gguf_single(real_repo_id: str, filename: str, fallback_size: float | None, timeout: float) -> Tuple[Dict[str, Any], float]: - url = f"{_get_hf_endpoint()}/{real_repo_id}/resolve/main/{filename}" + url = _hub_file_url(real_repo_id, filename) stream = RemoteFileStream(url, timeout=timeout) from modelinfo.parsers.gguf import parse_gguf_header tensors = parse_gguf_header(stream) @@ -205,7 +211,7 @@ def _fetch_remote_gguf_group(real_repo_id: str, gguf_files: List[Dict[str, Any]] header_target = gguf_files[0] header_file = header_target["filename"] - url = f"{_get_hf_endpoint()}/{real_repo_id}/resolve/main/{header_file}" + url = _hub_file_url(real_repo_id, header_file) stream = RemoteFileStream(url, timeout=timeout) from modelinfo.parsers.gguf import parse_gguf_header tensors = parse_gguf_header(stream) @@ -255,7 +261,7 @@ def _fetch_remote_safetensors_sharded( fetch_tensors: bool, timeout: float ) -> Tuple[Dict[str, Any], float]: - index_url = f"{_get_hf_endpoint()}/{real_repo_id}/resolve/main/model.safetensors.index.json" + index_url = _hub_file_url(real_repo_id, "model.safetensors.index.json") index_data = json.loads(_make_request(index_url, timeout=timeout).decode("utf-8")) weight_map = index_data.get("weight_map", {}) @@ -287,7 +293,7 @@ def _fetch_remote_safetensors_sharded( def _fetch_remote_safetensors_single(real_repo_id: str, timeout: float) -> Tuple[Dict[str, Any], float]: total_size = 0.0 - req = urllib.request.Request(f"{_get_hf_endpoint()}/{real_repo_id}/resolve/main/model.safetensors", method="HEAD") + req = urllib.request.Request(_hub_file_url(real_repo_id, "model.safetensors"), method="HEAD") token = _get_hf_token() if token: req.add_header("Authorization", f"Bearer {token}") @@ -329,7 +335,7 @@ def fetch_huggingface_repo(repo_id: str, fetch_tensors: bool = False, timeout: f config = None if "config.json" in filenames: - config_url = f"{_get_hf_endpoint()}/{real_repo_id}/resolve/main/config.json" + config_url = _hub_file_url(real_repo_id, "config.json") config = json.loads(_make_request(config_url, timeout=timeout).decode("utf-8")) # Find GGUF siblings diff --git a/tests/test_hf_filename_encoding.py b/tests/test_hf_filename_encoding.py new file mode 100644 index 0000000..dc3aac1 --- /dev/null +++ b/tests/test_hf_filename_encoding.py @@ -0,0 +1,24 @@ +import struct + +import pytest + +from modelinfo.parsers import huggingface as hf + + +@pytest.mark.parametrize(("filename", "suffix"), [ + ("sub dir/model.safetensors", "sub%20dir/model.safetensors"), + ("model#1.safetensors", "model%231.safetensors"), + ("model?x.safetensors", "model%3Fx.safetensors"), + ("model%20.safetensors", "model%2520.safetensors"), +]) +def test_file_names_are_encoded_as_path_components(monkeypatch, filename, suffix): + monkeypatch.setenv("HF_ENDPOINT", "https://hub.example") + urls = [] + + def request(url, **kwargs): + urls.append(url) + return struct.pack(" Date: Mon, 14 Sep 2026 08:25:31 -0400 Subject: [PATCH 06/12] Avoid retaining authentication tokens in caller request headers --- src/modelinfo/parsers/huggingface.py | 3 +-- tests/test_huggingface.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/modelinfo/parsers/huggingface.py b/src/modelinfo/parsers/huggingface.py index 0546955..7bc2f7e 100644 --- a/src/modelinfo/parsers/huggingface.py +++ b/src/modelinfo/parsers/huggingface.py @@ -59,8 +59,7 @@ def _make_request( limit: int | None = None, timeout: float = 10.0, ) -> bytes: - if headers is None: - headers = {} + headers = dict(headers) if headers is not None else {} token = _get_hf_token() if token: diff --git a/tests/test_huggingface.py b/tests/test_huggingface.py index 304047b..e6e8809 100644 --- a/tests/test_huggingface.py +++ b/tests/test_huggingface.py @@ -181,3 +181,18 @@ def urlopen(request, timeout): assert stream.read(20) == b"efghij" assert stream.tell() == 10 assert stream.read(1) == b"" + + +def test_request_does_not_retain_token_in_reused_headers(monkeypatch): + calls = [] + def urlopen(request, timeout): + calls.append(request) + return response(b"ok") + monkeypatch.setattr(hf.urllib.request, "urlopen", urlopen) + headers = {"Range": "bytes=0-1"} + hf._make_request("https://hub.example/file", headers=headers) + monkeypatch.setattr(hf, "_get_hf_token", lambda: None) + hf._make_request("https://hub.example/file", headers=headers) + assert calls[0].get_header("Authorization") == "Bearer test-token" + assert calls[1].get_header("Authorization") is None + assert headers == {"Range": "bytes=0-1"} From f2faeb2ef57547f3fecf99c577eb2b8405b0ea18 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:50:36 -0400 Subject: [PATCH 07/12] Reject truncated remote SafeTensors headers before decoding --- src/modelinfo/parsers/huggingface.py | 3 +++ tests/test_hf_truncated_header.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 tests/test_hf_truncated_header.py diff --git a/src/modelinfo/parsers/huggingface.py b/src/modelinfo/parsers/huggingface.py index 7bc2f7e..4a44137 100644 --- a/src/modelinfo/parsers/huggingface.py +++ b/src/modelinfo/parsers/huggingface.py @@ -111,6 +111,9 @@ def _fetch_safetensors_header(repo_id: str, filename: str, timeout: float = 10.0 headers = {"Range": f"bytes=8-{8+header_size-1}"} json_bytes = _make_request(url, headers=headers, limit=header_size, timeout=timeout) + if len(json_bytes) != header_size: + raise ValueError(f"SafeTensors header in {filename} is truncated: expected {header_size} bytes, got {len(json_bytes)}.") + return json.loads(json_bytes) def _get_remote_file_size_fallback(repo_id: str, filename: str, timeout: float = 10.0) -> float: diff --git a/tests/test_hf_truncated_header.py b/tests/test_hf_truncated_header.py new file mode 100644 index 0000000..b30f488 --- /dev/null +++ b/tests/test_hf_truncated_header.py @@ -0,0 +1,14 @@ +import struct +from unittest.mock import patch + +import pytest + +from modelinfo.parsers.huggingface import _fetch_safetensors_header + + +@pytest.mark.parametrize('declared_size', [64, 600000]) +def test_remote_header_rejects_truncation_even_when_json_is_complete(declared_size): + first_chunk = struct.pack(' Date: Wed, 16 Sep 2026 10:54:28 -0400 Subject: [PATCH 08/12] Honor tensor assignments in remote shard indexes --- src/modelinfo/parsers/huggingface.py | 9 +++++++-- tests/test_hf_index_authority.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 tests/test_hf_index_authority.py diff --git a/src/modelinfo/parsers/huggingface.py b/src/modelinfo/parsers/huggingface.py index 4a44137..4d0eb50 100644 --- a/src/modelinfo/parsers/huggingface.py +++ b/src/modelinfo/parsers/huggingface.py @@ -234,10 +234,15 @@ def _fetch_remote_gguf_group(real_repo_id: str, gguf_files: List[Dict[str, Any]] return tensors -def _fetch_shards_concurrently(real_repo_id: str, unique_shards: List[str], timeout: float) -> Tuple[Dict[str, Any], int]: +def _fetch_shards_concurrently(real_repo_id: str, unique_shards: List[str], timeout: float, weight_map: Dict[str, str] | None = None) -> Tuple[Dict[str, Any], int]: def fetch_shard(shard: str): try: header = _fetch_safetensors_header(real_repo_id, shard, timeout=timeout) + if weight_map is not None: + assigned = [name for name, filename in weight_map.items() if filename == shard] + if any(name not in header for name in assigned): + raise ValueError(f"Indexed tensor missing from shard {shard!r}") + header = {name: header[name] for name in assigned} return shard, header, None except Exception as e: return shard, {}, e @@ -283,7 +288,7 @@ def _fetch_remote_safetensors_sharded( "total_size": total_size } else: - tensors, missing_shards = _fetch_shards_concurrently(real_repo_id, unique_shards, timeout) + tensors, missing_shards = _fetch_shards_concurrently(real_repo_id, unique_shards, timeout, weight_map) tensors["__metadata__"] = { "missing_shards": missing_shards, "total_shards": len(unique_shards), diff --git a/tests/test_hf_index_authority.py b/tests/test_hf_index_authority.py new file mode 100644 index 0000000..3afc415 --- /dev/null +++ b/tests/test_hf_index_authority.py @@ -0,0 +1,19 @@ +import json +from unittest.mock import patch + +from modelinfo.parsers.huggingface import _fetch_remote_safetensors_sharded + + +def test_remote_shards_include_only_tensors_assigned_by_index(): + index = {'weight_map': {'weight': 'one.safetensors'}, 'metadata': {'total_size': 8}} + header = {'weight': {'shape': [2], 'dtype': 'F32'}, 'extra': {'shape': [100], 'dtype': 'F32'}} + with patch('modelinfo.parsers.huggingface._make_request', return_value=json.dumps(index).encode()), patch('modelinfo.parsers.huggingface._fetch_safetensors_header', return_value=header): + tensors, _ = _fetch_remote_safetensors_sharded('org/model', None, True, 10) + assert set(tensors) == {'weight', '__metadata__'} + + +def test_remote_shard_missing_an_indexed_tensor_is_incomplete(): + index = {'weight_map': {'weight': 'one.safetensors'}} + with patch('modelinfo.parsers.huggingface._make_request', return_value=json.dumps(index).encode()), patch('modelinfo.parsers.huggingface._fetch_safetensors_header', return_value={}): + tensors, _ = _fetch_remote_safetensors_sharded('org/model', None, True, 10) + assert tensors['__metadata__']['missing_shards'] == 1 From ed06ed73ae17958c1c96e79ccca9203395f5dcad Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:01:38 -0400 Subject: [PATCH 09/12] Honor Hugging Face token cache environment settings --- src/modelinfo/parsers/huggingface.py | 4 +++- tests/test_hf_token_paths.py | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 tests/test_hf_token_paths.py diff --git a/src/modelinfo/parsers/huggingface.py b/src/modelinfo/parsers/huggingface.py index 4d0eb50..6e4d024 100644 --- a/src/modelinfo/parsers/huggingface.py +++ b/src/modelinfo/parsers/huggingface.py @@ -35,7 +35,9 @@ def _get_hf_token() -> str | None: if token: return token - cache_path = os.path.expanduser("~/.cache/huggingface/token") + cache_home = os.environ.get("XDG_CACHE_HOME", "~/.cache") + hf_home = os.environ.get("HF_HOME", os.path.join(cache_home, "huggingface")) + cache_path = os.path.expanduser(os.environ.get("HF_TOKEN_PATH", os.path.join(hf_home, "token"))) if os.path.exists(cache_path): try: with open(cache_path, "r", encoding="utf-8") as f: diff --git a/tests/test_hf_token_paths.py b/tests/test_hf_token_paths.py new file mode 100644 index 0000000..7b77b3b --- /dev/null +++ b/tests/test_hf_token_paths.py @@ -0,0 +1,23 @@ +import pytest + +from modelinfo.parsers.huggingface import _get_hf_token + + +@pytest.mark.parametrize('variable,relative', [ + ('HF_TOKEN_PATH', 'custom-token'), + ('HF_HOME', 'custom-home/token'), + ('XDG_CACHE_HOME', 'custom-cache/huggingface/token'), +]) +def test_custom_huggingface_token_cache(monkeypatch, tmp_path, variable, relative): + for name in ('HF_TOKEN', 'HF_TOKEN_PATH', 'HF_HOME', 'XDG_CACHE_HOME'): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv('HOME', str(tmp_path)) + monkeypatch.setenv('USERPROFILE', str(tmp_path)) + token_file = tmp_path / relative + token_file.parent.mkdir(parents=True, exist_ok=True) + token_file.write_text('fixture-token\n') + value = token_file if variable == 'HF_TOKEN_PATH' else (token_file.parent if variable == 'HF_HOME' else token_file.parent.parent) + monkeypatch.setenv(variable, str(value)) + assert _get_hf_token() == 'fixture-token' + monkeypatch.setenv('HF_TOKEN', 'environment-fixture') + assert _get_hf_token() == 'environment-fixture' From 99b28aabf0d156c8fe399cf4f2db3b6596568f1f Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:02:18 -0400 Subject: [PATCH 10/12] Avoid remote requests for zero-length stream reads --- src/modelinfo/parsers/huggingface.py | 2 ++ tests/test_remote_zero_read.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 tests/test_remote_zero_read.py diff --git a/src/modelinfo/parsers/huggingface.py b/src/modelinfo/parsers/huggingface.py index 6e4d024..36eb1a7 100644 --- a/src/modelinfo/parsers/huggingface.py +++ b/src/modelinfo/parsers/huggingface.py @@ -141,6 +141,8 @@ def __init__(self, url: str, chunk_size: int = 1024*1024, timeout: float = 10.0) self.position = 0 def read(self, size: int = -1) -> bytes: + if size == 0: + return b"" if size == -1: raise NotImplementedError("Unlimited remote read is not supported.") diff --git a/tests/test_remote_zero_read.py b/tests/test_remote_zero_read.py new file mode 100644 index 0000000..8154cb7 --- /dev/null +++ b/tests/test_remote_zero_read.py @@ -0,0 +1,12 @@ +from unittest.mock import patch + +from modelinfo.parsers.huggingface import RemoteFileStream + + +def test_zero_length_read_after_seek_never_fetches_or_moves(): + stream = RemoteFileStream('https://example.test/model.gguf') + for position in (100, 60 * 1024 * 1024): + stream.seek(position) + with patch('modelinfo.parsers.huggingface._make_request', side_effect=AssertionError('unexpected network request')): + assert stream.read(0) == b'' + assert stream.tell() == position From 237d1a0a638b79e0eff07596192f245f26d7c356 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:05:08 -0400 Subject: [PATCH 11/12] Reject partial responses from the wrong byte offset --- src/modelinfo/parsers/huggingface.py | 8 ++++++++ tests/test_hf_content_range.py | 16 ++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 tests/test_hf_content_range.py diff --git a/src/modelinfo/parsers/huggingface.py b/src/modelinfo/parsers/huggingface.py index 36eb1a7..e641780 100644 --- a/src/modelinfo/parsers/huggingface.py +++ b/src/modelinfo/parsers/huggingface.py @@ -1,6 +1,7 @@ import concurrent.futures import json import os +import re import struct import urllib.error import urllib.parse @@ -75,6 +76,13 @@ def _make_request( and requested_range.split("=", 1)[1].split("-", 1)[0] != "0" and getattr(response, "status", None) == 200): raise ValueError("Server ignored the requested byte range; refusing data from the wrong offset.") + content_range = getattr(response, "headers", {}).get("Content-Range") + if requested_range.startswith("bytes=") and getattr(response, "status", None) == 206 and content_range: + match = re.fullmatch(r"bytes (\d+)-(\d+)/(?:\d+|\*)", content_range) + expected_start = int(requested_range.split("=", 1)[1].split("-", 1)[0]) + if (match is None or int(match[1]) != expected_start + or int(match[2]) < int(match[1])): + raise ValueError("Server returned data for an invalid or unexpected byte range.") if limit is not None: return response.read(limit) return response.read() diff --git a/tests/test_hf_content_range.py b/tests/test_hf_content_range.py new file mode 100644 index 0000000..e5853e7 --- /dev/null +++ b/tests/test_hf_content_range.py @@ -0,0 +1,16 @@ +import io +from unittest.mock import patch + +import pytest + +from modelinfo.parsers.huggingface import _make_request + + +@pytest.mark.parametrize('content_range', ['bytes 0-7/16', 'bytes 9-16/32', 'invalid']) +def test_partial_response_rejects_wrong_range(content_range): + response = io.BytesIO(b'bad-data') + response.status = 206 + response.headers = {'Content-Range': content_range} + with patch('modelinfo.parsers.huggingface._get_hf_token', return_value=None), patch('modelinfo.parsers.huggingface.urllib.request.urlopen', return_value=response): + with pytest.raises(ValueError, match='range'): + _make_request('https://example.test/file', {'Range': 'bytes=8-15'}, limit=8) From 5b8459fbbcdc2c26834c23bec7c32f4dcea266bf Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Sun, 20 Sep 2026 00:57:54 -0400 Subject: [PATCH 12/12] Reject non-object SafeTensors headers before shard processing --- src/modelinfo/parsers/huggingface.py | 5 ++++- src/modelinfo/parsers/safetensors.py | 5 ++++- tests/test_hf_header_object.py | 33 ++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 tests/test_hf_header_object.py diff --git a/src/modelinfo/parsers/huggingface.py b/src/modelinfo/parsers/huggingface.py index e641780..f5e5cc0 100644 --- a/src/modelinfo/parsers/huggingface.py +++ b/src/modelinfo/parsers/huggingface.py @@ -124,7 +124,10 @@ def _fetch_safetensors_header(repo_id: str, filename: str, timeout: float = 10.0 if len(json_bytes) != header_size: raise ValueError(f"SafeTensors header in {filename} is truncated: expected {header_size} bytes, got {len(json_bytes)}.") - return json.loads(json_bytes) + header = json.loads(json_bytes) + if not isinstance(header, dict): + raise ValueError(f"SafeTensors header in {filename} must be a JSON object.") + return header def _get_remote_file_size_fallback(repo_id: str, filename: str, timeout: float = 10.0) -> float: req = urllib.request.Request(_hub_file_url(repo_id, filename), method="HEAD") diff --git a/src/modelinfo/parsers/safetensors.py b/src/modelinfo/parsers/safetensors.py index 2e7d705..b76b438 100644 --- a/src/modelinfo/parsers/safetensors.py +++ b/src/modelinfo/parsers/safetensors.py @@ -19,7 +19,10 @@ def _read_single_header(path: str) -> dict[str, Any]: if len(json_bytes) != header_length: raise EOFError("Invalid SafeTensors file: Unexpected end of file while reading JSON header.") - return json.loads(json_bytes) + header = json.loads(json_bytes) + if not isinstance(header, dict): + raise ValueError(f"SafeTensors header in {path} must be a JSON object.") + return header def parse_safetensors_header(path: str) -> dict[str, Any]: dir_path = os.path.dirname(path) diff --git a/tests/test_hf_header_object.py b/tests/test_hf_header_object.py new file mode 100644 index 0000000..1d322a2 --- /dev/null +++ b/tests/test_hf_header_object.py @@ -0,0 +1,33 @@ +import json +import struct + +import pytest + +from modelinfo.parsers import huggingface as hf + + +@pytest.mark.parametrize('header', [None, [], [1], 'weights', 42, True]) +def test_remote_safetensors_header_requires_object(monkeypatch, header): + payload = json.dumps(header).encode() + monkeypatch.setattr(hf, '_make_request', lambda *a, **kw: struct.pack('