Skip to content

Commit f12f329

Browse files
committed
added decoder module
1 parent a8974e2 commit f12f329

8 files changed

Lines changed: 318 additions & 1 deletion

File tree

‎.gitignore‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ __pycache__/
1313
# Project
1414
/maps_list.txt
1515
/maps_files*
16-
/output
16+
/output*

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
## "JavaScript Source Maps Decoder
22

33
Тут скоро будет описание. :)
4+
5+
### Состав
6+
7+
- [downloader](downloader.py)
8+
- [decoder](decoder.py)
9+
- [merger]()

‎common/__init__.py‎

Whitespace-only changes.

‎common/error.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class Error:
2+
message: str
3+
4+
def __init__(self, message: str) -> None:
5+
self.message = message
6+
7+
8+
def error(message: str) -> Error:
9+
return Error(message)

‎common/map_counter.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
class MapCounter:
2+
"""Да, в Python есть [collections.Counter], но я захотел написать свой. :)"""
3+
4+
def __init__(self) -> None:
5+
self._stats: dict[str, int] = {}
6+
self._keys: list[str] = []
7+
8+
def increment(self, key: str) -> None:
9+
if key not in self._keys:
10+
self._stats[key] = 0
11+
self._keys.append(key)
12+
13+
self._stats[key] += 1
14+
15+
@property
16+
def total(self) -> dict[str, int]:
17+
return self._stats

‎common/safe_path.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from pathlib import Path
2+
from typing import Self
3+
4+
PyPath = Path | str
5+
6+
7+
class SafePath:
8+
"""Класс надстройка над [pathlib.Path], который не позволяет выйти за пределы указанного "базового" пути.
9+
10+
Оригинальный путь вычисляется при создании первого экземпляра данного класса.
11+
При последующем join путей, "базовый" путь берётся от родительского пути.
12+
13+
>>> safe = SafePath("base") # path: base, original_absolute: base
14+
>>> safe2 = safe / "folder" # path: base/folder, original_absolute: base
15+
"""
16+
17+
def __init__(self, path: PyPath) -> None:
18+
save_path = Path(path)
19+
self._safe_path = save_path
20+
self._original_absolute: str = str(save_path.resolve().absolute())
21+
22+
def __truediv__(self, path_part: PyPath | Self) -> Self:
23+
if isinstance(path_part, SafePath):
24+
add_path_part = path_part.path
25+
else:
26+
add_path_part = Path(path_part) # type: ignore [arg-type]
27+
28+
new_path = self._safe_path / add_path_part
29+
30+
new_absolute = str(new_path.resolve().absolute())
31+
if not new_absolute.startswith(self._original_absolute):
32+
raise RuntimeError(f"Путь '{new_absolute}' вышел из оригинального пути '{self._original_absolute}'")
33+
34+
save_path = SafePath(new_path)
35+
save_path._set_absolute(self._original_absolute)
36+
return save_path # type: ignore [return-value]
37+
38+
def _set_absolute(self, absolute_path: str) -> None:
39+
self._original_absolute = absolute_path
40+
41+
@property
42+
def path(self) -> Path:
43+
return self._safe_path
44+
45+
46+
def safe_path_join(original_path: PyPath, path_part: PyPath) -> Path:
47+
new_path = SafePath(original_path) / path_part
48+
return new_path.path

‎common/source_maps.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import json
2+
from pathlib import Path
3+
from typing import Any, NamedTuple, TypedDict
4+
5+
from common.error import Error, error
6+
from common.map_counter import MapCounter
7+
8+
9+
class SourceMap(NamedTuple):
10+
version: int
11+
sources: list[str]
12+
names: list[str] | None
13+
mappings: str
14+
file: str
15+
sourcesContent: list[str]
16+
sourceRoot: str
17+
18+
19+
class SourceMapStatistic(TypedDict):
20+
includesFiles: list[str]
21+
filesStatistics: dict[str, int]
22+
sourceMapPath: str
23+
24+
25+
class DecodeResult(NamedTuple):
26+
files: dict[str, str]
27+
sourceMapStatistic: SourceMapStatistic
28+
29+
30+
def remove_prefix(line: str) -> str:
31+
prefixes = ["webpack://"]
32+
for prefix in prefixes:
33+
line = line.removeprefix(prefix)
34+
35+
return line
36+
37+
38+
def parse_content(content: str) -> SourceMap:
39+
sm: dict[str, Any] = {
40+
"names": None,
41+
}
42+
sm |= json.loads(content)
43+
44+
return SourceMap(**sm)
45+
46+
47+
def parse_suffix(file_path: str) -> str:
48+
path = Path(file_path)
49+
suffix = path.suffix
50+
51+
return suffix.split("?")[0]
52+
53+
54+
def decode(content: str) -> Error | DecodeResult:
55+
sm = parse_content(content)
56+
57+
if sm.version != 3:
58+
return error(f"Source Maps версии {sm.version} не поддерживается")
59+
60+
files: dict[str, str] = {}
61+
suffix_stats = MapCounter()
62+
63+
for idx, file_path in enumerate(sm.sources):
64+
file_path = remove_prefix(file_path)
65+
file_content = sm.sourcesContent[idx]
66+
67+
suffix_stats.increment(parse_suffix(file_path))
68+
files[file_path] = file_content
69+
70+
sm_stat = SourceMapStatistic(
71+
includesFiles=sm.sources,
72+
filesStatistics=suffix_stats.total,
73+
sourceMapPath=sm.file,
74+
)
75+
76+
return DecodeResult(files, sm_stat)

‎decoder.py‎

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import argparse
2+
import json
3+
import sys
4+
from pathlib import Path
5+
from typing import Any, TypedDict
6+
7+
from common import source_maps
8+
from common.error import Error
9+
from common.safe_path import safe_path_join
10+
11+
FORBIDDEN_SYMBOL_LIST = ["|", "{", "}", "$", "^"]
12+
RENAMED_SYMBOL_LIST = [" "]
13+
14+
15+
class DecoderStatistic(TypedDict):
16+
forbiddenPaths: list[str]
17+
renamedPaths: dict[str, str]
18+
deduplicated: dict[str, str]
19+
20+
21+
def check_in_forbidden_symbols(file_path: str) -> bool:
22+
for forbidden_symbol in FORBIDDEN_SYMBOL_LIST:
23+
if forbidden_symbol in file_path:
24+
return True
25+
26+
return False
27+
28+
29+
def remove_renamed_symbols(file_path: str) -> str:
30+
for symbol in RENAMED_SYMBOL_LIST:
31+
file_path = file_path.replace(symbol, "_")
32+
33+
return file_path
34+
35+
36+
def get_saved_folder(map_file: Path) -> str:
37+
suffixes = [".map", ".js"]
38+
39+
file_name = map_file.name
40+
for suffix in suffixes:
41+
file_name = file_name.removesuffix(suffix)
42+
43+
assert not file_name.endswith(".js") and not file_name.endswith(".map"), "Не правильно удалились расширения"
44+
return file_name
45+
46+
47+
def save_statistic_file(output_file_folder: Path, statistic: Any) -> None:
48+
output_file_folder.write_text(json.dumps(statistic, indent=2))
49+
50+
51+
def save_decode_result(output_folder: Path, bundle_name: str, decode_result: source_maps.DecodeResult) -> None:
52+
folder_path = output_folder / bundle_name
53+
if not folder_path.exists():
54+
folder_path.mkdir()
55+
56+
files_folder_path = folder_path / "files"
57+
if not files_folder_path.exists():
58+
files_folder_path.mkdir()
59+
60+
forbidden_paths: list[str] = []
61+
renamed_paths: dict[str, str] = {}
62+
deduplicated: dict[str, str] = {}
63+
64+
# Папка в который лежит бандл, относительной данной папки, мы и будет сохранять файлы
65+
relative_folder_in_sm = Path(decode_result.sourceMapStatistic["sourceMapPath"]).parent
66+
for file_path, file_content in decode_result.files.items():
67+
if check_in_forbidden_symbols(file_path):
68+
forbidden_paths.append(file_path)
69+
print("[INFO]", f"Файл '{file_path}' был убран для сохранения")
70+
continue
71+
72+
if (new_value := remove_renamed_symbols(file_path)) != file_path:
73+
renamed_paths[file_path] = new_value
74+
print("[INFO]", f"Файл '{file_path}' был переименован")
75+
file_path = new_value
76+
77+
# Убираем первый '/', что-бы не попасть в корень диска
78+
file_path = file_path.removeprefix("/")
79+
80+
# Путь восстанавливаемого файла в Sources Map
81+
file_path_in_sm = relative_folder_in_sm / file_path
82+
83+
saved_file_path = safe_path_join(files_folder_path, file_path_in_sm)
84+
if not saved_file_path.parent.exists():
85+
saved_file_path.parent.mkdir(parents=True)
86+
87+
if saved_file_path.exists():
88+
old_file_path = str(saved_file_path)
89+
print("[INFO]", f"Задублирование файла '{saved_file_path}'")
90+
91+
saved_file_path = Path(f"{saved_file_path}?dep2")
92+
deduplicated[old_file_path] = str(saved_file_path)
93+
94+
saved_file_path.write_text(file_content)
95+
96+
decoder_statistic = DecoderStatistic(
97+
forbiddenPaths=forbidden_paths,
98+
renamedPaths=renamed_paths,
99+
deduplicated=deduplicated,
100+
)
101+
102+
save_statistic_file(folder_path / "statistic.json", decode_result.sourceMapStatistic)
103+
save_statistic_file(folder_path / "decoder.json", decoder_statistic)
104+
105+
106+
def get_sources_maps_files(input_path: Path) -> list[Path]:
107+
if not input_path.exists():
108+
exit(f"Файл или папки '{input_path}' не существует")
109+
110+
if input_path.is_dir():
111+
return [file for file in input_path.glob("*.map") if file.is_file()]
112+
113+
if input_path.is_file():
114+
return [input_path]
115+
116+
exit(f"Невозможно определить тип пути: {input_path}")
117+
118+
119+
def decoder(input_path: str, output_folder: str) -> None:
120+
output_path = Path(output_folder)
121+
if output_path.exists():
122+
print("[WARNING]", f"Папка '{output_folder}', уже существует, возможны ошибки при работе программы")
123+
else:
124+
output_path.mkdir(parents=True)
125+
126+
sm_files = get_sources_maps_files(Path(input_path))
127+
for sm_file in sm_files:
128+
result = source_maps.decode(sm_file.read_text(encoding="utf-8"))
129+
if isinstance(result, Error):
130+
print("[ERROR]", f"Произошла ошибка при декодировании файла '{sm_file}': {result.message}")
131+
continue
132+
133+
bundle_name = get_saved_folder(sm_file)
134+
save_decode_result(output_path, bundle_name, result)
135+
136+
137+
def main(argv: list[str]) -> None:
138+
parser = argparse.ArgumentParser(description="JavaScript Source Maps Decoder")
139+
parser.add_argument(
140+
"-I",
141+
"--input",
142+
dest="input_path",
143+
type=str,
144+
required=True,
145+
help="Путь до файла или директории c Source Map",
146+
)
147+
parser.add_argument(
148+
"-O",
149+
"--output",
150+
dest="output_folder",
151+
type=str,
152+
default="output",
153+
help="Путь до папки для вывода",
154+
)
155+
156+
args = parser.parse_args(argv)
157+
decoder(**vars(args))
158+
159+
160+
if __name__ == "__main__":
161+
main(sys.argv[1::])

0 commit comments

Comments
 (0)