puremagic 2.1.0__tar.gz → 2.2.0__tar.gz

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.
Files changed (148) hide show
  1. {puremagic-2.1.0 → puremagic-2.2.0}/CHANGELOG.md +17 -0
  2. {puremagic-2.1.0 → puremagic-2.2.0}/PKG-INFO +1 -1
  3. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic/main.py +25 -8
  4. puremagic-2.2.0/puremagic/scanners/asf_scanner.py +59 -0
  5. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic/scanners/cfbf_scanner.py +52 -3
  6. puremagic-2.2.0/puremagic/scanners/ebml_scanner.py +21 -0
  7. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic/scanners/hdf5_scanner.py +1 -1
  8. puremagic-2.2.0/puremagic/scanners/ogg_scanner.py +39 -0
  9. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic/scanners/text_scanner.py +4 -0
  10. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic/scanners/zip_scanner.py +147 -12
  11. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic.egg-info/PKG-INFO +1 -1
  12. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic.egg-info/SOURCES.txt +8 -0
  13. puremagic-2.2.0/test/resources/audio/test.oga +0 -0
  14. puremagic-2.2.0/test/resources/audio/test.ogg +0 -0
  15. puremagic-2.2.0/test/resources/audio/test.wma +0 -0
  16. puremagic-2.2.0/test/resources/video/test.ogv +0 -0
  17. puremagic-2.2.0/test/resources/video/test.webm +0 -0
  18. {puremagic-2.1.0 → puremagic-2.2.0}/test/test_common_extensions.py +0 -1
  19. puremagic-2.2.0/test/test_scanners.py +882 -0
  20. puremagic-2.2.0/uv.lock +882 -0
  21. puremagic-2.1.0/test/test_scanners.py +0 -118
  22. puremagic-2.1.0/uv.lock +0 -827
  23. {puremagic-2.1.0 → puremagic-2.2.0}/.claude/skills/changelog.md +0 -0
  24. {puremagic-2.1.0 → puremagic-2.2.0}/.coveragerc +0 -0
  25. {puremagic-2.1.0 → puremagic-2.2.0}/.github/workflows/pythonpublish.yml +0 -0
  26. {puremagic-2.1.0 → puremagic-2.2.0}/.github/workflows/tests.yml +0 -0
  27. {puremagic-2.1.0 → puremagic-2.2.0}/.gitignore +0 -0
  28. {puremagic-2.1.0 → puremagic-2.2.0}/.pre-commit-config.yaml +0 -0
  29. {puremagic-2.1.0 → puremagic-2.2.0}/AUTHORS.rst +0 -0
  30. {puremagic-2.1.0 → puremagic-2.2.0}/LICENSE +0 -0
  31. {puremagic-2.1.0 → puremagic-2.2.0}/MANIFEST.in +0 -0
  32. {puremagic-2.1.0 → puremagic-2.2.0}/README.rst +0 -0
  33. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic/__init__.py +0 -0
  34. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic/__main__.py +0 -0
  35. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic/magic_data.json +0 -0
  36. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic/py.typed +0 -0
  37. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic/scanners/__init__.py +0 -0
  38. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic/scanners/helpers.py +0 -0
  39. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic/scanners/json_scanner.py +0 -0
  40. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic/scanners/mpeg_audio_scanner.py +0 -0
  41. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic/scanners/pdf_scanner.py +0 -0
  42. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic/scanners/python_scanner.py +0 -0
  43. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic/scanners/sndhdr_scanner.py +0 -0
  44. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic.egg-info/dependency_links.txt +0 -0
  45. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic.egg-info/entry_points.txt +0 -0
  46. {puremagic-2.1.0 → puremagic-2.2.0}/puremagic.egg-info/top_level.txt +0 -0
  47. {puremagic-2.1.0 → puremagic-2.2.0}/pyproject.toml +0 -0
  48. {puremagic-2.1.0 → puremagic-2.2.0}/requirements-test.txt +0 -0
  49. {puremagic-2.1.0 → puremagic-2.2.0}/scripts/parse_ftk_kessler_sigs.py +0 -0
  50. {puremagic-2.1.0 → puremagic-2.2.0}/setup.cfg +0 -0
  51. {puremagic-2.1.0 → puremagic-2.2.0}/test/__init__.py +0 -0
  52. {puremagic-2.1.0 → puremagic-2.2.0}/test/common.py +0 -0
  53. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/archive/test.7z +0 -0
  54. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/archive/test.big_endian.pcapng +0 -0
  55. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/archive/test.bz2 +0 -0
  56. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/archive/test.cb7 +0 -0
  57. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/archive/test.cbt +0 -0
  58. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/archive/test.cbz +0 -0
  59. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/archive/test.fb2.zip +0 -0
  60. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/archive/test.fbz +0 -0
  61. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/archive/test.gz +0 -0
  62. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/archive/test.little_endian.pcapng +0 -0
  63. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/archive/test.pcapng +0 -0
  64. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/archive/test.rar +0 -0
  65. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/archive/test.tar +0 -0
  66. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/archive/test.txt.lz4 +0 -0
  67. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/archive/test.txt.zst +0 -0
  68. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/archive/test.xz +0 -0
  69. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/archive/test.zip +0 -0
  70. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/audio/test.aac +0 -0
  71. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/audio/test.aif +0 -0
  72. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/audio/test.flac +0 -0
  73. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/audio/test.mp3 +0 -0
  74. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/audio/test.opus +0 -0
  75. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/audio/test.sndr +0 -0
  76. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/audio/test.wav +0 -0
  77. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/audio/test_mp3_vbr_info_128k_notags.mp3 +0 -0
  78. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/audio/test_mp3_vbr_xing_128k_3di_id3v1.mp3 +0 -0
  79. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/audio/test_mp3_vbr_xing_128k_apev1.mp3 +0 -0
  80. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/audio/test_mp3_vbr_xing_128k_apev1_id3v1.mp3 +0 -0
  81. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/audio/test_mp3_vbr_xing_128k_apev2_tagplus_id3v1.mp3 +0 -0
  82. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/audio/test_mp3_vbr_xing_128k_ext_id3v1.mp3 +0 -0
  83. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/audio/test_mp3_vbr_xing_128k_lyrics3v2_id3v1.mp3 +0 -0
  84. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/audio/test_mp3_vbr_xing_128k_notags.mp3 +0 -0
  85. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/audio/test_mp3_vbr_xing_128k_tagplus_apev2_id3v1.mp3 +0 -0
  86. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/audio/test_mp3_vbr_xing_128k_tagplus_id3v1.mp3 +0 -0
  87. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/audio/test_mpeg2_mp3_VBR_128k_id3v2_24.mp3 +0 -0
  88. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/fake_file +0 -0
  89. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/images/test.avif +0 -0
  90. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/images/test.bmp +0 -0
  91. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/images/test.gif +0 -0
  92. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/images/test.ico +0 -0
  93. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/images/test.jpg +0 -0
  94. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/images/test.png +0 -0
  95. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/images/test.psd +0 -0
  96. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/images/test.svg +0 -0
  97. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/images/test.tga +0 -0
  98. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/images/test.tif +0 -0
  99. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/images/test.webp +0 -0
  100. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/images/test.xcf +0 -0
  101. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/images/test_varriant.svg +0 -0
  102. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/images/test_varriant_2.svg +0 -0
  103. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/media/test (single).vmdk +0 -0
  104. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/media/test (split).vmdk +0 -0
  105. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/media/test.iso +0 -0
  106. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/media/test.mdf +0 -0
  107. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/media/test.swf +0 -0
  108. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.doc +0 -0
  109. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.docm +0 -0
  110. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.docx +0 -0
  111. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.dotm +0 -0
  112. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.dotx +0 -0
  113. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.eml +0 -0
  114. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.fb2 +0 -0
  115. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.msg +0 -0
  116. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.odp +0 -0
  117. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.ods +0 -0
  118. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.odt +0 -0
  119. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.pdf +0 -0
  120. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.potm +0 -0
  121. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.potx +0 -0
  122. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.ppt +0 -0
  123. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.pptm +0 -0
  124. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.pptx +0 -0
  125. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.txt +0 -0
  126. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.xls +0 -0
  127. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.xlsb +0 -0
  128. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.xlsm +0 -0
  129. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.xlsx +0 -0
  130. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.xltm +0 -0
  131. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test.xltx +0 -0
  132. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/test_utf16le.csv +0 -0
  133. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/text_cr.txt +0 -0
  134. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/text_crlf.txt +0 -0
  135. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/office/text_lf.txt +0 -0
  136. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/system/test.exe +0 -0
  137. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/system/test.json +0 -0
  138. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/system/test.puremagic_multi_footer +0 -0
  139. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/system/test.py +0 -0
  140. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/system/test_list.json +0 -0
  141. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/video/test.3g2 +0 -0
  142. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/video/test.avi +0 -0
  143. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/video/test.flv +0 -0
  144. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/video/test.jxsv +0 -0
  145. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/video/test.mkv +0 -0
  146. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/video/test.mp4 +0 -0
  147. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/video/test.mpg +0 -0
  148. {puremagic-2.1.0 → puremagic-2.2.0}/test/resources/video/test.wmv +0 -0
@@ -1,6 +1,23 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
+ Version 2.2.0
5
+ -------------
6
+
7
+ - Adding Ogg scanner to distinguish Vorbis, Opus, Theora, FLAC, Speex, and OGM codecs
8
+ - Adding ASF scanner to distinguish WMV (video) from WMA (audio) files
9
+ - Adding EBML scanner to distinguish Matroska (.mkv) from WebM (.webm) files
10
+ - Adding MSI (Windows Installer) and MPP (Microsoft Project) detection to CFBF scanner
11
+ - Fixing #146 OOXML detection now uses `[Content_Types].xml` content types as primary method, correctly identifying docx/xlsx/pptx files from LibreOffice, Google Docs, and other non-Microsoft tools (thanks to jonasdeboeck79)
12
+ - Fixing ZIP deep scan now inspects all ZIP files instead of short-circuiting on .zip extension
13
+ - Fixing text scanner now treats files containing NUL bytes as binary data instead of misidentifying them as text
14
+ - Fixing mz5 HDF5 scanner typo in chromatogram dataset name
15
+
16
+ Version 2.1.1
17
+ -------------
18
+
19
+ - Fixing #141 deep scan no longer overrides valid binary format matches (e.g. JPEG) for files without extensions (thanks to marph91)
20
+
4
21
  Version 2.1.0
5
22
  -------------
6
23
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: puremagic
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Pure python implementation of magic file detection
5
5
  Author-email: Chris Griffith <chris@cdgriffith.com>
6
6
  License-Expression: MIT
@@ -32,10 +32,13 @@ if os.getenv("PUREMAGIC_DEEPSCAN") != "0":
32
32
  mpeg_audio_scanner,
33
33
  hdf5_scanner,
34
34
  cfbf_scanner,
35
+ ogg_scanner,
36
+ asf_scanner,
37
+ ebml_scanner,
35
38
  )
36
39
 
37
40
  __author__ = "Chris Griffith"
38
- __version__ = "2.1.0"
41
+ __version__ = "2.2.0"
39
42
  __all__ = [
40
43
  "magic_file",
41
44
  "magic_string",
@@ -206,19 +209,20 @@ def identify_all(header: bytes, footer: bytes, ext=None) -> list[PureMagicWithCo
206
209
  return determine_confidence(matches, ext)
207
210
 
208
211
 
209
- def perform_magic(header: bytes, footer: bytes, mime: bool, ext=None, filename=None) -> str:
212
+ def perform_magic(header: bytes | None, footer: bytes | None, mime: bool | None, ext=None, filename=None) -> str:
210
213
  """Discover what type of file it is based on the incoming string"""
211
214
  if not header:
212
215
  raise PureValueError("Input was empty")
216
+ if not footer:
217
+ footer = b""
213
218
  infos = identify_all(header, footer, ext)
214
219
  if filename and os.path.isfile(filename) and os.getenv("PUREMAGIC_DEEPSCAN") != "0":
215
- results = run_deep_scan(infos, filename, header, footer, raise_on_none=True)
216
- if results:
217
- if results[0].extension == "":
218
- raise PureError("Could not identify file")
220
+ results = run_deep_scan(infos, filename, header, footer, raise_on_none=not infos)
221
+ if results and results[0].extension != "":
219
222
  if mime:
220
223
  return results[0].mime_type
221
224
  return results[0].extension or ""
225
+ # Deep scan returned empty extension or no results — fall through to original matches
222
226
  if not infos:
223
227
  raise PureError("Could not identify file")
224
228
  info = infos[0]
@@ -467,6 +471,14 @@ def single_deep_scan(
467
471
  return result
468
472
  case cfbf_scanner.match_bytes | cfbf_scanner.match_bytes_short:
469
473
  return cfbf_scanner.main(filename, head, foot)
474
+ case ogg_scanner.match_bytes:
475
+ result = ogg_scanner.main(filename, head, foot)
476
+ if result and result.confidence > confidence:
477
+ return result
478
+ case asf_scanner.match_bytes:
479
+ return asf_scanner.main(filename, head, foot)
480
+ case ebml_scanner.match_bytes:
481
+ return ebml_scanner.main(filename, head, foot)
470
482
 
471
483
  if eml_result := text_scanner.eml_check(head):
472
484
  return eml_result
@@ -556,13 +568,18 @@ def run_deep_scan(
556
568
 
557
569
  # No specific scanner matched — try the catch-all text scanner
558
570
  # Only override when existing matches are very low confidence (e.g. 2-byte BOM signatures)
559
- if matches[0].confidence < 0.5:
571
+ # Only let the catch-all text scanner override when existing matches are
572
+ # generic text types (e.g. BOM-only signatures). If the magic database
573
+ # already identified a specific non-text file type, trust it over a generic text guess.
574
+ best_mime = matches[0].mime_type or ""
575
+ is_generic = best_mime.startswith("text/") or best_mime == "application/octet-stream" or not best_mime
576
+ if matches[0].confidence < 0.5 and is_generic:
560
577
  try:
561
578
  result = catch_all_deep_scan(filename, head, foot)
562
579
  except Exception:
563
580
  pass
564
581
  else:
565
- if result and result.confidence > matches[0].confidence:
582
+ if result and result.extension and result.confidence > matches[0].confidence:
566
583
  return [
567
584
  PureMagicWithConfidence(
568
585
  confidence=result.confidence,
@@ -0,0 +1,59 @@
1
+ import os
2
+ import struct
3
+
4
+ from puremagic.scanners.helpers import Match
5
+
6
+ # ASF Header Object GUID
7
+ match_bytes = b"\x30\x26\xb2\x75\x8e\x66\xcf\x11\xa6\xd9\x00\xaa\x00\x62\xce\x6c"
8
+
9
+ _STREAM_PROPS_GUID = b"\x91\x07\xdc\xb7\xb7\xa9\xcf\x11\x8e\xe6\x00\xc0\x0c\x20\x53\x65"
10
+ _AUDIO_MEDIA_GUID = b"\x40\x9e\x69\xf8\x4d\x5b\xcf\x11\xa8\xfd\x00\x80\x5f\x5c\x44\x2b"
11
+ _VIDEO_MEDIA_GUID = b"\xc0\xef\x19\xbc\x4d\x5b\xcf\x11\xa8\xfd\x00\x80\x5f\x5c\x44\x2b"
12
+
13
+
14
+ def main(file_path: os.PathLike, head: bytes, foot: bytes) -> Match | None:
15
+ if not head or len(head) < 30:
16
+ return None
17
+ if head[:16] != match_bytes:
18
+ return None
19
+
20
+ header_size = struct.unpack_from("<Q", head, 16)[0]
21
+ obj_count = struct.unpack_from("<I", head, 24)[0]
22
+
23
+ # Read the full ASF header if our head buffer is too small
24
+ if header_size > len(head):
25
+ try:
26
+ with open(file_path, "rb") as f:
27
+ data = f.read(min(int(header_size), 65536))
28
+ except (OSError, ValueError):
29
+ return None
30
+ else:
31
+ data = head
32
+
33
+ has_audio = False
34
+ has_video = False
35
+ offset = 30 # Past header GUID(16) + size(8) + count(4) + reserved(2)
36
+
37
+ for _ in range(min(obj_count, 50)):
38
+ if offset + 24 > len(data):
39
+ break
40
+ obj_guid = data[offset : offset + 16]
41
+ obj_size = struct.unpack_from("<Q", data, offset + 16)[0]
42
+ if obj_size < 24:
43
+ break
44
+
45
+ if obj_guid == _STREAM_PROPS_GUID and offset + 40 <= len(data):
46
+ stream_type = data[offset + 24 : offset + 40]
47
+ if stream_type == _VIDEO_MEDIA_GUID:
48
+ has_video = True
49
+ elif stream_type == _AUDIO_MEDIA_GUID:
50
+ has_audio = True
51
+
52
+ offset += int(obj_size)
53
+
54
+ if has_video:
55
+ return Match(".wmv", "Windows Media Video", "video/x-ms-wmv")
56
+ if has_audio:
57
+ return Match(".wma", "Windows Media Audio", "audio/x-ms-wma")
58
+
59
+ return Match(".asf", "Advanced Systems Format", "video/x-ms-asf")
@@ -24,6 +24,32 @@ _PREFIX_MATCHES = [
24
24
  ("__substg1.0_", ".msg", "Outlook Message", "application/vnd.ms-outlook"),
25
25
  ]
26
26
 
27
+ # Multi-stream detection: all listed streams must be present.
28
+ # Each entry: (required_streams, extension, name, mime_type)
29
+ _MULTI_STREAM_MATCHES = [
30
+ (("_StringPool", "_StringData"), ".msi", "Windows Installer Package", "application/x-msi"),
31
+ ]
32
+
33
+ # Root directory entry CLSIDs that identify specific formats.
34
+ # CLSIDs are stored in mixed-endian format in CFBF files.
35
+ # Each entry: (clsid_bytes, extension, name, mime_type)
36
+ _CLSID_MATCHES = [
37
+ # Microsoft Project 98/2000/2002/2003: {74b78f3a-c8c8-11d1-be11-00c04fb6faf1}
38
+ (
39
+ b"\x3a\x8f\xb7\x74\xc8\xc8\xd1\x11\xbe\x11\x00\xc0\x4f\xb6\xfa\xf1",
40
+ ".mpp",
41
+ "Microsoft Project",
42
+ "application/vnd.ms-project",
43
+ ),
44
+ # Microsoft Project 4.x: {72fd3320-9a05-11cf-85a4-00a0c904de5f}
45
+ (
46
+ b"\x20\x33\xfd\x72\x05\x9a\xcf\x11\x85\xa4\x00\xa0\xc9\x04\xde\x5f",
47
+ ".mpp",
48
+ "Microsoft Project",
49
+ "application/vnd.ms-project",
50
+ ),
51
+ ]
52
+
27
53
 
28
54
  def _extract_stream_names(dir_data: bytes) -> set[str]:
29
55
  """Parse CFBF directory entries and return the set of stream/storage names."""
@@ -45,8 +71,19 @@ def _extract_stream_names(dir_data: bytes) -> set[str]:
45
71
  return names
46
72
 
47
73
 
48
- def _identify_format(stream_names: set[str]) -> Match | None:
49
- """Match stream names against known CFBF format signatures."""
74
+ def _extract_root_clsid(dir_data: bytes) -> bytes | None:
75
+ """Extract the CLSID from the root directory entry (obj_type 5)."""
76
+ for i in range(0, len(dir_data), 128):
77
+ entry = dir_data[i : i + 128]
78
+ if len(entry) < 96:
79
+ break
80
+ if entry[66] == 5: # Root storage
81
+ return entry[80:96]
82
+ return None
83
+
84
+
85
+ def _identify_format(stream_names: set[str], dir_data: bytes) -> Match | None:
86
+ """Match stream names and CLSIDs against known CFBF format signatures."""
50
87
  # Check prefix matches first (e.g. __substg1.0_ for MSG)
51
88
  for name in stream_names:
52
89
  for prefix, ext, fmt_name, mime in _PREFIX_MATCHES:
@@ -58,6 +95,18 @@ def _identify_format(stream_names: set[str]) -> Match | None:
58
95
  if stream_name in stream_names:
59
96
  return Match(ext, fmt_name, mime)
60
97
 
98
+ # Check multi-stream matches (all required streams must be present)
99
+ for required_streams, ext, fmt_name, mime in _MULTI_STREAM_MATCHES:
100
+ if all(s in stream_names for s in required_streams):
101
+ return Match(ext, fmt_name, mime)
102
+
103
+ # Check root CLSID
104
+ root_clsid = _extract_root_clsid(dir_data)
105
+ if root_clsid:
106
+ for clsid, ext, fmt_name, mime in _CLSID_MATCHES:
107
+ if root_clsid == clsid:
108
+ return Match(ext, fmt_name, mime)
109
+
61
110
  return None
62
111
 
63
112
 
@@ -94,4 +143,4 @@ def main(file_path: os.PathLike, head: bytes, foot: bytes) -> Match | None:
94
143
  return None
95
144
 
96
145
  stream_names = _extract_stream_names(dir_data)
97
- return _identify_format(stream_names)
146
+ return _identify_format(stream_names, dir_data)
@@ -0,0 +1,21 @@
1
+ import os
2
+
3
+ from puremagic.scanners.helpers import Match
4
+
5
+ match_bytes = b"\x1a\x45\xdf\xa3"
6
+
7
+
8
+ def main(file_path: os.PathLike, head: bytes, foot: bytes) -> Match | None:
9
+ if not head or len(head) < 8:
10
+ return None
11
+ if head[:4] != match_bytes:
12
+ return None
13
+
14
+ # Search for DocType string in the EBML header (first 64 bytes)
15
+ search_area = head[:64]
16
+ if b"webm" in search_area:
17
+ return Match(".webm", "WebM Video", "video/webm")
18
+ if b"matroska" in search_area:
19
+ return Match(".mkv", "Matroska Video", "video/x-matroska")
20
+
21
+ return None
@@ -32,7 +32,7 @@ _SUBTYPES = [
32
32
  "application/x-biom2",
33
33
  ),
34
34
  # mz5 - mass spectrometry
35
- ([], [b"/SpectrumMetaData", b"/ChomatogramMetaData"], 1, ".mz5", "mz5 mass spectrometry data", "application/x-mz5"),
35
+ ([], [b"/SpectrumMetaData", b"/ChromatogramList"], 1, ".mz5", "mz5 mass spectrometry data", "application/x-mz5"),
36
36
  # h5mlm - ML model
37
37
  ([], [b"model_type", b"h5mlm"], 1, ".h5mlm", "HDF5 ML model", "application/x-h5mlm"),
38
38
  ]
@@ -0,0 +1,39 @@
1
+ import os
2
+
3
+ from puremagic.scanners.helpers import Match
4
+
5
+ match_bytes = b"OggS"
6
+
7
+ # Ogg codec identification signatures found at the start of the first page payload.
8
+ # Each entry: (codec_id_bytes, extension, name, mime_type)
9
+ _OGG_CODEC_MAP = [
10
+ (b"\x01vorbis", ".ogg", "Ogg Vorbis Audio", "audio/ogg"),
11
+ (b"OpusHead", ".opus", "Ogg Opus Audio", "audio/ogg"),
12
+ (b"\x80theora", ".ogv", "Ogg Theora Video", "video/ogg"),
13
+ (b"\x7fFLAC", ".oga", "Ogg FLAC Audio", "audio/ogg"),
14
+ (b"Speex ", ".spx", "Ogg Speex Audio", "audio/ogg"),
15
+ (b"fishead\x00", ".ogv", "Ogg Annodex", "video/ogg"),
16
+ (b"\x01video", ".ogm", "OGM Video", "video/x-ogm+ogg"),
17
+ ]
18
+
19
+
20
+ def main(file_path: os.PathLike, head: bytes, foot: bytes) -> Match | None:
21
+ if not head or len(head) < 28:
22
+ return None
23
+
24
+ # Verify OggS capture pattern, version 0, and beginning-of-stream flag
25
+ if head[:4] != match_bytes or head[4] != 0 or not (head[5] & 0x02):
26
+ return None
27
+
28
+ seg_count = head[26]
29
+ payload_start = 27 + seg_count
30
+
31
+ if payload_start >= len(head):
32
+ return None
33
+
34
+ payload = head[payload_start:]
35
+ for codec_id, ext, name, mime in _OGG_CODEC_MAP:
36
+ if payload.startswith(codec_id):
37
+ return Match(ext, name, mime, confidence=0.9)
38
+
39
+ return None
@@ -200,6 +200,10 @@ def main(file_path: os.PathLike | str, _, __) -> Match | None:
200
200
  if len(head) < 8:
201
201
  return Match("", "very short file", "application/octet-stream", confidence=0.5)
202
202
 
203
+ # NUL bytes indicate binary data, but skip this check for UTF-16 (which has NUL bytes naturally)
204
+ if b"\x00" in head and head[:2] not in (b"\xff\xfe", b"\xfe\xff"):
205
+ return Match("", "data", "application/octet-stream", confidence=0.5)
206
+
203
207
  try:
204
208
  text, encoding = decode_any(head)
205
209
  except TypeError:
@@ -8,6 +8,120 @@ match_bytes = b"PK\x03\x04"
8
8
  office_macro_enable_match = b"macroEnabled"
9
9
 
10
10
  application_re = re.compile(b"<Application>(.*)</Application>")
11
+ _content_type_re = re.compile(rb'ContentType="([^"]*main[^"]*)"')
12
+
13
+ # Maps OOXML main content type fragments (from [Content_Types].xml) to
14
+ # (extension, name, mime_type). Based on ECMA-376 Part 2.
15
+ _OOXML_CONTENT_TYPE_MAP: dict[str, tuple[str, str, str]] = {
16
+ # Word
17
+ "wordprocessingml.document.main+xml": (
18
+ ".docx",
19
+ "Word Document",
20
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
21
+ ),
22
+ "wordprocessingml.template.main+xml": (
23
+ ".dotx",
24
+ "Word Template",
25
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.template",
26
+ ),
27
+ "ms-word.document.macroEnabled.main+xml": (
28
+ ".docm",
29
+ "Word Document (Macro-Enabled)",
30
+ "application/vnd.ms-word.document.macroEnabled.12",
31
+ ),
32
+ "ms-word.template.macroEnabledTemplate.main+xml": (
33
+ ".dotm",
34
+ "Word Template (Macro-Enabled)",
35
+ "application/vnd.ms-word.template.macroEnabled.12",
36
+ ),
37
+ # Excel
38
+ "spreadsheetml.sheet.main+xml": (
39
+ ".xlsx",
40
+ "Excel Spreadsheet",
41
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
42
+ ),
43
+ "spreadsheetml.template.main+xml": (
44
+ ".xltx",
45
+ "Excel Template",
46
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.template",
47
+ ),
48
+ "ms-excel.sheet.macroEnabled.main+xml": (
49
+ ".xlsm",
50
+ "Excel Spreadsheet (Macro-Enabled)",
51
+ "application/vnd.ms-excel.sheet.macroEnabled.12",
52
+ ),
53
+ "ms-excel.template.macroEnabled.main+xml": (
54
+ ".xltm",
55
+ "Excel Template (Macro-Enabled)",
56
+ "application/vnd.ms-excel.template.macroEnabled.12",
57
+ ),
58
+ "ms-excel.sheet.binary.macroEnabled.main": (
59
+ ".xlsb",
60
+ "Excel Binary Workbook",
61
+ "application/vnd.ms-excel.sheet.binary.macroEnabled.12",
62
+ ),
63
+ "ms-excel.addin.macroEnabled.main+xml": (
64
+ ".xlam",
65
+ "Excel Add-In (Macro-Enabled)",
66
+ "application/vnd.ms-excel.addin.macroEnabled.12",
67
+ ),
68
+ # PowerPoint
69
+ "presentationml.presentation.main+xml": (
70
+ ".pptx",
71
+ "PowerPoint Presentation",
72
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
73
+ ),
74
+ "presentationml.template.main+xml": (
75
+ ".potx",
76
+ "PowerPoint Template",
77
+ "application/vnd.openxmlformats-officedocument.presentationml.template",
78
+ ),
79
+ "ms-powerpoint.presentation.macroEnabled.main+xml": (
80
+ ".pptm",
81
+ "PowerPoint Presentation (Macro-Enabled)",
82
+ "application/vnd.ms-powerpoint.presentation.macroEnabled.12",
83
+ ),
84
+ "ms-powerpoint.template.macroEnabled.main+xml": (
85
+ ".potm",
86
+ "PowerPoint Template (Macro-Enabled)",
87
+ "application/vnd.ms-powerpoint.template.macroEnabled.12",
88
+ ),
89
+ "presentationml.slideshow.main+xml": (
90
+ ".ppsx",
91
+ "PowerPoint Slideshow",
92
+ "application/vnd.openxmlformats-officedocument.presentationml.slideshow",
93
+ ),
94
+ "ms-powerpoint.slideshow.macroEnabled.main+xml": (
95
+ ".ppsm",
96
+ "PowerPoint Slideshow (Macro-Enabled)",
97
+ "application/vnd.ms-powerpoint.slideshow.macroEnabled.12",
98
+ ),
99
+ "ms-powerpoint.addin.macroEnabled.main+xml": (
100
+ ".ppam",
101
+ "PowerPoint Add-In (Macro-Enabled)",
102
+ "application/vnd.ms-powerpoint.addin.macroEnabled",
103
+ ),
104
+ }
105
+
106
+
107
+ def _detect_from_content_types(zip_file: ZipFile) -> Match | None:
108
+ """Detect OOXML format from [Content_Types].xml main content types.
109
+
110
+ This is the ECMA-376 standard approach, working with all compliant
111
+ implementations (Microsoft Office, LibreOffice, Google Docs, etc.).
112
+ """
113
+ try:
114
+ ct_data = zip_file.read("[Content_Types].xml")
115
+ except KeyError:
116
+ return None
117
+
118
+ for match in _content_type_re.finditer(ct_data):
119
+ content_type = match.group(1).decode("utf-8")
120
+ for key, (ext, name, mime) in _OOXML_CONTENT_TYPE_MAP.items():
121
+ if key in content_type:
122
+ return Match(ext, name, mime)
123
+
124
+ return None
11
125
 
12
126
 
13
127
  def open_office_check(internal_files: list[str], zip_file: ZipFile, extension: str | None = None) -> Match | None:
@@ -32,9 +146,10 @@ def open_office_check(internal_files: list[str], zip_file: ZipFile, extension: s
32
146
  return None
33
147
 
34
148
 
35
- def office_check(internal_files: list[str], zip_file: ZipFile, extension: str | None = None) -> Match | None:
36
- if "[Content_Types].xml" not in internal_files:
37
- return None
149
+ def _detect_from_application(
150
+ internal_files: list[str], zip_file: ZipFile, extension: str | None = None
151
+ ) -> Match | None:
152
+ """Fallback detection using docProps/app.xml Application tag."""
38
153
  if "docProps/app.xml" not in internal_files:
39
154
  return None
40
155
  app_type_matches = application_re.search(zip_file.read("docProps/app.xml"))
@@ -44,22 +159,30 @@ def office_check(internal_files: list[str], zip_file: ZipFile, extension: str |
44
159
 
45
160
  if "PowerPoint" in application_type:
46
161
  if extension:
162
+ if extension == "pptm":
163
+ return Match(".pptm", application_type, "application/vnd.ms-powerpoint.presentation.macroEnabled.12")
47
164
  if extension == "ppsm":
48
165
  return Match(".ppsm", application_type, "application/vnd.ms-powerpoint.slideshow.macroEnabled.12")
166
+ if extension == "ppsx":
167
+ return Match(
168
+ ".ppsx",
169
+ application_type,
170
+ "application/vnd.openxmlformats-officedocument.presentationml.slideshow",
171
+ )
49
172
  if extension == "potm":
50
173
  return Match(".potm", application_type, "application/vnd.ms-powerpoint.template.macroEnabled.12")
51
174
  if extension == "potx":
52
175
  return Match(
53
- "potx",
176
+ ".potx",
54
177
  application_type,
55
178
  "application/vnd.openxmlformats-officedocument.presentationml.template",
56
179
  )
57
180
  if extension == "ppam":
58
181
  return Match(".ppam", application_type, "application/vnd.ms-powerpoint.addin.macroEnabled")
59
182
  if office_macro_enable_match in zip_file.read("[Content_Types].xml"):
60
- return Match(".ppsm", application_type, "application/vnd.ms-powerpoint.slideshow.macroEnabled.12")
183
+ return Match(".pptm", application_type, "application/vnd.ms-powerpoint.presentation.macroEnabled.12")
61
184
  return Match(
62
- "pptx",
185
+ ".pptx",
63
186
  application_type,
64
187
  "application/vnd.openxmlformats-officedocument.presentationml.presentation",
65
188
  )
@@ -75,7 +198,7 @@ def office_check(internal_files: list[str], zip_file: ZipFile, extension: str |
75
198
  return Match(".xltm", application_type, "application/vnd.ms-excel.template.macroEnabled.12")
76
199
  if extension == "xltx":
77
200
  return Match(
78
- "xltx",
201
+ ".xltx",
79
202
  application_type,
80
203
  "application/vnd.openxmlformats-officedocument.spreadsheetml.template",
81
204
  )
@@ -91,19 +214,32 @@ def office_check(internal_files: list[str], zip_file: ZipFile, extension: str |
91
214
  return Match(".dotm", application_type, "application/vnd.ms-word.template.macroEnabled.12")
92
215
  if extension == "dotx":
93
216
  return Match(
94
- "dotx",
217
+ ".dotx",
95
218
  application_type,
96
219
  "application/vnd.openxmlformats-officedocument.wordprocessingml.template",
97
220
  )
98
221
  if office_macro_enable_match in zip_file.read("[Content_Types].xml"):
99
222
  return Match(".docm", application_type, "application/vnd.ms-word.document.macroEnabled.12")
100
223
  return Match(
101
- "docx", application_type, "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
224
+ ".docx", application_type, "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
102
225
  )
103
226
 
104
227
  return None
105
228
 
106
229
 
230
+ def office_check(internal_files: list[str], zip_file: ZipFile, extension: str | None = None) -> Match | None:
231
+ if "[Content_Types].xml" not in internal_files:
232
+ return None
233
+
234
+ # Primary: content-type-based detection (works with all OOXML creators)
235
+ result = _detect_from_content_types(zip_file)
236
+ if result:
237
+ return result
238
+
239
+ # Fallback: application-based detection for non-standard OOXML packages
240
+ return _detect_from_application(internal_files, zip_file, extension)
241
+
242
+
107
243
  def jar_check(internal_files: list[str], zip_file: ZipFile) -> Match | None:
108
244
  if "META-INF/MANIFEST.MF" not in internal_files:
109
245
  return None
@@ -156,8 +292,6 @@ def cbz_check(internal_files: list[str], extension: str) -> Match | None:
156
292
 
157
293
  def main(file_path: os.PathLike, _, __) -> Match | None:
158
294
  extension = str(file_path).split(".")[-1].lower()
159
- if extension == "zip" and not str(file_path).endswith(".fb2.zip"):
160
- return Match(".zip", "ZIP archive", "application/zip")
161
295
 
162
296
  with ZipFile(file_path) as myzip:
163
297
  internal_files = myzip.namelist()
@@ -189,4 +323,5 @@ def main(file_path: os.PathLike, _, __) -> Match | None:
189
323
  if cbz_result:
190
324
  return cbz_result
191
325
 
192
- return None
326
+ # No specific format detected — return generic ZIP (same confidence as other matches)
327
+ return Match(".zip", "ZIP archive", "application/zip")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: puremagic
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Pure python implementation of magic file detection
5
5
  Author-email: Chris Griffith <chris@cdgriffith.com>
6
6
  License-Expression: MIT
@@ -24,11 +24,14 @@ puremagic.egg-info/dependency_links.txt
24
24
  puremagic.egg-info/entry_points.txt
25
25
  puremagic.egg-info/top_level.txt
26
26
  puremagic/scanners/__init__.py
27
+ puremagic/scanners/asf_scanner.py
27
28
  puremagic/scanners/cfbf_scanner.py
29
+ puremagic/scanners/ebml_scanner.py
28
30
  puremagic/scanners/hdf5_scanner.py
29
31
  puremagic/scanners/helpers.py
30
32
  puremagic/scanners/json_scanner.py
31
33
  puremagic/scanners/mpeg_audio_scanner.py
34
+ puremagic/scanners/ogg_scanner.py
32
35
  puremagic/scanners/pdf_scanner.py
33
36
  puremagic/scanners/python_scanner.py
34
37
  puremagic/scanners/sndhdr_scanner.py
@@ -61,9 +64,12 @@ test/resources/audio/test.aac
61
64
  test/resources/audio/test.aif
62
65
  test/resources/audio/test.flac
63
66
  test/resources/audio/test.mp3
67
+ test/resources/audio/test.oga
68
+ test/resources/audio/test.ogg
64
69
  test/resources/audio/test.opus
65
70
  test/resources/audio/test.sndr
66
71
  test/resources/audio/test.wav
72
+ test/resources/audio/test.wma
67
73
  test/resources/audio/test_mp3_vbr_info_128k_notags.mp3
68
74
  test/resources/audio/test_mp3_vbr_xing_128k_3di_id3v1.mp3
69
75
  test/resources/audio/test_mp3_vbr_xing_128k_apev1.mp3
@@ -134,4 +140,6 @@ test/resources/video/test.jxsv
134
140
  test/resources/video/test.mkv
135
141
  test/resources/video/test.mp4
136
142
  test/resources/video/test.mpg
143
+ test/resources/video/test.ogv
144
+ test/resources/video/test.webm
137
145
  test/resources/video/test.wmv
@@ -269,7 +269,6 @@ def test_riff_wav_mime():
269
269
  assert mime == "audio/wav"
270
270
 
271
271
 
272
-
273
272
  def test_cfbf_doc():
274
273
  """CFBF scanner correctly identifies Word .doc"""
275
274
  ext = puremagic.from_file(os.path.join(OFFICE_DIR, "test.doc"))