scrubproof 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- metascrub/__init__.py +30 -0
- metascrub/__main__.py +5 -0
- metascrub/capabilities.py +438 -0
- metascrub/cli.py +386 -0
- metascrub/engines/__init__.py +51 -0
- metascrub/engines/av_engine.py +219 -0
- metascrub/engines/base.py +98 -0
- metascrub/engines/exiftool_engine.py +194 -0
- metascrub/engines/odf_engine.py +438 -0
- metascrub/engines/ole2_engine.py +408 -0
- metascrub/engines/ooxml_engine.py +199 -0
- metascrub/engines/pdf_engine.py +135 -0
- metascrub/engines/svg_engine.py +444 -0
- metascrub/exif_io.py +309 -0
- metascrub/gui.py +719 -0
- metascrub/scrubber.py +450 -0
- metascrub/selftest.py +179 -0
- metascrub/theme.py +161 -0
- metascrub/verify.py +463 -0
- scrubproof-1.0.0.dist-info/METADATA +844 -0
- scrubproof-1.0.0.dist-info/RECORD +25 -0
- scrubproof-1.0.0.dist-info/WHEEL +5 -0
- scrubproof-1.0.0.dist-info/entry_points.txt +6 -0
- scrubproof-1.0.0.dist-info/licenses/LICENSE +201 -0
- scrubproof-1.0.0.dist-info/top_level.txt +1 -0
metascrub/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
metascrub: remove metadata from documents, images, audio and video, and prove
|
|
3
|
+
the removal actually happened.
|
|
4
|
+
|
|
5
|
+
Built on the ExifSanitizer from the parent project (cli/sanitizer.py), extended
|
|
6
|
+
from eight image extensions to five format families across four engines, with
|
|
7
|
+
verification that does not trust the engine that performed the write.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .capabilities import (
|
|
11
|
+
CAPABILITIES, DEFERRED, Completeness, Container, Engine, FormatSpec,
|
|
12
|
+
deferral_for, is_supported, spec_for, supported_extensions,
|
|
13
|
+
)
|
|
14
|
+
from .scrubber import (
|
|
15
|
+
ExifSanitizer, MetadataScrubber,
|
|
16
|
+
STATUS_CLEAN, STATUS_DEFERRED, STATUS_ERROR, STATUS_SANITIZED, STATUS_UNSUPPORTED,
|
|
17
|
+
)
|
|
18
|
+
from .verify import Verdict, Verification
|
|
19
|
+
|
|
20
|
+
__version__ = "1.0.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"MetadataScrubber", "ExifSanitizer",
|
|
24
|
+
"CAPABILITIES", "DEFERRED", "Completeness", "Container", "Engine", "FormatSpec",
|
|
25
|
+
"spec_for", "is_supported", "deferral_for", "supported_extensions",
|
|
26
|
+
"Verdict", "Verification",
|
|
27
|
+
"STATUS_SANITIZED", "STATUS_CLEAN", "STATUS_UNSUPPORTED",
|
|
28
|
+
"STATUS_DEFERRED", "STATUS_ERROR",
|
|
29
|
+
"__version__",
|
|
30
|
+
]
|
metascrub/__main__.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Format capability table.
|
|
3
|
+
|
|
4
|
+
This replaces the boolean `_is_supported_file()` gate inherited from the the parent project
|
|
5
|
+
sanitizer (cli/sanitizer.py:326), which answered a single yes/no for a set of
|
|
6
|
+
eight image extensions. A boolean was adequate while exiftool handled every
|
|
7
|
+
supported format. It stops being adequate the moment PDF, OOXML, OLE2 and video
|
|
8
|
+
enter, because those need different engines and carry different guarantees.
|
|
9
|
+
|
|
10
|
+
"Supported" and "completely scrubbable" are two different states and must never
|
|
11
|
+
share one representation. They are separate fields here for that reason.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import Dict, Optional
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Engine(str, Enum):
|
|
23
|
+
"""The tool that actually performs the removal for a given format."""
|
|
24
|
+
|
|
25
|
+
EXIFTOOL = "exiftool" # exiftool -all=, in-place, safe for these containers
|
|
26
|
+
PDF = "pdf" # pikepdf full rewrite; exiftool is NOT safe here
|
|
27
|
+
OOXML = "ooxml" # zip container rewrite (docx/xlsx/pptx)
|
|
28
|
+
ODF = "odf" # zip container rewrite (odt/ods/odp/odg + templates)
|
|
29
|
+
OLE2 = "ole2" # legacy compound-file property streams
|
|
30
|
+
AV = "av" # ffmpeg remux, stream copy
|
|
31
|
+
SVG = "svg" # XML text rewrite; exiftool cannot write SVG at all
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Completeness(str, Enum):
|
|
35
|
+
"""
|
|
36
|
+
How much of the file's metadata the engine can actually remove.
|
|
37
|
+
|
|
38
|
+
COMPLETE means: after a successful run there is no known metadata carrier of
|
|
39
|
+
this format left behind, and the residual scan is expected to come back
|
|
40
|
+
clean.
|
|
41
|
+
|
|
42
|
+
PARTIAL means: the engine removes the standard carriers but known residue
|
|
43
|
+
can survive (for example application-specific records this tool does not
|
|
44
|
+
parse). A PARTIAL result must be reported as PARTIAL. A silent partial is
|
|
45
|
+
worse than a refusal, because the user acts on the belief the file is clean.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
COMPLETE = "complete"
|
|
49
|
+
PARTIAL = "partial"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Container(str, Enum):
|
|
53
|
+
"""
|
|
54
|
+
How the bytes are packed. This drives the residual scan strategy: a value
|
|
55
|
+
sitting inside a deflated zip member will not appear in the raw file bytes,
|
|
56
|
+
so scanning the raw bytes of a .docx would produce a false "clean".
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
RAW = "raw" # metadata is findable in the file bytes as-is
|
|
60
|
+
ZIP = "zip" # must inflate members before scanning
|
|
61
|
+
PDF = "pdf" # raw, but streams may be compressed
|
|
62
|
+
OLE2 = "ole2" # raw; property streams are uncompressed
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class FormatSpec:
|
|
67
|
+
"""One row of the capability table."""
|
|
68
|
+
|
|
69
|
+
engine: Engine
|
|
70
|
+
completeness: Completeness
|
|
71
|
+
container: Container
|
|
72
|
+
rewrites_container: bool # True when output is a rebuilt file, not patched in place
|
|
73
|
+
note: str = ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _exif(note: str = "") -> FormatSpec:
|
|
77
|
+
"""Formats where exiftool -all= is a genuine, in-place, complete removal."""
|
|
78
|
+
return FormatSpec(Engine.EXIFTOOL, Completeness.COMPLETE, Container.RAW, False, note)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# TIER 1: exiftool is sufficient and complete.
|
|
82
|
+
_IMAGE: Dict[str, FormatSpec] = {
|
|
83
|
+
".jpg": _exif(), ".jpeg": _exif(), ".jpe": _exif(),
|
|
84
|
+
# TIFF is structurally EXIF: exiftool answers "Can't delete IFD0 from TIFF"
|
|
85
|
+
# and `-all=` leaves Artist and Copyright in place. The exiftool engine
|
|
86
|
+
# follows up with a targeted sweep for these, which does remove them.
|
|
87
|
+
".tif": _exif("IFD0 cannot be dropped wholesale; identity tags swept individually"),
|
|
88
|
+
".tiff": _exif("IFD0 cannot be dropped wholesale; identity tags swept individually"),
|
|
89
|
+
".png": _exif("also drops iTXt/tEXt/zTXt text chunks"),
|
|
90
|
+
".heic": _exif(), ".heif": _exif(), ".avif": _exif(),
|
|
91
|
+
".webp": _exif("EXIF, XMP and ICC chunks"),
|
|
92
|
+
".gif": _exif("comment blocks, XMP and application extension blocks"),
|
|
93
|
+
".jp2": _exif(), ".psd": _exif(),
|
|
94
|
+
# .bmp is deliberately absent. Measured 2026-09-04: exiftool 13.29 answers
|
|
95
|
+
# "Writing of BMP files is not yet supported" and exits 1. Listing a format
|
|
96
|
+
# this tool cannot actually write would be a promise it cannot keep.
|
|
97
|
+
# Raw camera formats. exiftool edits these safely, but a raw file is a
|
|
98
|
+
# container of maker-specific records; treat as PARTIAL rather than claim
|
|
99
|
+
# more than can be verified.
|
|
100
|
+
".dng": FormatSpec(Engine.EXIFTOOL, Completeness.PARTIAL, Container.RAW, False,
|
|
101
|
+
"raw container; maker notes may retain private records"),
|
|
102
|
+
".cr2": FormatSpec(Engine.EXIFTOOL, Completeness.PARTIAL, Container.RAW, False,
|
|
103
|
+
"raw container; maker notes may retain private records"),
|
|
104
|
+
".nef": FormatSpec(Engine.EXIFTOOL, Completeness.PARTIAL, Container.RAW, False,
|
|
105
|
+
"raw container; maker notes may retain private records"),
|
|
106
|
+
".arw": FormatSpec(Engine.EXIFTOOL, Completeness.PARTIAL, Container.RAW, False,
|
|
107
|
+
"raw container; maker notes may retain private records"),
|
|
108
|
+
".orf": FormatSpec(Engine.EXIFTOOL, Completeness.PARTIAL, Container.RAW, False,
|
|
109
|
+
"raw container; maker notes may retain private records"),
|
|
110
|
+
".rw2": FormatSpec(Engine.EXIFTOOL, Completeness.PARTIAL, Container.RAW, False,
|
|
111
|
+
"raw container; maker notes may retain private records"),
|
|
112
|
+
|
|
113
|
+
# Added 2026-09-04. Each one was measured, not assumed: a TIFF was written
|
|
114
|
+
# under the extension, exiftool identified it as that specific FileType
|
|
115
|
+
# (not as TIFF) and accepted a write, and the full metascrub pipeline then
|
|
116
|
+
# removed the sentinel from the output bytes with the file still opening.
|
|
117
|
+
#
|
|
118
|
+
# Formats deliberately NOT added, with the measured reason:
|
|
119
|
+
# .cr3 .raf .x3f .crw .mrw .cs1 .psb exiftool reports FileType TIFF for a
|
|
120
|
+
# file with that extension and refuses to write it as the target
|
|
121
|
+
# format. These containers are not TIFF-based, so no fixture can be
|
|
122
|
+
# built without genuine camera samples, and the .tiff proxy used by
|
|
123
|
+
# the coverage gate would be a false claim rather than a shortcut.
|
|
124
|
+
# .3fr .fff exiftool identifies them correctly but will not write one
|
|
125
|
+
# that was synthesised rather than produced by a camera.
|
|
126
|
+
".pef": FormatSpec(Engine.EXIFTOOL, Completeness.PARTIAL, Container.RAW, False,
|
|
127
|
+
"raw container; maker notes may retain private records"),
|
|
128
|
+
".srw": FormatSpec(Engine.EXIFTOOL, Completeness.PARTIAL, Container.RAW, False,
|
|
129
|
+
"raw container; maker notes may retain private records"),
|
|
130
|
+
".erf": FormatSpec(Engine.EXIFTOOL, Completeness.PARTIAL, Container.RAW, False,
|
|
131
|
+
"raw container; maker notes may retain private records"),
|
|
132
|
+
".mos": FormatSpec(Engine.EXIFTOOL, Completeness.PARTIAL, Container.RAW, False,
|
|
133
|
+
"raw container; maker notes may retain private records"),
|
|
134
|
+
".iiq": FormatSpec(Engine.EXIFTOOL, Completeness.PARTIAL, Container.RAW, False,
|
|
135
|
+
"raw container; maker notes may retain private records"),
|
|
136
|
+
".arq": FormatSpec(Engine.EXIFTOOL, Completeness.PARTIAL, Container.RAW, False,
|
|
137
|
+
"raw container; maker notes may retain private records"),
|
|
138
|
+
".sr2": FormatSpec(Engine.EXIFTOOL, Completeness.PARTIAL, Container.RAW, False,
|
|
139
|
+
"raw container; maker notes may retain private records"),
|
|
140
|
+
".rwl": FormatSpec(Engine.EXIFTOOL, Completeness.PARTIAL, Container.RAW, False,
|
|
141
|
+
"raw container; maker notes may retain private records"),
|
|
142
|
+
".nrw": FormatSpec(Engine.EXIFTOOL, Completeness.PARTIAL, Container.RAW, False,
|
|
143
|
+
"raw container; maker notes may retain private records"),
|
|
144
|
+
".raw": FormatSpec(Engine.EXIFTOOL, Completeness.PARTIAL, Container.RAW, False,
|
|
145
|
+
"raw container; maker notes may retain private records"),
|
|
146
|
+
".gpr": FormatSpec(Engine.EXIFTOOL, Completeness.PARTIAL, Container.RAW, False,
|
|
147
|
+
"raw container; maker notes may retain private records"),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# TIER 2: PDF. exiftool is NOT used here and must not be.
|
|
151
|
+
#
|
|
152
|
+
# Measured 2026-09-04 with exiftool 13.29: `exiftool -all=` on a PDF performs an
|
|
153
|
+
# incremental update. It appends the change and leaves the previous metadata in
|
|
154
|
+
# the file. The test document grew from 1519 to 1846 bytes and the original
|
|
155
|
+
# author and title strings were still present in the raw bytes twice each, while
|
|
156
|
+
# `exiftool -Author -Title` reported them absent. exiftool itself warns:
|
|
157
|
+
# "ExifTool PDF edits are reversible. Deleted tags may be recovered!"
|
|
158
|
+
#
|
|
159
|
+
# A pikepdf rewrite of the same document produced zero residual hits and shrank
|
|
160
|
+
# the file to 1025 bytes.
|
|
161
|
+
_PDF: Dict[str, FormatSpec] = {
|
|
162
|
+
".pdf": FormatSpec(Engine.PDF, Completeness.COMPLETE, Container.PDF, True,
|
|
163
|
+
"full rewrite; exiftool would leave recoverable residue"),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
# TIER 3: OOXML. exiftool will not rewrite inside the zip container.
|
|
167
|
+
_OOXML_NOTE = ("docProps core/app/custom, lastModifiedBy, revision, edit time, "
|
|
168
|
+
"template path, and w:rsid revision-save identifiers")
|
|
169
|
+
_OOXML: Dict[str, FormatSpec] = {
|
|
170
|
+
ext: FormatSpec(Engine.OOXML, Completeness.COMPLETE, Container.ZIP, True, _OOXML_NOTE)
|
|
171
|
+
for ext in (".docx", ".docm", ".xlsx", ".xlsm", ".pptx", ".pptm")
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# ODF BLOCK BEGINS. Kept together so it can be moved or merged in one piece.
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
#
|
|
178
|
+
# TIER 3B: OpenDocument. Also a zip container, but NOT the OOXML engine.
|
|
179
|
+
#
|
|
180
|
+
# exiftool reads every one of these and writes none of them. Measured 2026-09-04
|
|
181
|
+
# with exiftool 13.29 on LibreOffice-produced files:
|
|
182
|
+
# .odt/.ods/.odp/.odg "Writing of ODT files is not yet supported", exit 1
|
|
183
|
+
# .ott "Writing of this type of file is not supported", exit 1
|
|
184
|
+
# (.ott differs because exiftool identifies it as FileType ZIP; it still reads
|
|
185
|
+
# the metadata, which is all the baseline read needs.)
|
|
186
|
+
#
|
|
187
|
+
# COMPLETE, and the note says what that covers. The claim rests on measurement,
|
|
188
|
+
# not on the engine reporting success: for every extension below a fixture
|
|
189
|
+
# seeded a sentinel into meta.xml, settings.xml and the package RDF, and
|
|
190
|
+
# tests/test_odf.py searches the INFLATED output members for each sentinel
|
|
191
|
+
# afterwards, including the base64 spelling of the printer blob.
|
|
192
|
+
#
|
|
193
|
+
# settings.xml is the carrier that justifies a dedicated engine. Measured on a
|
|
194
|
+
# LibreOffice .odt built on this machine: PrinterName held the workstation's
|
|
195
|
+
# default printer name and PrinterSetup held 11316 base64 characters decoding to
|
|
196
|
+
# 8487 bytes of Windows DEVMODE carrying that printer name twice in ASCII, once
|
|
197
|
+
# in UTF-16LE, and the driver string "<printer model redacted>". Nothing else in
|
|
198
|
+
# this tool looks at settings.xml, and exiftool does not report it at all.
|
|
199
|
+
#
|
|
200
|
+
# Formats deliberately NOT added, with the measured reason:
|
|
201
|
+
# .fodt .fods .fodp flat XML ODF, a single uncompressed XML file rather than
|
|
202
|
+
# a package. Measured: exiftool sees .fodt as FileType XML, reads Title
|
|
203
|
+
# and Creator, and refuses to write it with "[minor] Can't handle XMP
|
|
204
|
+
# attribute 'office:mimetype'". They need a Container.RAW engine that
|
|
205
|
+
# edits XML in place, which is a different engine and not a table row.
|
|
206
|
+
# Recorded in DEFERRED below rather than left silent.
|
|
207
|
+
_ODF_NOTE = ("meta.xml replaced with an empty skeleton; printer name and "
|
|
208
|
+
"printer driver blob, revision-save identifiers, thumbnails and "
|
|
209
|
+
"package RDF removed; annotations and tracked changes are "
|
|
210
|
+
"reported, not removed")
|
|
211
|
+
_ODF: Dict[str, FormatSpec] = {
|
|
212
|
+
ext: FormatSpec(Engine.ODF, Completeness.COMPLETE, Container.ZIP, True, _ODF_NOTE)
|
|
213
|
+
for ext in (".odt", ".ott", ".ods", ".ots", ".odp", ".otp", ".odg", ".otg")
|
|
214
|
+
}
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# ODF BLOCK ENDS.
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
# TIER 4: legacy OLE2 compound files. Deferred until 2026-09-04, now shipped.
|
|
220
|
+
#
|
|
221
|
+
# .doc, .xls and .ppt keep their metadata in the \005SummaryInformation and
|
|
222
|
+
# \005DocumentSummaryInformation property streams of an OLE2 compound file, and
|
|
223
|
+
# olefile can only overwrite a stream in place at its existing length. The
|
|
224
|
+
# deferral was never about that constraint; it was about not being able to build
|
|
225
|
+
# a fixture, and therefore not being able to test a rewrite path for documents a
|
|
226
|
+
# user cannot regenerate.
|
|
227
|
+
#
|
|
228
|
+
# The discharge condition was: a real fixture set under tests/fixtures/ole2/ and
|
|
229
|
+
# tests/test_ole2.py passing against it. Both now exist. LibreOffice's MS Word
|
|
230
|
+
# 97 / MS Excel 97 / MS PowerPoint 97 export filters produce genuine compound
|
|
231
|
+
# files (magic d0cf11e0a1b11ae1) carrying seeded metadata, so the fixture is
|
|
232
|
+
# generated at test time and the tests skip, never fail, where LibreOffice is
|
|
233
|
+
# absent. tests/test_coverage_gate.py still enforces the condition.
|
|
234
|
+
#
|
|
235
|
+
# PARTIAL, not COMPLETE, and the note says exactly what survives. Measured with
|
|
236
|
+
# exiftool 13.59 on a scrubbed .doc: the [MS-DOC] group still reports CreateDate,
|
|
237
|
+
# ModifyDate, RevisionNumber, TotalEditTime and the document statistics, because
|
|
238
|
+
# the DOP inside the WordDocument/Table streams keeps its own copy of them. The
|
|
239
|
+
# give-away that it is a second carrier rather than a leftover is that the
|
|
240
|
+
# surviving CreateDate is shifted by the local UTC offset: it is a local-time
|
|
241
|
+
# field in the DOP, not the FILETIME in the property set.
|
|
242
|
+
# The notes are per extension rather than shared, because the carriers differ.
|
|
243
|
+
# A .doc row promising that Excel's user name was removed would be describing a
|
|
244
|
+
# structure the file does not have, and a reader cannot tell an irrelevant
|
|
245
|
+
# promise from a kept one.
|
|
246
|
+
_OLE2_COMMON = ("summary and document-summary property sets and the CompObj "
|
|
247
|
+
"application user type are removed")
|
|
248
|
+
_OLE2: Dict[str, FormatSpec] = {
|
|
249
|
+
".doc": FormatSpec(
|
|
250
|
+
Engine.OLE2, Completeness.PARTIAL, Container.OLE2, True,
|
|
251
|
+
_OLE2_COMMON + "; the DOP copy of the timestamps, edit time, revision "
|
|
252
|
+
"count and document statistics survives in the WordDocument/Table "
|
|
253
|
+
"streams, as do SttbfAssoc and SttbSavedBy on documents written by "
|
|
254
|
+
"Microsoft Word"),
|
|
255
|
+
".xls": FormatSpec(
|
|
256
|
+
Engine.OLE2, Completeness.PARTIAL, Container.OLE2, True,
|
|
257
|
+
_OLE2_COMMON + ", as is the WRITEACCESS user name in the Workbook "
|
|
258
|
+
"globals; other BIFF records this engine does not parse survive"),
|
|
259
|
+
".ppt": FormatSpec(
|
|
260
|
+
Engine.OLE2, Completeness.PARTIAL, Container.OLE2, True,
|
|
261
|
+
_OLE2_COMMON + ", as is the last-editor name in the Current User "
|
|
262
|
+
"stream; per-edit user records in the PowerPoint Document stream "
|
|
263
|
+
"survive"),
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
# A deferral is an obligation with a due date, and tests/test_coverage_gate.py
|
|
267
|
+
# is what collects it rather than anybody's memory. Prose in the README and in
|
|
268
|
+
# the README is NOT the mechanism: an entry that is not in this dict is
|
|
269
|
+
# collected by nothing, because every gate that reads deferrals iterates this
|
|
270
|
+
# table and a loop over an empty container is the quietest possible pass.
|
|
271
|
+
# Every entry must name the measured reason and the condition that discharges
|
|
272
|
+
# it.
|
|
273
|
+
#
|
|
274
|
+
# `.qt`, `.mqv`, `.lrv` and `.f4a` were deferred here until the
|
|
275
|
+
# flavour-preserving muxer selection shipped on 2026-09-04 and each of the four
|
|
276
|
+
# passed the sentinel byte search; they are in the AV tier below now.
|
|
277
|
+
#
|
|
278
|
+
# The gates in tests/test_coverage_gate.py do NOT lose their teeth when this
|
|
279
|
+
# table happens to be empty. They are written as functions over a table and are
|
|
280
|
+
# exercised against synthetic tables, so the mechanism is proven whether or not
|
|
281
|
+
# anything is deferred today. That was the 2026-09-04 audit finding: an empty
|
|
282
|
+
# table used to make three gates pass having asserted nothing at all.
|
|
283
|
+
DEFERRED: Dict[str, str] = {
|
|
284
|
+
# --- ODF BLOCK BEGINS ---
|
|
285
|
+
# Flat XML ODF: one uncompressed XML file rather than a zip package, so the
|
|
286
|
+
# ODF engine's whole strategy (rebuild the package, hoist mimetype, replace
|
|
287
|
+
# meta.xml) does not apply. Measured 2026-09-04 with exiftool 13.29: a
|
|
288
|
+
# .fodt reports FileType XML, reads Title and Creator correctly, and
|
|
289
|
+
# refuses to write with "[minor] Can't handle XMP attribute
|
|
290
|
+
# 'office:mimetype'". Discharge condition: a Container.RAW engine that
|
|
291
|
+
# edits the office:document XML in place, plus a fixture per extension and
|
|
292
|
+
# a tests/test_flat_odf.py that searches the output bytes.
|
|
293
|
+
".fodt": ("flat XML OpenDocument, not a zip package; exiftool reads it as "
|
|
294
|
+
"XML and refuses to write it. Needs a raw-XML engine, not a "
|
|
295
|
+
"table row. Discharged by a Container.RAW engine plus fixtures "
|
|
296
|
+
"and tests/test_flat_odf.py"),
|
|
297
|
+
".fods": ("flat XML OpenDocument, not a zip package; exiftool reads it as "
|
|
298
|
+
"XML and refuses to write it. Needs a raw-XML engine, not a "
|
|
299
|
+
"table row. Discharged by a Container.RAW engine plus fixtures "
|
|
300
|
+
"and tests/test_flat_odf.py"),
|
|
301
|
+
".fodp": ("flat XML OpenDocument, not a zip package; exiftool reads it as "
|
|
302
|
+
"XML and refuses to write it. Needs a raw-XML engine, not a "
|
|
303
|
+
"table row. Discharged by a Container.RAW engine plus fixtures "
|
|
304
|
+
"and tests/test_flat_odf.py"),
|
|
305
|
+
# --- ODF BLOCK ENDS ---
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
# TIER 5: audio and video. ffmpeg remux, not exiftool.
|
|
309
|
+
_AV_NOTE = "container, per-stream and chapter metadata; remux without re-encoding"
|
|
310
|
+
#
|
|
311
|
+
# The 2026-09-04 additions (.f4v .m4b .ts .m2ts .aiff .aif) were each measured
|
|
312
|
+
# end to end: ffmpeg muxed a fixture that stored the tag, the pipeline removed
|
|
313
|
+
# the sentinel from the output bytes, and ffprobe still parsed the result.
|
|
314
|
+
#
|
|
315
|
+
# Containers deliberately NOT added, with the measured reason:
|
|
316
|
+
# .wmv .wma the remux leaves a value behind and verification reports
|
|
317
|
+
# residual_found. ASF is not cleanable by this engine, so
|
|
318
|
+
# listing it would be a promise the tool cannot keep.
|
|
319
|
+
# .aac .ac3 raw bitstreams with no metadata container; ffmpeg accepts the
|
|
320
|
+
# -metadata flag and stores nothing, so there is no fixture and
|
|
321
|
+
# nothing to remove.
|
|
322
|
+
# .3gp .mpg .amr no fixture could be built that actually stored the tag with
|
|
323
|
+
# the codecs available here.
|
|
324
|
+
# .qt .mqv .lrv .f4a DEFERRED rather than refused; see the DEFERRED table
|
|
325
|
+
# above, which is what the coverage gates actually read.
|
|
326
|
+
_AV: Dict[str, FormatSpec] = {
|
|
327
|
+
ext: FormatSpec(Engine.AV, Completeness.COMPLETE, Container.RAW, True, _AV_NOTE)
|
|
328
|
+
for ext in (".mp4", ".m4v", ".mov", ".mkv", ".webm", ".avi",
|
|
329
|
+
".m4a", ".mp3", ".flac", ".wav", ".ogg", ".opus",
|
|
330
|
+
".f4v", ".m4b", ".ts", ".m2ts", ".aiff", ".aif",
|
|
331
|
+
# Shipped 2026-09-04 with av_engine.MUXER_FOR_EXT. Each was
|
|
332
|
+
# measured end to end and separately: a fixture that really
|
|
333
|
+
# stored the sentinel, the sentinel gone from the output bytes
|
|
334
|
+
# afterwards, ffprobe still parsing the result, and the ftyp
|
|
335
|
+
# brand unchanged, so the remux is not quietly converting the
|
|
336
|
+
# container to a different one.
|
|
337
|
+
".qt", ".mqv", ".lrv", ".f4a")
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
# TIER 6: SVG. Begin SVG block.
|
|
342
|
+
#
|
|
343
|
+
# exiftool cannot write SVG at all. Measured 2026-09-04 with exiftool 13.29:
|
|
344
|
+
# SVG is absent from `-listwf`, and `exiftool -all= -overwrite_original` answers
|
|
345
|
+
# "ExifTool does not yet support writing of SVG images" and exits 1. It READS
|
|
346
|
+
# SVG usefully, so the baseline and the verification still come from exiftool
|
|
347
|
+
# while svg_engine.py does the writing.
|
|
348
|
+
#
|
|
349
|
+
# PARTIAL, and the note says exactly what remains. Both survivors were measured
|
|
350
|
+
# rather than assumed, and both are invisible to the two checks this tool makes:
|
|
351
|
+
#
|
|
352
|
+
# base64-embedded rasters a 32x32 JPEG carrying Artist and GPSLatitude was
|
|
353
|
+
# base64ed into an <image> data: URI. exiftool reported only Xmlns,
|
|
354
|
+
# ImageWidth, ImageHeight and ViewBox on the wrapping SVG, so there was no
|
|
355
|
+
# needle, and the value is not findable in the raw bytes because it is
|
|
356
|
+
# base64-wrapped, so the residual scan cannot see it either. The file
|
|
357
|
+
# would report VERIFIED_CLEAN while carrying a photographer's name and
|
|
358
|
+
# coordinates. Removing it would delete the picture.
|
|
359
|
+
#
|
|
360
|
+
# external local references an xlink:href of file:///C:/Users/<name>/... is
|
|
361
|
+
# not read by exiftool and is not removed, because removing it deletes the
|
|
362
|
+
# image from the drawing. It is reported as a note instead.
|
|
363
|
+
#
|
|
364
|
+
# .svgz is deliberately absent. It is gzipped SVG, so Container.RAW scanning
|
|
365
|
+
# would be blind to every byte of it, and shipping a format whose residual scan
|
|
366
|
+
# silently cannot see anything is worse than not shipping it. It needs its own
|
|
367
|
+
# Container value so searchable_bytes() decompresses first, which is a verify.py
|
|
368
|
+
# change of the same shape as the existing ZIP branch.
|
|
369
|
+
_SVG_NOTE = ("XML comments, the <metadata> RDF block, editor state and "
|
|
370
|
+
"editor-private sodipodi/inkscape/ooo attributes are removed; "
|
|
371
|
+
"EXIF inside base64-embedded raster images, external local "
|
|
372
|
+
"file references, and -inkscape-* CSS properties inside style "
|
|
373
|
+
"attributes survive and are reported")
|
|
374
|
+
_SVG: Dict[str, FormatSpec] = {
|
|
375
|
+
".svg": FormatSpec(Engine.SVG, Completeness.PARTIAL, Container.RAW, True, _SVG_NOTE),
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
# .svgz is DEFERRED rather than merely absent, so a user is told why instead of
|
|
379
|
+
# getting the same flat "unsupported" a .txt file gets, and so the gates in
|
|
380
|
+
# tests/test_coverage_gate.py collect it. This project's rule is that a deferral
|
|
381
|
+
# is enforced by a test, not by memory.
|
|
382
|
+
#
|
|
383
|
+
# Written as an update rather than as an entry in the DEFERRED literal above so
|
|
384
|
+
# that the three engine branches in flight can each add their own deferrals
|
|
385
|
+
# without colliding on one dict.
|
|
386
|
+
DEFERRED.update({
|
|
387
|
+
".svgz": (
|
|
388
|
+
"gzipped SVG. The engine would be a one-line gzip wrapper, but "
|
|
389
|
+
"Container.RAW makes the residual scan search the compressed bytes, "
|
|
390
|
+
"where it can see nothing at all, so the format would verify clean "
|
|
391
|
+
"unconditionally. Discharge condition: a Container value whose "
|
|
392
|
+
"searchable_bytes() branch decompresses first, of the same shape as "
|
|
393
|
+
"the existing ZIP branch, plus a fixture whose sentinel is found "
|
|
394
|
+
"before the scrub and absent after."
|
|
395
|
+
),
|
|
396
|
+
})
|
|
397
|
+
# End SVG block.
|
|
398
|
+
# ---------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
CAPABILITIES: Dict[str, FormatSpec] = {}
|
|
401
|
+
for _table in (_IMAGE, _PDF, _OOXML, _ODF, _OLE2, _AV, _SVG):
|
|
402
|
+
CAPABILITIES.update(_table)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def deferral_for(path: str) -> Optional[str]:
|
|
406
|
+
"""
|
|
407
|
+
Return the reason a format is knowingly not handled yet, or None.
|
|
408
|
+
|
|
409
|
+
This exists so the CLI can tell a user "this format is deferred, here is
|
|
410
|
+
why" instead of the same flat "unsupported" it gives for a .txt file. Those
|
|
411
|
+
are different situations and a user acts differently on each.
|
|
412
|
+
"""
|
|
413
|
+
_, ext = os.path.splitext(path.lower())
|
|
414
|
+
return DEFERRED.get(ext)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def spec_for(path: str) -> Optional[FormatSpec]:
|
|
418
|
+
"""
|
|
419
|
+
Return the capability row for a path, or None when the format is not
|
|
420
|
+
handled. Callers must branch on None rather than on a truthiness test, so
|
|
421
|
+
that an unsupported format is never confused with a format that simply has
|
|
422
|
+
no metadata.
|
|
423
|
+
"""
|
|
424
|
+
_, ext = os.path.splitext(path.lower())
|
|
425
|
+
return CAPABILITIES.get(ext)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def is_supported(path: str) -> bool:
|
|
429
|
+
"""
|
|
430
|
+
Kept for drop-in compatibility with the the parent project ExifSanitizer API. Prefer
|
|
431
|
+
spec_for(), which tells you which engine will run and what it guarantees.
|
|
432
|
+
"""
|
|
433
|
+
return spec_for(path) is not None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def supported_extensions() -> list:
|
|
437
|
+
"""Sorted extension list, for CLI help and documentation."""
|
|
438
|
+
return sorted(CAPABILITIES)
|