@@ -339,6 +339,73 @@ def test_get_hash(self, pack_dir):
339339 assert hash_val.startswith("sha256:")
340340 assert len(hash_val) > 10
341341
342+ def test_get_hash_incremental_chunking(self, temp_dir):
343+ """Regression: get_hash() must read in bounded chunks, not f.read().
344+
345+ Creates a file larger than 64 KiB to exercise multiple chunk
346+ iterations, verifies the digest matches hashlib.sha256(content),
347+ and asserts that raw read() is never called with no size limit.
348+ """
349+ import hashlib
350+ from unittest.mock import patch
351+
352+ # Build valid YAML content larger than 64 KiB. The padding field
353+ # is a long string that inflates the file without breaking YAML.
354+ padding = "P" * (200 * 1024) # 200 KiB
355+ yaml_content = (
356+ "schema_version: '1.0'\n"
357+ "preset:\n"
358+ " id: large-test\n"
359+ " name: Large Test\n"
360+ " version: '1.0.0'\n"
361+ " description: chunking test\n"
362+ " author: test\n"
363+ "requires:\n"
364+ " speckit_version: '>=0.1.0'\n"
365+ "provides:\n"
366+ " templates:\n"
367+ " - type: template\n"
368+ " name: test\n"
369+ " file: test.md\n"
370+ f" padding: \"{padding}\"\n"
371+ )
372+ content = yaml_content.encode("utf-8")
373+ assert len(content) > 65536 # Ensure multi-chunk coverage.
374+
375+ manifest_path = temp_dir / "large_preset.yml"
376+ manifest_path.write_bytes(content)
377+
378+ manifest = PresetManifest(manifest_path)
379+
380+ # Spy on read() to reject unbounded calls.
381+ original_open = open
382+
383+ read_sizes: list[int] = []
384+
385+ def tracking_open(path, *args, **kwargs):
386+ fh = original_open(path, *args, **kwargs)
387+ if args and args[0] == "rb" or kwargs.get("mode") == "rb":
388+ orig_read = fh.read
389+ def tracking_read(n=-1):
390+ if n == -1 or n is None:
391+ raise RuntimeError(
392+ "f.read() called without size limit — "
393+ "use bounded chunked reads instead"
394+ )
395+ read_sizes.append(n)
396+ return orig_read(n)
397+ fh.read = tracking_read
398+ return fh
399+
400+ with patch("builtins.open", side_effect=tracking_open):
401+ result = manifest.get_hash()
402+
403+ expected = f"sha256:{hashlib.sha256(content).hexdigest()}"
404+ assert result == expected
405+ # Verify bounded reads happened (at least 3 chunks of <= 65536 bytes).
406+ assert len(read_sizes) >= 3
407+ assert all(s <= 65536 for s in read_sizes)
408+
342409 def test_multiple_templates(self, temp_dir, valid_pack_data):
343410 """Test pack with multiple templates of different types."""
344411 valid_pack_data["provides"]["templates"] = [
0 commit comments