fh-tool 2.3.6__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.
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: fh-tool
3
+ Version: 2.3.6
4
+ Summary: 本CLI工具用于计算、存储、验证磁盘文件的hash值,可检查文件是否被篡改。
5
+ Author-email: Ma Lin <malincns@163.com>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://bitbucket.org/wjssz/filehash
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Topic :: System :: Archiving
11
+ Classifier: Topic :: System :: Archiving :: Backup
12
+ Classifier: Topic :: System :: Shells
13
+ Classifier: Topic :: Utilities
14
+ Classifier: Operating System :: Unix
15
+ Classifier: Operating System :: POSIX
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: Microsoft :: Windows
18
+ Requires-Python: >=3.7
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: colorama
21
+
22
+ This tool only provides Simplified Chinese user interface.
23
+
24
+ ### 简介
25
+
26
+ 本CLI工具用于计算、存储、验证磁盘文件的hash值,可检查文件是否被篡改。
27
+
28
+ 有以下功能:
29
+
30
+ - 将磁盘文件的**hash值**、**大小**、**路径**、**登记时间**、**数据库ID**保存到数据库的hash链中
31
+ - 验证数据库中的记录,检查记录的路径、hash值、大小
32
+ - 验证磁盘文件,将hash值、大小、路径与数据库中的记录对比
33
+ - 仅打印数据库中的记录
34
+ - 仅计算磁盘文件的hash值
35
+
36
+ 建议配置环境变量使用。目前大多数下载网站提供`sha256`值,因此默认使用`sha256`算法。
37
+
38
+ ### CLI命令
39
+
40
+ 路径使用[glob](https://docs.python.org/3/library/glob.html)语法:`*` `?` `[]` `**`
41
+
42
+ 🟢添加文件信息到数据库:
43
+ ```shell
44
+ filehash add 'E:\software\python-3.13.12-amd64.exe'
45
+ filehash a 'E:\software\*.exe'
46
+ filehash a 'E:\software\**\*.exe' # **遍历子目录
47
+ filehash a '*.exe' # 当前目录下所有.exe文件
48
+ filehash a '*' # 当前目录下所有文件
49
+ ```
50
+
51
+ 🟠验证数据库中的记录:
52
+ ```shell
53
+ # 如果磁盘文件已被删除,会提示文件不存在,但不会中止。
54
+ filehash verify_record '*\python*.exe'
55
+ filehash vr '*\software\*.exe'
56
+ filehash vr '*\software\**\*.exe'
57
+ filehash vr '*.exe' # 验证数据库中的所有.exe文件
58
+ filehash vr '*' # 验证数据库中的所有记录
59
+ ```
60
+
61
+ 🔵验证磁盘上的文件:
62
+ ```shell
63
+ # 如果数据库中没有该hash值、大小的数据,程序会报错、中止。
64
+ # 此功能可Windows/Linux双系统时使用
65
+ filehash verify_file 'E:\software\python-3.13.12-amd64.exe'
66
+ filehash vf 'E:\software\*.exe'
67
+ filehash vf 'E:\software\**\*.exe'
68
+ ```
69
+
70
+ 🟤在终端运行filehash,打印完整的帮助信息:
71
+ ```
72
+ PS E:\> filehash
73
+ usage: filehash [-h] [-m HASH_METH] [-n] [--db-dir DIR] [--backup-dir DIR] [--backup-size SIZE]
74
+ [CMD] [PATH]
75
+
76
+ 文件hash校验。版本: 2.3.6
77
+ https://pypi.org/project/filehash-tool
78
+
79
+ positional arguments:
80
+ CMD 命令
81
+ PATH 路径,使用glob语法,*表示所有文件,dir/**/*.exe遍历子目录。
82
+
83
+ options:
84
+ -h, --help show this help message and exit
85
+ -m, --hash-meth HASH_METH
86
+ 创建数据库时使用的hash算法,覆盖FILEHASH_HASH_METH环境变量。
87
+ -n, --no-space 打印hash时,不添加空格。
88
+ --db-dir DIR 数据库目录,覆盖FILEHASH_DB_DIR环境变量。
89
+ --backup-dir DIR 备份保存的数据库目录,覆盖FILEHASH_BACKUP_DIR环境变量。
90
+ --backup-size SIZE 备份保存的数据库数量,覆盖FILEHASH_BACKUP_SIZE环境变量。
91
+
92
+ 可用命令:
93
+ add/a 登记文件到数据库
94
+ verify_record/vr 验证数据库中的记录
95
+ print_record/pr 打印数据库中的记录
96
+ print_existing_record/per 打印数据库中尚存在的记录
97
+ verify_file/vf 验证磁盘文件
98
+ print_file/pf 计算文件hash值 (不加载数据库)
99
+ print_accumulated_hash 打印指定ID的累积hash
100
+
101
+ 保证存在的hash算法: blake2b, blake2s, md5, sha1, sha224, sha256, sha384,
102
+ sha3_224, sha3_256, sha3_384, sha3_512, sha512, shake_128, shake_256
103
+ 其它可用的hash算法: md5-sha1, ripemd160, sha512_224, sha512_256, sm3
104
+ 当前创建数据库使用的hash算法: sha256
105
+ ```
106
+
107
+ ### 更新日志
108
+
109
+ 2.3.0: 对数据库记录的glob语法:支持`**`遍历子目录,运行在Linux/macOS时不再区分大小写、可使用`/`或`\`匹配数据库记录的路径分隔符。
110
+
111
+ 2.2.0: 数据库向前不兼容。改变数据库格式,所有元素加入hash链。
@@ -0,0 +1,9 @@
1
+ filehash_tool/__init__.py,sha256=pBp9Cf4sDnYn4ke9e6XesccdioVTCfjacnk38p-oZ_c,85
2
+ filehash_tool/cr.py,sha256=PEcddIHOiirbFKG8b-AZHNZyaKfEA_YaCA1J3m8P8g4,1486
3
+ filehash_tool/filehash.py,sha256=2DD5ae3GcUNjpSeNGSp3M2hRtAP_U1rbWC5VR411xsY,30088
4
+ filehash_tool/globmatch.py,sha256=xckf26vwVr5ym9TzhMy9ilDtNGjVJ2GuByBV0aCMabQ,5452
5
+ fh_tool-2.3.6.dist-info/METADATA,sha256=afa5YlNNgcJdYNRV9f4GY6rkwqyimEkiesFts_WcWYs,4580
6
+ fh_tool-2.3.6.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
+ fh_tool-2.3.6.dist-info/entry_points.txt,sha256=OogxwCaVXb6hm3_0Jesb3vMmxMdO0PHgVJ-YtgXlPtw,57
8
+ fh_tool-2.3.6.dist-info/top_level.txt,sha256=rZsnXX1Mr2HX4_KUEqvcPKS7CsPhzqHJrgtE73g72dk,14
9
+ fh_tool-2.3.6.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ filehash = filehash_tool.filehash:main
@@ -0,0 +1 @@
1
+ filehash_tool
@@ -0,0 +1,4 @@
1
+ import os
2
+ import sys
3
+
4
+ sys.path.append(os.path.dirname(os.path.realpath(__file__)))
filehash_tool/cr.py ADDED
@@ -0,0 +1,59 @@
1
+ try:
2
+ import colorama
3
+ except ImportError:
4
+ colorama = None # type: ignore
5
+
6
+ __all__ = ('ENABLED',
7
+ 'Fore', 'Back',
8
+ 'BRIGHT', 'DIM', 'NORMAL', 'RESET_ALL',
9
+ 'p', 'cr_block')
10
+
11
+ ENABLED = (colorama is not None)
12
+
13
+ if colorama:
14
+ Fore = colorama.Fore
15
+ Back = colorama.Back
16
+
17
+ BRIGHT = colorama.Style.BRIGHT
18
+ DIM = colorama.Style.DIM
19
+ NORMAL = colorama.Style.NORMAL
20
+ RESET_ALL = colorama.Style.RESET_ALL
21
+ else:
22
+ class Colors:
23
+ def __getattribute__(self, _: str):
24
+ return ''
25
+
26
+ Fore = Back = Colors() # type: ignore
27
+
28
+ BRIGHT = ''
29
+ DIM = ''
30
+ NORMAL = ''
31
+ RESET_ALL = ''
32
+
33
+ def colored(obj, *args):
34
+ return ''.join((*args, str(obj), RESET_ALL))
35
+
36
+ def p(*args):
37
+ s = ''.join(args)
38
+ print(s)
39
+
40
+ class cr_block:
41
+ def __init__(self, prompt_pip=False):
42
+ if colorama:
43
+ if hasattr(colorama, 'just_fix_windows_console'):
44
+ # just_fix_windows_console() is available in colorama v0.4.6+
45
+ colorama.just_fix_windows_console()
46
+ else:
47
+ colorama.init()
48
+ elif prompt_pip:
49
+ print('安装colorama模块显示颜色文字信息: pip install colorama')
50
+
51
+ def close(self):
52
+ if colorama:
53
+ colorama.deinit()
54
+
55
+ def __enter__(self):
56
+ return self
57
+
58
+ def __exit__(self, exc_type, exc_value, traceback):
59
+ self.close()
@@ -0,0 +1,809 @@
1
+ import argparse
2
+ import datetime
3
+ import hashlib
4
+ import os
5
+ import shutil
6
+ import sqlite3
7
+ import time
8
+
9
+ from collections import defaultdict
10
+ from dataclasses import dataclass
11
+ from glob import glob
12
+ from pathlib import Path
13
+ from typing import Tuple, List, Dict, Union
14
+
15
+ # OpenSSL版本<1.0.2L或<1.1.1
16
+ try:
17
+ from ssl import OPENSSL_VERSION_INFO as OV
18
+ if ((OV[:2] == (1, 0) and OV[:4] < (1, 0, 2, 12))
19
+ or
20
+ (OV[:2] == (1, 1) and OV[:3] < (1, 1, 1))):
21
+ raise RuntimeError(f'OpenSSL的SHA实现有误,版本:{OV}')
22
+ except ImportError:
23
+ pass
24
+
25
+ import cr
26
+ from globmatch import str_globmatch
27
+
28
+ VER1 = 2
29
+ VER2 = 3
30
+ VER3 = 6
31
+ HASH_METH = 'sha256'
32
+ NO_SPACE = False
33
+ DB_MIN_BACKUP = 10
34
+ READ_BUFFER = 64*1024
35
+
36
+ std_path = lambda path: str(Path(path).resolve())
37
+
38
+ class FHException(Exception):
39
+ pass
40
+
41
+ def hash_str(hash_bytes):
42
+ h = hash_bytes.hex()
43
+ sep = '' if NO_SPACE else ' '
44
+ h = sep.join([cr.colored(h[i : i + 8], cr.Fore.YELLOW)
45
+ for i in range(0, len(h), 8)])
46
+ return h
47
+
48
+ UNITS = ('B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB')
49
+ def s_size(size):
50
+ i = sub = 0
51
+ left = size
52
+ while left >= 1024 and i < len(UNITS) - 1:
53
+ left, t = divmod(left, 1024)
54
+ sub = t or (1 if sub else 0)
55
+ i += 1
56
+ if sub:
57
+ sub = f'{sub/1024:.2f}'
58
+ if sub[0] == '1':
59
+ sub = '0.99'
60
+ return f'{left}{sub[1:4]} {UNITS[i]} ({size:,})'
61
+ else:
62
+ return f'{left} {UNITS[i]} ({size:,})'
63
+
64
+ _chars_map = {chr(i): f'\\x{i:02X}' for i in range(0x20)}
65
+ _chars_map.update({'\t': '\\t',
66
+ '\n': '\\n',
67
+ '\r': '\\r',
68
+ '\x7F': '\\x7F'})
69
+ _chars_map = str.maketrans(_chars_map)
70
+
71
+ def p_esc(s):
72
+ return s.translate(_chars_map)
73
+
74
+ def progress_bar(total, title, width=50):
75
+ last_percent = -1
76
+ if len(title) > width:
77
+ left, rem = divmod(width - 3, 2)
78
+ right = left + rem
79
+ title = title[:left] + '...' + title[-right:]
80
+ else:
81
+ title += '-' * (width - len(title))
82
+
83
+ def progress(progress):
84
+ nonlocal total, title, width, last_percent
85
+ percent = (progress + 1) / total
86
+ if percent - last_percent < 0.001:
87
+ return
88
+ last_percent = percent
89
+ now = int(width * percent)
90
+ bar = title[:now] + '|' + title[now+1:]
91
+ print("%s %.1f%%" % (bar, 100*percent), end="\r", flush=True)
92
+
93
+ def finish():
94
+ print((width + 9) * ' ', end='\r')
95
+
96
+ return progress, finish
97
+
98
+ @dataclass
99
+ class File:
100
+ db_id: int
101
+ time: int
102
+ path: str
103
+ size: int
104
+ hash: bytes
105
+ acc_hash: bytes
106
+
107
+ def __str__(self):
108
+ return (f'ID: {self.db_id:,} 大小: {s_size(self.size)}\n'
109
+ f'文件: {p_esc(self.path)}\n'
110
+ f'文件hash: {hash_str(self.hash)}\n'
111
+ f'累积hash: {hash_str(self.acc_hash)}')
112
+
113
+ class Database:
114
+ def __init__(self, db_dir, backup_dir, backup_size, write_mode: bool) -> None:
115
+ # 检查参数
116
+ if db_dir and not os.path.isdir(db_dir):
117
+ raise FHException(f'--db_dir不是目录: {db_dir}')
118
+ if backup_dir and not os.path.isdir(backup_dir):
119
+ raise FHException(f'--backup_dir不是目录: {backup_dir}')
120
+ if backup_size < DB_MIN_BACKUP:
121
+ raise FHException(f'--backup_size应不小于{DB_MIN_BACKUP}')
122
+
123
+ self.write_mode = write_mode
124
+ self.db_version = 0
125
+ self.hash_method = ''
126
+
127
+ # 获取数据库路径
128
+ root_path = os.getcwd()
129
+ self.db_folder = db_dir or \
130
+ os.path.join(root_path, 'filehash_database')
131
+ if not os.path.isdir(self.db_folder):
132
+ os.makedirs(self.db_folder)
133
+
134
+ print(f' - 数库目录: {self.db_folder}')
135
+ print(f' - 备份目录: {backup_dir or "无"}')
136
+ print(f' - 备份数量: {backup_size}')
137
+ self.backup_dir = backup_dir
138
+ self.backup_size = backup_size
139
+
140
+ self.open_db()
141
+
142
+ def __enter__(self):
143
+ return self
144
+
145
+ def __exit__(self, exc_type, exc_value, traceback):
146
+ self.close_db()
147
+
148
+ # get sorted db file list
149
+ def _get_dbfile_list(self, folder) -> List[str]:
150
+ lst = glob(os.path.join(folder, 'files_*.db'))
151
+ lst.sort()
152
+ return lst
153
+
154
+ def _del_old_dbfile(self, folder) -> None:
155
+ db_lst = self._get_dbfile_list(folder)
156
+
157
+ assert self.backup_size >= DB_MIN_BACKUP
158
+ if len(db_lst) > self.backup_size:
159
+ db_lst = db_lst[0:len(db_lst)-self.backup_size]
160
+ for del_fn in db_lst:
161
+ try:
162
+ os.remove(del_fn)
163
+ except Exception as e:
164
+ print(f'删除旧备份数据库文件{del_fn}时出错。', e)
165
+
166
+ # 创建数据库
167
+ def _creat_db(self) -> None:
168
+ # info table
169
+ sql = ('CREATE TABLE IF NOT EXISTS info( '
170
+ 'key TEXT NOT NULL UNIQUE, '
171
+ 'value TEXT);')
172
+ self.cursor.execute(sql)
173
+
174
+ # 写入创建数据库的程序版本
175
+ sql = "INSERT INTO info VALUES('db_version', ?);"
176
+ self.cursor.execute(sql, (str(10000*VER1 + 100*VER2 + VER3),))
177
+
178
+ # 写入hash方法
179
+ sql = "INSERT INTO info VALUES('hash_method', ?);"
180
+ self.cursor.execute(sql, (HASH_METH,))
181
+
182
+ # files table
183
+ sql = ('CREATE TABLE IF NOT EXISTS files( '
184
+ 'id INTEGER PRIMARY KEY AUTOINCREMENT, '
185
+ 'time INTEGER NOT NULL, '
186
+ 'path TEXT NOT NULL, '
187
+ 'size INTEGER NOT NULL, '
188
+ 'hash BLOB NOT NULL, '
189
+ 'acc_hash BLOB NOT NULL);')
190
+ self.cursor.execute(sql)
191
+
192
+ def _check_db_integrity(self):
193
+ r = self.cursor.execute('PRAGMA integrity_check;')
194
+ lst = r.fetchall()
195
+ if lst == [('ok',),]:
196
+ print('数据库已通过完整性检查。')
197
+ else:
198
+ for row in lst:
199
+ print(row)
200
+ raise FHException('数据库完整性检查失败。')
201
+
202
+ @staticmethod
203
+ def file_hash(hash_method: str, path: str) -> bytes:
204
+ # 计算文件hash
205
+ h = hashlib.new(hash_method)
206
+ buf = bytearray(READ_BUFFER)
207
+ view = memoryview(buf)
208
+ progress, finish = progress_bar(os.path.getsize(path),
209
+ os.path.basename(path))
210
+ acc_size = 0
211
+
212
+ with open(path, 'rb') as f:
213
+ while True:
214
+ size = f.readinto(buf)
215
+ if not size:
216
+ break
217
+ h.update(view[:size])
218
+ acc_size += size
219
+ progress(acc_size)
220
+ finish()
221
+ return h.digest()
222
+
223
+ @staticmethod
224
+ def _int_to_8bytes(i: int) -> bytes:
225
+ return i.to_bytes(length = 8,
226
+ byteorder = 'little',
227
+ signed = False)
228
+
229
+ def _get_acc_hash(self,
230
+ id: int,
231
+ time: int,
232
+ path: str,
233
+ size: int,
234
+ file_hash: bytes) -> bytes:
235
+ # 得到acc_hash
236
+ h = hashlib.new(self.hash_method)
237
+ if self.lst:
238
+ acc_hash = self.lst[-1].acc_hash
239
+ else:
240
+ # 长度digest_size,填充0。
241
+ acc_hash = bytearray(h.digest_size)
242
+
243
+ # 重要信息在前,id在最后,用户可见路径在倒数第二。
244
+ h.update(acc_hash)
245
+ h.update(file_hash)
246
+ h.update(self._int_to_8bytes(size))
247
+ h.update(self._int_to_8bytes(time))
248
+ h.update(path.encode('utf_32_le'))
249
+ h.update(self._int_to_8bytes(id))
250
+ return h.digest()
251
+
252
+ def print_db_info(self, id = None) -> None:
253
+ if id is None:
254
+ if not self.lst:
255
+ return
256
+ else:
257
+ index= -1
258
+ else:
259
+ index = id - 1
260
+ if not 0 <= index < len(self.lst):
261
+ raise FHException(f'指定的ID不存在:{id}')
262
+
263
+ c = cr.colored(self.lst[index].db_id,
264
+ cr.Fore.LIGHTCYAN_EX,
265
+ cr.Back.MAGENTA)
266
+ print(f'数据库ID: {c}')
267
+
268
+ # 累积hash
269
+ ah = self.lst[index].acc_hash
270
+
271
+ # 打印累积hash
272
+ print(f'累积hash: ', end='')
273
+ GROUP_SIZE = 4
274
+ ROW_GROUP = 4
275
+ for i in range(0, len(ah), GROUP_SIZE):
276
+ group = i // GROUP_SIZE + 1
277
+ if group % ROW_GROUP == 0:
278
+ if group == len(ah) // GROUP_SIZE:
279
+ end = ''
280
+ else:
281
+ end = '\n' + 10 * ' '
282
+ else:
283
+ end = ' '
284
+
285
+ # 打印组序号
286
+ c = cr.colored(f'{group:02}' if cr.ENABLED else f'[{group:02}]',
287
+ cr.Fore.YELLOW,
288
+ cr.Back.BLACK)
289
+ print(c, end=' ')
290
+ # 打印组内容
291
+ c = cr.colored(' '.join(f'{one:02x}'
292
+ for one
293
+ in ah[i : i + GROUP_SIZE]),
294
+ cr.Fore.LIGHTYELLOW_EX,
295
+ cr.Back.BLUE)
296
+ print(c, end=end)
297
+ print()
298
+
299
+ def close_db(self) -> None:
300
+ # 关闭数据库
301
+ self.cursor.close()
302
+ self.conn.close()
303
+
304
+ # 只读模式,使用已有数据库文件。
305
+ if not self.write_mode:
306
+ print('数据库已正常关闭。')
307
+ return
308
+
309
+ c = cr.colored(len(self.lst)-self.db_row, cr.Fore.LIGHTGREEN_EX)
310
+ print(f'数据库已正常关闭,添加了{c}条数据。')
311
+
312
+ if len(self.lst) == self.db_row:
313
+ # 未修改,删除未修改的新文件。
314
+ try:
315
+ os.remove(self.db_file)
316
+ except Exception as e:
317
+ print(f'删除文件{self.db_file}时出错。', e)
318
+ else:
319
+ # 有修改,重命名。
320
+ fn = 'files_%s_%d.db' % (time.strftime('%y%m%d_%H%M%S'),
321
+ len(self.lst))
322
+ path = os.path.join(self.db_folder, fn)
323
+ try:
324
+ os.rename(self.db_file, path)
325
+ except Exception as e:
326
+ print(f'重命名数据库文件{self.db_file}到{path}时出错:\n{e}')
327
+ raise
328
+
329
+ # 打印信息
330
+ print(f'最新数据库文件: {path}\n')
331
+ self.print_db_info()
332
+
333
+ # 删除旧备份
334
+ self._del_old_dbfile(self.db_folder)
335
+
336
+ # 备份
337
+ if self.backup_dir:
338
+ shutil.copy(path, self.backup_dir)
339
+ # 删除旧备份
340
+ self._del_old_dbfile(self.backup_dir)
341
+
342
+ def open_db(self):
343
+ db_lst = self._get_dbfile_list(self.db_folder)
344
+ if self.write_mode:
345
+ self.db_file = os.path.join(self.db_folder, '~temp.db')
346
+ if os.path.isfile(self.db_file):
347
+ msg = (f'数据库文件{self.db_file}已存在,无法写入。'
348
+ f'请等待其它filehash进程结束。'
349
+ f'如果没有其它filehash进程在运行,可手动删除该文件。')
350
+ raise FHException(msg)
351
+ if db_lst:
352
+ shutil.copy(db_lst[-1], self.db_file)
353
+ else:
354
+ if db_lst:
355
+ self.db_file = db_lst[-1]
356
+ else:
357
+ self.db_file = ':memory:'
358
+
359
+ # autocommit=True && isolation_level=None:
360
+ # sqlite3 module doesn't issue BEGIN and commit implicitly
361
+ # sqlite3 engine uses autocommit mode
362
+ if hasattr(sqlite3, 'LEGACY_TRANSACTION_CONTROL'):
363
+ self.conn = sqlite3.connect(self.db_file,
364
+ autocommit=True)
365
+ else:
366
+ self.conn = sqlite3.connect(self.db_file,
367
+ isolation_level=None)
368
+ self.cursor = self.conn.cursor()
369
+
370
+ # create table if not exists
371
+ if not db_lst:
372
+ self._creat_db()
373
+
374
+ # load data
375
+ self.load_all_entries()
376
+
377
+ def _append_one_entry_data(self, f: File) -> None:
378
+ self.lst.append(f)
379
+ self.path_dict[f.path] = f
380
+ self.hash_dict[(f.hash, f.size)].append(f)
381
+ self.acc_size += f.size
382
+
383
+ def load_all_entries(self) -> None:
384
+ t1 = time.perf_counter()
385
+
386
+ # 检查数据库完整性
387
+ self._check_db_integrity()
388
+
389
+ # 读取创建数据库的程序版本
390
+ sql = "SELECT value FROM info WHERE key = 'db_version'"
391
+ r = self.cursor.execute(sql)
392
+ db_version = r.fetchone()
393
+ try:
394
+ self.db_version = int(db_version[0])
395
+ except ValueError:
396
+ raise FHException(f'无法解析数据库的版本号:{db_version[0]}')
397
+ if self.db_version < 220:
398
+ raise FHException('创建数据库的程序版本应>=2.2.0')
399
+
400
+ # 读取hash方法
401
+ sql = "SELECT value FROM info WHERE key = 'hash_method'"
402
+ r = self.cursor.execute(sql)
403
+ self.hash_method = r.fetchone()[0]
404
+ print(f'当前hash算法: {cr.colored(self.hash_method, cr.Fore.YELLOW)}')
405
+
406
+ # 加载数据
407
+ sql = 'SELECT * FROM files ORDER BY id'
408
+ r = self.cursor.execute(sql)
409
+
410
+ self.lst : List[File] = []
411
+ self.path_dict : Dict[str, File] = {}
412
+ self.hash_dict : Dict[Tuple[bytes, int], List[File]] = defaultdict(list)
413
+ self.acc_size : int = 0
414
+
415
+ for row in r.fetchall():
416
+ f = File(row[0], row[1], row[2],
417
+ row[3], row[4], row[5])
418
+
419
+ # 计算累积hash
420
+ acc_hash = self._get_acc_hash(f.db_id, f.time, f.path,
421
+ f.size, f.hash)
422
+ if acc_hash != f.acc_hash:
423
+ c = cr.colored('数据库的hash链不可信',
424
+ cr.Fore.WHITE,
425
+ cr.Back.RED)
426
+ print(c)
427
+
428
+ raise FHException((f'数据库文件: {self.db_file}\n'
429
+ f'ID为{f.db_id:,}的数据,累积hash值有误。\n'
430
+ f'计算的累积hash: {hash_str(acc_hash)}\n'
431
+ f'数库的累积hash: {hash_str(f.acc_hash)}'))
432
+
433
+ self._append_one_entry_data(f)
434
+
435
+ # 记录数据库行数
436
+ self.db_row = len(self.lst)
437
+
438
+ t2 = time.perf_counter()
439
+ print(f'加载数据用时{t2-t1:.5f}秒, 共有{len(self.lst):,}条记录。\n')
440
+
441
+ # 打印数据库信息
442
+ self.print_db_info()
443
+ print()
444
+
445
+ def add_one(self, path) -> Union[File, None]:
446
+ path = std_path(path)
447
+ if path in self.path_dict:
448
+ c = cr.colored('已存在',
449
+ cr.Fore.LIGHTYELLOW_EX,
450
+ cr.Back.BLUE)
451
+ print(f'文件{c}于数据库,不再计算、录入hash值:\n{p_esc(path)}\n')
452
+ return None
453
+
454
+ # 计算文件hash
455
+ file_hash = self.file_hash(self.hash_method, path)
456
+
457
+ # 文件大小
458
+ file_size = os.path.getsize(path)
459
+
460
+ # 当前计算时间,单位秒。
461
+ hash_time = int(time.time())
462
+
463
+ self.cursor.execute('BEGIN')
464
+ try:
465
+ # 插入数据库
466
+ sql = ('INSERT INTO files(id, time, path, size, hash, acc_hash) '
467
+ 'VALUES(NULL, ?, ?, ?, ?, ?);')
468
+ self.cursor.execute(sql,
469
+ (hash_time, path, file_size,
470
+ file_hash, b''))
471
+ last_id = self.cursor.lastrowid
472
+
473
+ # 计算累积hash
474
+ acc_hash = self._get_acc_hash(last_id, hash_time, path,
475
+ file_size, file_hash)
476
+ sql = 'UPDATE files SET acc_hash = ? WHERE id = ?'
477
+ self.cursor.execute(sql, (acc_hash, last_id))
478
+
479
+ self.cursor.execute('COMMIT')
480
+ except:
481
+ self.cursor.execute('ROLLBACK')
482
+ raise
483
+
484
+ # 加入list
485
+ f = File(last_id, hash_time, path, file_size,
486
+ file_hash, acc_hash)
487
+ self._append_one_entry_data(f)
488
+
489
+ # 打印信息
490
+ print(cr.colored('成功添加一条信息', cr.Fore.LIGHTGREEN_EX))
491
+ print(f, '\n')
492
+
493
+ return f
494
+
495
+ def add(self, path: str):
496
+ f_lst = []
497
+ for one in glob(path, recursive=True):
498
+ if os.path.isfile(one):
499
+ f = self.add_one(one)
500
+ if f is not None:
501
+ f_lst.append(f)
502
+
503
+ count = cr.colored(len(f_lst), cr.Fore.LIGHTGREEN_EX)
504
+ print(f'添加了{count}个文件。')
505
+ if f_lst:
506
+ for f in f_lst:
507
+ print(p_esc(f.path))
508
+ print()
509
+
510
+ def verify_record(self, glob_path: str) -> None:
511
+ # 查找匹配的路径
512
+ lst = []
513
+ for f in self.lst:
514
+ if str_globmatch(f.path, glob_path,
515
+ case_sensitive=False,
516
+ cross_fs=True):
517
+ lst.append(f)
518
+
519
+ # 验证文件
520
+ count = 0
521
+ for i, f in enumerate(lst, 1):
522
+ # 文件不存在
523
+ if not os.path.isfile(f.path):
524
+ c = cr.colored('文件不存在',
525
+ cr.Fore.LIGHTYELLOW_EX,
526
+ cr.Back.MAGENTA)
527
+ print(f'({i}/{len(lst)}){c}: {p_esc(f.path)}')
528
+ continue
529
+
530
+ # 验证文件大小
531
+ size = os.path.getsize(f.path)
532
+ if size != f.size:
533
+ c = cr.colored('文件验证失败', cr.Fore.WHITE, cr.Back.RED)
534
+ print(f'{c}: {p_esc(f.path)}')
535
+ raise FHException((f'文件{f.path}的大小与数据库不符。\n'
536
+ f'文件大小: {s_size(size)}\n'
537
+ f'数库大小: {s_size(f.size)}\n'
538
+ f'数库hash: {hash_str(f.hash)}'))
539
+
540
+ # 验证hash
541
+ hash = self.file_hash(self.hash_method, f.path)
542
+ if hash != f.hash:
543
+ c = cr.colored('文件验证失败', cr.Fore.WHITE, cr.Back.RED)
544
+ print(f'{c}: {p_esc(f.path)}')
545
+ raise FHException((f'文件{f.path}的hash值与数据库不符。\n'
546
+ f'文件hash: {hash_str(hash)}\n'
547
+ f'数库hash: {hash_str(f.hash)}\n'
548
+ f'文件大小符合数据库记录: {s_size(f.size)}'))
549
+
550
+ # 验证成功
551
+ c = cr.colored('文件验证成功',
552
+ cr.Fore.LIGHTWHITE_EX,
553
+ cr.Back.GREEN)
554
+ print(f'({i}/{len(lst)}){c}: {p_esc(f.path)}' )
555
+ count += 1
556
+
557
+ # 打印数据库信息
558
+ count = cr.colored(count, cr.Fore.LIGHTRED_EX)
559
+ print(f'验证了{count}个文件。\n')
560
+ self.print_db_info()
561
+
562
+ def print_record(self, glob_path: str, *, only_existing: bool) -> None:
563
+ count = exist_count = 0
564
+ for f in self.lst:
565
+ if str_globmatch(f.path, glob_path,
566
+ case_sensitive=False,
567
+ cross_fs=True):
568
+ count += 1
569
+
570
+ # 文件是否存在
571
+ if os.path.isfile(f.path):
572
+ exist = cr.colored('文件存在',
573
+ cr.Fore.LIGHTWHITE_EX,
574
+ cr.Back.GREEN)
575
+ exist_count += 1
576
+ else:
577
+ if only_existing:
578
+ continue
579
+ exist = cr.colored('文件不存在',
580
+ cr.Fore.LIGHTWHITE_EX,
581
+ cr.Back.RED)
582
+
583
+ time = datetime.datetime.fromtimestamp(f.time).\
584
+ strftime('%Y-%m-%d %H:%M:%S')
585
+ print((f'文件: {p_esc(f.path)}\n'
586
+ f'大小: {s_size(f.size)}\n'
587
+ f'hash: {hash_str(f.hash)}\n'
588
+ f'登记: {time}\n'
589
+ f'状态: {exist}\n'))
590
+
591
+ # 打印数据库信息
592
+ count = cr.colored(count, cr.Fore.LIGHTRED_EX)
593
+ exist_count = cr.colored(exist_count, cr.Fore.LIGHTRED_EX)
594
+ if only_existing:
595
+ print(f'匹配了{count}条记录,其中{exist_count}个文件存在。\n')
596
+ else:
597
+ print(f'打印了{count}条记录,其中{exist_count}个文件存在。\n')
598
+ self.print_db_info()
599
+
600
+ def verify_file_one(self, path) -> None:
601
+ path = std_path(path)
602
+ size = os.path.getsize(path)
603
+ hash = self.file_hash(self.hash_method, path)
604
+
605
+ # 在hash_dict中
606
+ lst = self.hash_dict.get((hash, size))
607
+ if lst:
608
+ c = cr.colored('文件验证成功',
609
+ cr.Fore.LIGHTWHITE_EX,
610
+ cr.Back.GREEN)
611
+ print(f'{c}: {p_esc(path)}')
612
+
613
+ for f in lst:
614
+ if len(lst) > 1 or path != f.path:
615
+ c = cr.colored('数据库中路径',
616
+ cr.Fore.LIGHTWHITE_EX,
617
+ cr.Back.MAGENTA)
618
+ print(f'{c}: {p_esc(f.path)}')
619
+
620
+ print((f'大小: {s_size(size)}\n'
621
+ f'hash: {hash_str(hash)}\n'))
622
+ return
623
+
624
+ # 在path_dict中
625
+ if path in self.path_dict:
626
+ f = self.path_dict[path]
627
+
628
+ if size != f.size:
629
+ c = cr.colored('文件验证失败', cr.Fore.WHITE, cr.Back.RED)
630
+ print(f'{c}: {p_esc(path)}')
631
+ raise FHException((f'文件{path}的大小与数据库不符。\n'
632
+ f'文件大小: {s_size(size)}\n'
633
+ f'数库大小: {s_size(f.size)}\n'
634
+ f'数库hash: {hash_str(f.hash)}'))
635
+
636
+ if hash != f.hash:
637
+ c = cr.colored('文件验证失败', cr.Fore.WHITE, cr.Back.RED)
638
+ print(f'{c}: {p_esc(path)}')
639
+ raise FHException((f'文件{path}的hash值与数据库不符。\n'
640
+ f'文件hash: {hash_str(hash)}\n'
641
+ f'数库hash: {hash_str(f.hash)}\n'
642
+ f'文件大小符合数据库记录: {s_size(f.size)}'))
643
+
644
+ raise Exception('不可到达代码路径')
645
+
646
+ c = cr.colored('文件验证失败', cr.Fore.WHITE, cr.Back.RED)
647
+ print(f'{c}: {p_esc(path)}')
648
+ raise FHException((f'文件{path}的hash值、大小不在数据库中。\n'
649
+ f'文件hash: {hash_str(hash)}\n'
650
+ f'文件大小: {s_size(size)}'))
651
+
652
+ def verify_file(self, path: str):
653
+ count = 0
654
+ for one in glob(path, recursive=True):
655
+ if os.path.isfile(one):
656
+ self.verify_file_one(one)
657
+ count += 1
658
+
659
+ # 打印数据库信息
660
+ count = cr.colored(count, cr.Fore.LIGHTRED_EX)
661
+ print(f'验证了{count}个文件。\n')
662
+ self.print_db_info()
663
+
664
+ def print_file(path: str):
665
+ print(f'使用hash算法: {cr.colored(HASH_METH, cr.Fore.YELLOW)}\n')
666
+
667
+ count = 0
668
+ for one in glob(path, recursive=True):
669
+ if os.path.isfile(one):
670
+ file_size = os.path.getsize(one)
671
+ hash = Database.file_hash(HASH_METH, one)
672
+ s = (f'文件: {p_esc(std_path(one))}\n'
673
+ f'大小: {s_size(file_size)}\n'
674
+ f'hash: {hash_str(hash)}\n')
675
+ print(s)
676
+ count += 1
677
+
678
+ count = cr.colored(count, cr.Fore.LIGHTRED_EX)
679
+ print(f'计算了{count}个文件。')
680
+
681
+ def args_config(args):
682
+ # hash方法
683
+ global HASH_METH
684
+ HASH_METH = (args.hash_meth[0] or
685
+ os.getenv('FILEHASH_HASH_METH', HASH_METH))
686
+ if HASH_METH not in hashlib.algorithms_available:
687
+ raise FHException(f'指定的hash算法"{HASH_METH}"不可用。')
688
+
689
+ # print文件hash时无空格
690
+ global NO_SPACE
691
+ NO_SPACE = args.no_space
692
+
693
+ def get_hash_meth_list():
694
+ guaranteed = list(hashlib.algorithms_guaranteed)
695
+ guaranteed.sort()
696
+ s = '保证存在的hash算法: ' + ', '.join(guaranteed)
697
+
698
+ available = hashlib.algorithms_available.difference(hashlib.algorithms_guaranteed)
699
+ available = list(available)
700
+ available.sort()
701
+ s += '\n其它可用的hash算法: ' + ', '.join(available)
702
+ return s
703
+
704
+ def backup_config(args):
705
+ # 数据库目录
706
+ db_dir = (args.db_dir[0] or
707
+ os.getenv('FILEHASH_DB_DIR', ''))
708
+
709
+ # 备份目录
710
+ backup_dir = (args.backup_dir[0] or
711
+ os.getenv('FILEHASH_BACKUP_DIR', ''))
712
+
713
+ # 备份数量
714
+ backup_size_str = (args.backup_size[0] or
715
+ os.getenv('FILEHASH_BACKUP_SIZE', '20'))
716
+
717
+ try:
718
+ backup_size = int(backup_size_str)
719
+ except ValueError:
720
+ raise FHException((f'解析环境变量FILEHASH_BACKUP_SIZE或'
721
+ f'参数--backup_size失败: {backup_size_str}'))
722
+
723
+ return db_dir, backup_dir, backup_size
724
+
725
+ def main_impl():
726
+ parser = argparse.ArgumentParser(
727
+ description = (f'文件hash校验。版本: {VER1}.{VER2}.{VER3}\n'
728
+ f'https://pypi.org/project/filehash-tool'),
729
+ epilog = ('可用命令:\n'
730
+ ' add/a 登记文件到数据库\n'
731
+ ' verify_record/vr 验证数据库中的记录\n'
732
+ ' print_record/pr 打印数据库中的记录\n'
733
+ ' print_existing_record/per 打印数据库中尚存在的记录\n'
734
+ ' verify_file/vf 验证磁盘文件\n'
735
+ ' print_file/pf 计算文件hash值 (不加载数据库)\n'
736
+ ' print_accumulated_hash 打印指定ID的累积hash\n\n') +
737
+ get_hash_meth_list(),
738
+ formatter_class = argparse.RawDescriptionHelpFormatter)
739
+ cmds = ['',
740
+ 'a', 'add',
741
+ 'vr', 'verify_record',
742
+ 'pr', 'print_record',
743
+ 'print_existing_record', 'per',
744
+ 'vf', 'verify_file',
745
+ 'pf', 'print_file',
746
+ 'print_accumulated_hash']
747
+ parser.add_argument('command', nargs='?', default='', choices=cmds,
748
+ metavar='CMD', help='命令')
749
+ parser.add_argument('path', nargs='?', default='.',
750
+ metavar='PATH',
751
+ help='路径,使用glob语法,*表示所有文件,dir/**/*.exe遍历子目录。')
752
+ parser.add_argument('-m', '--hash-meth', nargs=1, default=[''], metavar='HASH_METH',
753
+ help='创建数据库时使用的hash算法,覆盖FILEHASH_HASH_METH环境变量。')
754
+ parser.add_argument('-n', '--no-space', action='store_true', dest='no_space', default=False,
755
+ help='打印hash时,不添加空格。')
756
+ parser.add_argument('--db-dir', nargs=1, default=[''], metavar='DIR',
757
+ help='数据库目录,覆盖FILEHASH_DB_DIR环境变量。')
758
+ parser.add_argument('--backup-dir', nargs=1, default=[''], metavar='DIR',
759
+ help='备份保存的数据库目录,覆盖FILEHASH_BACKUP_DIR环境变量。')
760
+ parser.add_argument('--backup-size', nargs=1, default=[''], metavar='SIZE',
761
+ help='备份保存的数据库数量,覆盖FILEHASH_BACKUP_SIZE环境变量。')
762
+ args = parser.parse_args()
763
+ cmd = args.command.lower()
764
+ path = args.path
765
+ if cmd != '' and path == '.':
766
+ raise FHException('PATH路径不支持"."默认路径,需明确指定路径。')
767
+ args_config(args)
768
+
769
+ if cmd in ('print_file', 'pf'):
770
+ print_file(path)
771
+ elif cmd == '':
772
+ parser.print_help()
773
+ print(f'当前创建数据库使用的hash算法: {HASH_METH}')
774
+ else:
775
+ db_dir, backup_dir, backup_size = backup_config(args)
776
+ with Database(db_dir, backup_dir, backup_size,
777
+ cmd in ('add', 'a')) as db:
778
+ if cmd in ('add', 'a'):
779
+ db.add(path)
780
+ elif cmd in ('verify_record', 'vr'):
781
+ db.verify_record(path)
782
+ elif cmd in ('print_record', 'pr'):
783
+ db.print_record(path, only_existing=False)
784
+ elif cmd in ('print_existing_record', 'per'):
785
+ db.print_record(path, only_existing=True)
786
+ elif cmd in ('verify_file', 'vf'):
787
+ db.verify_file(path)
788
+ elif cmd == 'print_accumulated_hash':
789
+ try:
790
+ id = int(path)
791
+ except ValueError:
792
+ raise FHException(f'无法将ID转换为整数:{path}')
793
+ print('指定ID的累积hash:')
794
+ db.print_db_info(id)
795
+ else:
796
+ raise ValueError('未知参数')
797
+
798
+ def main():
799
+ with cr.cr_block():
800
+ try:
801
+ main_impl()
802
+ except FHException as e:
803
+ c = cr.colored('程序出现异常,中止运行:',
804
+ cr.Fore.WHITE,
805
+ cr.Back.RED)
806
+ print(f'\n{c}\n{e}')
807
+
808
+ if __name__ == '__main__':
809
+ main()
@@ -0,0 +1,151 @@
1
+ import functools
2
+ import re
3
+
4
+ __all__ = ('globmatch', 'str_globmatch')
5
+
6
+ def globmatch(path, pat, *,
7
+ case_sensitive: bool = True,
8
+ cross_fs: bool = False) -> bool:
9
+ if isinstance(path, bytes):
10
+ path = str(path, 'ISO-8859-1')
11
+ pat = str(pat, 'ISO-8859-1')
12
+ return str_globmatch(path, pat,
13
+ case_sensitive=case_sensitive,
14
+ cross_fs=cross_fs)
15
+
16
+ def str_globmatch(path: str, pat: str, *,
17
+ case_sensitive: bool = True,
18
+ cross_fs: bool = False) -> bool:
19
+ # detect path separator, use `/` if not found.
20
+ m = re.match(r'(?s)^.*?([/\\])', path)
21
+ path_sep = m.group(1) if m else '/'
22
+ match = _compile_pattern(pat, case_sensitive, cross_fs, path_sep)
23
+ return match(path) is not None
24
+
25
+ @functools.lru_cache(maxsize=65536)
26
+ def _compile_pattern(pat, case_sensitive, cross_fs, path_sep):
27
+ res = translate(pat, cross_fs, path_sep)
28
+ flags = 0 if case_sensitive else re.IGNORECASE
29
+ return re.compile(res, flags).match
30
+
31
+ def translate(pat, cross_fs, path_sep):
32
+ parts, star_indices = _translate(pat, '*', '.', cross_fs, path_sep)
33
+ return _join_translated_parts(parts, star_indices)
34
+
35
+ _re_setops_sub = re.compile(r'([&~|])').sub
36
+ _re_escape = functools.lru_cache(maxsize=None)(re.escape)
37
+
38
+ def _translate(pat, star, question_mark, cross_fs, path_sep):
39
+ res = []
40
+ add = res.append
41
+ star_indices = []
42
+ if cross_fs:
43
+ sep_chr = ('/', '\\')
44
+ sep_pat = r'[/\\]' # escaped
45
+ else:
46
+ sep_chr = (path_sep,)
47
+ sep_pat = _re_escape(path_sep)
48
+
49
+ i, n = 0, len(pat)
50
+ while i < n:
51
+ c = pat[i]
52
+ i = i+1
53
+ if c == '*':
54
+ # store the position of the wildcard
55
+ star_indices.append(len(res))
56
+ add(star)
57
+ # compress consecutive `*` into one
58
+ while i < n and pat[i] == '*':
59
+ i += 1
60
+ elif c == '?':
61
+ add(question_mark)
62
+ elif c in sep_chr:
63
+ if pat[i:i+2] == '**' and pat[i+2:i+3] in sep_chr:
64
+ j = i+3
65
+ while pat[j:j+2] == '**' and pat[j+2:j+3] in sep_chr:
66
+ j += 3
67
+
68
+ add(sep_pat)
69
+ star_indices.append(len(res))
70
+ add(star)
71
+ add(rf'(?<={sep_pat})')
72
+ i = j
73
+ else:
74
+ add(sep_pat)
75
+ elif c == '[':
76
+ j = i
77
+ if j < n and pat[j] == '!':
78
+ j = j+1
79
+ if j < n and pat[j] == ']':
80
+ j = j+1
81
+ while j < n and pat[j] != ']':
82
+ j = j+1
83
+ if j >= n:
84
+ add('\\[')
85
+ else:
86
+ stuff = pat[i:j]
87
+ if '-' not in stuff:
88
+ stuff = stuff.replace('\\', r'\\')
89
+ else:
90
+ chunks = []
91
+ k = i+2 if pat[i] == '!' else i+1
92
+ while True:
93
+ k = pat.find('-', k, j)
94
+ if k < 0:
95
+ break
96
+ chunks.append(pat[i:k])
97
+ i = k+1
98
+ k = k+3
99
+ chunk = pat[i:j]
100
+ if chunk:
101
+ chunks.append(chunk)
102
+ else:
103
+ chunks[-1] += '-'
104
+ # Remove empty ranges -- invalid in RE.
105
+ for k in range(len(chunks)-1, 0, -1):
106
+ if chunks[k-1][-1] > chunks[k][0]:
107
+ chunks[k-1] = chunks[k-1][:-1] + chunks[k][1:]
108
+ del chunks[k]
109
+ # Escape backslashes and hyphens for set difference (--).
110
+ # Hyphens that create ranges shouldn't be escaped.
111
+ stuff = '-'.join(s.replace('\\', r'\\').replace('-', r'\-')
112
+ for s in chunks)
113
+ i = j+1
114
+ if not stuff:
115
+ # Empty range: never match.
116
+ add('(?!)')
117
+ elif stuff == '!':
118
+ # Negated empty range: match any character.
119
+ add('.')
120
+ else:
121
+ # Escape set operations (&&, ~~ and ||).
122
+ stuff = _re_setops_sub(r'\\\1', stuff)
123
+ if stuff[0] == '!':
124
+ stuff = '^' + stuff[1:]
125
+ elif stuff[0] in ('^', '['):
126
+ stuff = '\\' + stuff
127
+ add(f'[{stuff}]')
128
+ else:
129
+ add(_re_escape(c))
130
+ assert i == n
131
+ return res, star_indices
132
+
133
+ def _join_translated_parts(parts, star_indices):
134
+ if not star_indices:
135
+ return fr'(?s:{"".join(parts)})\Z'
136
+ iter_star_indices = iter(star_indices)
137
+ j = next(iter_star_indices)
138
+ buffer = parts[:j] # fixed pieces at the start
139
+ append, extend = buffer.append, buffer.extend
140
+ i = j + 1
141
+ for groupnum, j in enumerate(iter_star_indices, 1):
142
+ # Now deal with STAR fixed STAR fixed ...
143
+ # For an interior `STAR fixed` pairing, we want to do a minimal
144
+ # .*? match followed by `fixed`, with no possibility of backtracking.
145
+ append((f"(?=(?P<g{groupnum}>.*?{''.join(parts[i:j])}))"
146
+ f"(?P=g{groupnum})"))
147
+ i = j + 1
148
+ append('.*')
149
+ extend(parts[i:])
150
+ res = ''.join(buffer)
151
+ return fr'(?s:{res})\Z'