US01-03: Discover and Reconcile Stable Assets (#48)

This commit was merged in pull request #48.
This commit is contained in:
2026-07-15 16:47:09 +02:00
parent a723b63d55
commit c25d45d8f7
7 changed files with 666 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
"""Hashing: byte identity vs normalized-pixel identity."""
import numpy as np
from PIL import Image
from photo_pipeline.services import hashing
def _write(path, seed, size=(48, 32)):
rng = np.random.default_rng(seed)
arr = rng.integers(0, 256, (size[1], size[0], 3), dtype=np.uint8)
Image.fromarray(arr).save(path, quality=95)
def test_sha256_is_deterministic_and_distinct(tmp_path):
a, b = tmp_path / "a.jpg", tmp_path / "b.jpg"
_write(a, 1)
_write(b, 2)
assert hashing.sha256_file(a) == hashing.sha256_file(a)
assert hashing.sha256_file(a) != hashing.sha256_file(b)
def test_trailing_bytes_change_sha_but_not_pixels(tmp_path):
"""Appending bytes after a JPEG (an EXIF-only-style edit) changes the file
hash while decoded pixels — and the pixel hash — stay the same."""
img = tmp_path / "a.jpg"
_write(img, 1)
sha_before = hashing.sha256_file(img)
pix_before = hashing.pixel_sha256(img)
with open(img, "ab") as handle:
handle.write(b"\xff\xfe trailing comment bytes")
assert hashing.sha256_file(img) != sha_before
assert hashing.pixel_sha256(img) == pix_before
def test_different_images_have_different_pixel_hashes(tmp_path):
a, b = tmp_path / "a.jpg", tmp_path / "b.jpg"
_write(a, 1)
_write(b, 2)
assert hashing.pixel_sha256(a) != hashing.pixel_sha256(b)
def test_safe_pixel_sha256_returns_none_for_garbage(tmp_path):
bad = tmp_path / "bad.jpg"
bad.write_bytes(b"not an image")
assert hashing.safe_pixel_sha256(bad) is None

View File

@@ -0,0 +1,83 @@
"""Path policy: supported extensions, exclusion, deterministic discovery, and
symlink-escape rejection."""
import os
import pytest
from photo_pipeline import path_policy
def test_is_supported_is_case_insensitive():
assert path_policy.is_supported("a.jpg")
assert path_policy.is_supported("a.JPG")
assert path_policy.is_supported("a.HEIC")
assert not path_policy.is_supported("a.txt")
assert not path_policy.is_supported("a.mp4")
def test_is_excluded_matches_ignore_and_thumb_anywhere():
assert path_policy.is_excluded("lib/_IGNORE/secret.jpg")
assert path_policy.is_excluded("lib/album/.@__thumb/t.jpg")
assert not path_policy.is_excluded("lib/album/photo.jpg")
def test_discovery_finds_supported_and_skips_excluded_and_unsupported(tmp_path):
(tmp_path / "album").mkdir()
(tmp_path / "_IGNORE").mkdir()
(tmp_path / "album" / ".@__thumb").mkdir()
keep = [tmp_path / "root.jpg", tmp_path / "album" / "a.PNG"]
for p in keep:
p.write_bytes(b"x")
(tmp_path / "notes.txt").write_bytes(b"x")
(tmp_path / "_IGNORE" / "secret.jpg").write_bytes(b"x")
(tmp_path / "album" / ".@__thumb" / "thumb.jpg").write_bytes(b"x")
found = path_policy.discover([tmp_path])
assert found == sorted(keep)
def test_discovery_is_deterministic(tmp_path):
for i in range(5):
(tmp_path / f"{i}.jpg").write_bytes(b"x")
assert path_policy.discover([tmp_path]) == path_policy.discover([tmp_path])
def test_resolve_within_accepts_inside_and_rejects_outside(tmp_path):
root = tmp_path / "lib"
root.mkdir()
inside = root / "a.jpg"
inside.write_bytes(b"x")
assert path_policy.resolve_within(root, inside) == inside.resolve()
with pytest.raises(path_policy.PathPolicyError):
path_policy.resolve_within(root, tmp_path / "outside.jpg")
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks unsupported")
def test_symlink_escaping_root_fails_discovery(tmp_path):
root = tmp_path / "lib"
root.mkdir()
outside = tmp_path / "outside.jpg"
outside.write_bytes(b"x")
link = root / "link.jpg"
try:
os.symlink(outside, link)
except (OSError, NotImplementedError):
pytest.skip("cannot create symlink on this platform")
with pytest.raises(path_policy.PathPolicyError):
path_policy.discover([root])
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks unsupported")
def test_symlink_within_root_is_allowed(tmp_path):
root = tmp_path / "lib"
root.mkdir()
target = root / "real.jpg"
target.write_bytes(b"x")
link = root / "alias.jpg"
try:
os.symlink(target, link)
except (OSError, NotImplementedError):
pytest.skip("cannot create symlink on this platform")
found = path_policy.discover([root])
assert target in found and link in found