mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:37:03 +01:00
tool/tagger.py did not run at all: 48957a1 renamed pack.default_dir to
default_dirs and missed the caller, so the tagger raised ImportError at
module scope through two releases. Nothing could have caught it -- tool/ is
outside --cov and mypy ran with [signal_bot, fake_signal]. Both are widened
here, and tests/test_tool_imports.py imports every script under tool/.
--import was a filename check and shutil.copy2. Pointed at a phone export
that overlaps the album it re-imported the same photo under whatever name
the exporter used that time. It now identifies files by magic bytes, strips
them through the same media_strip path !add_photo uses, and dedups on exact
bytes twice: the source hash, then -- only for what survives -- the hash
after stripping. sources.json records both, so a second run on the same
folder is a no-op. What the album refuses is moved to _skipped/<reason>/
beside the source, never deleted.
Two things that made "strip on import" impossible until now:
media_tags claims -all= zeroes the QuickTime date tags and allows them by
value. It does not; measured, it leaves CreateDate at its real value, and
every mp4 was being refused. -time:all= is what zeroes them.
And a tag allowlist cannot see the worst class at all. IMG_6924.mp4 carries
mebx timed-metadata holding per-frame face bounding boxes and a face id,
and those survive a complete tag strip because they live in mdat as opaque
samples. media_tracks.py drops the track with ffmpeg and verifies with
ffprobe that only video and audio survived. This is not the ffmpeg mistake
media_strip warns about: that was ffmpeg replacing exiftool for tags, this
is track removal with exiftool still doing every tag, and the gate is a
positive read-back rather than an exit code.
The roster was write-once. The setup form only appeared on an empty album,
Album.setup overwrote the file whole, and keys were positional (p{len+1}),
so inserting somebody renumbered everyone after them and repointed every
tags.json entry at the wrong person. Keys now come from the highest number
in use, the form merges, and a person can be added mid-session.
--untagged filters the page and never the server's list. /media/{index} and
/api/tag both index the full album, so a shorter list server-side would
show item i of the album while tagging item i of the filtered set.
Measured on the live album: 174 source files -> 1 not media, 82 already in
pack, 24 duplicate in batch, 67 imported, and re-running imports nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBrE1b9HvFw3xvG9bJiDFt
233 lines
9.1 KiB
Python
233 lines
9.1 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""The stripper, driven against stub binaries rather than the real exiftool.
|
|
|
|
Stubs, not mocks of ``subprocess``: the thing worth testing is that the gate
|
|
reads the *output back* and refuses on what it finds there, and a stub that
|
|
really writes a file and really prints a report exercises that end to end
|
|
without needing exiftool installed. ``scripts/e2e.sh`` runs the real one.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import stat
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
|
|
from signal_bot.media_sniff import JPEG, MP4, SVG, WEBM, MediaKind
|
|
from signal_bot.media_strip import FALLBACK_PATHS, ExiftoolStripper, locate
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
CLEAN_REPORT = """[ExifTool] ExifToolVersion : 13.55
|
|
[File] FileName : out.jpg
|
|
[File] MIMEType : image/jpeg
|
|
[Composite] ImageSize : 48x48
|
|
"""
|
|
|
|
# A geotag as a phone actually writes it into an mp4: a QuickTime atom, which
|
|
# a gate looking only at "GPS" would wave straight through.
|
|
LEAKY_REPORT = """[File] FileName : out.mp4
|
|
[QuickTime] MajorBrand : MP4 Base Media v1
|
|
[QuickTime] GPSCoordinates : 52.2297 21.0122 0
|
|
"""
|
|
|
|
# Lines the parser must simply skip rather than trip over.
|
|
NOISY_REPORT = """Warning: something exiftool felt like saying
|
|
[File] FileName : out.jpg
|
|
|
|
======== out.jpg
|
|
[Composite] ImageSize : 48x48
|
|
"""
|
|
|
|
STUB = """#!/usr/bin/env python3
|
|
import sys
|
|
args = sys.argv[1:]
|
|
if "-all=" in args:
|
|
{strip}
|
|
else:
|
|
sys.stdout.write({report!r})
|
|
sys.exit(0)
|
|
"""
|
|
|
|
|
|
def _stub(tmp_path: Path, name: str, *, strip: str, report: str = CLEAN_REPORT) -> str:
|
|
"""Write an executable stand-in for exiftool and return its path."""
|
|
path = tmp_path / name
|
|
path.write_text(STUB.format(strip=strip, report=report), encoding="utf-8")
|
|
path.chmod(path.stat().st_mode | stat.S_IEXEC)
|
|
return str(path)
|
|
|
|
|
|
COPIES = 'open(args[args.index("-o") + 1], "wb").write(open(args[-1], "rb").read())'
|
|
WRITES_NOTHING = "pass"
|
|
FAILS = "sys.exit(3)"
|
|
|
|
|
|
def test_a_clean_file_comes_back_stripped(tmp_path: Path) -> None:
|
|
stripper = ExiftoolStripper(_stub(tmp_path, "ok", strip=COPIES))
|
|
assert stripper.strip(b"\xff\xd8\xffdata", JPEG) == b"\xff\xd8\xffdata"
|
|
|
|
|
|
def test_a_tag_that_survived_the_strip_refuses_the_upload(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""The whole point: the exit code was 0 and the file is still dirty."""
|
|
# flatten is stubbed out so this reaches the tag check. Without it the
|
|
# re-mux refuses the fixture first -- it is twelve bytes, not a video --
|
|
# and the test would still pass, for the wrong reason.
|
|
monkeypatch.setattr("signal_bot.media_strip.flatten", lambda payload, suffix: payload)
|
|
stripper = ExiftoolStripper(_stub(tmp_path, "leaky", strip=COPIES, report=LEAKY_REPORT))
|
|
assert stripper.strip(b"\x00\x00\x00\x20ftypmp42", MP4) is None
|
|
|
|
|
|
def test_lines_that_are_not_tags_are_skipped(tmp_path: Path) -> None:
|
|
stripper = ExiftoolStripper(_stub(tmp_path, "noisy", strip=COPIES, report=NOISY_REPORT))
|
|
assert stripper.strip(b"\xff\xd8\xffdata", JPEG) == b"\xff\xd8\xffdata"
|
|
|
|
|
|
def test_a_strip_that_exits_nonzero_stores_nothing(tmp_path: Path) -> None:
|
|
stripper = ExiftoolStripper(_stub(tmp_path, "fails", strip=FAILS))
|
|
assert stripper.strip(b"\xff\xd8\xffdata", JPEG) is None
|
|
|
|
|
|
def test_a_strip_that_writes_no_output_stores_nothing(tmp_path: Path) -> None:
|
|
"""Exit 0 and no file is exactly the shape of the ffmpeg regression."""
|
|
stripper = ExiftoolStripper(_stub(tmp_path, "silent", strip=WRITES_NOTHING))
|
|
assert stripper.strip(b"\xff\xd8\xffdata", JPEG) is None
|
|
|
|
|
|
def test_a_report_that_fails_to_run_stores_nothing(tmp_path: Path) -> None:
|
|
path = tmp_path / "half"
|
|
path.write_text(
|
|
"#!/usr/bin/env python3\n"
|
|
"import sys\n"
|
|
"args = sys.argv[1:]\n"
|
|
'if "-all=" in args:\n'
|
|
' open(args[args.index("-o") + 1], "wb").write(open(args[-1], "rb").read())\n'
|
|
" sys.exit(0)\n"
|
|
"sys.exit(4)\n",
|
|
encoding="utf-8",
|
|
)
|
|
path.chmod(path.stat().st_mode | stat.S_IEXEC)
|
|
assert ExiftoolStripper(str(path)).strip(b"\xff\xd8\xffdata", JPEG) is None
|
|
|
|
|
|
def test_a_missing_binary_refuses_rather_than_raising(tmp_path: Path) -> None:
|
|
stripper = ExiftoolStripper(str(tmp_path / "not-here"))
|
|
assert stripper.strip(b"\xff\xd8\xffdata", JPEG) is None
|
|
|
|
|
|
def test_no_exiftool_at_all_refuses_the_upload(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Never stores it dirty instead: a strip that cannot run is a refusal.
|
|
|
|
Both lookups have to be blinded, not just PATH -- on a machine that has
|
|
perl-image-exiftool the fallback list finds it and this branch is never
|
|
reached, which is exactly how it went uncovered until the live run.
|
|
"""
|
|
monkeypatch.setattr("signal_bot.media_strip.shutil.which", lambda _: None)
|
|
monkeypatch.setattr("signal_bot.media_strip.Path.is_file", lambda self: False)
|
|
assert ExiftoolStripper().strip(b"\xff\xd8\xffdata", JPEG) is None
|
|
|
|
|
|
@pytest.mark.parametrize("kind", [SVG, WEBM])
|
|
def test_the_two_unwritable_formats_are_stored_as_they_arrived(
|
|
tmp_path: Path, kind: MediaKind
|
|
) -> None:
|
|
"""kuhy's decision of 2026-08-31, and it must not need exiftool at all."""
|
|
stripper = ExiftoolStripper(str(tmp_path / "never-invoked"))
|
|
payload = b"<svg><metadata>a name</metadata></svg>"
|
|
assert stripper.strip(payload, kind) == payload
|
|
|
|
|
|
def test_locate_prefers_path(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("signal_bot.media_strip.shutil.which", lambda _: "/somewhere/exiftool")
|
|
assert locate() == "/somewhere/exiftool"
|
|
|
|
|
|
def test_locate_falls_back_to_the_known_install_locations(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The live bug of 2026-08-31, pinned.
|
|
|
|
Arch's perl-image-exiftool installs to /usr/bin/vendor_perl, which is on
|
|
an interactive shell's PATH and not on a systemd unit's -- so the
|
|
stripper passed every test and refused every upload in production.
|
|
"""
|
|
monkeypatch.setattr("signal_bot.media_strip.shutil.which", lambda _: None)
|
|
monkeypatch.setattr(
|
|
"signal_bot.media_strip.Path.is_file",
|
|
lambda self: str(self) == "/usr/bin/vendor_perl/exiftool",
|
|
)
|
|
assert locate() == "/usr/bin/vendor_perl/exiftool"
|
|
|
|
|
|
def test_locate_returns_none_when_it_really_is_absent(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr("signal_bot.media_strip.shutil.which", lambda _: None)
|
|
monkeypatch.setattr("signal_bot.media_strip.Path.is_file", lambda self: False)
|
|
assert locate() is None
|
|
|
|
|
|
def test_the_arch_vendor_perl_path_is_on_the_fallback_list() -> None:
|
|
"""Named explicitly: this is the one that actually bit."""
|
|
assert "/usr/bin/vendor_perl/exiftool" in FALLBACK_PATHS
|
|
|
|
|
|
RECORDS_ARGV = (
|
|
'open("ARGV_PATH", "w").write(" ".join(args)); '
|
|
'open(args[args.index("-o") + 1], "wb").write(open(args[-1], "rb").read())'
|
|
)
|
|
|
|
|
|
def test_the_date_tags_are_zeroed_explicitly(tmp_path: Path) -> None:
|
|
# media_tags allows the QuickTime date tags "by value", on the stated
|
|
# grounds that -all= zeroes rather than deletes them. Measured on
|
|
# 2026-08-31 that is false: -all= alone left QuickTime:CreateDate at its
|
|
# real value and every mp4 in the live album was refused because of it.
|
|
# -time:all= is what actually zeroes them, so it must stay in the argv.
|
|
argv = tmp_path / "argv.txt"
|
|
stripper = ExiftoolStripper(
|
|
_stub(tmp_path, "records", strip=RECORDS_ARGV.replace("ARGV_PATH", str(argv)))
|
|
)
|
|
stripper.strip(b"\xff\xd8\xffdata", JPEG)
|
|
assert "-time:all=" in argv.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_a_video_has_its_metadata_tracks_dropped_first(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# exiftool cannot see a mebx track at all -- see media_tracks for the
|
|
# face-detection payload that survives a complete tag strip -- so the
|
|
# re-mux has to happen here or not at all.
|
|
seen: list[tuple[bytes, str]] = []
|
|
|
|
def fake_flatten(payload: bytes, suffix: str) -> bytes:
|
|
seen.append((payload, suffix))
|
|
return b"flattened"
|
|
|
|
monkeypatch.setattr("signal_bot.media_strip.flatten", fake_flatten)
|
|
ExiftoolStripper(_stub(tmp_path, "ok1", strip=COPIES)).strip(b"payload", MP4)
|
|
assert seen == [(b"payload", ".mp4")]
|
|
|
|
|
|
def test_a_still_image_is_not_sent_through_the_remux(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
def explode(payload: bytes, suffix: str) -> bytes:
|
|
raise AssertionError
|
|
|
|
monkeypatch.setattr("signal_bot.media_strip.flatten", explode)
|
|
stripper = ExiftoolStripper(_stub(tmp_path, "ok2", strip=COPIES))
|
|
assert stripper.strip(b"\xff\xd8\xffdata", JPEG) is not None
|
|
|
|
|
|
def test_a_video_whose_tracks_cannot_be_dropped_is_refused(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr("signal_bot.media_strip.flatten", lambda payload, suffix: None)
|
|
stripper = ExiftoolStripper(_stub(tmp_path, "ok3", strip=COPIES))
|
|
assert stripper.strip(b"payload", MP4) is None
|