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:
Aria
2026-08-13 13:35:00 -07:00
committed by GitHub
co-authored by Guillaume Meyer
parent e7e3b4ec90
commit 256d90d1b1
8 changed files with 481 additions and 15 deletions
+18
View File
@@ -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
+28 -2
View File
@@ -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,
+7 -1
View File
@@ -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,
+145 -8
View File
@@ -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:
+18 -2
View File
@@ -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
+253
View File
@@ -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"