11#!/usr/bin/env python3
2+ import atexit
23import sys
34from pathlib import Path
4- from typing import Iterable , Optional
5+ from typing import Dict , Iterable , List , Optional , Tuple
56
67import click
8+ import pkg_resources
9+ from rich .console import Console
10+ from rich .panel import Panel
11+ from rich .table import Table
712from structlog import get_logger
813
914from unblob .models import DirectoryHandlers , Handlers , ProcessResult
1015from unblob .plugins import UnblobPluginManager
11- from unblob .report import Severity
16+ from unblob .report import ChunkReport , Severity , StatReport , UnknownChunkReport
1217
1318from .cli_options import verbosity_option
1419from .dependencies import get_dependencies , pretty_format_dependencies
2530logger = get_logger ()
2631
2732
33+ def restore_cursor ():
34+ # Restore cursor visibility
35+ sys .stdout .write ("\033 [?25h" ) # ANSI escape code to show cursor
36+
37+
2838def show_external_dependencies (
2939 ctx : click .Context , _param : click .Option , value : bool # noqa: FBT001
3040) -> None :
@@ -70,7 +80,7 @@ def __init__(
7080 handlers : Optional [Handlers ] = None ,
7181 dir_handlers : Optional [DirectoryHandlers ] = None ,
7282 plugin_manager : Optional [UnblobPluginManager ] = None ,
73- ** kwargs
83+ ** kwargs ,
7484 ):
7585 super ().__init__ (* args , ** kwargs )
7686 handlers = handlers or BUILTIN_HANDLERS
@@ -157,6 +167,13 @@ def __init__(
157167 type = click .Path (path_type = Path ),
158168 help = "File to store metadata generated during the extraction process (in JSON format)." ,
159169)
170+ @click .option (
171+ "--log" ,
172+ "log_path" ,
173+ default = Path ("unblob.log" ),
174+ type = click .Path (path_type = Path ),
175+ help = "File to save logs (in text format). Defaults to unblob.log." ,
176+ )
160177@click .option (
161178 "-s" ,
162179 "--skip_extraction" ,
@@ -185,6 +202,7 @@ def cli(
185202 file : Path ,
186203 extract_root : Path ,
187204 report_file : Optional [Path ],
205+ log_path : Path ,
188206 force : bool , # noqa: FBT001
189207 process_num : int ,
190208 depth : int ,
@@ -198,7 +216,7 @@ def cli(
198216 plugin_manager : UnblobPluginManager ,
199217 verbose : int ,
200218) -> ProcessResult :
201- configure_logger (verbose , extract_root )
219+ configure_logger (verbose , extract_root , log_path )
202220
203221 plugin_manager .import_plugins (plugins_path )
204222 extra_handlers = plugin_manager .load_handlers_from_plugins ()
@@ -219,10 +237,14 @@ def cli(
219237 handlers = handlers ,
220238 dir_handlers = dir_handlers ,
221239 keep_extracted_chunks = keep_extracted_chunks ,
240+ verbose = verbose ,
222241 )
223242
224243 logger .info ("Start processing file" , file = file )
225- return process_file (config , file , report_file )
244+ process_results = process_file (config , file , report_file )
245+ if verbose == 0 :
246+ print_report (process_results )
247+ return process_results
226248
227249
228250cli .context_class = UnblobContext
@@ -242,6 +264,108 @@ def get_exit_code_from_reports(reports: ProcessResult) -> int:
242264 return 0
243265
244266
267+ def human_size (size : float ):
268+ units = ["B" , "KB" , "MB" , "GB" , "TB" ]
269+ i = 0
270+ while size >= 1024 and i < len (units ) - 1 :
271+ size /= 1024
272+ i += 1
273+ return f"{ size :.2f} { units [i ]} "
274+
275+
276+ def get_chunks_distribution (task_results : List ) -> Dict :
277+ chunks_distribution = {"unknown" : 0 }
278+ for task_result in task_results :
279+ chunk_reports = [
280+ report
281+ for report in task_result .reports
282+ if isinstance (report , (ChunkReport , UnknownChunkReport ))
283+ ]
284+
285+ for chunk_report in chunk_reports :
286+ if isinstance (chunk_report , UnknownChunkReport ):
287+ chunks_distribution ["unknown" ] += chunk_report .size
288+ continue
289+ if chunk_report .handler_name not in chunks_distribution :
290+ chunks_distribution [chunk_report .handler_name ] = 0
291+ chunks_distribution [chunk_report .handler_name ] += chunk_report .size
292+
293+ return chunks_distribution
294+
295+
296+ def get_size_report (task_results : List ) -> Tuple [int , int , int , int ]:
297+ total_files = 0
298+ total_dirs = 0
299+ total_links = 0
300+ extracted_size = 0
301+
302+ for task_result in task_results :
303+ stat_reports = list (
304+ filter (lambda x : isinstance (x , StatReport ), task_result .reports )
305+ )
306+ for stat_report in stat_reports :
307+ total_files += stat_report .is_file
308+ total_dirs += stat_report .is_dir
309+ total_links += stat_report .is_link
310+ if stat_report .is_file :
311+ extracted_size += stat_report .size
312+
313+ return total_files , total_dirs , total_links , extracted_size
314+
315+
316+ def print_report (reports : ProcessResult ):
317+ total_files , total_dirs , total_links , extracted_size = get_size_report (
318+ reports .results
319+ )
320+ chunks_distribution = get_chunks_distribution (reports .results )
321+
322+ valid_size = 0
323+ total_size = 0
324+ for handler , size in chunks_distribution .items ():
325+ if handler != "unknown" :
326+ valid_size += size
327+ total_size += size
328+
329+ if total_size == 0 :
330+ return
331+
332+ summary = Panel (
333+ f"""Extracted files: [#00FFC8]{ total_files } [/#00FFC8]
334+ Extracted directories: [#00FFC8]{ total_dirs } [/#00FFC8]
335+ Extracted links: [#00FFC8]{ total_links } [/#00FFC8]
336+ Extraction directory size: [#00FFC8]{ human_size (extracted_size )} [/#00FFC8]
337+ Chunks identification ratio: [#00FFC8]{ (valid_size / total_size ) * 100 :0.2f} %[/#00FFC8]""" ,
338+ subtitle = "Summary" ,
339+ title = f"unblob ({ get_version ()} )" ,
340+ )
341+
342+ console = Console ()
343+ console .print (summary )
344+
345+ chunks_table = Table (title = "Chunks distribution" )
346+ chunks_table .add_column ("Chunk type" , justify = "left" , style = "#00FFC8" , no_wrap = True )
347+ chunks_table .add_column ("Size" , justify = "center" , style = "#00FFC8" , no_wrap = True )
348+ chunks_table .add_column ("Ratio" , justify = "center" , style = "#00FFC8" , no_wrap = True )
349+
350+ for handler , size in sorted (
351+ chunks_distribution .items (), key = lambda item : item [1 ], reverse = True
352+ ):
353+ chunks_table .add_row (
354+ handler .upper (), human_size (size ), f"{ (size / total_size ) * 100 :0.2f} %"
355+ )
356+
357+ console .print (chunks_table )
358+
359+ if len (reports .errors ):
360+ errors_table = Table (title = "Encountered errors" )
361+ errors_table .add_column ("Severity" , justify = "left" , style = "cyan" , no_wrap = True )
362+ errors_table .add_column ("Name" , justify = "left" , style = "cyan" , no_wrap = True )
363+
364+ for error in reports .errors :
365+ errors_table .add_row (str (error .severity ), error .__class__ .__name__ )
366+ console .print (errors_table )
367+
368+
245369def main ():
246370 try :
247371 # Click argument parsing
@@ -261,6 +385,8 @@ def main():
261385 except Exception :
262386 logger .exception ("Unhandled exception during unblob" )
263387 sys .exit (1 )
388+ finally :
389+ atexit .register (restore_cursor )
264390
265391 sys .exit (get_exit_code_from_reports (reports ))
266392
0 commit comments