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 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,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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)