mirror of
https://github.com/guillaumemeyer/watermarks-remover.git
synced 2026-08-22 13:11:57 +02:00
Refuse binary input in the text-only tools (#24)
* Refuse binary input in the text-only tools inspect_text.py, clean_text.py and rewrite_text.py accept any path and decode it with errors="surrogateescape". Pointed at a .docx - a zip - they walk deflate-compressed bytes and report whatever codepoints fall out of them. The counts look like findings but track the compression, not the content: in one sample set a document with nothing hidden in its text reported 12 "suspicious" characters, while another with 54 real no-break spaces reported 11, none of which were the no-break spaces. clean_text.py is worse than misleading. It writes the mangled decode back, so `clean_text.py report.docx` reports "removed=1" and silently corrupts the document - the output still passes zipfile.is_zipfile() because the end-of- central-directory record survives, but reading a member raises. common.looks_binary() now sniffs magic numbers plus a control-byte ratio, and guard_binary() refuses with a message naming the tool that does handle the format. The ratio test is deliberately conservative so text in encodings other than UTF-8 keeps working, and every entry point takes --force-text to override. clean_file.py gets the same check on the branch where classify() falls back to "text" for unrecognised bytes. Adds tests covering magic-number and heuristic detection, the override, refusal without writing or backing up, and that clean_file.py still routes a .docx to the container path. * Address review: backup ordering, stdin sniff, router advice Three fixes from the review on #24. clean_file.py sniffed after --in-place had already taken the backup, so `clean_file.py --in-place mystery.bin` left a mystery.bin.bak sidecar behind before exiting 2 — for a file the run never touches, and exactly what clean_text.py avoids. The sniff now runs before backup_path(). The same hole applied to `--as text` on a .docx, which bypasses classify() entirely. The stdin path decoded before sniffing, which made detection depend on the console codec. It was worse than codec drift: the text layer also translates newlines, so PNG's `\x89PNG\r\n\x1a\n` arrived as `\x89PNG\n\x1a\n` and the magic number never matched — the file was refused by the NUL-byte heuristic instead, and would have sailed through had it lacked NULs. _read_stdin_capped now reads sys.stdin.buffer and guards the raw octets, matching the file path, with a text fallback for a replaced stdin. guard_binary always advised "Use inspect_file.py / clean_file.py", which is circular when the caller is one of them and classify() has already ruled out every known container. The advice is now a parameter: the text-only scripts keep the pointer to the routers, and the routers say the bytes match no supported format and point at --force-text / --as. Adds tests for the backup ordering (both --in-place paths), the advice split, and stdin magic that is not ASCII, across default, cp1252 and latin-1 stdio codecs — the previous stdin test piped a ZIP, whose "PK" header is ASCII and survives any of them. --------- Co-authored-by: Guillaume Meyer (The Opinionated Man) <1385518+guillaumemeyer@users.noreply.github.com>
This commit is contained in:
@@ -79,6 +79,24 @@ python3 "$SCRIPTS/inspect_image.py" shot.png
|
||||
python3 "$SCRIPTS/clean_image.py" shot.png -o shot.cleaned.png
|
||||
```
|
||||
|
||||
### Text tools refuse binary input
|
||||
|
||||
`inspect_text.py`, `clean_text.py` and `rewrite_text.py` operate on text. Pointed
|
||||
at a `.docx`, `.pdf` or image they used to decode the compressed bytes and report
|
||||
whatever codepoints fell out — noise that tracks the compression, not the
|
||||
content — and `clean_text.py` then wrote those mangled bytes back, destroying the
|
||||
file. They now refuse binary input and name the tool that handles it:
|
||||
|
||||
```bash
|
||||
python3 "$SCRIPTS/inspect_text.py" report.docx
|
||||
# refusing to treat report.docx as text: it looks like a ZIP container (DOCX, ODT, …).
|
||||
# Use inspect_file.py / clean_file.py, which route by format,
|
||||
# or pass --force-text to scan the raw bytes anyway.
|
||||
```
|
||||
|
||||
Detection is by magic number plus a control-byte ratio, so text in encodings
|
||||
other than UTF-8 keeps working. `--force-text` overrides it everywhere.
|
||||
|
||||
## Optional SynthID pixel scoring
|
||||
|
||||
`inspect_image.py` and `clean_image.py` can report a pixel-domain SynthID
|
||||
|
||||
@@ -10,7 +10,15 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from common import MAX_INPUT_BYTES, backup_path, cleaned_path, eprint, safe_write_text # noqa: E402
|
||||
from common import ( # noqa: E402
|
||||
MAX_INPUT_BYTES,
|
||||
backup_path,
|
||||
cleaned_path,
|
||||
eprint,
|
||||
ROUTER_ADVICE,
|
||||
guard_binary,
|
||||
safe_write_text,
|
||||
)
|
||||
from container_meta import clean_container, detect_container_format # noqa: E402
|
||||
from image_meta import clean_image, detect_format as detect_image_format # noqa: E402
|
||||
from text_unicode import clean_text # noqa: E402
|
||||
@@ -68,6 +76,11 @@ def main() -> int:
|
||||
choices=("auto", "text", "image", "container"),
|
||||
default="auto",
|
||||
)
|
||||
p.add_argument(
|
||||
"--force-text",
|
||||
action="store_true",
|
||||
help="Clean as text even when the bytes look like a binary container",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
if not args.path.is_file():
|
||||
@@ -80,6 +93,19 @@ def main() -> int:
|
||||
|
||||
kind = args.force_type if args.force_type != "auto" else classify(args.path)
|
||||
|
||||
# classify() falls back to "text" for unrecognised bytes, so an unknown
|
||||
# binary would otherwise be decoded, scrubbed and written back mangled.
|
||||
# Sniff before --in-place takes a backup: refusing afterwards would leave a
|
||||
# .bak sidecar behind for a file this run never touches.
|
||||
raw = args.path.read_bytes() if kind == "text" else None
|
||||
if raw is not None:
|
||||
guard_binary(
|
||||
raw,
|
||||
str(args.path),
|
||||
allow_binary=args.force_text,
|
||||
advice=ROUTER_ADVICE,
|
||||
)
|
||||
|
||||
if args.in_place:
|
||||
bak = backup_path(args.path)
|
||||
dest = args.path
|
||||
@@ -89,7 +115,7 @@ def main() -> int:
|
||||
dest = args.output or cleaned_path(args.path)
|
||||
|
||||
if kind == "text":
|
||||
text = src.read_text(encoding="utf-8", errors="surrogateescape")
|
||||
text = raw.decode("utf-8", errors="surrogateescape")
|
||||
cleaned, stats = clean_text(
|
||||
text,
|
||||
nfkc=args.nfkc,
|
||||
|
||||
@@ -35,6 +35,12 @@ def main() -> int:
|
||||
help="Strip emoji presentation selectors/ZWJ even after an emoji base (paranoid)",
|
||||
)
|
||||
p.add_argument("--stats", action="store_true", help="Print stats JSON to stderr")
|
||||
p.add_argument(
|
||||
"--force-text",
|
||||
action="store_true",
|
||||
help="Clean even when the input looks like a binary container "
|
||||
"(this rewrites the bytes and will corrupt the file)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--in-place",
|
||||
action="store_true",
|
||||
@@ -42,7 +48,7 @@ def main() -> int:
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
text = read_text_input(args.path)
|
||||
text = read_text_input(args.path, allow_binary=args.force_text)
|
||||
cleaned, stats = clean_text(
|
||||
text,
|
||||
nfkc=args.nfkc,
|
||||
|
||||
@@ -51,9 +51,109 @@ def _configure_stdio() -> None:
|
||||
_configure_stdio()
|
||||
|
||||
|
||||
def read_text_input(path: str | None) -> str:
|
||||
# Containers that get mistaken for text on the command line. Decoding one as
|
||||
# text walks compressed bytes and reports whatever codepoints fall out of them:
|
||||
# noise that tracks the compression, not the content. Worse, cleaning such a
|
||||
# "text" writes the mangled bytes back and destroys the file.
|
||||
BINARY_MAGIC: tuple[tuple[bytes, str], ...] = (
|
||||
(b"PK\x03\x04", "a ZIP container (DOCX, ODT, XLSX, PPTX, EPUB, JAR)"),
|
||||
(b"PK\x05\x06", "an empty ZIP container"),
|
||||
(b"PK\x07\x08", "a spanned ZIP container"),
|
||||
(b"%PDF-", "a PDF"),
|
||||
(b"\x89PNG\r\n\x1a\n", "a PNG image"),
|
||||
(b"\xff\xd8\xff", "a JPEG image"),
|
||||
(b"GIF87a", "a GIF image"),
|
||||
(b"GIF89a", "a GIF image"),
|
||||
(b"II*\x00", "a TIFF image"),
|
||||
(b"MM\x00*", "a TIFF image"),
|
||||
(b"RIFF", "a RIFF container (WEBP, WAV, AVI)"),
|
||||
(b"OggS", "an Ogg media file"),
|
||||
(b"\x1f\x8b", "a gzip archive"),
|
||||
(b"BZh", "a bzip2 archive"),
|
||||
(b"\xfd7zXZ\x00", "an xz archive"),
|
||||
(b"7z\xbc\xaf\x27\x1c", "a 7-Zip archive"),
|
||||
(b"Rar!\x1a\x07", "a RAR archive"),
|
||||
(b"\x7fELF", "an ELF binary"),
|
||||
(b"\xca\xfe\xba\xbe", "a Java class or Mach-O fat binary"),
|
||||
(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", "a legacy Office document (.doc, .xls, .ppt)"),
|
||||
(b"SQLite format 3\x00", "a SQLite database"),
|
||||
(b"8BPS", "a Photoshop document"),
|
||||
(b"wOFF", "a WOFF font"),
|
||||
(b"wOF2", "a WOFF2 font"),
|
||||
(b"\x00\x01\x00\x00\x00", "a TrueType font"),
|
||||
(b"OTTO", "an OpenType font"),
|
||||
)
|
||||
|
||||
BINARY_SNIFF_BYTES = 8192
|
||||
|
||||
# Real text runs ~0% control bytes; compressed and executable data runs far
|
||||
# above this. Tab, LF, CR, FF and ESC are excluded as legitimate in text.
|
||||
_CONTROL_RATIO_LIMIT = 0.05
|
||||
_ALLOWED_CONTROLS = frozenset({0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x1B})
|
||||
|
||||
|
||||
def looks_binary(data: bytes) -> str | None:
|
||||
"""Describe why *data* is not plausibly text, or None when it looks like text.
|
||||
|
||||
Deliberately conservative: encodings other than UTF-8 must keep working, so
|
||||
undecodable bytes alone are not proof. Every caller offers an override.
|
||||
"""
|
||||
if not data:
|
||||
return None
|
||||
for magic, label in BINARY_MAGIC:
|
||||
if data.startswith(magic):
|
||||
return label
|
||||
head = data[:BINARY_SNIFF_BYTES]
|
||||
if b"\x00" in head:
|
||||
return "binary data (contains NUL bytes)"
|
||||
controls = sum(1 for b in head if b < 0x20 and b not in _ALLOWED_CONTROLS)
|
||||
if controls / len(head) > _CONTROL_RATIO_LIMIT:
|
||||
return "binary data (dense in control bytes)"
|
||||
return None
|
||||
|
||||
|
||||
# Advice for the text-only scripts: another tool in this repo handles the file.
|
||||
TEXT_TOOL_ADVICE = (
|
||||
"Use inspect_file.py / clean_file.py, which route by format,",
|
||||
"or pass --force-text to scan the raw bytes anyway.",
|
||||
)
|
||||
|
||||
# Advice for the routers themselves. They *are* inspect_file.py / clean_file.py,
|
||||
# and classify() has already ruled out every known container, so pointing back
|
||||
# at them would be circular.
|
||||
ROUTER_ADVICE = (
|
||||
"These bytes match no supported text, image or container format.",
|
||||
"Pass --force-text to handle them as text anyway, or --as to force a format.",
|
||||
)
|
||||
|
||||
|
||||
def guard_binary(
|
||||
data: bytes,
|
||||
origin: str,
|
||||
*,
|
||||
allow_binary: bool = False,
|
||||
advice: tuple[str, ...] | None = None,
|
||||
) -> None:
|
||||
"""Refuse binary input for the text-only tools unless explicitly overridden."""
|
||||
if allow_binary:
|
||||
return
|
||||
kind = looks_binary(data)
|
||||
if kind is None:
|
||||
return
|
||||
eprint(f"refusing to treat {origin} as text: it looks like {kind}.")
|
||||
for line in advice or TEXT_TOOL_ADVICE:
|
||||
eprint(line)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def read_text_input(
|
||||
path: str | None,
|
||||
*,
|
||||
allow_binary: bool = False,
|
||||
advice: tuple[str, ...] | None = None,
|
||||
) -> str:
|
||||
if path is None or path == "-":
|
||||
return _read_stdin_capped()
|
||||
return _read_stdin_capped(allow_binary=allow_binary, advice=advice)
|
||||
p = Path(path)
|
||||
try:
|
||||
size = p.stat().st_size
|
||||
@@ -62,23 +162,60 @@ def read_text_input(path: str | None) -> str:
|
||||
if size > MAX_INPUT_BYTES:
|
||||
eprint(f"refusing input larger than {MAX_INPUT_BYTES} bytes: {path}")
|
||||
raise SystemExit(2)
|
||||
return p.read_text(encoding="utf-8", errors="surrogateescape")
|
||||
data = p.read_bytes()
|
||||
guard_binary(data, str(path), allow_binary=allow_binary, advice=advice)
|
||||
return data.decode("utf-8", errors="surrogateescape")
|
||||
|
||||
|
||||
def _read_stdin_capped() -> str:
|
||||
"""Read stdin with a hard cap (uncapped stdin was a memory-DoS hole)."""
|
||||
chunks: list[str] = []
|
||||
def _read_stdin_capped(
|
||||
*,
|
||||
allow_binary: bool = False,
|
||||
advice: tuple[str, ...] | None = None,
|
||||
) -> str:
|
||||
"""Read stdin with a hard cap (uncapped stdin was a memory-DoS hole).
|
||||
|
||||
Read the raw byte stream rather than the decoded text, so the binary sniff
|
||||
sees the real octets. Going through the text layer first makes detection
|
||||
depend on the console codec: under cp1252 a PNG's leading 0x89 comes back
|
||||
as 0xe2 0x80 0xb0 and the magic number is gone before we look. That the
|
||||
decode is UTF-8 today is only true while _configure_stdio() succeeds, and
|
||||
its reconfigure() is deliberately best-effort.
|
||||
"""
|
||||
stream = getattr(sys.stdin, "buffer", None)
|
||||
if stream is None:
|
||||
# A replaced or non-binary stdin (pytest capture, custom harness).
|
||||
# Fall back to the text layer; the sniff is then codec-dependent.
|
||||
text = sys.stdin.read()
|
||||
if len(text.encode("utf-8", errors="surrogateescape")) > MAX_STDIN_BYTES:
|
||||
eprint(f"refusing stdin input larger than {MAX_STDIN_BYTES} bytes")
|
||||
raise SystemExit(2)
|
||||
guard_binary(
|
||||
text[:BINARY_SNIFF_BYTES].encode("utf-8", errors="surrogateescape"),
|
||||
"stdin",
|
||||
allow_binary=allow_binary,
|
||||
advice=advice,
|
||||
)
|
||||
return text
|
||||
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = sys.stdin.read(1 << 20)
|
||||
chunk = stream.read(1 << 20)
|
||||
if not chunk:
|
||||
break
|
||||
if not chunks:
|
||||
guard_binary(
|
||||
chunk[:BINARY_SNIFF_BYTES],
|
||||
"stdin",
|
||||
allow_binary=allow_binary,
|
||||
advice=advice,
|
||||
)
|
||||
total += len(chunk)
|
||||
if total > MAX_STDIN_BYTES:
|
||||
eprint(f"refusing stdin input larger than {MAX_STDIN_BYTES} bytes")
|
||||
raise SystemExit(2)
|
||||
chunks.append(chunk)
|
||||
return "".join(chunks)
|
||||
return b"".join(chunks).decode("utf-8", errors="surrogateescape")
|
||||
|
||||
|
||||
def write_text_output(text: str, path: str | None) -> None:
|
||||
|
||||
@@ -9,7 +9,14 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from common import MAX_INPUT_BYTES, classify_finding_confidence, emit_json, eprint, read_text_input # noqa: E402
|
||||
from common import ( # noqa: E402
|
||||
MAX_INPUT_BYTES,
|
||||
ROUTER_ADVICE,
|
||||
classify_finding_confidence,
|
||||
emit_json,
|
||||
eprint,
|
||||
read_text_input,
|
||||
)
|
||||
from container_meta import detect_container_format, inspect_container # noqa: E402
|
||||
from image_meta import detect_format as detect_image_format # noqa: E402
|
||||
from image_meta import inspect_image # noqa: E402
|
||||
@@ -49,6 +56,11 @@ def main() -> int:
|
||||
choices=("text", "image", "container", "auto"),
|
||||
default="auto",
|
||||
)
|
||||
p.add_argument(
|
||||
"--force-text",
|
||||
action="store_true",
|
||||
help="Scan as text even when the bytes look like a binary container",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
if not args.path.is_file():
|
||||
@@ -62,7 +74,11 @@ def main() -> int:
|
||||
kind = args.force_type if args.force_type != "auto" else classify(args.path)
|
||||
|
||||
if kind == "text":
|
||||
text = read_text_input(str(args.path))
|
||||
text = read_text_input(
|
||||
str(args.path),
|
||||
allow_binary=args.force_text,
|
||||
advice=ROUTER_ADVICE,
|
||||
)
|
||||
report = inspect_text(text, aggressive=args.aggressive)
|
||||
if args.json:
|
||||
emit_json({"kind": "text", **report.to_dict()})
|
||||
|
||||
@@ -28,9 +28,14 @@ def main() -> int:
|
||||
action="store_true",
|
||||
help="Flag emoji presentation selectors/ZWJ even after an emoji base (paranoid)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--force-text",
|
||||
action="store_true",
|
||||
help="Scan even when the input looks like a binary container",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
text = read_text_input(args.path)
|
||||
text = read_text_input(args.path, allow_binary=args.force_text)
|
||||
report = inspect_text(
|
||||
text,
|
||||
aggressive=args.aggressive,
|
||||
|
||||
@@ -382,9 +382,14 @@ def main() -> int:
|
||||
help="Skip Layer A scrub on model output",
|
||||
)
|
||||
p.add_argument("--json-stats", action="store_true", help="Stats JSON on stderr")
|
||||
p.add_argument(
|
||||
"--force-text",
|
||||
action="store_true",
|
||||
help="Rewrite even when the input looks like a binary container",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
text = read_text_input(args.path)
|
||||
text = read_text_input(args.path, allow_binary=args.force_text)
|
||||
allow_remote = (
|
||||
args.allow_remote
|
||||
if args.allow_remote is not None
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Tests for the binary-input guard on the text-only tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS = ROOT / "skills" / "remove-ai-marks" / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from common import guard_binary, looks_binary # noqa: E402
|
||||
|
||||
DOCX_XML = (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
|
||||
"<w:body><w:p><w:r><w:t>Table 1 holds the results.</w:t></w:r></w:p></w:body>"
|
||||
"</w:document>"
|
||||
)
|
||||
|
||||
|
||||
def make_docx(path: Path) -> Path:
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("[Content_Types].xml", "<Types/>")
|
||||
zf.writestr("word/document.xml", DOCX_XML)
|
||||
return path
|
||||
|
||||
|
||||
def run(script: str, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPTS / script), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
|
||||
# --- looks_binary ----------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data,expected_fragment",
|
||||
[
|
||||
(b"PK\x03\x04rest", "ZIP"),
|
||||
(b"%PDF-1.7\n", "PDF"),
|
||||
(b"\x89PNG\r\n\x1a\nrest", "PNG"),
|
||||
(b"\xff\xd8\xff\xe0rest", "JPEG"),
|
||||
(b"\x7fELF\x02\x01", "ELF"),
|
||||
(b"SQLite format 3\x00", "SQLite"),
|
||||
(b"plain text\x00with a nul", "NUL"),
|
||||
],
|
||||
)
|
||||
def test_flags_binary(data, expected_fragment):
|
||||
kind = looks_binary(data)
|
||||
assert kind is not None
|
||||
assert expected_fragment.lower() in kind.lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data",
|
||||
[
|
||||
b"",
|
||||
b"Just some prose.\n",
|
||||
b"# Markdown\n\n- bullet\n",
|
||||
"Accented prose: naïve café résumé\n".encode("utf-8"),
|
||||
"Zero width and nbsp here\n".encode("utf-8"),
|
||||
b"Latin-1 bytes: caf\xe9 na\xefve\n", # not UTF-8, still text
|
||||
b"a\tb\r\nc\x0cd\x1b[0m\n", # tabs, CRLF, form feed, ANSI escape
|
||||
],
|
||||
)
|
||||
def test_allows_text(data):
|
||||
assert looks_binary(data) is None
|
||||
|
||||
|
||||
def test_compressed_bytes_are_flagged(tmp_path):
|
||||
data = make_docx(tmp_path / "x.docx").read_bytes()
|
||||
assert looks_binary(data) is not None
|
||||
|
||||
|
||||
def test_guard_binary_can_be_overridden():
|
||||
guard_binary(b"PK\x03\x04", "x.docx", allow_binary=True) # must not raise
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
guard_binary(b"PK\x03\x04", "x.docx")
|
||||
assert exc.value.code == 2
|
||||
|
||||
|
||||
# --- CLI behaviour ---------------------------------------------------------
|
||||
|
||||
def test_inspect_text_refuses_docx(tmp_path):
|
||||
docx = make_docx(tmp_path / "doc.docx")
|
||||
r = run("inspect_text.py", str(docx))
|
||||
assert r.returncode == 2
|
||||
assert "looks like" in r.stderr
|
||||
assert "inspect_file.py" in r.stderr
|
||||
assert "Suspicious:" not in r.stdout
|
||||
|
||||
|
||||
def test_inspect_text_force_text_still_works(tmp_path):
|
||||
docx = make_docx(tmp_path / "doc.docx")
|
||||
r = run("inspect_text.py", str(docx), "--force-text")
|
||||
assert r.returncode in (0, 1)
|
||||
assert "Length:" in r.stdout
|
||||
|
||||
|
||||
def test_clean_text_refuses_docx_and_writes_nothing(tmp_path):
|
||||
docx = make_docx(tmp_path / "doc.docx")
|
||||
before = docx.read_bytes()
|
||||
out = tmp_path / "doc.cleaned.docx"
|
||||
r = run("clean_text.py", str(docx), "-o", str(out))
|
||||
assert r.returncode == 2
|
||||
assert not out.exists()
|
||||
assert docx.read_bytes() == before
|
||||
|
||||
|
||||
def test_clean_text_in_place_leaves_docx_intact(tmp_path):
|
||||
docx = make_docx(tmp_path / "doc.docx")
|
||||
before = docx.read_bytes()
|
||||
r = run("clean_text.py", str(docx), "--in-place")
|
||||
assert r.returncode == 2
|
||||
assert docx.read_bytes() == before
|
||||
assert not (tmp_path / "doc.docx.bak").exists()
|
||||
|
||||
|
||||
def test_clean_file_still_routes_docx_to_container(tmp_path):
|
||||
docx = make_docx(tmp_path / "doc.docx")
|
||||
out = tmp_path / "out.docx"
|
||||
r = run("clean_file.py", str(docx), "-o", str(out), "--json")
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert out.exists()
|
||||
with zipfile.ZipFile(out) as zf:
|
||||
assert zf.testzip() is None
|
||||
assert "word/document.xml" in zf.namelist()
|
||||
|
||||
|
||||
def test_clean_file_refuses_unknown_binary(tmp_path):
|
||||
blob = tmp_path / "mystery.bin"
|
||||
blob.write_bytes(b"\x00\x01\x02\x03" * 64)
|
||||
out = tmp_path / "out.bin"
|
||||
r = run("clean_file.py", str(blob), "-o", str(out))
|
||||
assert r.returncode == 2
|
||||
assert not out.exists()
|
||||
|
||||
|
||||
def test_clean_file_in_place_refuses_before_taking_a_backup(tmp_path):
|
||||
"""The refusal must land before backup_path(), not after.
|
||||
|
||||
Sniffing after the backup left a .bak sidecar for a file the run never
|
||||
touches, which is exactly what clean_text.py avoids.
|
||||
"""
|
||||
blob = tmp_path / "mystery.bin"
|
||||
blob.write_bytes(b"\x00\x01\x02\x03" * 64)
|
||||
before = blob.read_bytes()
|
||||
r = run("clean_file.py", str(blob), "--in-place")
|
||||
assert r.returncode == 2
|
||||
assert blob.read_bytes() == before
|
||||
assert not (tmp_path / "mystery.bin.bak").exists()
|
||||
assert list(tmp_path.iterdir()) == [blob]
|
||||
|
||||
|
||||
def test_clean_file_in_place_as_text_on_docx_leaves_no_backup(tmp_path):
|
||||
"""--as text bypasses classify(), so the guard is the only thing left."""
|
||||
docx = make_docx(tmp_path / "doc.docx")
|
||||
before = docx.read_bytes()
|
||||
r = run("clean_file.py", str(docx), "--in-place", "--as", "text")
|
||||
assert r.returncode == 2
|
||||
assert docx.read_bytes() == before
|
||||
assert not (tmp_path / "doc.docx.bak").exists()
|
||||
|
||||
|
||||
def test_router_advice_is_not_circular(tmp_path):
|
||||
"""clean_file.py / inspect_file.py must not point back at themselves."""
|
||||
blob = tmp_path / "mystery.bin"
|
||||
blob.write_bytes(b"\x00\x01\x02\x03" * 64)
|
||||
for script in ("clean_file.py", "inspect_file.py"):
|
||||
r = run(script, str(blob))
|
||||
assert r.returncode == 2, script
|
||||
assert "no supported text, image or container format" in r.stderr, script
|
||||
assert "Use inspect_file.py / clean_file.py" not in r.stderr, script
|
||||
assert "--force-text" in r.stderr, script
|
||||
|
||||
|
||||
def test_text_only_scripts_keep_the_pointer_to_the_routers(tmp_path):
|
||||
docx = make_docx(tmp_path / "doc.docx")
|
||||
r = run("clean_text.py", str(docx))
|
||||
assert r.returncode == 2
|
||||
assert "Use inspect_file.py / clean_file.py" in r.stderr
|
||||
|
||||
|
||||
def test_text_files_are_unaffected(tmp_path):
|
||||
src = tmp_path / "note.txt"
|
||||
src.write_text("Hiddenmark here.\n", encoding="utf-8")
|
||||
out = tmp_path / "note.cleaned.txt"
|
||||
r = run("clean_text.py", str(src), "-o", str(out))
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert out.read_text(encoding="utf-8") == "Hiddenmark here.\n"
|
||||
|
||||
|
||||
def test_stdin_binary_is_refused():
|
||||
docx = io.BytesIO()
|
||||
with zipfile.ZipFile(docx, "w") as zf:
|
||||
zf.writestr("word/document.xml", DOCX_XML)
|
||||
r = subprocess.run(
|
||||
[sys.executable, str(SCRIPTS / "inspect_text.py")],
|
||||
input=docx.getvalue(),
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert r.returncode == 2
|
||||
assert b"looks like" in r.stderr
|
||||
|
||||
|
||||
PNG_HEADER = b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + b"\x00" * 32
|
||||
JPEG_HEADER = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00" + b"A" * 64
|
||||
|
||||
|
||||
@pytest.mark.parametrize("data,label", [(PNG_HEADER, "PNG"), (JPEG_HEADER, "JPEG")])
|
||||
@pytest.mark.parametrize("io_encoding", [None, "cp1252", "latin-1"])
|
||||
def test_stdin_non_ascii_magic_is_refused_whatever_the_codec(data, label, io_encoding):
|
||||
"""The ZIP test alone could not catch this: 'PK' is ASCII.
|
||||
|
||||
PNG's 0x89 and JPEG's 0xff only survive to the sniff if stdin is read as
|
||||
bytes. Decoding first makes detection depend on the console codec.
|
||||
"""
|
||||
env = dict(os.environ)
|
||||
if io_encoding is None:
|
||||
env.pop("PYTHONIOENCODING", None)
|
||||
else:
|
||||
env["PYTHONIOENCODING"] = io_encoding
|
||||
r = subprocess.run(
|
||||
[sys.executable, str(SCRIPTS / "inspect_text.py")],
|
||||
input=data,
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
env=env,
|
||||
)
|
||||
assert r.returncode == 2, (label, io_encoding, r.stderr)
|
||||
assert label.encode() in r.stderr, (label, io_encoding, r.stderr)
|
||||
|
||||
|
||||
def test_stdin_text_still_flows_through():
|
||||
r = subprocess.run(
|
||||
[sys.executable, str(SCRIPTS / "clean_text.py")],
|
||||
input="plaintext\n".encode("utf-8"),
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert r.stdout.replace(b"\r\n", b"\n") == b"plaintext\n"
|
||||
Reference in New Issue
Block a user