humanish 1.0.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.
- humanish-1.0.0/.gitignore +20 -0
- humanish-1.0.0/LICENSE +21 -0
- humanish-1.0.0/PKG-INFO +67 -0
- humanish-1.0.0/README.md +50 -0
- humanish-1.0.0/pyproject.toml +30 -0
- humanish-1.0.0/src/humanish/__init__.py +16 -0
- humanish-1.0.0/src/humanish/check_format.py +108 -0
- humanish-1.0.0/src/humanish/humanish.py +28 -0
- humanish-1.0.0/src/humanish/read.py +451 -0
humanish-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hunter Hall
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
humanish-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: humanish
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Detect a file's real format from its bytes and read a human-readable summary of its contents
|
|
5
|
+
Project-URL: Homepage, https://github.com/hunterhall/humanish
|
|
6
|
+
Project-URL: Repository, https://github.com/hunterhall/humanish
|
|
7
|
+
Author-email: Hunter Hall <hunter.hall.hgh@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: detection,file-format,file-type,magic-bytes,metadata
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Utilities
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# humanish
|
|
19
|
+
|
|
20
|
+
Detect a file's real format from its magic bytes (not its extension) and get a
|
|
21
|
+
human-readable summary of what's inside it.
|
|
22
|
+
|
|
23
|
+
Supports PNG, JPEG, BMP, WAV, MP3, ZIP, TAR, GZIP, PDF, PE (EXE/DLL), ELF, and
|
|
24
|
+
MP4. Pure standard library, no dependencies.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install git+https://github.com/hunterhall/humanish.git
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from humanish import humanish, detect_format
|
|
36
|
+
|
|
37
|
+
detect_format("mystery.bin") # -> "png"
|
|
38
|
+
|
|
39
|
+
humanish("photo.jpg")
|
|
40
|
+
# {
|
|
41
|
+
# "format": "jpeg",
|
|
42
|
+
# "width": 1920,
|
|
43
|
+
# "height": 1080,
|
|
44
|
+
# "components": 3,
|
|
45
|
+
# ...
|
|
46
|
+
# }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Detect from bytes you already have in memory:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from humanish import detect_format_from_bytes
|
|
53
|
+
|
|
54
|
+
detect_format_from_bytes(open("mystery.bin", "rb").read(64))
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Command line
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
humanish path/to/file
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Prints the format and content summary as JSON.
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
MIT
|
humanish-1.0.0/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# humanish
|
|
2
|
+
|
|
3
|
+
Detect a file's real format from its magic bytes (not its extension) and get a
|
|
4
|
+
human-readable summary of what's inside it.
|
|
5
|
+
|
|
6
|
+
Supports PNG, JPEG, BMP, WAV, MP3, ZIP, TAR, GZIP, PDF, PE (EXE/DLL), ELF, and
|
|
7
|
+
MP4. Pure standard library, no dependencies.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install git+https://github.com/hunterhall/humanish.git
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from humanish import humanish, detect_format
|
|
19
|
+
|
|
20
|
+
detect_format("mystery.bin") # -> "png"
|
|
21
|
+
|
|
22
|
+
humanish("photo.jpg")
|
|
23
|
+
# {
|
|
24
|
+
# "format": "jpeg",
|
|
25
|
+
# "width": 1920,
|
|
26
|
+
# "height": 1080,
|
|
27
|
+
# "components": 3,
|
|
28
|
+
# ...
|
|
29
|
+
# }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Detect from bytes you already have in memory:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from humanish import detect_format_from_bytes
|
|
36
|
+
|
|
37
|
+
detect_format_from_bytes(open("mystery.bin", "rb").read(64))
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Command line
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
humanish path/to/file
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Prints the format and content summary as JSON.
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "humanish"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Detect a file's real format from its bytes and read a human-readable summary of its contents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Hunter Hall", email = "hunter.hall.hgh@gmail.com" }]
|
|
13
|
+
keywords = ["file-format", "magic-bytes", "file-type", "detection", "metadata"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Utilities",
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/hunterhall/humanish"
|
|
24
|
+
Repository = "https://github.com/hunterhall/humanish"
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
humanish = "humanish.humanish:main"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/humanish"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from humanish.check_format import (
|
|
2
|
+
detect_format,
|
|
3
|
+
detect_format_from_bytes,
|
|
4
|
+
)
|
|
5
|
+
from humanish.humanish import humanish
|
|
6
|
+
from humanish.read import READERS
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"humanish",
|
|
12
|
+
"detect_format",
|
|
13
|
+
"detect_format_from_bytes",
|
|
14
|
+
"READERS",
|
|
15
|
+
"__version__",
|
|
16
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
_SIGNATURES = [
|
|
4
|
+
(b"\x89PNG\r\n\x1a\n", 0, "png"),
|
|
5
|
+
(b"\xFF\xD8\xFF", 0, "jpeg"),
|
|
6
|
+
(b"BM", 0, "bmp"),
|
|
7
|
+
(b"ID3", 0, "mp3"),
|
|
8
|
+
(b"PK\x03\x04", 0, "zip"),
|
|
9
|
+
(b"PK\x05\x06", 0, "zip"),
|
|
10
|
+
(b"\x1F\x8B", 0, "gzip"),
|
|
11
|
+
(b"%PDF-", 0, "pdf"),
|
|
12
|
+
(b"MZ", 0, "pe"),
|
|
13
|
+
(b"\x7fELF", 0, "elf"),
|
|
14
|
+
(b"ustar", 257, "tar"),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _matches_mp3_frame_sync(raw: bytes) -> bool:
|
|
19
|
+
return len(raw) >= 2 and raw[0] == 0xFF and (raw[1] & 0xE0) == 0xE0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _matches_riff(raw: bytes) -> str | None:
|
|
23
|
+
if len(raw) < 12 or raw[0:4] != b"RIFF":
|
|
24
|
+
return None
|
|
25
|
+
form_type = raw[8:12]
|
|
26
|
+
if form_type == b"WAVE":
|
|
27
|
+
return "wav"
|
|
28
|
+
return "riff"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _matches_mp4(raw: bytes) -> bool:
|
|
32
|
+
return len(raw) >= 12 and raw[4:8] == b"ftyp"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _classify_pe(raw: bytes) -> str:
|
|
36
|
+
DLL_CHARACTERISTIC_FLAG = 0x2000
|
|
37
|
+
try:
|
|
38
|
+
pe_header_offset = int.from_bytes(raw[0x3C:0x40], "little")
|
|
39
|
+
coff_header = raw[pe_header_offset + 4: pe_header_offset + 24]
|
|
40
|
+
characteristics = int.from_bytes(coff_header[18:20], "little")
|
|
41
|
+
return "dll" if characteristics & DLL_CHARACTERISTIC_FLAG else "exe"
|
|
42
|
+
except IndexError:
|
|
43
|
+
return "pe"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def detect_format(path: str) -> str:
|
|
47
|
+
raw = Path(path).read_bytes()
|
|
48
|
+
|
|
49
|
+
if _matches_mp4(raw):
|
|
50
|
+
return "mp4"
|
|
51
|
+
|
|
52
|
+
riff_type = _matches_riff(raw)
|
|
53
|
+
if riff_type == "wav":
|
|
54
|
+
return "wav"
|
|
55
|
+
|
|
56
|
+
for signature, offset, name in _SIGNATURES:
|
|
57
|
+
if raw[offset:offset + len(signature)] == signature:
|
|
58
|
+
if name == "pe":
|
|
59
|
+
return _classify_pe(raw)
|
|
60
|
+
return name
|
|
61
|
+
|
|
62
|
+
if _matches_mp3_frame_sync(raw):
|
|
63
|
+
return "mp3"
|
|
64
|
+
|
|
65
|
+
return "unknown"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def detect_format_from_bytes(raw: bytes) -> str:
|
|
69
|
+
if _matches_mp4(raw):
|
|
70
|
+
return "mp4"
|
|
71
|
+
|
|
72
|
+
riff_type = _matches_riff(raw)
|
|
73
|
+
if riff_type == "wav":
|
|
74
|
+
return "wav"
|
|
75
|
+
|
|
76
|
+
for signature, offset, name in _SIGNATURES:
|
|
77
|
+
if raw[offset:offset + len(signature)] == signature:
|
|
78
|
+
if name == "pe":
|
|
79
|
+
return _classify_pe(raw)
|
|
80
|
+
return name
|
|
81
|
+
|
|
82
|
+
if _matches_mp3_frame_sync(raw):
|
|
83
|
+
return "mp3"
|
|
84
|
+
|
|
85
|
+
return "unknown"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def identify_and_read(path: str) -> dict:
|
|
89
|
+
from humanish.read import READERS
|
|
90
|
+
|
|
91
|
+
fmt = detect_format(path)
|
|
92
|
+
reader = READERS.get(fmt)
|
|
93
|
+
if reader is None:
|
|
94
|
+
return {"format": fmt, "error": "no reader available for this format"}
|
|
95
|
+
|
|
96
|
+
result = reader(path)
|
|
97
|
+
result["format"] = fmt
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
import sys
|
|
103
|
+
|
|
104
|
+
if len(sys.argv) != 2:
|
|
105
|
+
print("Usage: python -m humanish.check_format <path>")
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
|
|
108
|
+
print(detect_format(sys.argv[1]))
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from humanish.check_format import detect_format
|
|
2
|
+
from humanish.read import READERS
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def humanish(path: str) -> dict:
|
|
6
|
+
fmt = detect_format(path)
|
|
7
|
+
reader = READERS.get(fmt)
|
|
8
|
+
if reader is None:
|
|
9
|
+
return {"format": fmt, "error": "no reader available for this format"}
|
|
10
|
+
|
|
11
|
+
result = reader(path)
|
|
12
|
+
result["format"] = fmt
|
|
13
|
+
return result
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
if len(sys.argv) != 2:
|
|
21
|
+
print("Usage: humanish <path>", file=sys.stderr)
|
|
22
|
+
sys.exit(1)
|
|
23
|
+
|
|
24
|
+
print(json.dumps(humanish(sys.argv[1]), indent=2, default=str))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
main()
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import gzip
|
|
2
|
+
import re
|
|
3
|
+
import tarfile
|
|
4
|
+
import wave
|
|
5
|
+
import zipfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def read_png(path: str) -> dict:
|
|
10
|
+
color_type_names = {
|
|
11
|
+
0: "grayscale",
|
|
12
|
+
2: "rgb",
|
|
13
|
+
3: "palette",
|
|
14
|
+
4: "grayscale+alpha",
|
|
15
|
+
6: "rgba",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
raw = Path(path).read_bytes()
|
|
19
|
+
|
|
20
|
+
image_info = None
|
|
21
|
+
chunk_list = []
|
|
22
|
+
|
|
23
|
+
position = 8
|
|
24
|
+
while position + 8 <= len(raw):
|
|
25
|
+
chunk_length = int.from_bytes(raw[position:position + 4], "big")
|
|
26
|
+
chunk_type = raw[position + 4:position + 8].decode("ascii", errors="replace")
|
|
27
|
+
chunk_data = raw[position + 8: position + 8 + chunk_length]
|
|
28
|
+
|
|
29
|
+
chunk_list.append({
|
|
30
|
+
"type": chunk_type,
|
|
31
|
+
"length": chunk_length,
|
|
32
|
+
"offset": position,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
if chunk_type == "IHDR":
|
|
36
|
+
image_info = {
|
|
37
|
+
"width": int.from_bytes(chunk_data[0:4], "big"),
|
|
38
|
+
"height": int.from_bytes(chunk_data[4:8], "big"),
|
|
39
|
+
"bit_depth": chunk_data[8],
|
|
40
|
+
"color_type": color_type_names.get(chunk_data[9], chunk_data[9]),
|
|
41
|
+
"interlaced": bool(chunk_data[12]),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
position += 4 + 4 + chunk_length + 4
|
|
45
|
+
|
|
46
|
+
if chunk_type == "IEND":
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
"image_info": image_info,
|
|
51
|
+
"chunks": chunk_list,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def read_jpeg(path: str) -> dict:
|
|
56
|
+
raw = Path(path).read_bytes()
|
|
57
|
+
if raw[:2] != b"\xFF\xD8":
|
|
58
|
+
return {"error": "not a JPEG file"}
|
|
59
|
+
|
|
60
|
+
width = height = components = None
|
|
61
|
+
markers = []
|
|
62
|
+
|
|
63
|
+
position = 2
|
|
64
|
+
while position < len(raw) - 1:
|
|
65
|
+
if raw[position] != 0xFF:
|
|
66
|
+
position += 1
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
marker = raw[position + 1]
|
|
70
|
+
|
|
71
|
+
if marker in (0xD8, 0xD9, 0x01) or 0xD0 <= marker <= 0xD7:
|
|
72
|
+
position += 2
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
if position + 4 > len(raw):
|
|
76
|
+
break
|
|
77
|
+
|
|
78
|
+
segment_length = int.from_bytes(raw[position + 2:position + 4], "big")
|
|
79
|
+
markers.append({"marker": f"0x{marker:02X}", "offset": position, "length": segment_length})
|
|
80
|
+
|
|
81
|
+
if marker in (0xC0, 0xC1, 0xC2, 0xC3):
|
|
82
|
+
height = int.from_bytes(raw[position + 5:position + 7], "big")
|
|
83
|
+
width = int.from_bytes(raw[position + 7:position + 9], "big")
|
|
84
|
+
components = raw[position + 9]
|
|
85
|
+
|
|
86
|
+
if marker == 0xDA:
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
position += 2 + segment_length
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
"width": width,
|
|
93
|
+
"height": height,
|
|
94
|
+
"components": components,
|
|
95
|
+
"markers": markers,
|
|
96
|
+
"byte_size": len(raw),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def read_bmp(path: str) -> dict:
|
|
101
|
+
raw = Path(path).read_bytes()
|
|
102
|
+
if raw[:2] != b"BM":
|
|
103
|
+
return {"error": "not a BMP file"}
|
|
104
|
+
|
|
105
|
+
file_size = int.from_bytes(raw[2:6], "little")
|
|
106
|
+
pixel_data_offset = int.from_bytes(raw[10:14], "little")
|
|
107
|
+
width = int.from_bytes(raw[18:22], "little", signed=True)
|
|
108
|
+
height = int.from_bytes(raw[22:26], "little", signed=True)
|
|
109
|
+
bits_per_pixel = int.from_bytes(raw[28:30], "little")
|
|
110
|
+
compression = int.from_bytes(raw[30:34], "little")
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
"width": width,
|
|
114
|
+
"height": abs(height),
|
|
115
|
+
"top_down": height < 0,
|
|
116
|
+
"bits_per_pixel": bits_per_pixel,
|
|
117
|
+
"compression": compression,
|
|
118
|
+
"pixel_data_offset": pixel_data_offset,
|
|
119
|
+
"byte_size": file_size,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def read_wav(path: str) -> dict:
|
|
124
|
+
with wave.open(path, "rb") as audio:
|
|
125
|
+
frame_count = audio.getnframes()
|
|
126
|
+
sample_rate = audio.getframerate()
|
|
127
|
+
duration = frame_count / sample_rate if sample_rate else 0.0
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
"channels": audio.getnchannels(),
|
|
131
|
+
"sample_width_bytes": audio.getsampwidth(),
|
|
132
|
+
"sample_rate_hz": sample_rate,
|
|
133
|
+
"frame_count": frame_count,
|
|
134
|
+
"duration_seconds": round(duration, 3),
|
|
135
|
+
"compression_type": audio.getcomptype(),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def read_mp3(path: str) -> dict:
|
|
140
|
+
mpeg_version_names = {0b00: "MPEG 2.5", 0b10: "MPEG 2", 0b11: "MPEG 1"}
|
|
141
|
+
layer_names = {0b01: "Layer III", 0b10: "Layer II", 0b11: "Layer I"}
|
|
142
|
+
channel_mode_names = {0b00: "stereo", 0b01: "joint_stereo", 0b10: "dual_channel", 0b11: "mono"}
|
|
143
|
+
bitrate_table_v1_l3 = [None, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, None]
|
|
144
|
+
sample_rate_table_v1 = [44100, 48000, 32000, None]
|
|
145
|
+
|
|
146
|
+
raw = Path(path).read_bytes()
|
|
147
|
+
|
|
148
|
+
id3_version = None
|
|
149
|
+
id3_size = 0
|
|
150
|
+
if raw[:3] == b"ID3":
|
|
151
|
+
id3_version = f"2.{raw[3]}.{raw[4]}"
|
|
152
|
+
size_bytes = raw[6:10]
|
|
153
|
+
id3_size = (
|
|
154
|
+
((size_bytes[0] & 0x7F) << 21)
|
|
155
|
+
| ((size_bytes[1] & 0x7F) << 14)
|
|
156
|
+
| ((size_bytes[2] & 0x7F) << 7)
|
|
157
|
+
| (size_bytes[3] & 0x7F)
|
|
158
|
+
) + 10
|
|
159
|
+
|
|
160
|
+
frame_info = None
|
|
161
|
+
position = id3_size
|
|
162
|
+
while position < len(raw) - 4:
|
|
163
|
+
if raw[position] == 0xFF and (raw[position + 1] & 0xE0) == 0xE0:
|
|
164
|
+
b1, b2, b3, b4 = raw[position:position + 4]
|
|
165
|
+
version_bits = (b2 >> 3) & 0b11
|
|
166
|
+
layer_bits = (b2 >> 1) & 0b11
|
|
167
|
+
bitrate_index = (b3 >> 4) & 0b1111
|
|
168
|
+
sample_rate_index = (b3 >> 2) & 0b11
|
|
169
|
+
channel_mode_bits = (b4 >> 6) & 0b11
|
|
170
|
+
|
|
171
|
+
bitrate_kbps = None
|
|
172
|
+
if version_bits == 0b11 and layer_bits == 0b01 and 0 < bitrate_index < 15:
|
|
173
|
+
bitrate_kbps = bitrate_table_v1_l3[bitrate_index]
|
|
174
|
+
|
|
175
|
+
sample_rate_hz = None
|
|
176
|
+
if version_bits == 0b11 and sample_rate_index < 3:
|
|
177
|
+
sample_rate_hz = sample_rate_table_v1[sample_rate_index]
|
|
178
|
+
|
|
179
|
+
frame_info = {
|
|
180
|
+
"mpeg_version": mpeg_version_names.get(version_bits, "unknown"),
|
|
181
|
+
"layer": layer_names.get(layer_bits, "unknown"),
|
|
182
|
+
"bitrate_kbps": bitrate_kbps,
|
|
183
|
+
"sample_rate_hz": sample_rate_hz,
|
|
184
|
+
"channel_mode": channel_mode_names.get(channel_mode_bits, "unknown"),
|
|
185
|
+
"offset": position,
|
|
186
|
+
}
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
position += 1
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
"id3_version": id3_version,
|
|
193
|
+
"id3_tag_size": id3_size,
|
|
194
|
+
"first_frame": frame_info,
|
|
195
|
+
"byte_size": len(raw),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def read_zip(path: str) -> dict:
|
|
200
|
+
with zipfile.ZipFile(path) as archive:
|
|
201
|
+
first_corrupt_file = archive.testzip()
|
|
202
|
+
|
|
203
|
+
entries = []
|
|
204
|
+
for info in archive.infolist():
|
|
205
|
+
entries.append({
|
|
206
|
+
"name": info.filename,
|
|
207
|
+
"is_folder": info.is_dir(),
|
|
208
|
+
"uncompressed_size": info.file_size,
|
|
209
|
+
"compressed_size": info.compress_size,
|
|
210
|
+
"last_modified": "%04d-%02d-%02d %02d:%02d:%02d" % info.date_time,
|
|
211
|
+
"checksum_crc32": format(info.CRC, "08x"),
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
"first_corrupt_file": first_corrupt_file,
|
|
216
|
+
"entries": entries,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def read_tar(path: str) -> dict:
|
|
221
|
+
with tarfile.open(path) as archive:
|
|
222
|
+
entries = []
|
|
223
|
+
for member in archive.getmembers():
|
|
224
|
+
entries.append({
|
|
225
|
+
"name": member.name,
|
|
226
|
+
"is_folder": member.isdir(),
|
|
227
|
+
"size": member.size,
|
|
228
|
+
"mode": oct(member.mode),
|
|
229
|
+
"modified_unix": member.mtime,
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
"entries": entries,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def read_gzip(path: str) -> dict:
|
|
238
|
+
FLAG_EXTRA_FIELD = 0b00000100
|
|
239
|
+
FLAG_HAS_FILENAME = 0b00001000
|
|
240
|
+
FLAG_HAS_COMMENT = 0b00010000
|
|
241
|
+
|
|
242
|
+
raw = Path(path).read_bytes()
|
|
243
|
+
flags = raw[3]
|
|
244
|
+
position = 10
|
|
245
|
+
|
|
246
|
+
if flags & FLAG_EXTRA_FIELD:
|
|
247
|
+
extra_length = int.from_bytes(raw[position:position + 2], "little")
|
|
248
|
+
position += 2 + extra_length
|
|
249
|
+
|
|
250
|
+
original_name = None
|
|
251
|
+
if flags & FLAG_HAS_FILENAME:
|
|
252
|
+
end = raw.index(b"\x00", position)
|
|
253
|
+
original_name = raw[position:end].decode("latin-1")
|
|
254
|
+
position = end + 1
|
|
255
|
+
|
|
256
|
+
comment = None
|
|
257
|
+
if flags & FLAG_HAS_COMMENT:
|
|
258
|
+
end = raw.index(b"\x00", position)
|
|
259
|
+
comment = raw[position:end].decode("latin-1")
|
|
260
|
+
position = end + 1
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
"modified_time_unix": int.from_bytes(raw[4:8], "little"),
|
|
264
|
+
"original_filename": original_name,
|
|
265
|
+
"comment": comment,
|
|
266
|
+
"decompressed_size_bytes": len(gzip.decompress(raw)),
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def read_pdf(path: str) -> dict:
|
|
271
|
+
raw = Path(path).read_bytes()
|
|
272
|
+
|
|
273
|
+
version_match = re.match(rb"%PDF-(\d+\.\d+)", raw)
|
|
274
|
+
declared_size_match = re.search(rb"/Size\s+(\d+)", raw)
|
|
275
|
+
|
|
276
|
+
object_ids_found = set(re.findall(rb"(\d+)\s+(\d+)\s+obj\b", raw))
|
|
277
|
+
page_count = len(re.findall(rb"/Type\s*/Page\b", raw))
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
"pdf_version": version_match.group(1).decode() if version_match else None,
|
|
281
|
+
"declared_object_count": int(declared_size_match.group(1)) if declared_size_match else None,
|
|
282
|
+
"objects_actually_found": len(object_ids_found),
|
|
283
|
+
"page_count": page_count,
|
|
284
|
+
"is_encrypted": b"/Encrypt" in raw,
|
|
285
|
+
"ends_with_eof_marker": raw.rstrip().endswith(b"%%EOF"),
|
|
286
|
+
"byte_size": len(raw),
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def read_pe(path: str) -> dict:
|
|
291
|
+
DLL_CHARACTERISTIC_FLAG = 0x2000
|
|
292
|
+
|
|
293
|
+
machine_type_names = {
|
|
294
|
+
0x014C: "x86 (32-bit)",
|
|
295
|
+
0x8664: "x86-64 (64-bit)",
|
|
296
|
+
0xAA64: "ARM64",
|
|
297
|
+
}
|
|
298
|
+
subsystem_names = {
|
|
299
|
+
2: "windows_gui",
|
|
300
|
+
3: "windows_console",
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
raw = Path(path).read_bytes()
|
|
304
|
+
|
|
305
|
+
pe_header_offset = int.from_bytes(raw[0x3C:0x40], "little")
|
|
306
|
+
|
|
307
|
+
coff_header = raw[pe_header_offset + 4: pe_header_offset + 24]
|
|
308
|
+
machine = int.from_bytes(coff_header[0:2], "little")
|
|
309
|
+
section_count = int.from_bytes(coff_header[2:4], "little")
|
|
310
|
+
timestamp = int.from_bytes(coff_header[4:8], "little")
|
|
311
|
+
characteristics = int.from_bytes(coff_header[18:20], "little")
|
|
312
|
+
optional_header_size = int.from_bytes(coff_header[16:18], "little")
|
|
313
|
+
|
|
314
|
+
optional_header = raw[pe_header_offset + 24: pe_header_offset + 24 + optional_header_size]
|
|
315
|
+
subsystem = None
|
|
316
|
+
if optional_header_size >= 70:
|
|
317
|
+
subsystem = int.from_bytes(optional_header[68:70], "little")
|
|
318
|
+
|
|
319
|
+
is_dll = bool(characteristics & DLL_CHARACTERISTIC_FLAG)
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
"architecture": machine_type_names.get(machine, f"unknown (0x{machine:04x})"),
|
|
323
|
+
"file_kind": "dll" if is_dll else "exe",
|
|
324
|
+
"section_count": section_count,
|
|
325
|
+
"build_timestamp_unix": timestamp,
|
|
326
|
+
"subsystem": subsystem_names.get(subsystem, subsystem),
|
|
327
|
+
"byte_size": len(raw),
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def read_elf(path: str) -> dict:
|
|
332
|
+
type_names = {1: "relocatable", 2: "executable", 3: "shared_object", 4: "core"}
|
|
333
|
+
machine_names = {0x03: "x86", 0x3E: "x86-64", 0x28: "ARM", 0xB7: "ARM64"}
|
|
334
|
+
|
|
335
|
+
raw = Path(path).read_bytes()
|
|
336
|
+
if raw[:4] != b"\x7fELF":
|
|
337
|
+
return {"error": "not an ELF file"}
|
|
338
|
+
|
|
339
|
+
is_64bit = raw[4] == 2
|
|
340
|
+
endianness = "little" if raw[5] == 1 else "big"
|
|
341
|
+
e_type = int.from_bytes(raw[16:18], endianness)
|
|
342
|
+
e_machine = int.from_bytes(raw[18:20], endianness)
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
"class": "ELF64" if is_64bit else "ELF32",
|
|
346
|
+
"endianness": endianness,
|
|
347
|
+
"type": type_names.get(e_type, f"unknown ({e_type})"),
|
|
348
|
+
"machine": machine_names.get(e_machine, f"unknown (0x{e_machine:02x})"),
|
|
349
|
+
"byte_size": len(raw),
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _walk_mp4_boxes(data: bytes, start: int, end: int):
|
|
354
|
+
position = start
|
|
355
|
+
while position + 8 <= end:
|
|
356
|
+
box_size = int.from_bytes(data[position:position + 4], "big")
|
|
357
|
+
box_type = data[position + 4:position + 8].decode("latin-1")
|
|
358
|
+
content_start = position + 8
|
|
359
|
+
|
|
360
|
+
if box_size == 1:
|
|
361
|
+
box_size = int.from_bytes(data[position + 8:position + 16], "big")
|
|
362
|
+
content_start = position + 16
|
|
363
|
+
elif box_size == 0:
|
|
364
|
+
box_size = end - position
|
|
365
|
+
|
|
366
|
+
box_end = position + box_size
|
|
367
|
+
if box_end <= content_start or box_end > end:
|
|
368
|
+
break
|
|
369
|
+
|
|
370
|
+
yield box_type, position, content_start, box_end
|
|
371
|
+
position = box_end
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _read_movie_header(data: bytes, content_start: int) -> dict | None:
|
|
375
|
+
version = data[content_start]
|
|
376
|
+
|
|
377
|
+
if version == 1:
|
|
378
|
+
timescale = int.from_bytes(data[content_start + 20:content_start + 24], "big")
|
|
379
|
+
duration_units = int.from_bytes(data[content_start + 24:content_start + 32], "big")
|
|
380
|
+
else:
|
|
381
|
+
timescale = int.from_bytes(data[content_start + 12:content_start + 16], "big")
|
|
382
|
+
duration_units = int.from_bytes(data[content_start + 16:content_start + 20], "big")
|
|
383
|
+
|
|
384
|
+
if not timescale:
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
"timescale": timescale,
|
|
389
|
+
"duration_units": duration_units,
|
|
390
|
+
"duration_seconds": round(duration_units / timescale, 3),
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def read_mp4(path: str) -> dict:
|
|
395
|
+
raw = Path(path).read_bytes()
|
|
396
|
+
|
|
397
|
+
major_brand = None
|
|
398
|
+
compatible_brands = []
|
|
399
|
+
movie_info = None
|
|
400
|
+
box_list = []
|
|
401
|
+
|
|
402
|
+
for box_type, box_start, content_start, box_end in _walk_mp4_boxes(raw, 0, len(raw)):
|
|
403
|
+
box_list.append({
|
|
404
|
+
"type": box_type,
|
|
405
|
+
"offset": box_start,
|
|
406
|
+
"size": box_end - box_start,
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
if box_type == "ftyp":
|
|
410
|
+
major_brand = raw[content_start:content_start + 4].decode("latin-1").strip()
|
|
411
|
+
compatible_brands = [
|
|
412
|
+
raw[i:i + 4].decode("latin-1").strip()
|
|
413
|
+
for i in range(content_start + 8, box_end, 4)
|
|
414
|
+
]
|
|
415
|
+
|
|
416
|
+
elif box_type == "moov":
|
|
417
|
+
for inner_type, _s, inner_content_start, _e in _walk_mp4_boxes(raw, content_start, box_end):
|
|
418
|
+
if inner_type == "mvhd":
|
|
419
|
+
movie_info = _read_movie_header(raw, inner_content_start)
|
|
420
|
+
break
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
"major_brand": major_brand,
|
|
424
|
+
"compatible_brands": compatible_brands,
|
|
425
|
+
"box_count": len(box_list),
|
|
426
|
+
"boxes": box_list,
|
|
427
|
+
"movie_info": movie_info,
|
|
428
|
+
"byte_size": len(raw),
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
READERS = {
|
|
433
|
+
"png": read_png,
|
|
434
|
+
"jpeg": read_jpeg,
|
|
435
|
+
"jpg": read_jpeg,
|
|
436
|
+
"bmp": read_bmp,
|
|
437
|
+
"wav": read_wav,
|
|
438
|
+
"mp3": read_mp3,
|
|
439
|
+
"zip": read_zip,
|
|
440
|
+
"tar": read_tar,
|
|
441
|
+
"gzip": read_gzip,
|
|
442
|
+
"pdf": read_pdf,
|
|
443
|
+
"exe": read_pe,
|
|
444
|
+
"dll": read_pe,
|
|
445
|
+
"elf": read_elf,
|
|
446
|
+
"mp4": read_mp4,
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
if __name__ == "__main__":
|
|
451
|
+
print(read_mp3('file.mp3'))
|