|
| 1 | +import io |
| 2 | +import struct |
| 3 | +import zipfile |
| 4 | +import zlib |
| 5 | + |
| 6 | +import pytest |
| 7 | + |
| 8 | +from le_utils.archive import contents_sha256 |
| 9 | + |
| 10 | +MEMBERS = [ |
| 11 | + ("README.txt", b"Kolibri\n"), |
| 12 | + ("index.html", b"<html><body><p>Kolibri</p></body></html>\n"), |
| 13 | + ("css/site/style.css", b"p { color: #333; }\n"), |
| 14 | + ("assets/data.bin", bytes(range(256)) * 400), |
| 15 | + ("données/été.txt", "café\n".encode("utf-8")), |
| 16 | + ("empty.txt", b""), |
| 17 | +] |
| 18 | +EXPECTED_DIGEST = "9ccc8c88bedb2106e59879ba70e7c84f38f098bf8fa4a7a2476dad63eab0ae15" |
| 19 | + |
| 20 | + |
| 21 | +def _zip(members, compression=zipfile.ZIP_DEFLATED): |
| 22 | + archive = io.BytesIO() |
| 23 | + with zipfile.ZipFile(archive, "w", compression) as zf: |
| 24 | + for name, data in members: |
| 25 | + zf.writestr(name, data) |
| 26 | + archive.seek(0) |
| 27 | + return archive |
| 28 | + |
| 29 | + |
| 30 | +@pytest.mark.parametrize("compression", [zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED], ids=["stored", "deflated"]) |
| 31 | +def test_contents_sha256_is_pinned(compression): |
| 32 | + assert contents_sha256(_zip(MEMBERS, compression)) == EXPECTED_DIGEST |
| 33 | + |
| 34 | + |
| 35 | +def test_contents_sha256_ignores_member_order(): |
| 36 | + assert contents_sha256(_zip(reversed(MEMBERS))) == EXPECTED_DIGEST |
| 37 | + |
| 38 | + |
| 39 | +def test_contents_sha256_ignores_directory_entries(): |
| 40 | + directories = [("css/", b""), ("css/site/", b""), ("données/", b"")] |
| 41 | + assert contents_sha256(_zip(directories + MEMBERS)) == EXPECTED_DIGEST |
| 42 | + |
| 43 | + |
| 44 | +def test_contents_sha256_depends_on_member_paths(): |
| 45 | + moved = [("moved/" + name if name == "index.html" else name, data) for name, data in MEMBERS] |
| 46 | + assert contents_sha256(_zip(moved)) != EXPECTED_DIGEST |
| 47 | + |
| 48 | + |
| 49 | +def _with_metadata(name): |
| 50 | + info = zipfile.ZipInfo(name, date_time=(2001, 2, 3, 4, 5, 6)) |
| 51 | + info.external_attr = 0o100755 << 16 |
| 52 | + info.comment = b"comment" |
| 53 | + # Python >= 3.12 replaces ZipInfo.filename with this Unicode Path field's name. |
| 54 | + renamed = ("renamed/" + name).encode("utf-8") |
| 55 | + info.extra = struct.pack("<HHBL", 0x7075, 5 + len(renamed), 1, zlib.crc32(name.encode("utf-8"))) + renamed |
| 56 | + return info |
| 57 | + |
| 58 | + |
| 59 | +def test_contents_sha256_ignores_metadata(): |
| 60 | + archive = io.BytesIO() |
| 61 | + with zipfile.ZipFile(archive, "w") as zf: |
| 62 | + zf.comment = b"archive comment" |
| 63 | + for name, data in MEMBERS: |
| 64 | + zf.writestr(_with_metadata(name), data) |
| 65 | + assert contents_sha256(archive) == EXPECTED_DIGEST |
| 66 | + |
| 67 | + |
| 68 | +def test_contents_sha256_is_independent_of_file_position(): |
| 69 | + archive = _zip(MEMBERS) |
| 70 | + archive.read() |
| 71 | + assert contents_sha256(archive) == EXPECTED_DIGEST |
| 72 | + assert not archive.closed |
| 73 | + |
| 74 | + |
| 75 | +def test_contents_sha256_rejects_duplicate_paths(): |
| 76 | + with pytest.warns(UserWarning): |
| 77 | + archive = _zip(MEMBERS + [("index.html", b"other")]) |
| 78 | + with pytest.raises(ValueError): |
| 79 | + contents_sha256(archive) |
| 80 | + |
| 81 | + |
| 82 | +def test_contents_sha256_rejects_nul_in_path(): |
| 83 | + # zipfile truncates names at NUL when writing, so patch the stored bytes. |
| 84 | + # Trailing "/": NUL must be rejected before directory entries are skipped. |
| 85 | + archive = io.BytesIO(_zip([("nul#name/", b"x")]).getvalue().replace(b"nul#name", b"nul\0name")) |
| 86 | + with pytest.raises(ValueError): |
| 87 | + contents_sha256(archive) |
0 commit comments