mirror of
https://github.com/guillaumemeyer/watermarks-remover.git
synced 2026-08-22 13:11:57 +02:00
* feat: split skill from service, add HTTP API and Docker distribution The agent skill (skills/remove-ai-marks/) is now a code-free remote client: all implementation moved to service/scripts/ and runs behind a stdlib HTTP service (server.py) with /health, /capabilities, /inspect, /clean and a dynamically generated OpenAPI 3.0.3 spec at /openapi.json. - Move scripts/ and the backend Dockerfiles under service/ - server.py: JSON/base64 HTTP entrypoint with size caps, binary guard, atomic writes, loopback default, optional bearer auth - Core Dockerfile (exiftool/qpdf/c2patool preinstalled) and a GHCR publish workflow for the core/markllm/markdiffusion images - compose.yaml (wr-* services, harness/heavy profiles) + compose-check.sh to validate the running stack (exit code only) - Fix markllm image build (tokenizers 0.22.2, CPU-only torch) and ctrlregen build (python:3.11 base for the 2023-era research pins) - Fix markllm/markdiffusion harness images missing common.py at runtime * docs: add .env.example and service configuration guide * fix: disable chain-of-thought for openai-compatible Layer B rewrites deepseek-v4-flash is a reasoning model: a one-line paraphrase burned 9,894 reasoning tokens (~100s) and hit the default timeout. Send reasoning_effort=none by default for the openai-compatible backend (--reasoning-effort / WATERMARKS_REWRITE_REASONING_EFFORT; 'off' omits the parameter), cutting the same rewrite to ~1s / 12 tokens. Tested end-to-end against api.deepseek.com. * fix: sanitize client-supplied filename in HTTP service CodeQL 'uncontrolled data in path expression' (server.py): a name like '../../x' flowed into Path(tmpdir) / name, letting an upload escape the request temp dir on write. Sanitize name to its basename in _decode_input (_safe_name) and refuse any joined path whose parent is not the tmpdir at the write sites (_tmp_path). Tests cover traversal names. * chore: gitignore .env (contains local rewrite credentials) * chore: deny-by-default gitignore and dockerignore; document compose env config .gitignore and service/.dockerignore now exclude everything by default and explicitly allow only what is publishable/needed: tracked source, docs, tests, .github, and (for images) the service/scripts/ tree that every Dockerfile COPYs. Root .dockerignore documents that all builds use service/ as context. README Configuration section now covers .env setup for docker compose, host-side export for CLI runs, and the full variable table.
94 lines
2.8 KiB
Python
94 lines
2.8 KiB
Python
"""Tests for how c2patool output is interpreted by run_optional_tools."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SCRIPTS = ROOT / "service" / "scripts"
|
|
sys.path.insert(0, str(SCRIPTS))
|
|
|
|
import image_meta # noqa: E402
|
|
|
|
MANIFEST_OUTPUT = """{
|
|
"active_manifest": "urn:c2pa:0000",
|
|
"manifests": {
|
|
"urn:c2pa:0000": {
|
|
"claim_generator": "some_tool/1.0",
|
|
"assertions": [{"label": "c2pa.actions"}]
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
|
|
class _Completed:
|
|
def __init__(self, returncode: int, stdout: str = "", stderr: str = "") -> None:
|
|
self.returncode = returncode
|
|
self.stdout = stdout
|
|
self.stderr = stderr
|
|
|
|
|
|
def _fake_c2patool(monkeypatch, output: str, returncode: int, on_stderr: bool = False):
|
|
"""Pretend c2patool exists and emits `output`; hide every other tool."""
|
|
|
|
def fake_which(cmd: str):
|
|
return "/fake/bin/c2patool" if cmd == "c2patool" else None
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
if on_stderr:
|
|
return _Completed(returncode, stderr=output)
|
|
return _Completed(returncode, stdout=output)
|
|
|
|
monkeypatch.setattr(image_meta, "which", fake_which)
|
|
monkeypatch.setattr(image_meta.subprocess, "run", fake_run)
|
|
|
|
|
|
def test_no_claim_found_is_not_a_manifest(monkeypatch, tmp_path):
|
|
"""Absence of a manifest must not read as a hit.
|
|
|
|
c2patool reports a missing manifest as "Error: No claim found", which
|
|
contains the substring "claim"; a positive branch that is not vetoed by
|
|
the negative markers flags every clean asset as carrying C2PA.
|
|
"""
|
|
_fake_c2patool(monkeypatch, "Error: No claim found\n", 1, on_stderr=True)
|
|
path = tmp_path / "clean.png"
|
|
path.write_bytes(b"\x89PNG\r\n\x1a\n")
|
|
|
|
tools = image_meta.run_optional_tools(path)
|
|
|
|
assert tools["c2patool"]["available"] is True
|
|
assert tools["c2patool"]["has_manifest"] is False
|
|
|
|
|
|
def test_no_jumbf_data_is_not_a_manifest(monkeypatch, tmp_path):
|
|
_fake_c2patool(monkeypatch, "No JUMBF data found\n", 1, on_stderr=True)
|
|
path = tmp_path / "clean.jpg"
|
|
path.write_bytes(b"\xff\xd8\xff")
|
|
|
|
tools = image_meta.run_optional_tools(path)
|
|
|
|
assert tools["c2patool"]["has_manifest"] is False
|
|
|
|
|
|
def test_real_manifest_is_detected(monkeypatch, tmp_path):
|
|
"""The negative guard must not suppress genuine manifests."""
|
|
_fake_c2patool(monkeypatch, MANIFEST_OUTPUT, 0)
|
|
path = tmp_path / "signed.png"
|
|
path.write_bytes(b"\x89PNG\r\n\x1a\n")
|
|
|
|
tools = image_meta.run_optional_tools(path)
|
|
|
|
assert tools["c2patool"]["has_manifest"] is True
|
|
|
|
|
|
def test_missing_c2patool_is_reported_unavailable(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(image_meta, "which", lambda cmd: None)
|
|
path = tmp_path / "any.png"
|
|
path.write_bytes(b"\x89PNG\r\n\x1a\n")
|
|
|
|
tools = image_meta.run_optional_tools(path)
|
|
|
|
assert tools["c2patool"] == {"available": False}
|