From 32e71fb069c44c5823400742e8e850555eda258a Mon Sep 17 00:00:00 2001 From: Miguel Palma Date: Fri, 19 Jun 2026 10:47:26 +0100 Subject: [PATCH 1/3] v.0.0.6 - TAR support, archive security, GUI # --- TAR support --- - Added .tar / .tar.gz / .tgz input support (streaming r|* mode) # --- Archive security --- - Block absolute paths and directory traversal at extraction time - Double-check resolved path stays inside dest with commonpath() - Block TAR hard links and symlinks unconditionally - Sanitize Windows reserved names (CON/NUL/COM*/LPT*) and illegal chars # --- GUI (SQLiteWalkerGUI.py) --- - New tkinter GUI, no extra dependencies - Live log panel, progress bar, stat pills, Open Output button - Cross OS support # --- Fixes --- - Collision-safe output folders and extracted filenames (-001, -002...) --- SQLiteWalker.py | 724 ++++++++++++++++++++---------- SQLiteWalkerGUI.py | 1071 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1547 insertions(+), 248 deletions(-) create mode 100644 SQLiteWalkerGUI.py diff --git a/SQLiteWalker.py b/SQLiteWalker.py index e30de78..729a08b 100644 --- a/SQLiteWalker.py +++ b/SQLiteWalker.py @@ -1,14 +1,22 @@ import csv import argparse import os +import re import shutil import sqlite3 import sys import time -from zipfile import ZipFile +import tarfile import zipfile +from zipfile import ZipFile + +# --------------------------------------------------------------------------- +# Version +# --------------------------------------------------------------------------- -ascii_art = ''' +VERSION = "0.6.0" + +ascii_art = rf''' _______. ______ __ __ .___________. _______ / | / __ \ | | | | | || ____| | (----`| | | | | | | | `---| |----`| |__ @@ -22,277 +30,497 @@ \ /\ / / _____ \ | `----.| . \ | |____ | |\ \----. \__/ \__/ /__/ \__\ |_______||__|\__\ |_______|| _| `._____| - SQLiteWalker v0.0.5 + SQLiteWalker v{VERSION} https://github.com/stark4n6/SQLiteWalker @KevinPagano3 | @stark4n6 | startme.stark4n6.com ''' + +# --------------------------------------------------------------------------- +# Platform helpers +# --------------------------------------------------------------------------- + def is_platform_windows(): - '''Returns True if running on Windows''' return os.name == 'nt' +# --------------------------------------------------------------------------- +# SQLite helpers +# --------------------------------------------------------------------------- + def open_sqlite_db_readonly(path): - '''Opens an sqlite db in read-only mode, so original db (and -wal/journal are intact)''' + '''Opens a SQLite db read-only so the original file and its -wal/-shm are never modified.''' if is_platform_windows(): - if path.startswith('\\\\?\\UNC\\'): # UNC long path + # Encode the \\?\ long-path prefix into its percent-encoded form for the URI + if path.startswith('\\\\?\\UNC\\'): # UNC long path path = "%5C%5C%3F%5C" + path[4:] - elif path.startswith('\\\\?\\'): # normal long path + elif path.startswith('\\\\?\\'): # normal long path path = "%5C%5C%3F%5C" + path[4:] - elif path.startswith('\\\\'): # UNC path + elif path.startswith('\\\\'): # plain UNC path = "%5C%5C%3F%5C\\UNC" + path[1:] - else: # normal path + else: # normal drive path path = "%5C%5C%3F%5C" + path return sqlite3.connect(f"file:{path}?mode=ro", uri=True) -def main(): - - base = "SQLiteWalker_Out_" - data_list = [] - error_list = [] - data_headers = ('File Name','Export Path','Tables') - error_headers = ('File Name','Export Path','Error') - count = 0 - error_count = 0 - wal_count = 0 - shm_count = 0 - splitter = '' +# --------------------------------------------------------------------------- +# Archive safety +# --------------------------------------------------------------------------- + +# Device names that are illegal as file/folder names on Windows regardless of extension +WINDOWS_RESERVED_NAMES = { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", +} + + +def sanitize_archive_member_name(member_name): + ''' + Validates and cleans an archive member path before extraction. + + Raises RuntimeError for: + - absolute paths (/etc/passwd, C:\\Windows\\...) + - directory traversal (../../secret) + - empty components after normalization + + Strips illegal characters on Windows and handles reserved device names. + Returns an OS-appropriate relative path string. + ''' + member_name = member_name.replace("\\", "/") + + # Block absolute paths — both POSIX-style and Windows drive-letter style + if member_name.startswith("/") or re.match(r"^[A-Za-z]:", member_name): + raise RuntimeError(f"Unsafe absolute archive path blocked: {member_name}") + + safe_parts = [] + for part in member_name.split("/"): + # Empty segments, current-dir dots, and parent-dir traversal are all unsafe + if part in ("", ".", ".."): + raise RuntimeError(f"Unsafe archive path component blocked: {member_name}") + + # Replace characters that are illegal in Windows filenames + safe_part = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", part) + # Windows also forbids trailing spaces and dots on file/folder names + safe_part = safe_part.rstrip(" .") + + if not safe_part: + safe_part = "_" + + # Prefix reserved device names so they can't be created on Windows + stem = safe_part.split(".", 1)[0].upper() + if stem in WINDOWS_RESERVED_NAMES: + safe_part = f"_{safe_part}" + + safe_parts.append(safe_part) + + return os.path.join(*safe_parts) + + +def unique_dest_path(dest_path): + '''Returns dest_path unchanged if it doesn't exist, otherwise appends -001, -002, etc.''' + if not os.path.exists(dest_path): + return dest_path + + folder, filename = os.path.split(dest_path) + stem, ext = os.path.splitext(filename) + + for n in range(1, 1000): + candidate = os.path.join(folder, f"{stem}-{n:03d}{ext}") + if not os.path.exists(candidate): + return candidate + + raise RuntimeError(f"Could not create a unique output path for {filename}") + + +def safe_archive_dest_path(member_name, destination): + ''' + Resolves the final extraction path for an archive member and verifies it + stays inside the destination directory (guards against path traversal + even after sanitization). + ''' + dest_root = os.path.abspath(destination) + safe_name = sanitize_archive_member_name(member_name) + dest_path = os.path.abspath(os.path.join(dest_root, safe_name)) + + # commonpath() raises ValueError when the paths are on different drives (Windows) + try: + common = os.path.commonpath([dest_root, dest_path]) + except ValueError: + common = "" + + if common != dest_root: + raise RuntimeError(f"Unsafe archive path blocked after resolution: {member_name}") + + return unique_dest_path(dest_path) + + +def safe_zip_extract(zip_archive, member, destination): + '''Extracts one ZIP member to destination using the safe path helpers above.''' + dest_path = safe_archive_dest_path(member.filename, destination) + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + with zip_archive.open(member) as src, open(dest_path, "wb") as out: + shutil.copyfileobj(src, out) + return dest_path + + +def safe_tar_write_file(member, source, destination, initial_data=b""): + ''' + Writes one TAR member to destination. + Hard and symbolic links are blocked outright — they can point outside the + destination tree and cannot be made safe by path sanitization alone. + initial_data lets the caller prepend bytes already read (e.g. the magic-byte + header we consumed to identify the file type). + ''' + if member.islnk() or member.issym(): + raise RuntimeError(f"Unsafe TAR link blocked: {member.name}") + + dest_path = safe_archive_dest_path(member.name, destination) + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + + with open(dest_path, "wb") as out: + if initial_data: + out.write(initial_data) + shutil.copyfileobj(source, out) + + return dest_path + +# --------------------------------------------------------------------------- +# Output folder creation +# --------------------------------------------------------------------------- + +def create_output_folder(output_path, base, splitter): + ''' + Creates the timestamped output folder. If a folder with that timestamp + already exists (two scans fired within the same second) it appends -001, + -002, etc. rather than crashing. + ''' + output_ts = time.strftime("%Y%m%d-%H%M%S") + + for n in range(1000): + suffix = "" if n == 0 else f"-{n:03d}" + out_folder = output_path + base + output_ts + suffix + try: + os.makedirs(out_folder + splitter + "db_out") + return out_folder + except FileExistsError: + continue + + raise RuntimeError(f"Could not create a unique output folder for {base}{output_ts}") + +# --------------------------------------------------------------------------- +# Core scan +# --------------------------------------------------------------------------- + +SQLITE_MAGIC = b"\x53\x51\x4c\x69\x74\x65\x20\x66\x6f\x72\x6d\x61\x74\x20\x33\x00" + + +def run_scan(input_path, output_path, quiet_mode): + base = "SQLiteWalker_Out_" + data_list = [] + error_list = [] + data_headers = ('File Name', 'Export Path', 'Tables') + error_headers = ('File Name', 'Export Path', 'Error') + count = error_count = wal_count = shm_count = 0 + splitter = "\\" if is_platform_windows() else "/" start_time = time.time() - - #Command line arguments - parser = argparse.ArgumentParser(description='SQLiteWalker v0.0.5 by @KevinPagano3 | @stark4n6 | https://github.com/stark4n6/SQLiteWalker') - parser.add_argument('-i', '--input_path', required=True, type=str, action="store", help='Input file/folder path') - parser.add_argument('-o', '--output_path', required=True, type=str, action="store", help='Output folder path') - parser.add_argument('-q', '--quiet_mode', required=False, action="store_true", help='Turns off console path output') - - args = parser.parse_args() - - input_path = args.input_path - output_path = args.output_path - quiet_mode = args.quiet_mode - - if args.output_path is None: - parser.error('No OUTPUT folder path provided') - return - else: - output_path = os.path.abspath(args.output_path) - - if output_path is None: - parser.error('No OUTPUT folder selected. Run the program again.') - return - - if input_path is None: - parser.error('No INPUT file or folder selected. Run the program again.') - return - - if not os.path.exists(input_path): - parser.error('INPUT file/folder does not exist! Run the program again.') - return - - if not os.path.exists(output_path): - parser.error('OUTPUT folder does not exist! Run the program again.') - return - - # File system extractions can contain paths > 260 char, which causes problems - # This fixes the problem by prefixing \\?\ on each windows path. + + # Prefix Windows paths to handle >260-char paths if is_platform_windows(): - if input_path[1] == ':': input_path = '\\\\?\\' + input_path.replace('/', '\\') - if output_path[1] == ':': output_path = '\\\\?\\' + output_path.replace('/', '\\') - - if not output_path.endswith('\\'): - output_path = output_path + '\\' - - platform = is_platform_windows() - if platform: - splitter = '\\' - else: - splitter = '/' - #------------------------------- - - print(ascii_art) - print() - print('Source: '+ input_path) - print('Destination: '+ output_path) - print('-'* (len('Source: '+ input_path))) - - if quiet_mode: - print('Quiet mode enabled.') - print('These aren\'t the logs you\'re looking for.') - - folder, basename = os.path.split(input_path) - - output_ts = time.strftime("%Y%m%d-%H%M%S") - out_folder = output_path + base + output_ts - os.makedirs(out_folder + splitter + 'db_out') - - if basename.find('.') > 0: - if basename.endswith('.zip'): - with zipfile.ZipFile(input_path, 'r') as my_zip: - files = my_zip.namelist() - for file in files: - file_name = file.rsplit("/",1) - if file.endswith(('-shm','-wal')): - my_zip.extract(file,(out_folder + splitter + 'db_out')) - if file.startswith('/'): - file = file[1:] - new_path = out_folder + splitter + 'db_out' + splitter + file - if platform: - new_path = new_path.replace('/','\\') - if file.endswith('-shm'): - shm_count += 1 - if not quiet_mode: - print('SHM ' + str(shm_count) + ': ' + file) - else: - wal_count += 1 - if not quiet_mode: - print('WAL ' + str(wal_count) + ': ' + file) - - data_list.append((file_name[1], new_path[4:], '')) - else: - with my_zip.open(file) as f: - header = f.read(100) - if header.startswith(b'\x53\x51\x4c\x69\x74\x65\x20\x66\x6f\x72\x6d\x61\x74\x20\x33\x00'): - my_zip.extract(file,(out_folder + splitter + 'db_out')) - try: - if file.startswith('/'): - file = file[1:] - new_path = out_folder + splitter + 'db_out' + splitter + file - - if platform: - new_path = new_path.replace('/','\\') - db_connect = open_sqlite_db_readonly(new_path) - - sql_query = """SELECT name FROM sqlite_master - WHERE type='table';""" - - cursor = db_connect.cursor() - cursor.execute(sql_query) - - # printing all tables list - tables = cursor.fetchall() - tables_list = [] - - entries = len(tables) - if entries > 0: - for row in tables: - tables_list.append(row[0]) - - data_list.append((file_name[1], new_path[4:], tables_list)) - count += 1 - if not quiet_mode: - print('DB ' + str(count) + ': ' + file) - - else: - data_list.append((file_name[1], new_path[4:], '')) - count += 1 - if not quiet_mode: - print('DB ' + str(count) + ': ' + file) - - except sqlite3.Error as error: - if not quiet_mode: - print("Failed to open the database: ", error) - print(file) - error_list.append((file_name[1], new_path[4:], error)) - error_count += 1 - - finally: - if db_connect: - db_connect.close() + if len(input_path) > 1 and input_path[1] == ":": + input_path = "\\\\?\\" + input_path.replace("/", "\\") + if len(output_path) > 1 and output_path[1] == ":": + output_path = "\\\\?\\" + output_path.replace("/", "\\") + + # Trailing separator required so base concatenates into the right directory + if not output_path.endswith(splitter): + output_path += splitter + + out_folder = create_output_folder(output_path, base, splitter) + _, basename = os.path.split(input_path) + basename_lower = basename.lower() + + # ------------------------------------------------------------------ + # Archive branch (.zip / .tar / .tar.gz / .tgz) + # ------------------------------------------------------------------ + if os.path.isfile(input_path): + + if basename_lower.endswith(".zip"): + archive_type = "zip" + elif basename_lower.endswith((".tar", ".tar.gz", ".tgz")): + archive_type = "tar" else: - print('File is not a .zip, please try again. Exiting......') - sys.exit() - - else: + print("Input file is not a supported archive (.zip / .tar / .tar.gz / .tgz). Exiting.") + sys.exit(1) + + print(f"Opening {archive_type.upper()}: {input_path}") + + if archive_type == "zip": + archive = zipfile.ZipFile(input_path, "r") + entries = archive.infolist() + total = len(entries) + else: + # r|* = streaming read, works on .tar.gz without seeking + archive = tarfile.open(input_path, "r|*") + entries = archive # iterator — no random access, total unknown up front + total = 0 + + for idx, entry in enumerate(entries, 1): + file = entry.filename if archive_type == "zip" else entry.name + file_name = file.rsplit("/", 1) + + # ----- WAL / SHM companion files ----- + if file.endswith(("-shm", "-wal")): + try: + if archive_type == "zip": + if entry.is_dir(): + continue + new_path = safe_zip_extract(archive, entry, out_folder + splitter + "db_out") + else: + if not entry.isfile(): + continue + f = archive.extractfile(entry) + if f is None: + continue + with f: + new_path = safe_tar_write_file(entry, f, out_folder + splitter + "db_out") + except RuntimeError as e: + print(f" BLOCKED ARCHIVE ENTRY: {e}") + continue + + if file.startswith("/"): + file = file[1:] + if is_platform_windows(): + new_path = new_path.replace("/", "\\") + + if file.endswith("-shm"): + shm_count += 1 + if not quiet_mode: + print(f" SHM {shm_count}: {file}") + else: + wal_count += 1 + if not quiet_mode: + print(f" WAL {wal_count}: {file}") + + dest = new_path[4:] if is_platform_windows() else new_path + data_list.append((file_name[-1], dest, "")) + + # ----- Potential SQLite database files ----- + else: + db = None + new_path = None + + if archive_type == "zip": + if entry.is_dir(): + continue + with archive.open(entry) as f: + header = f.read(100) + if not header.startswith(SQLITE_MAGIC): + continue + try: + new_path = safe_zip_extract(archive, entry, out_folder + splitter + "db_out") + except RuntimeError as e: + print(f" BLOCKED ZIP ENTRY: {e}") + continue + + else: + if not entry.isfile(): + continue + f = archive.extractfile(entry) + if f is None: + continue + with f: + header = f.read(100) + if not header.startswith(SQLITE_MAGIC): + continue + try: + # Pass header back in — it was consumed during identification + new_path = safe_tar_write_file( + entry, f, out_folder + splitter + "db_out", header + ) + except RuntimeError as e: + print(f" BLOCKED TAR ENTRY: {e}") + continue + + try: + if file.startswith("/"): + file = file[1:] + if is_platform_windows(): + new_path = new_path.replace("/", "\\") + db = open_sqlite_db_readonly(new_path) + cursor = db.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") + tables_list = [r[0] for r in cursor.fetchall()] + dest = new_path[4:] if is_platform_windows() else new_path + data_list.append((file_name[-1], dest, tables_list)) + count += 1 + if not quiet_mode: + print(f" DB {count}: {file} [{len(tables_list)} tables]") + except sqlite3.Error as e: + print(f" ERROR: {file} — {e}") + dest = new_path[4:] if is_platform_windows() else new_path + error_list.append((file_name[-1], dest, e)) + error_count += 1 + finally: + try: + if db: db.close() + except Exception: + pass + + archive.close() + + # ------------------------------------------------------------------ + # Folder walk branch + # ------------------------------------------------------------------ + elif os.path.isdir(input_path): + print(f"Walking folder: {input_path}") + for root, dirs, files in os.walk(input_path): for file in files: - if file.endswith(('-shm','-wal')): - src_file_path = os.path.join(root, file) - - dest_folder_path = os.path.join(out_folder, os.path.relpath(root, input_path)) - dest_file_path = os.path.join(dest_folder_path, file) - os.makedirs(dest_folder_path, exist_ok=True) - - shutil.copy2(src_file_path, dest_file_path) - - if file.endswith('-shm'): - shm_count += 1 - if not quiet_mode: - print('SHM ' + str(shm_count) + ': ' + file) + + # ----- WAL / SHM companion files ----- + if file.endswith(("-shm", "-wal")): + src_path = os.path.join(root, file) + rel = os.path.relpath(root, input_path) + dest_dir = out_folder if rel == "." else os.path.join(out_folder, rel) + dest_file = os.path.join(dest_dir, file) + os.makedirs(dest_dir, exist_ok=True) + shutil.copy2(src_path, dest_file) + + if file.endswith("-shm"): + shm_count += 1 + if not quiet_mode: + print(f" SHM {shm_count}: {file}") else: wal_count += 1 if not quiet_mode: - print('WAL ' + str(wal_count) + ': ' + file) - - data_list.append((file, dest_file_path[4:], '')) - - else: - src_file_path = os.path.join(root, file) - if os.path.isfile(src_file_path): - if os.path.getsize(src_file_path) > 100: - with open(src_file_path,'r', encoding = "ISO-8859-1") as f: - header = f.read(100) - if header.startswith('SQLite format 3'): - try: - db_connect = open_sqlite_db_readonly(src_file_path) - - sql_query = """SELECT name FROM sqlite_master - WHERE type='table';""" - - cursor = db_connect.cursor() - cursor.execute(sql_query) - - tables = cursor.fetchall() - tables_list = [] - - for row in tables: - tables_list.append(row[0]) - - count += 1 - if not quiet_mode: - print('DB ' + str(count) + ': ' + src_file_path) - - src_file_path = os.path.join(root, file) - - dest_folder_path = os.path.join(out_folder, os.path.relpath(root, input_path)) - dest_file_path = os.path.join(dest_folder_path, file) - os.makedirs(dest_folder_path, exist_ok=True) - - shutil.copy2(src_file_path, dest_file_path) - data_list.append((file, dest_file_path[4:], tables_list)) - - except sqlite3.Error as error: - if not quiet_mode: - print("Failed to execute the above query", error) - print(file) - error_list.append((file, dest_file_path[4:], error)) - error_count += 1 - - finally: - if db_connect: - db_connect.close() - - with open(out_folder + splitter + 'db_list.tsv', 'w', newline='') as f_output: - tsv_writer = csv.writer(f_output, delimiter='\t') - tsv_writer.writerow(data_headers) - for i in data_list: - tsv_writer.writerow(i) - + print(f" WAL {wal_count}: {file}") + + data_list.append((file, dest_file, "")) + continue + + # ----- Potential SQLite database files ----- + src_path = os.path.join(root, file) + if not os.path.isfile(src_path) or os.path.getsize(src_path) <= 100: + continue + + try: + with open(src_path, "r", encoding="ISO-8859-1") as f: + hdr = f.read(100) + except Exception: + continue + + if not hdr.startswith("SQLite format 3"): + continue + + db = None + try: + db = open_sqlite_db_readonly(src_path) + cursor = db.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") + tables_list = [r[0] for r in cursor.fetchall()] + + rel = os.path.relpath(root, input_path) + dest_dir = out_folder if rel == "." else os.path.join(out_folder, rel) + dest_file = os.path.join(dest_dir, file) + os.makedirs(dest_dir, exist_ok=True) + shutil.copy2(src_path, dest_file) + + data_list.append((file, dest_file, tables_list)) + count += 1 + if not quiet_mode: + print(f" DB {count}: {src_path} [{len(tables_list)} tables]") + except sqlite3.Error as e: + print(f" ERROR: {file} — {e}") + error_list.append((file, src_path, e)) + error_count += 1 + finally: + try: + if db: db.close() + except Exception: + pass + + else: + print("Input path is not a file or folder. Exiting.") + sys.exit(1) + + # ------------------------------------------------------------------ + # Write TSV output(s) + # ------------------------------------------------------------------ + + tsv_path = out_folder + splitter + "db_list.tsv" + with open(tsv_path, "w", newline="") as f_out: + w = csv.writer(f_out, delimiter="\t") + w.writerow(data_headers) + for row in data_list: + w.writerow(row) + if error_count > 0: - with open(out_folder + splitter + 'error_list.tsv', 'w', newline='') as f_output: - tsv_writer = csv.writer(f_output, delimiter='\t') - tsv_writer.writerow(error_headers) - for i in error_list: - tsv_writer.writerow(i) - + err_path = out_folder + splitter + "error_list.tsv" + with open(err_path, "w", newline="") as f_out: + w = csv.writer(f_out, delimiter="\t") + w.writerow(error_headers) + for row in error_list: + w.writerow(row) + + # ------------------------------------------------------------------ + # Summary + # ------------------------------------------------------------------ + + elapsed = round(time.time() - start_time, 2) print() - print('****JOB FINISHED****') - print('Runtime: %s seconds' % (time.time() - start_time)) - print('DBs Found: ' + str(count)) - print('SHMs Found: ' + str(shm_count)) - print('WALs Found: ' + str(wal_count)) - print('Error Count: ' + str(error_count)) + print("****JOB FINISHED****") + print(f"Runtime : {elapsed}s") + print(f"DBs Found : {count}") + print(f"SHMs Found : {shm_count}") + print(f"WALs Found : {wal_count}") + print(f"Error Count : {error_count}") if error_count > 0: - print('Check error file error_list.tsv for details') + print("Check error_list.tsv for details") + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser( + description=f"SQLiteWalker v{VERSION} by @KevinPagano3 | @stark4n6 | https://github.com/stark4n6/SQLiteWalker" + ) + parser.add_argument('-i', '--input_path', required=True, type=str, action="store", + help='Input path: folder, .zip, .tar, .tar.gz, or .tgz') + parser.add_argument('-o', '--output_path', required=True, type=str, action="store", + help='Output folder path') + parser.add_argument('-q', '--quiet_mode', required=False, action="store_true", + help='Suppress per-file console output') + args = parser.parse_args() + + input_path = args.input_path + output_path = os.path.abspath(args.output_path) + quiet_mode = args.quiet_mode + + if not os.path.exists(input_path): + parser.error("INPUT path does not exist.") + if not os.path.exists(output_path): + parser.error("OUTPUT folder does not exist.") + + # Windows: prefix paths to handle >260-char paths (done again inside run_scan, + # but doing it here ensures the argparse validation above works on long paths too) + if is_platform_windows(): + if len(input_path) > 1 and input_path[1] == ":": + input_path = "\\\\?\\" + input_path.replace("/", "\\") + + print(ascii_art) + print() + print(f"Source : {input_path}") + print(f"Destination : {output_path}") + print("-" * max(len(f"Source : {input_path}"), 40)) + + if quiet_mode: + print("Quiet mode enabled.") + print("These aren't the logs you're looking for.") + print() + + run_scan(input_path, output_path, quiet_mode) + -if __name__ == '__main__': - main() +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/SQLiteWalkerGUI.py b/SQLiteWalkerGUI.py new file mode 100644 index 0000000..a7689f3 --- /dev/null +++ b/SQLiteWalkerGUI.py @@ -0,0 +1,1071 @@ +import csv +import os +import shutil +import sqlite3 +import subprocess +import sys +import time +import threading +import traceback +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +import zipfile # ZIP File support +import base64 +import platform as _platform_mod +import tempfile +import tarfile # TAR File Support +import re + +VERSION = "0.6.0" + +ASCII_BANNER = ( + " SQLiteWalker v" + VERSION + "\n" + " https://github.com/stark4n6/SQLiteWalker\n" + " @KevinPagano3 | @stark4n6\n" +) + +# --------------------------------------------------------------------------- +# Platform +# --------------------------------------------------------------------------- + +_SYS = _platform_mod.system() # "Windows", "Darwin", or "Linux" + +def _is_windows(): return _SYS == "Windows" +def _is_mac(): return _SYS == "Darwin" +def _is_linux(): return _SYS == "Linux" + +# Font families per OS - Segoe/Courier don't exist on macOS or Linux +if _is_windows(): + _SANS, _MONO = "Segoe UI", "Courier New" +elif _is_mac(): + _SANS, _MONO = "SF Pro Text", "Menlo" # SF Pro falls back to Helvetica Neue +else: + _SANS, _MONO = "DejaVu Sans", "DejaVu Sans Mono" + +MONO = (_MONO, 9) +SANS_SM = (_SANS, 9) +SANS_MD = (_SANS, 10) +HEADER = (_MONO, 14, "bold") + +# --------------------------------------------------------------------------- +# Palette +# --------------------------------------------------------------------------- + +BG = "#1a1d23" +BG_PANEL = "#21252e" +BG_INPUT = "#161920" +ACCENT = "#00d4aa" +ACCENT_DIM = "#007a63" +FG = "#d0d6e0" +FG_DIM = "#6b7280" +RED = "#f87171" +YELLOW = "#fbbf24" + +# --------------------------------------------------------------------------- +# Embedded icon - 32x32 "SW" PNG + ICO, no external files needed +# --------------------------------------------------------------------------- + +_ICON_PNG_B64 = ( + "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAC70lEQVR4nM2XTUgUYRjHfzPOzLru" + "ru4mfh78oA5+XbqI2KXoEGL0QWCXIIIiQokOHRI6VAR18GSHDhUEWZBR0CkijIiITlGQeihl19QN" + "dW21VdfZ3ZkOs6677exX7KrPZWffeeb9/57/O8z7vLDNIeT8xLdhPe39tp6c5sw+OZPwf4JkTvpH" + "uLarP2367KtbOYGkB4gTzyScFiQNRGqAqHiuwilBUkCIhRRPmCPFO5QMkEfxbCASAQogngnCfAm2" + "MDYBClj9Rpi5IKVKjlS5WDrXjdrWgGa3IgZViub9uG4MIXl9LJ85RKBnPyUjn3EOPANgqe8oK90d" + "yJNeKnoHAVjtasd/8TiWr5OUX7mXpCPGE8VXv3j1FMHOFnbdHKLmxDXK+x8gTXjRLTIAyqgHALWl" + "PvaM2mxchxqr0UssxlirMaaMuU1dMH0HtFIboT21COEI8qQXYT2E/H0a18AwsvtXdEIP6DrhmnI0" + "lwPdaiHUWI00NQeCgNpUlwCojHlMnTYFENQQaBq6LDF39xJL5w8TbG9CVzZXTAysIf2cj1WpNteB" + "IGB//j4mrLkchGvKQdeRx6dyAAiqOJ68BSBS6WTl2D4Wr59m7v5lwg1VsbyNqtSW+lilxZ/GkWYW" + "jLGo/ZJnDnElmD0AgOPxCBV9d7A/fYc86TVgKsr4c/LAJsCoOwFAmp5HXF5FGXWjNtWx3tYIgCW6" + "/jkBAMgTs5Q+fE1F7yD2Fx8A0K2WJAdCu2tRm+pi/5VRN7pVYe3gXmOeMXP7UwJotmJ8t88S7GhG" + "c9nRbMVEKp1GNV9+xPKkWR/i7wC6VIRuVeIAjF/Nbk0ANQvT74AQikAozNKFI2hlNhAEiub9OB69" + "wfbyY0KuMu4h2NkaFTIqlWYWEP0BNKcd0R9A8vpSAmxukVvwJYTk7XkH7QVRoqSWKo9h1pzsIAeg" + "oC6kas2SHSgARLq+0HwJ8giRqSndwW25CUQ2IPk9mKQByRh5O5rlCpLj4XTb4y/cQz1QNZiA/wAA" + "AABJRU5ErkJggg==" +) + +_ICON_ICO_B64 = ( + "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" +) + + +def _set_window_icon(root): + # Windows needs a real .ico file on disk; write to temp and clean up immediately + # macOS/Linux accept a PhotoImage via iconphoto() + try: + if _is_windows(): + tmp = tempfile.NamedTemporaryFile(suffix=".ico", delete=False) + tmp.write(base64.b64decode(_ICON_ICO_B64)) + tmp.close() + root.iconbitmap(tmp.name) + os.unlink(tmp.name) + else: + img = tk.PhotoImage(data=_ICON_PNG_B64) + root.iconphoto(True, img) + root._icon_ref = img # keep ref so GC doesn't collect it + except Exception: + pass # icon is cosmetic - never crash over it + + +def _open_folder(path): + # Each OS has its own "open folder" verb + try: + if _is_windows(): + os.startfile(path) + elif _is_mac(): + subprocess.Popen(["open", path]) + else: + subprocess.Popen(["xdg-open", path]) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# SQLite helpers +# --------------------------------------------------------------------------- + +# Windows long-path prefix: paths >260 chars need the \\?\ URI escape +def open_sqlite_db_readonly(path): + if _is_windows(): + if path.startswith("\\\\?\\UNC\\"): + path = "%5C%5C%3F%5C" + path[4:] + elif path.startswith("\\\\?\\"): + path = "%5C%5C%3F%5C" + path[4:] + elif path.startswith("\\\\"): + path = "%5C%5C%3F%5C\\UNC" + path[1:] + else: + path = "%5C%5C%3F%5C" + path + return sqlite3.connect(f"file:{path}?mode=ro", uri=True) + +WINDOWS_RESERVED_NAMES = { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", +} + + +def sanitize_archive_member_name(member_name): + member_name = member_name.replace("\\", "/") + + if member_name.startswith("/") or re.match(r"^[A-Za-z]:", member_name): + raise RuntimeError( + f"Unsafe absolute archive path blocked: {member_name}" + ) + + safe_parts = [] + for part in member_name.split("/"): + if part in ("", ".", ".."): + raise RuntimeError( + f"Unsafe archive path component blocked: {member_name}" + ) + + safe_part = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", part) + safe_part = safe_part.rstrip(" .") + + if not safe_part: + safe_part = "_" + + stem = safe_part.split(".", 1)[0].upper() + if stem in WINDOWS_RESERVED_NAMES: + safe_part = f"_{safe_part}" + + safe_parts.append(safe_part) + + return os.path.join(*safe_parts) + + +def unique_dest_path(dest_path): + if not os.path.exists(dest_path): + return dest_path + + folder, filename = os.path.split(dest_path) + stem, ext = os.path.splitext(filename) + + for suffix_num in range(1, 1000): + candidate = os.path.join(folder, f"{stem}-{suffix_num:03d}{ext}") + if not os.path.exists(candidate): + return candidate + + raise RuntimeError( + f"Could not create a unique output path for {filename}" + ) + + +def safe_archive_dest_path(member_name, destination): + dest_root = os.path.abspath(destination) + safe_member_name = sanitize_archive_member_name(member_name) + dest_path = os.path.abspath(os.path.join(dest_root, safe_member_name)) + + try: + common_path = os.path.commonpath([dest_root, dest_path]) + except ValueError: + common_path = "" + + if common_path != dest_root: + raise RuntimeError( + f"Unsafe archive path blocked: {member_name}" + ) + + return unique_dest_path(dest_path) + + +def safe_tar_write_file(member, source, destination, initial_data=b""): + if member.islnk() or member.issym(): + raise RuntimeError( + f"Unsafe TAR link blocked: {member.name}" + ) + + dest_path = safe_archive_dest_path(member.name, destination) + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + + with open(dest_path, "wb") as out: + if initial_data: + out.write(initial_data) + shutil.copyfileobj(source, out) + + return dest_path + + +def safe_zip_extract(zip_archive, member, destination): + dest_path = safe_archive_dest_path(member.filename, destination) + + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + with zip_archive.open(member) as source, open(dest_path, "wb") as out: + shutil.copyfileobj(source, out) + + return dest_path + + +def create_output_folder(output_path, base, splitter): + output_ts = time.strftime("%Y%m%d-%H%M%S") + + for suffix_num in range(1000): + suffix = "" if suffix_num == 0 else f"-{suffix_num:03d}" + out_folder = output_path + base + output_ts + suffix + + try: + os.makedirs(out_folder + splitter + "db_out") + return out_folder + except FileExistsError: + continue + + raise RuntimeError( + f"Could not create a unique output folder for {base}{output_ts}" + ) + +# --------------------------------------------------------------------------- +# Core scan - runs entirely in a worker thread, never touches tkinter directly +# --------------------------------------------------------------------------- + +def run_scan(input_path, output_path, quiet_mode, log_cb, progress_cb, done_cb): + base = "SQLiteWalker_Out_" + data_list = [] + error_list = [] + data_headers = ("File Name", "Export Path", "Tables") + error_headers = ("File Name", "Export Path", "Error") + count = error_count = wal_count = shm_count = 0 + splitter = "\\" if _is_windows() else "/" + + start_time = time.time() + + # Prefix Windows paths to handle >260-char paths + if _is_windows(): + if len(input_path) > 1 and input_path[1] == ":": + input_path = "\\\\?\\" + input_path.replace("/", "\\") + if len(output_path) > 1 and output_path[1] == ":": + output_path = "\\\\?\\" + output_path.replace("/", "\\") + if not output_path.endswith("\\"): + output_path += "\\" + + out_folder = create_output_folder(output_path, base, splitter) + + _, basename = os.path.split(input_path) + basename_lower = basename.lower() + + if os.path.isfile(input_path): + if basename_lower.endswith(".zip"): + archive_type = "zip" + + elif basename_lower.endswith((".tar", ".tar.gz", ".tgz")): + archive_type = "tar" + + else: + log_cb("Input file is not a supported archive (.zip/.tar). Aborting.\n", "error") + done_cb(0, 0, 0, 0, 0, out_folder) + return + + log_cb(f"Opening {archive_type.upper()}: {input_path}\n", "info") + if archive_type == "zip": + archive = zipfile.ZipFile(input_path, "r") + files = archive.infolist() + total = len(files) + + else: + archive = tarfile.open(input_path, "r|*") + files = archive + total = 0 + + for idx, entry in enumerate(files, 1): + progress_cb(idx, total) + file = entry.filename if archive_type == "zip" else entry.name + file_name = file.rsplit("/", 1) + + if file.endswith(("-shm", "-wal")): + try: + if archive_type == "zip": + if entry.is_dir(): + continue + new_path = safe_zip_extract( + archive, + entry, + out_folder + splitter + "db_out" + ) + else: + if not entry.isfile(): + continue + + f = archive.extractfile(entry) + + if f is None: + continue + + with f: + new_path = safe_tar_write_file( + entry, + f, + out_folder + splitter + "db_out" + ) + + except RuntimeError as e: + log_cb(f" BLOCKED ARCHIVE ENTRY: {e}\n", "error") + continue + + if file.startswith("/"): + file = file[1:] + if _is_windows(): + new_path = new_path.replace("/", "\\") + if file.endswith("-shm"): + shm_count += 1 + if not quiet_mode: + log_cb(f" SHM {shm_count}: {file}\n", "shm") + else: + wal_count += 1 + if not quiet_mode: + log_cb(f" WAL {wal_count}: {file}\n", "wal") + dest = new_path[4:] if _is_windows() else new_path + data_list.append((file_name[-1], dest, "")) + + else: + db = None + new_path = None + + if archive_type == "zip": + if entry.is_dir(): + continue + + with archive.open(entry) as f: + header = f.read(100) + + # SQLite magic bytes: "SQLite format 3\x00" + if not header.startswith(b"\x53\x51\x4c\x69\x74\x65\x20\x66\x6f\x72\x6d\x61\x74\x20\x33\x00"): + continue + + try: + new_path = safe_zip_extract( + archive, + entry, + out_folder + splitter + "db_out" + ) + + except RuntimeError as e: + log_cb(f" BLOCKED ZIP ENTRY: {e}\n", "error") + continue + + else: + if not entry.isfile(): + continue + + f = archive.extractfile(entry) + + if f is None: + continue + + with f: + header = f.read(100) + + # SQLite magic bytes: "SQLite format 3\x00" + if not header.startswith(b"\x53\x51\x4c\x69\x74\x65\x20\x66\x6f\x72\x6d\x61\x74\x20\x33\x00"): + continue + + try: + new_path = safe_tar_write_file( + entry, + f, + out_folder + splitter + "db_out", + header + ) + + except RuntimeError as e: + log_cb(f" BLOCKED TAR ENTRY: {e}\n", "error") + continue + + try: + if file.startswith("/"): + file = file[1:] + if _is_windows(): + new_path = new_path.replace("/", "\\") + db = open_sqlite_db_readonly(new_path) + cursor = db.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") + tables_list = [r[0] for r in cursor.fetchall()] + dest = new_path[4:] if _is_windows() else new_path + data_list.append((file_name[-1], dest, tables_list)) + count += 1 + if not quiet_mode: + log_cb(f" DB {count}: {file} [{len(tables_list)} tables]\n", "db") + except sqlite3.Error as e: + log_cb(f" ERROR: {file} - {e}\n", "error") + dest = new_path[4:] if _is_windows() else new_path + error_list.append((file_name[-1], dest, e)) + error_count += 1 + finally: + try: db.close() + except Exception: pass + + archive.close() + + elif os.path.isdir(input_path): + log_cb(f"Walking folder: {input_path}\n", "info") + # Collect all paths up front so we have a real total for the progress bar + all_files = [(r, fn) for r, _, fns in os.walk(input_path) for fn in fns] + total = max(len(all_files), 1) + + for idx, (root, file) in enumerate(all_files, 1): + progress_cb(idx, total) + src = os.path.join(root, file) + + if file.endswith(("-shm", "-wal")): + rel_path = os.path.relpath(root, input_path) + if rel_path == ".": + dest_dir = out_folder + else: + dest_dir = os.path.join(out_folder, rel_path) + + dest_file = os.path.join(dest_dir, file) + os.makedirs(dest_dir, exist_ok=True) + shutil.copy2(src, dest_file) + if file.endswith("-shm"): + shm_count += 1 + if not quiet_mode: + log_cb(f" SHM {shm_count}: {file}\n", "shm") + else: + wal_count += 1 + if not quiet_mode: + log_cb(f" WAL {wal_count}: {file}\n", "wal") + data_list.append((file, dest_file, "")) + continue + + if not os.path.isfile(src) or os.path.getsize(src) <= 100: + continue + try: + with open(src, "r", encoding="ISO-8859-1") as f: + hdr = f.read(100) + except Exception: + continue + if not hdr.startswith("SQLite format 3"): + continue + + db = None + try: + db = open_sqlite_db_readonly(src) + cursor = db.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") + tables_list = [r[0] for r in cursor.fetchall()] + + rel_path = os.path.relpath(root, input_path) + if rel_path == ".": + dest_dir = out_folder + else: + dest_dir = os.path.join(out_folder, rel_path) + dest_file = os.path.join(dest_dir, file) + os.makedirs(dest_dir, exist_ok=True) + shutil.copy2(src, dest_file) + + data_list.append((file, dest_file, tables_list)) + count += 1 + if not quiet_mode: + log_cb(f" DB {count}: {src} [{len(tables_list)} tables]\n", "db") + except sqlite3.Error as e: + log_cb(f" ERROR: {file} - {e}\n", "error") + error_list.append((file, src, e)) + error_count += 1 + finally: + try: + if db: db.close() + except Exception: pass + + else: + log_cb("Input path is not a file or folder. Aborting.\n", "error") + done_cb(0, 0, 0, 0, 0, out_folder) + return + + # Write results TSV; errors get a separate file only when there are any + tsv_path = out_folder + splitter + "db_list.tsv" + with open(tsv_path, "w", newline="") as f_out: + w = csv.writer(f_out, delimiter="\t") + w.writerow(data_headers) + for row in data_list: + w.writerow(row) + + if error_count > 0: + err_path = out_folder + splitter + "error_list.tsv" + with open(err_path, "w", newline="") as f_out: + w = csv.writer(f_out, delimiter="\t") + w.writerow(error_headers) + for row in error_list: + w.writerow(row) + + elapsed = round(time.time() - start_time, 2) + done_cb(count, shm_count, wal_count, error_count, elapsed, out_folder) + + +# --------------------------------------------------------------------------- +# GUI +# --------------------------------------------------------------------------- + +class SQLiteWalkerApp(tk.Tk): + def __init__(self): + super().__init__() + self.title(f"SQLiteWalker v{VERSION}") + self.configure(bg=BG) + self.minsize(780, 580) + self.resizable(True, True) + + _set_window_icon(self) + + self._scanning = False + self._last_output = None # set after each successful scan + + self._build_ui() + + self._log(ASCII_BANNER, "banner") + self._log(f" Platform : {_SYS}\n", "info") + self._log(f" Python : {sys.version.split()[0]}\n\n", "info") + + # ------------------------------------------------------------------ + # Layout + # ------------------------------------------------------------------ + + def _build_ui(self): + self.columnconfigure(0, weight=1) + self.rowconfigure(1, weight=1) + + self._build_topbar() + self._build_body() + self._build_statusbar() + + def _build_topbar(self): + bar = tk.Frame(self, bg=BG_PANEL, pady=10, padx=18) + bar.grid(row=0, column=0, sticky="ew") + bar.columnconfigure(1, weight=1) + + tk.Label(bar, text="SQLiteWalker", font=HEADER, + bg=BG_PANEL, fg=ACCENT).grid(row=0, column=0, sticky="w") + tk.Label(bar, text=f"v{VERSION} ", + font=SANS_SM, bg=BG_PANEL, fg=FG_DIM).grid(row=0, column=1, sticky="w", padx=(10, 0)) + tk.Button(bar, text="About", font=SANS_SM, + bg=BG_PANEL, fg=FG_DIM, + activebackground=BG_PANEL, activeforeground=ACCENT, + relief="flat", bd=0, cursor="hand2", + command=self._show_about).grid(row=0, column=2, sticky="e") + + def _build_body(self): + body = tk.Frame(self, bg=BG) + body.grid(row=1, column=0, sticky="nsew", padx=18, pady=(12, 0)) + body.columnconfigure(0, weight=1) + body.rowconfigure(4, weight=1) # row 4 = log panel, stretches with window + + # Source type: radio pair avoids the old "are you picking a zip?" messagebox + src_row = tk.Frame(body, bg=BG) + src_row.grid(row=0, column=0, sticky="w", pady=(0, 4)) + tk.Label(src_row, text="Source type", font=SANS_MD, + bg=BG, fg=FG, width=12, anchor="w").pack(side="left") + self.src_type = tk.StringVar(value="folder") + for val, lbl in [ + ("folder", "Folder"), + ("zip", "ZIP file"), + ("tar", "TAR archive") + ]: + tk.Radiobutton(src_row, text=lbl, + variable=self.src_type, + value=val, + font=SANS_SM, + bg=BG, + fg=FG, + selectcolor=BG_INPUT, + activebackground=BG, + activeforeground=ACCENT, + relief="flat", + bd=0, + command=self._on_src_type_change).pack( + side="left", padx=(0, 14)) + + self._build_path_row(body, 1, "Source", "input_path", self._browse_source, "Path to scan") + self._build_path_row(body, 2, "Output Folder", "output_path", self._browse_output, "Folder where results will be saved") + + # Options row - quiet mode on the left, action buttons on the right + opts = tk.Frame(body, bg=BG) + opts.grid(row=3, column=0, sticky="ew", pady=(4, 10)) + + self.quiet_var = tk.BooleanVar(value=False) + tk.Checkbutton(opts, text="Quiet mode (suppress path logging)", + variable=self.quiet_var, + font=SANS_SM, bg=BG, fg=FG, selectcolor=BG_INPUT, + activebackground=BG, activeforeground=ACCENT, + relief="flat", bd=0).pack(side="left") + + # "Open Output" is disabled until at least one scan completes + self.open_btn = tk.Button(opts, text="Open Output", + font=SANS_SM, bg=BG_PANEL, fg=FG_DIM, + activebackground=ACCENT_DIM, activeforeground=BG, + relief="flat", bd=0, padx=12, pady=5, + cursor="hand2", state="disabled", + command=self._open_last_output) + self.open_btn.pack(side="right", padx=(6, 0)) + + self.scan_btn = tk.Button(opts, text="Run Scan", + font=(_SANS, 10, "bold"), + bg=ACCENT, fg=BG, + activebackground=ACCENT_DIM, activeforeground=BG, + disabledforeground=BG, + relief="flat", bd=0, padx=18, pady=5, + cursor="hand2", command=self._start_scan) + self.scan_btn.pack(side="right") + + self._build_log_panel(body) + + def _build_log_panel(self, parent): + frame = tk.Frame(parent, bg=BG_PANEL) + frame.grid(row=4, column=0, sticky="nsew") + frame.rowconfigure(1, weight=1) + frame.columnconfigure(0, weight=1) + + # Header row with "Log" label and a Clear button + hdr = tk.Frame(frame, bg=BG_PANEL, padx=10, pady=6) + hdr.grid(row=0, column=0, sticky="ew") + tk.Label(hdr, text="Log", font=SANS_SM, bg=BG_PANEL, fg=FG_DIM).pack(side="left") + tk.Button(hdr, text="Clear", font=SANS_SM, + bg=BG_PANEL, fg=FG_DIM, relief="flat", bd=0, + activebackground=BG_PANEL, activeforeground=ACCENT, + cursor="hand2", command=self._clear_log).pack(side="right") + + # Text widget + scrollbars + txt_frame = tk.Frame(frame, bg=BG_PANEL) + txt_frame.grid(row=1, column=0, sticky="nsew", padx=(10, 0), pady=(0, 4)) + txt_frame.rowconfigure(0, weight=1) + txt_frame.columnconfigure(0, weight=1) + + self.log_text = tk.Text(txt_frame, bg=BG_INPUT, fg=FG, font=MONO, + wrap="none", relief="flat", bd=0, + insertbackground=ACCENT, state="disabled", spacing1=1) + self.log_text.grid(row=0, column=0, sticky="nsew") + + sb_y = ttk.Scrollbar(txt_frame, orient="vertical", command=self.log_text.yview) + sb_x = ttk.Scrollbar(txt_frame, orient="horizontal", command=self.log_text.xview) + sb_y.grid(row=0, column=1, sticky="ns") + sb_x.grid(row=1, column=0, sticky="ew") + self.log_text.configure(yscrollcommand=sb_y.set, xscrollcommand=sb_x.set) + + self.log_text.tag_config("banner", foreground=ACCENT) + self.log_text.tag_config("info", foreground=FG_DIM) + self.log_text.tag_config("db", foreground=ACCENT) + self.log_text.tag_config("shm", foreground=YELLOW) + self.log_text.tag_config("wal", foreground=YELLOW) + self.log_text.tag_config("error", foreground=RED) + self.log_text.tag_config("done", foreground=ACCENT) + self.log_text.tag_config("normal", foreground=FG) + + # Progress bar - determinate because we pre-count files before walking + # NOTE: pady=(0,6) on a Frame() constructor triggers "bad screen distance" + # on some Tk versions - use grid(pady=...) instead + prog_frame = tk.Frame(frame, bg=BG_PANEL) + prog_frame.grid(row=2, column=0, sticky="ew", padx=10, pady=(0, 6)) + prog_frame.columnconfigure(0, weight=1) + + style = ttk.Style(self) + style.theme_use("default") + style.configure("Scan.Horizontal.TProgressbar", + troughcolor=BG_INPUT, background=ACCENT, + darkcolor=ACCENT, lightcolor=ACCENT, + bordercolor=BG_INPUT, troughrelief="flat", relief="flat") + style.configure("Vertical.TScrollbar", + troughcolor=BG_INPUT, background=BG_PANEL, + arrowcolor=FG_DIM, bordercolor=BG_INPUT) + style.configure("Horizontal.TScrollbar", + troughcolor=BG_INPUT, background=BG_PANEL, + arrowcolor=FG_DIM, bordercolor=BG_INPUT) + + self.progress = ttk.Progressbar(prog_frame, style="Scan.Horizontal.TProgressbar", + orient="horizontal", length=400, mode="determinate") + self.progress.grid(row=0, column=0, sticky="ew") + + self.prog_label = tk.Label(prog_frame, text="", font=SANS_SM, + bg=BG_PANEL, fg=FG_DIM, width=14, anchor="e") + self.prog_label.grid(row=0, column=1, padx=(8, 0)) + + def _build_statusbar(self): + bar = tk.Frame(self, bg=BG_PANEL, padx=18, pady=6) + bar.grid(row=2, column=0, sticky="ew") + bar.columnconfigure(1, weight=1) + + self.status_var = tk.StringVar(value="Ready") + tk.Label(bar, textvariable=self.status_var, + font=SANS_SM, bg=BG_PANEL, fg=FG_DIM, anchor="w").grid(row=0, column=0, sticky="w") + + pills = tk.Frame(bar, bg=BG_PANEL) + pills.grid(row=0, column=1, sticky="e") + + self._stat_vars = {} + for key, label, color in [ + ("db", "DBs", ACCENT), + ("wal", "WALs", ACCENT), + ("shm", "SHMs", ACCENT), + ("err", "Errors", RED), + ]: + v = tk.StringVar(value="-") + self._stat_vars[key] = v + pill = tk.Frame(pills, bg=BG, padx=8, pady=2) + pill.pack(side="left", padx=3) + tk.Label(pill, textvariable=v, font=(_MONO, 10, "bold"), + bg=BG, fg=color).pack(side="left") + tk.Label(pill, text=f" {label}", font=SANS_SM, + bg=BG, fg=FG_DIM).pack(side="left") + + def _build_path_row(self, parent, row, label_text, attr, browse_cmd, placeholder): + row_frame = tk.Frame(parent, bg=BG) + row_frame.grid(row=row, column=0, sticky="ew", pady=(0, 6)) + row_frame.columnconfigure(1, weight=1) + + tk.Label(row_frame, text=label_text, font=SANS_MD, + bg=BG, fg=FG, width=12, anchor="w").grid(row=0, column=0, sticky="w") + + entry = tk.Entry(row_frame, font=MONO, bg=BG_INPUT, fg=FG, + insertbackground=ACCENT, relief="flat", bd=0, + highlightthickness=1, + highlightcolor=ACCENT, highlightbackground=BG_PANEL) + entry.grid(row=0, column=1, sticky="ew", ipady=5, padx=(6, 6)) + entry.insert(0, placeholder) + entry.config(fg=FG_DIM) + + # Placeholder behaviour: clear on focus, restore if left empty + def _focus_in(e, ph=placeholder, en=entry): + if en.get() == ph: + en.delete(0, "end") + en.config(fg=FG) + + def _focus_out(e, ph=placeholder, en=entry): + if not en.get(): + en.insert(0, ph) + en.config(fg=FG_DIM) + + entry.bind("", _focus_in) + entry.bind("", _focus_out) + setattr(self, attr, entry) + + tk.Button(row_frame, text="Browse...", font=SANS_SM, + bg=BG_PANEL, fg=ACCENT, + activebackground=ACCENT_DIM, activeforeground=BG, + relief="flat", bd=0, padx=10, pady=5, + cursor="hand2", command=browse_cmd).grid(row=0, column=2, sticky="e") + + # ------------------------------------------------------------------ + # Source type toggle + # ------------------------------------------------------------------ + + def _on_src_type_change(self): + # Clear the path field when the user switches between folder/zip + ph = "Path to scan" + if self.input_path.get() not in (ph, ""): + self.input_path.delete(0, "end") + self.input_path.insert(0, ph) + self.input_path.config(fg=FG_DIM) + + # ------------------------------------------------------------------ + # Browse dialogs + # ------------------------------------------------------------------ + + def _browse_source(self): + if self.src_type.get() in ("zip", "tar"): + + if self.src_type.get() == "zip": + title = "Select ZIP archive" + types = [ + ("ZIP archives", "*.zip"), + ("All files", "*.*") + ] + + else: + title = "Select TAR archive" + types = [ + ("TAR archives", "*.tar"), + ("Compressed TAR archives", "*.tar.gz *.tgz"), + ("All files", "*.*") + ] + + path = filedialog.askopenfilename( + title=title, + filetypes=types + ) + + else: + path = filedialog.askdirectory( + title="Select source folder" + ) + if path: + self.input_path.delete(0, "end") + self.input_path.insert(0, path) + self.input_path.config(fg=FG) + + def _browse_output(self): + path = filedialog.askdirectory(title="Select output folder") + if path: + self.output_path.delete(0, "end") + self.output_path.insert(0, path) + self.output_path.config(fg=FG) + + # ------------------------------------------------------------------ + # About dialog + # ------------------------------------------------------------------ + + def _show_about(self): + win = tk.Toplevel(self) + win.title("About SQLiteWalker") + win.configure(bg=BG) + win.resizable(False, False) + win.transient(self) + _set_window_icon(win) + + content = tk.Frame(win, bg=BG, padx=26, pady=22) + content.grid(row=0, column=0, sticky="nsew") + content.columnconfigure(1, weight=1) + + tk.Label(content, text="SQLiteWalker", font=HEADER, + bg=BG, fg=ACCENT).grid(row=0, column=0, columnspan=2, sticky="w") + tk.Label(content, text="Python script to walk a folder, zip or tar file looking for SQLite databases", + font=SANS_MD, bg=BG, fg=FG).grid(row=1, column=0, columnspan=2, sticky="w", pady=(4, 12)) + + tk.Frame(content, bg=BG_PANEL, height=1).grid( + row=2, column=0, columnspan=2, sticky="ew", pady=(0, 12) + ) + + rows = [ + ("Version", VERSION), + ("Author", "@KevinPagano3 | @stark4n6"), + ("GitHub", "https://github.com/stark4n6/SQLiteWalker"), + ("Website", "startme.stark4n6.com"), + ("GUI contributor", "@Mipa97"), + ("Runtime", f"{_SYS} | Python {sys.version.split()[0]}"), + ] + + for row_idx, (label, value) in enumerate(rows, 3): + tk.Label(content, text=label, font=SANS_SM, bg=BG, fg=FG_DIM, + width=15, anchor="w").grid(row=row_idx, column=0, sticky="nw", pady=2) + tk.Label(content, text=value, font=SANS_SM, bg=BG, fg=FG, + anchor="w", wraplength=340, justify="left").grid( + row=row_idx, column=1, sticky="w", pady=2 + ) + + btn_row = tk.Frame(content, bg=BG) + btn_row.grid(row=3 + len(rows), column=0, columnspan=2, sticky="e", pady=(16, 0)) + tk.Button(btn_row, text="Close", font=SANS_SM, + bg=BG_PANEL, fg=ACCENT, + activebackground=ACCENT_DIM, activeforeground=BG, + relief="flat", bd=0, padx=18, pady=5, + cursor="hand2", command=win.destroy).pack() + + win.bind("", lambda _e: win.destroy()) + win.update_idletasks() + x = self.winfo_rootx() + (self.winfo_width() - win.winfo_width()) // 2 + y = self.winfo_rooty() + (self.winfo_height() - win.winfo_height()) // 2 + win.geometry(f"+{max(x, 0)}+{max(y, 0)}") + win.grab_set() + win.focus_set() + + # ------------------------------------------------------------------ + # Log helpers + # ------------------------------------------------------------------ + + def _log(self, text, tag="normal"): + self.log_text.config(state="normal") + self.log_text.insert("end", text, tag) + self.log_text.see("end") + self.log_text.config(state="disabled") + + def _clear_log(self): + self.log_text.config(state="normal") + self.log_text.delete("1.0", "end") + self.log_text.config(state="disabled") + self._log(ASCII_BANNER, "banner") + + # Thread-safe: worker calls these, .after() marshals to the main thread + def _thread_log(self, text, tag="normal"): + self.after(0, self._log, text, tag) + + def _thread_progress(self, current, total): + def _up(): + if total: + pct = int(current / total * 100) + self.progress["value"] = pct + self.prog_label.config(text=f"{current} / {total}") + else: + self.progress["value"] = 0 + self.prog_label.config(text=f"{current} scanned") + self.after(0, _up) + + # ------------------------------------------------------------------ + # Open output folder + # ------------------------------------------------------------------ + + def _open_last_output(self): + if self._last_output and os.path.isdir(self._last_output): + _open_folder(self._last_output) + + # ------------------------------------------------------------------ + # Scan lifecycle + # ------------------------------------------------------------------ + + def _start_scan(self): + if self._scanning: + return + + placeholders = {"Path to scan", "Folder where results will be saved"} + inp = self.input_path.get().strip() + out = self.output_path.get().strip() + + if inp in placeholders or not inp: + messagebox.showerror("Missing input", "Please select a source file or folder.") + return + if out in placeholders or not out: + messagebox.showerror("Missing output", "Please select an output folder.") + return + if not os.path.exists(inp): + messagebox.showerror("Not found", f"Source path does not exist:\n{inp}") + return + if not os.path.exists(out): + messagebox.showerror("Not found", f"Output folder does not exist:\n{out}") + return + + self._scanning = True + self.open_btn.config(state="disabled") + self.scan_btn.config(state="disabled", text="Scanning...", bg=ACCENT_DIM, fg=BG) + self.status_var.set("Scanning...") + self.progress["value"] = 0 + self.prog_label.config(text="") + for v in self._stat_vars.values(): + v.set("-") + + self._log("\n" + "-" * 60 + "\n", "info") + self._log(f"Source : {inp}\n", "info") + self._log(f"Dest : {out}\n", "info") + self._log("-" * 60 + "\n", "info") + + def _run_scan_safely(): + try: + run_scan( + inp, + out, + self.quiet_var.get(), + self._thread_log, + self._thread_progress, + self._scan_done + ) + except Exception as e: + self._thread_log(f"\nERROR: Scan failed: {e}\n", "error") + self._thread_log(traceback.format_exc(), "error") + self._scan_failed(str(e)) + + threading.Thread( + target=_run_scan_safely, + daemon=True, + ).start() + + def _scan_failed(self, message): + def _update(): + self._scanning = False + self.scan_btn.config(state="normal", text="Run Scan", bg=ACCENT, fg=BG) + self.open_btn.config(state="disabled", fg=FG_DIM) + self.progress["value"] = 0 + self.prog_label.config(text="Failed") + self.status_var.set(f"Scan failed: {message}") + + self.after(0, _update) + + def _scan_done(self, count, shm_count, wal_count, error_count, elapsed, out_folder): + # Called from the worker thread - must schedule UI updates via after() + def _update(): + self._scanning = False + self._last_output = out_folder + + self.scan_btn.config(state="normal", text="Run Scan", bg=ACCENT, fg=BG) + self.open_btn.config(state="normal", fg=ACCENT) + self.progress["value"] = 100 + self.prog_label.config(text="Done") + + self._stat_vars["db"].set(str(count)) + self._stat_vars["wal"].set(str(wal_count)) + self._stat_vars["shm"].set(str(shm_count)) + self._stat_vars["err"].set(str(error_count)) + self.status_var.set(f"Scan complete | {elapsed}s | {out_folder}") + + self._log("\n" + "-" * 60 + "\n", "info") + self._log("JOB FINISHED\n", "done") + self._log(f" Runtime : {elapsed}s\n", "done") + self._log(f" DBs : {count}\n", "done") + self._log(f" SHMs : {shm_count}\n","done") + self._log(f" WALs : {wal_count}\n","done") + if error_count: + self._log(f" Errors : {error_count} (see error_list.tsv)\n", "error") + self._log(f" Output : {out_folder}\n", "done") + self._log("-" * 60 + "\n", "info") + + self.after(0, _update) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main(): + app = SQLiteWalkerApp() + app.mainloop() + + +if __name__ == "__main__": + main() From 896cdeb9e734dcc32d942e64f7951b9156d4383d Mon Sep 17 00:00:00 2001 From: Miguel Palma Date: Mon, 22 Jun 2026 09:09:46 +0100 Subject: [PATCH 2/3] Minor Updates on GUI v.0.6.0 --- SQLiteWalkerGUI.py | 60 +++++++++++++--------------------------------- 1 file changed, 17 insertions(+), 43 deletions(-) diff --git a/SQLiteWalkerGUI.py b/SQLiteWalkerGUI.py index a7689f3..042cbbe 100644 --- a/SQLiteWalkerGUI.py +++ b/SQLiteWalkerGUI.py @@ -317,7 +317,7 @@ def run_scan(input_path, output_path, quiet_mode, log_cb, progress_cb, done_cb): archive_type = "tar" else: - log_cb("Input file is not a supported archive (.zip/.tar). Aborting.\n", "error") + log_cb("Input file is not a supported archive (.zip / .tar / .tar.gz / .tgz). Aborting.\n", "error") done_cb(0, 0, 0, 0, 0, out_folder) return @@ -373,12 +373,10 @@ def run_scan(input_path, output_path, quiet_mode, log_cb, progress_cb, done_cb): new_path = new_path.replace("/", "\\") if file.endswith("-shm"): shm_count += 1 - if not quiet_mode: - log_cb(f" SHM {shm_count}: {file}\n", "shm") + log_cb(f" SHM {shm_count}: {file}\n", "shm") else: wal_count += 1 - if not quiet_mode: - log_cb(f" WAL {wal_count}: {file}\n", "wal") + log_cb(f" WAL {wal_count}: {file}\n", "wal") dest = new_path[4:] if _is_windows() else new_path data_list.append((file_name[-1], dest, "")) @@ -448,8 +446,7 @@ def run_scan(input_path, output_path, quiet_mode, log_cb, progress_cb, done_cb): dest = new_path[4:] if _is_windows() else new_path data_list.append((file_name[-1], dest, tables_list)) count += 1 - if not quiet_mode: - log_cb(f" DB {count}: {file} [{len(tables_list)} tables]\n", "db") + log_cb(f" DB {count}: {file} [{len(tables_list)} tables]\n", "db") except sqlite3.Error as e: log_cb(f" ERROR: {file} - {e}\n", "error") dest = new_path[4:] if _is_windows() else new_path @@ -483,12 +480,10 @@ def run_scan(input_path, output_path, quiet_mode, log_cb, progress_cb, done_cb): shutil.copy2(src, dest_file) if file.endswith("-shm"): shm_count += 1 - if not quiet_mode: - log_cb(f" SHM {shm_count}: {file}\n", "shm") + log_cb(f" SHM {shm_count}: {file}\n", "shm") else: wal_count += 1 - if not quiet_mode: - log_cb(f" WAL {wal_count}: {file}\n", "wal") + log_cb(f" WAL {wal_count}: {file}\n", "wal") data_list.append((file, dest_file, "")) continue @@ -520,8 +515,7 @@ def run_scan(input_path, output_path, quiet_mode, log_cb, progress_cb, done_cb): data_list.append((file, dest_file, tables_list)) count += 1 - if not quiet_mode: - log_cb(f" DB {count}: {src} [{len(tables_list)} tables]\n", "db") + log_cb(f" DB {count}: {file} [{len(tables_list)} tables]\n", "db") except sqlite3.Error as e: log_cb(f" ERROR: {file} - {e}\n", "error") error_list.append((file, src, e)) @@ -620,8 +614,7 @@ def _build_body(self): self.src_type = tk.StringVar(value="folder") for val, lbl in [ ("folder", "Folder"), - ("zip", "ZIP file"), - ("tar", "TAR archive") + ("archive", "Archive (.zip / .tar / .tar.gz / .tgz)"), ]: tk.Radiobutton(src_row, text=lbl, variable=self.src_type, @@ -644,12 +637,7 @@ def _build_body(self): opts = tk.Frame(body, bg=BG) opts.grid(row=3, column=0, sticky="ew", pady=(4, 10)) - self.quiet_var = tk.BooleanVar(value=False) - tk.Checkbutton(opts, text="Quiet mode (suppress path logging)", - variable=self.quiet_var, - font=SANS_SM, bg=BG, fg=FG, selectcolor=BG_INPUT, - activebackground=BG, activeforeground=ACCENT, - relief="flat", bd=0).pack(side="left") + self.quiet_var = tk.BooleanVar(value=True) # always quiet; only hits are logged # "Open Output" is disabled until at least one scan completes self.open_btn = tk.Button(opts, text="Open Output", @@ -810,7 +798,7 @@ def _focus_out(e, ph=placeholder, en=entry): # ------------------------------------------------------------------ def _on_src_type_change(self): - # Clear the path field when the user switches between folder/zip + # Clear the path field when the user switches between folder/archive ph = "Path to scan" if self.input_path.get() not in (ph, ""): self.input_path.delete(0, "end") @@ -822,28 +810,14 @@ def _on_src_type_change(self): # ------------------------------------------------------------------ def _browse_source(self): - if self.src_type.get() in ("zip", "tar"): - - if self.src_type.get() == "zip": - title = "Select ZIP archive" - types = [ - ("ZIP archives", "*.zip"), - ("All files", "*.*") - ] - - else: - title = "Select TAR archive" - types = [ - ("TAR archives", "*.tar"), - ("Compressed TAR archives", "*.tar.gz *.tgz"), - ("All files", "*.*") - ] - + if self.src_type.get() == "archive": path = filedialog.askopenfilename( - title=title, - filetypes=types + title="Select archive", + filetypes=[ + ("Supported archives", "*.zip *.tar *.tar.gz *.tgz"), + ("All files", "*.*"), + ] ) - else: path = filedialog.askdirectory( title="Select source folder" @@ -1068,4 +1042,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file From 8220e3721785d734fdd401544e833dc5f0ede4c4 Mon Sep 17 00:00:00 2001 From: Miguel Palma Date: Tue, 23 Jun 2026 15:11:23 +0100 Subject: [PATCH 3/3] Merge GUI into main python file Adjustments on the main python file and merge of GUI code into it to make SQLiteWalker into one portable python file. Added icon to the application --- SQLiteWalker.py | 1154 +++++++++++++++++++++++++++++++++----------- SQLiteWalkerGUI.py | 1045 --------------------------------------- logo.png | Bin 0 -> 21541 bytes 3 files changed, 866 insertions(+), 1333 deletions(-) delete mode 100644 SQLiteWalkerGUI.py create mode 100644 logo.png diff --git a/SQLiteWalker.py b/SQLiteWalker.py index 729a08b..84ba0cf 100644 --- a/SQLiteWalker.py +++ b/SQLiteWalker.py @@ -1,70 +1,124 @@ -import csv import argparse +import base64 +import csv import os +import platform as _platform_mod import re import shutil import sqlite3 +import subprocess import sys -import time import tarfile +import tempfile +import threading +import time +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +import traceback +import webbrowser import zipfile -from zipfile import ZipFile + +VERSION = "1.0.0" + +ASCII_BANNER = ( + " SQLiteWalker v" + VERSION + "\n" + " https://github.com/stark4n6/SQLiteWalker\n" + " @KevinPagano3 | @stark4n6\n" +) # --------------------------------------------------------------------------- -# Version +# Platform # --------------------------------------------------------------------------- -VERSION = "0.6.0" - -ascii_art = rf''' - _______. ______ __ __ .___________. _______ - / | / __ \ | | | | | || ____| - | (----`| | | | | | | | `---| |----`| |__ - \ \ | | | | | | | | | | | __| - .----) | | `--' '--.| `----.| | | | | |____ - |_______/ \_____\_____\_______||__| |__| |_______| -____ __ ____ ___ __ __ ___ _______ .______ -\ \ / \ / / / \ | | | |/ / | ____|| _ \ - \ \/ \/ / / ^ \ | | | ' / | |__ | |_) | - \ / / /_\ \ | | | < | __| | / - \ /\ / / _____ \ | `----.| . \ | |____ | |\ \----. - \__/ \__/ /__/ \__\ |_______||__|\__\ |_______|| _| `._____| - - SQLiteWalker v{VERSION} - https://github.com/stark4n6/SQLiteWalker - @KevinPagano3 | @stark4n6 | startme.stark4n6.com - ''' +_SYS = _platform_mod.system() # "Windows", "Darwin", or "Linux" + +def _is_windows(): return _SYS == "Windows" +def _is_mac(): return _SYS == "Darwin" +def _is_linux(): return _SYS == "Linux" + +# Font families per OS +if _is_windows(): + _SANS, _MONO = "DejaVu Sans", "DejaVu Sans Mono" +elif _is_mac(): + _SANS, _MONO = "SF Pro Text", "Menlo" # SF Pro falls back to Helvetica Neue +else: + _SANS, _MONO = "DejaVu Sans", "DejaVu Sans Mono" + +MONO = (_MONO, 9) +SANS_SM = (_SANS, 9) +SANS_MD = (_SANS, 10) +HEADER = (_MONO, 14, "bold") + +# --------------------------------------------------------------------------- +# Palette +# --------------------------------------------------------------------------- + +BG = "#1a1d23" +BG_PANEL = "#21252e" +BG_INPUT = "#030303" +ACCENT = "#00d4aa" +ACCENT_DIM = "#007a63" +FG = "#d0d6e0" +FG_DIM = "#6b7280" +RED = "#f87171" +YELLOW = "#fbbf24" # --------------------------------------------------------------------------- -# Platform helpers +# Embedded icon - 32x32 "SW" PNG + ICO, no external files needed # --------------------------------------------------------------------------- -def is_platform_windows(): - return os.name == 'nt' +_ICON_PNG_B64 = ( + "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" +) + +_ICON_ICO_B64 = ( + "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" +) + +def _set_window_icon(root): + try: + if _is_windows(): + tmp = tempfile.NamedTemporaryFile(suffix=".ico", delete=False) + tmp.write(base64.b64decode(_ICON_ICO_B64)) + tmp.close() + root.iconbitmap(tmp.name) + os.unlink(tmp.name) + else: + img = tk.PhotoImage(data=_ICON_PNG_B64) + root.iconphoto(True, img) + root._icon_ref = img + except Exception: + pass + + +def _open_folder(path): + try: + if _is_windows(): + os.startfile(path) + elif _is_mac(): + subprocess.Popen(["open", path]) + else: + subprocess.Popen(["xdg-open", path]) + except Exception: + pass + # --------------------------------------------------------------------------- # SQLite helpers # --------------------------------------------------------------------------- def open_sqlite_db_readonly(path): - '''Opens a SQLite db read-only so the original file and its -wal/-shm are never modified.''' - if is_platform_windows(): - # Encode the \\?\ long-path prefix into its percent-encoded form for the URI - if path.startswith('\\\\?\\UNC\\'): # UNC long path + if _is_windows(): + if path.startswith("\\\\?\\UNC\\"): path = "%5C%5C%3F%5C" + path[4:] - elif path.startswith('\\\\?\\'): # normal long path + elif path.startswith("\\\\?\\"): path = "%5C%5C%3F%5C" + path[4:] - elif path.startswith('\\\\'): # plain UNC + elif path.startswith("\\\\"): path = "%5C%5C%3F%5C\\UNC" + path[1:] - else: # normal drive path + else: path = "%5C%5C%3F%5C" + path return sqlite3.connect(f"file:{path}?mode=ro", uri=True) -# --------------------------------------------------------------------------- -# Archive safety -# --------------------------------------------------------------------------- - -# Device names that are illegal as file/folder names on Windows regardless of extension WINDOWS_RESERVED_NAMES = { "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", @@ -73,38 +127,26 @@ def open_sqlite_db_readonly(path): def sanitize_archive_member_name(member_name): - ''' - Validates and cleans an archive member path before extraction. - - Raises RuntimeError for: - - absolute paths (/etc/passwd, C:\\Windows\\...) - - directory traversal (../../secret) - - empty components after normalization - - Strips illegal characters on Windows and handles reserved device names. - Returns an OS-appropriate relative path string. - ''' member_name = member_name.replace("\\", "/") - # Block absolute paths — both POSIX-style and Windows drive-letter style if member_name.startswith("/") or re.match(r"^[A-Za-z]:", member_name): - raise RuntimeError(f"Unsafe absolute archive path blocked: {member_name}") + raise RuntimeError( + f"Unsafe absolute archive path blocked: {member_name}" + ) safe_parts = [] for part in member_name.split("/"): - # Empty segments, current-dir dots, and parent-dir traversal are all unsafe if part in ("", ".", ".."): - raise RuntimeError(f"Unsafe archive path component blocked: {member_name}") + raise RuntimeError( + f"Unsafe archive path component blocked: {member_name}" + ) - # Replace characters that are illegal in Windows filenames safe_part = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", part) - # Windows also forbids trailing spaces and dots on file/folder names safe_part = safe_part.rstrip(" .") if not safe_part: safe_part = "_" - # Prefix reserved device names so they can't be created on Windows stem = safe_part.split(".", 1)[0].upper() if stem in WINDOWS_RESERVED_NAMES: safe_part = f"_{safe_part}" @@ -115,62 +157,45 @@ def sanitize_archive_member_name(member_name): def unique_dest_path(dest_path): - '''Returns dest_path unchanged if it doesn't exist, otherwise appends -001, -002, etc.''' if not os.path.exists(dest_path): return dest_path folder, filename = os.path.split(dest_path) stem, ext = os.path.splitext(filename) - for n in range(1, 1000): - candidate = os.path.join(folder, f"{stem}-{n:03d}{ext}") + for suffix_num in range(1, 1000): + candidate = os.path.join(folder, f"{stem}-{suffix_num:03d}{ext}") if not os.path.exists(candidate): return candidate - raise RuntimeError(f"Could not create a unique output path for {filename}") + raise RuntimeError( + f"Could not create a unique output path for {filename}" + ) def safe_archive_dest_path(member_name, destination): - ''' - Resolves the final extraction path for an archive member and verifies it - stays inside the destination directory (guards against path traversal - even after sanitization). - ''' - dest_root = os.path.abspath(destination) - safe_name = sanitize_archive_member_name(member_name) - dest_path = os.path.abspath(os.path.join(dest_root, safe_name)) - - # commonpath() raises ValueError when the paths are on different drives (Windows) + dest_root = os.path.abspath(destination) + safe_member_name = sanitize_archive_member_name(member_name) + dest_path = os.path.abspath(os.path.join(dest_root, safe_member_name)) + try: - common = os.path.commonpath([dest_root, dest_path]) + common_path = os.path.commonpath([dest_root, dest_path]) except ValueError: - common = "" + common_path = "" - if common != dest_root: - raise RuntimeError(f"Unsafe archive path blocked after resolution: {member_name}") + if common_path != dest_root: + raise RuntimeError( + f"Unsafe archive path blocked: {member_name}" + ) return unique_dest_path(dest_path) -def safe_zip_extract(zip_archive, member, destination): - '''Extracts one ZIP member to destination using the safe path helpers above.''' - dest_path = safe_archive_dest_path(member.filename, destination) - os.makedirs(os.path.dirname(dest_path), exist_ok=True) - with zip_archive.open(member) as src, open(dest_path, "wb") as out: - shutil.copyfileobj(src, out) - return dest_path - - def safe_tar_write_file(member, source, destination, initial_data=b""): - ''' - Writes one TAR member to destination. - Hard and symbolic links are blocked outright — they can point outside the - destination tree and cannot be made safe by path sanitization alone. - initial_data lets the caller prepend bytes already read (e.g. the magic-byte - header we consumed to identify the file type). - ''' if member.islnk() or member.issym(): - raise RuntimeError(f"Unsafe TAR link blocked: {member.name}") + raise RuntimeError( + f"Unsafe TAR link blocked: {member.name}" + ) dest_path = safe_archive_dest_path(member.name, destination) os.makedirs(os.path.dirname(dest_path), exist_ok=True) @@ -180,95 +205,113 @@ def safe_tar_write_file(member, source, destination, initial_data=b""): out.write(initial_data) shutil.copyfileobj(source, out) + # Preserve metadata modification timestamp from TAR header + try: + if hasattr(member, "mtime") and member.mtime: + os.utime(dest_path, (member.mtime, member.mtime)) + except Exception: + pass + + return dest_path + + +def safe_zip_extract(zip_archive, member, destination): + dest_path = safe_archive_dest_path(member.filename, destination) + + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + with zip_archive.open(member) as source, open(dest_path, "wb") as out: + shutil.copyfileobj(source, out) + + # Preserve metadata modification timestamp from ZIP header + try: + zip_time = time.mktime(member.date_time + (0, 0, -1)) + os.utime(dest_path, (zip_time, zip_time)) + except Exception: + pass + return dest_path -# --------------------------------------------------------------------------- -# Output folder creation -# --------------------------------------------------------------------------- def create_output_folder(output_path, base, splitter): - ''' - Creates the timestamped output folder. If a folder with that timestamp - already exists (two scans fired within the same second) it appends -001, - -002, etc. rather than crashing. - ''' output_ts = time.strftime("%Y%m%d-%H%M%S") - for n in range(1000): - suffix = "" if n == 0 else f"-{n:03d}" + for suffix_num in range(1000): + suffix = "" if suffix_num == 0 else f"-{suffix_num:03d}" out_folder = output_path + base + output_ts + suffix + try: os.makedirs(out_folder + splitter + "db_out") return out_folder except FileExistsError: continue - raise RuntimeError(f"Could not create a unique output folder for {base}{output_ts}") + raise RuntimeError( + f"Could not create a unique output folder for {base}{output_ts}" + ) # --------------------------------------------------------------------------- -# Core scan +# Core scan - runs entirely in a worker thread, never touches tkinter directly # --------------------------------------------------------------------------- -SQLITE_MAGIC = b"\x53\x51\x4c\x69\x74\x65\x20\x66\x6f\x72\x6d\x61\x74\x20\x33\x00" - - -def run_scan(input_path, output_path, quiet_mode): +def run_scan(input_path, output_path, src_type_choice, quiet_mode, log_cb, progress_cb, live_count_cb, done_cb): base = "SQLiteWalker_Out_" data_list = [] error_list = [] - data_headers = ('File Name', 'Export Path', 'Tables') - error_headers = ('File Name', 'Export Path', 'Error') + data_headers = ("File Name", "File Type", "Export Path", "Tables") + error_headers = ("File Name", "Export Path", "Error") count = error_count = wal_count = shm_count = 0 - splitter = "\\" if is_platform_windows() else "/" + splitter = "\\" if _is_windows() else "/" start_time = time.time() # Prefix Windows paths to handle >260-char paths - if is_platform_windows(): + if _is_windows(): if len(input_path) > 1 and input_path[1] == ":": input_path = "\\\\?\\" + input_path.replace("/", "\\") if len(output_path) > 1 and output_path[1] == ":": output_path = "\\\\?\\" + output_path.replace("/", "\\") - - # Trailing separator required so base concatenates into the right directory - if not output_path.endswith(splitter): - output_path += splitter + if not output_path.endswith("\\"): + output_path += "\\" out_folder = create_output_folder(output_path, base, splitter) - - _, basename = os.path.split(input_path) - basename_lower = basename.lower() - - # ------------------------------------------------------------------ - # Archive branch (.zip / .tar / .tar.gz / .tgz) - # ------------------------------------------------------------------ - if os.path.isfile(input_path): - + + # Get the normalized absolute output folder name to trim matches relative to it + clean_out_root = os.path.abspath(out_folder) + if _is_windows() and clean_out_root.startswith("\\\\?\\"): + clean_out_root = clean_out_root[4:] + + if src_type_choice == "archive": + _, basename = os.path.split(input_path) + basename_lower = basename.lower() if basename_lower.endswith(".zip"): archive_type = "zip" elif basename_lower.endswith((".tar", ".tar.gz", ".tgz")): archive_type = "tar" else: - print("Input file is not a supported archive (.zip / .tar / .tar.gz / .tgz). Exiting.") - sys.exit(1) - - print(f"Opening {archive_type.upper()}: {input_path}") + log_cb("Input file is not a supported archive (.zip / .tar / .tar.gz / .tgz). Aborting.\n", "error") + done_cb(0, 0, 0, 0, 0, out_folder) + return + log_cb(f"Opening {archive_type.upper()}: {input_path}\n", "info") if archive_type == "zip": archive = zipfile.ZipFile(input_path, "r") - entries = archive.infolist() - total = len(entries) + files = archive.infolist() + total = len(files) else: - # r|* = streaming read, works on .tar.gz without seeking + log_cb("Pre-counting items inside TAR file to synchronize progress bar...\n", "info") + total = 0 + with tarfile.open(input_path, "r|*") as count_archive: + for _ in count_archive: + total += 1 + archive = tarfile.open(input_path, "r|*") - entries = archive # iterator — no random access, total unknown up front - total = 0 + files = archive - for idx, entry in enumerate(entries, 1): - file = entry.filename if archive_type == "zip" else entry.name + for idx, entry in enumerate(files, 1): + progress_cb(idx, total) + file = entry.filename if archive_type == "zip" else entry.name file_name = file.rsplit("/", 1) - # ----- WAL / SHM companion files ----- if file.endswith(("-shm", "-wal")): try: if archive_type == "zip": @@ -284,29 +327,32 @@ def run_scan(input_path, output_path, quiet_mode): with f: new_path = safe_tar_write_file(entry, f, out_folder + splitter + "db_out") except RuntimeError as e: - print(f" BLOCKED ARCHIVE ENTRY: {e}") + log_cb(f" BLOCKED ARCHIVE ENTRY: {e}\n", "error") continue if file.startswith("/"): file = file[1:] - if is_platform_windows(): + if _is_windows(): new_path = new_path.replace("/", "\\") + + dest = new_path[4:] if _is_windows() else new_path + trimmed_dest = os.path.relpath(dest, os.path.dirname(clean_out_root)) if file.endswith("-shm"): shm_count += 1 - if not quiet_mode: - print(f" SHM {shm_count}: {file}") + live_count_cb("shm", shm_count) + log_cb(f" SHM {shm_count}: {file}\n", "shm") + f_type = "-SHM" else: wal_count += 1 - if not quiet_mode: - print(f" WAL {wal_count}: {file}") + live_count_cb("wal", wal_count) + log_cb(f" WAL {wal_count}: {file}\n", "wal") + f_type = "-WAL" - dest = new_path[4:] if is_platform_windows() else new_path - data_list.append((file_name[-1], dest, "")) + data_list.append((file_name[-1], f_type, trimmed_dest, "")) - # ----- Potential SQLite database files ----- else: - db = None + db = None new_path = None if archive_type == "zip": @@ -314,14 +360,13 @@ def run_scan(input_path, output_path, quiet_mode): continue with archive.open(entry) as f: header = f.read(100) - if not header.startswith(SQLITE_MAGIC): + if not header.startswith(b"\x53\x51\x4c\x69\x74\x65\x20\x66\x6f\x72\x6d\x61\x74\x20\x33\x00"): continue try: new_path = safe_zip_extract(archive, entry, out_folder + splitter + "db_out") except RuntimeError as e: - print(f" BLOCKED ZIP ENTRY: {e}") + log_cb(f" BLOCKED ZIP ENTRY: {e}\n", "error") continue - else: if not entry.isfile(): continue @@ -330,123 +375,126 @@ def run_scan(input_path, output_path, quiet_mode): continue with f: header = f.read(100) - if not header.startswith(SQLITE_MAGIC): + if not header.startswith(b"\x53\x51\x4c\x69\x74\x65\x20\x66\x6f\x72\x6d\x61\x74\x20\x33\x00"): continue try: - # Pass header back in — it was consumed during identification - new_path = safe_tar_write_file( - entry, f, out_folder + splitter + "db_out", header - ) + new_path = safe_tar_write_file(entry, f, out_folder + splitter + "db_out", header) except RuntimeError as e: - print(f" BLOCKED TAR ENTRY: {e}") + log_cb(f" BLOCKED TAR ENTRY: {e}\n", "error") continue try: if file.startswith("/"): file = file[1:] - if is_platform_windows(): + if _is_windows(): new_path = new_path.replace("/", "\\") db = open_sqlite_db_readonly(new_path) cursor = db.cursor() cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") tables_list = [r[0] for r in cursor.fetchall()] - dest = new_path[4:] if is_platform_windows() else new_path - data_list.append((file_name[-1], dest, tables_list)) + dest = new_path[4:] if _is_windows() else new_path + trimmed_dest = os.path.relpath(dest, os.path.dirname(clean_out_root)) + count += 1 - if not quiet_mode: - print(f" DB {count}: {file} [{len(tables_list)} tables]") + live_count_cb("db", count) + data_list.append((file_name[-1], "DB", trimmed_dest, tables_list)) + log_cb(f" DB {count}: {file} [{len(tables_list)} tables]\n", "db") except sqlite3.Error as e: - print(f" ERROR: {file} — {e}") - dest = new_path[4:] if is_platform_windows() else new_path - error_list.append((file_name[-1], dest, e)) + dest = new_path[4:] if _is_windows() else new_path + trimmed_dest = os.path.relpath(dest, os.path.dirname(clean_out_root)) error_count += 1 + live_count_cb("err", error_count) + log_cb(f" ERROR: {file} - {e}\n", "error") + error_list.append((file_name[-1], trimmed_dest, e)) finally: - try: - if db: db.close() - except Exception: - pass + try: db.close() + except Exception: pass archive.close() - # ------------------------------------------------------------------ - # Folder walk branch - # ------------------------------------------------------------------ - elif os.path.isdir(input_path): - print(f"Walking folder: {input_path}") - - for root, dirs, files in os.walk(input_path): - for file in files: - - # ----- WAL / SHM companion files ----- - if file.endswith(("-shm", "-wal")): - src_path = os.path.join(root, file) - rel = os.path.relpath(root, input_path) - dest_dir = out_folder if rel == "." else os.path.join(out_folder, rel) - dest_file = os.path.join(dest_dir, file) - os.makedirs(dest_dir, exist_ok=True) - shutil.copy2(src_path, dest_file) - - if file.endswith("-shm"): - shm_count += 1 - if not quiet_mode: - print(f" SHM {shm_count}: {file}") - else: - wal_count += 1 - if not quiet_mode: - print(f" WAL {wal_count}: {file}") + elif src_type_choice == "folder": + log_cb(f"Walking folder: {input_path}\n", "info") + all_files = [(r, fn) for r, _, fns in os.walk(input_path) for fn in fns] + total = max(len(all_files), 1) - data_list.append((file, dest_file, "")) - continue + for idx, (root, file) in enumerate(all_files, 1): + progress_cb(idx, total) + src = os.path.join(root, file) - # ----- Potential SQLite database files ----- - src_path = os.path.join(root, file) - if not os.path.isfile(src_path) or os.path.getsize(src_path) <= 100: - continue - - try: - with open(src_path, "r", encoding="ISO-8859-1") as f: - hdr = f.read(100) - except Exception: - continue - - if not hdr.startswith("SQLite format 3"): - continue + if file.endswith(("-shm", "-wal")): + rel_path = os.path.relpath(root, input_path) + if rel_path == ".": + dest_dir = out_folder + else: + dest_dir = os.path.join(out_folder, rel_path) + + dest_file = os.path.join(dest_dir, file) + os.makedirs(dest_dir, exist_ok=True) + shutil.copy2(src, dest_file) + + clean_dest = dest_file[4:] if _is_windows() and dest_file.startswith("\\\\?\\") else dest_file + trimmed_dest = os.path.relpath(clean_dest, os.path.dirname(clean_out_root)) - db = None + if file.endswith("-shm"): + shm_count += 1 + live_count_cb("shm", shm_count) + log_cb(f" SHM {shm_count}: {file}\n", "shm") + f_type = "-SHM" + else: + wal_count += 1 + live_count_cb("wal", wal_count) + log_cb(f" WAL {wal_count}: {file}\n", "wal") + f_type = "-WAL" + + data_list.append((file, f_type, trimmed_dest, "")) + continue + + if not os.path.isfile(src) or os.path.getsize(src) <= 100: + continue + try: + with open(src, "r", encoding="ISO-8859-1") as f: + hdr = f.read(100) + except Exception: + continue + if not hdr.startswith("SQLite format 3"): + continue + + db = None + try: + db = open_sqlite_db_readonly(src) + cursor = db.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") + tables_list = [r[0] for r in cursor.fetchall()] + + rel_path = os.path.relpath(root, input_path) + if rel_path == ".": + dest_dir = out_folder + else: + dest_dir = os.path.join(out_folder, rel_path) + dest_file = os.path.join(dest_dir, file) + os.makedirs(dest_dir, exist_ok=True) + shutil.copy2(src, dest_file) + + clean_dest = dest_file[4:] if _is_windows() and dest_file.startswith("\\\\?\\") else dest_file + trimmed_dest = os.path.relpath(clean_dest, os.path.dirname(clean_out_root)) + + count += 1 + live_count_cb("db", count) + data_list.append((file, "DB", trimmed_dest, tables_list)) + log_cb(f" DB {count}: {file} [{len(tables_list)} tables]\n", "db") + except sqlite3.Error as e: + clean_src = src[4:] if _is_windows() and src.startswith("\\\\?\\") else src + trimmed_src = os.path.relpath(clean_src, os.path.dirname(clean_out_root)) + error_count += 1 + live_count_cb("err", error_count) + log_cb(f" ERROR: {file} - {e}\n", "error") + error_list.append((file, trimmed_src, e)) + finally: try: - db = open_sqlite_db_readonly(src_path) - cursor = db.cursor() - cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") - tables_list = [r[0] for r in cursor.fetchall()] - - rel = os.path.relpath(root, input_path) - dest_dir = out_folder if rel == "." else os.path.join(out_folder, rel) - dest_file = os.path.join(dest_dir, file) - os.makedirs(dest_dir, exist_ok=True) - shutil.copy2(src_path, dest_file) - - data_list.append((file, dest_file, tables_list)) - count += 1 - if not quiet_mode: - print(f" DB {count}: {src_path} [{len(tables_list)} tables]") - except sqlite3.Error as e: - print(f" ERROR: {file} — {e}") - error_list.append((file, src_path, e)) - error_count += 1 - finally: - try: - if db: db.close() - except Exception: - pass - - else: - print("Input path is not a file or folder. Exiting.") - sys.exit(1) - - # ------------------------------------------------------------------ - # Write TSV output(s) - # ------------------------------------------------------------------ + if db: db.close() + except Exception: pass + # Write results TSV tsv_path = out_folder + splitter + "db_list.tsv" with open(tsv_path, "w", newline="") as f_out: w = csv.writer(f_out, delimiter="\t") @@ -462,64 +510,594 @@ def run_scan(input_path, output_path, quiet_mode): for row in error_list: w.writerow(row) + # Write Results to SQLite Database with Indexes on all columns + sqlite_out_path = out_folder + splitter + "db_list.db" + try: + out_db = sqlite3.connect(sqlite_out_path) + out_curr = out_db.cursor() + + out_curr.execute(""" + CREATE TABLE IF NOT EXISTS db_list ( + file_name TEXT, + file_type TEXT, + export_path TEXT, + tables TEXT + ); + """) + + for row in data_list: + tbls_str = ", ".join(row[3]) if isinstance(row[3], list) else str(row[3]) + out_curr.execute("INSERT INTO db_list VALUES (?, ?, ?, ?);", (row[0], row[1], row[2], tbls_str)) + + out_curr.execute("CREATE INDEX IF NOT EXISTS idx_filename ON db_list(file_name);") + out_curr.execute("CREATE INDEX IF NOT EXISTS idx_filetype ON db_list(file_type);") + out_curr.execute("CREATE INDEX IF NOT EXISTS idx_exportpath ON db_list(export_path);") + out_curr.execute("CREATE INDEX IF NOT EXISTS idx_tables ON db_list(tables);") + + if error_count > 0: + out_curr.execute(""" + CREATE TABLE IF NOT EXISTS error_list ( + file_name TEXT, + export_path TEXT, + error TEXT + ); + """) + for row in error_list: + out_curr.execute("INSERT INTO error_list VALUES (?, ?, ?);", (row[0], row[1], str(row[2]))) + + out_curr.execute("CREATE INDEX IF NOT EXISTS idx_err_filename ON error_list(file_name);") + out_curr.execute("CREATE INDEX IF NOT EXISTS idx_err_exportpath ON error_list(export_path);") + out_curr.execute("CREATE INDEX IF NOT EXISTS idx_err_error ON error_list(error);") + + out_db.commit() + out_db.close() + except Exception as dbe: + log_cb(f" DATABASE WRITE ERROR: Failed compiling db_list.db - {dbe}\n", "error") + + elapsed = round(time.time() - start_time, 2) + done_cb(count, shm_count, wal_count, error_count, elapsed, out_folder) + + +# --------------------------------------------------------------------------- +# GUI App Class +# --------------------------------------------------------------------------- + +class SQLiteWalkerApp(tk.Tk): + def __init__(self): + super().__init__() + self.title(f"SQLiteWalker v{VERSION}") + self.configure(bg=BG) + self.minsize(780, 580) + self.resizable(True, True) + + _set_window_icon(self) + + self._scanning = False + self._last_output = None + + self._build_ui() + + self._log(ASCII_BANNER, "banner") + self._log(f" Platform : {_SYS}\n", "info") + self._log(f" Python : {sys.version.split()[0]}\n\n", "info") + # ------------------------------------------------------------------ - # Summary + # Layout # ------------------------------------------------------------------ - elapsed = round(time.time() - start_time, 2) - print() - print("****JOB FINISHED****") - print(f"Runtime : {elapsed}s") - print(f"DBs Found : {count}") - print(f"SHMs Found : {shm_count}") - print(f"WALs Found : {wal_count}") - print(f"Error Count : {error_count}") - if error_count > 0: - print("Check error_list.tsv for details") + def _build_ui(self): + self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=0) # Topbar frame row + self.rowconfigure(1, weight=1) # Main Body frame row + self.rowconfigure(2, weight=0) # Statusbar frame row + + self._build_topbar() + self._build_body() + self._build_statusbar() + + def _build_topbar(self): + # Frame container configured to grid title text components and the right-aligned halved logo + bar = tk.Frame(self, bg=BG_PANEL, pady=10, padx=18) + bar.grid(row=0, column=0, sticky="ew") + bar.columnconfigure(1, weight=1) + + # Left Column Stack for Application Labels & Repositioned About Button + title_frame = tk.Frame(bar, bg=BG_PANEL) + title_frame.grid(row=0, column=0, sticky="nw") + + tk.Label(title_frame, text="SQLiteWalker", font=HEADER, + bg=BG_PANEL, fg=ACCENT).grid(row=0, column=0, sticky="w") + tk.Label(title_frame, text=f"v{VERSION} ", + font=SANS_SM, bg=BG_PANEL, fg=FG_DIM).grid(row=0, column=1, sticky="w", padx=(6, 0)) + + # Repositioned About Button beneath Title string metadata stack + tk.Button(title_frame, text="About", font=SANS_SM, + bg=BG_PANEL, fg=ACCENT, activebackground=BG_PANEL, activeforeground=ACCENT_DIM, + relief="flat", bd=0, cursor="hand2", anchor="w", + command=self._show_about).grid(row=1, column=0, columnspan=2, sticky="w", pady=(4, 0)) + + # Right Column Layout checking for external asset availability + script_dir = os.path.dirname(os.path.abspath(__file__)) if "__file__" in locals() else os.getcwd() + logo_path = os.path.join(script_dir, "logo.png") + + if os.path.exists(logo_path): + try: + # Load external photo reference and divide layout dimensions strictly by 3 + full_img = tk.PhotoImage(file=logo_path) + self._main_logo_img = full_img.subsample(3, 3) + + logo_label = tk.Label(bar, image=self._main_logo_img, bg=BG_PANEL, cursor="hand2") + logo_label.grid(row=0, column=2, sticky="e") + + # Double feature: clicking logo opens script repository hub + logo_label.bind("", lambda e: webbrowser.open_new_tab("https://github.com/stark4n6/SQLiteWalker")) + except Exception: + pass + + def _build_body(self): + body = tk.Frame(self, bg=BG) + body.grid(row=1, column=0, sticky="nsew", padx=18, pady=(12, 0)) + body.columnconfigure(0, weight=1) + body.rowconfigure(4, weight=1) + + src_row = tk.Frame(body, bg=BG) + src_row.grid(row=0, column=0, sticky="w", pady=(0, 4)) + tk.Label(src_row, text="Source type", font=SANS_MD, + bg=BG, fg=FG, width=12, anchor="w").pack(side="left") + self.src_type = tk.StringVar(value="folder") + for val, lbl in [ + ("folder", "Folder"), + ("archive", "Archive (.zip / .tar / .tar.gz / .tgz)"), + ]: + tk.Radiobutton(src_row, text=lbl, + variable=self.src_type, + value=val, + font=SANS_SM, + bg=BG, + fg=FG, + selectcolor=BG_INPUT, + activebackground=BG, + activeforeground=ACCENT, + relief="flat", + bd=0, + command=self._on_src_type_change).pack( + side="left", padx=(0, 14)) + + self._build_path_row(body, 1, "Source", "input_path", self._browse_source, "Path to scan") + self._build_path_row(body, 2, "Output Folder", "output_path", self._browse_output, "Folder where results will be saved") + + opts = tk.Frame(body, bg=BG) + opts.grid(row=3, column=0, sticky="ew", pady=(4, 10)) + + self.quiet_var = tk.BooleanVar(value=True) + + self.open_btn = tk.Button(opts, text="Open Output", + font=SANS_SM, bg=BG_PANEL, fg=FG_DIM, + activebackground=ACCENT_DIM, activeforeground=BG, + relief="flat", bd=0, padx=12, pady=5, + cursor="hand2", state="disabled", + command=self._open_last_output) + self.open_btn.pack(side="right", padx=(6, 0)) + + self.scan_btn = tk.Button(opts, text="Run Scan", + font=(_SANS, 10, "bold"), + bg=ACCENT, fg=BG, + activebackground=ACCENT_DIM, activeforeground=BG, + disabledforeground=BG, + relief="flat", bd=0, padx=18, pady=5, + cursor="hand2", command=self._start_scan) + self.scan_btn.pack(side="right") + + self._build_log_panel(body) + + def _build_log_panel(self, parent): + frame = tk.Frame(parent, bg=BG_PANEL) + frame.grid(row=4, column=0, sticky="nsew") + frame.rowconfigure(1, weight=1) + frame.columnconfigure(0, weight=1) + + hdr = tk.Frame(frame, bg=BG_PANEL, padx=10, pady=6) + hdr.grid(row=0, column=0, sticky="ew") + tk.Label(hdr, text="Log", font=SANS_SM, bg=BG_PANEL, fg=FG_DIM).pack(side="left") + tk.Button(hdr, text="Clear", font=SANS_SM, + bg=BG_PANEL, fg=FG_DIM, relief="flat", bd=0, + activebackground=BG_PANEL, activeforeground=ACCENT, + cursor="hand2", command=self._clear_log).pack(side="right") + + txt_frame = tk.Frame(frame, bg=BG_PANEL) + txt_frame.grid(row=1, column=0, sticky="nsew", padx=(10, 0), pady=(0, 4)) + txt_frame.rowconfigure(0, weight=1) + txt_frame.columnconfigure(0, weight=1) + + self.log_text = tk.Text(txt_frame, bg=BG_INPUT, fg=FG, font=MONO, + wrap="none", relief="flat", bd=0, + insertbackground=ACCENT, state="disabled", spacing1=1) + self.log_text.grid(row=0, column=0, sticky="nsew") + + sb_y = ttk.Scrollbar(txt_frame, orient="vertical", command=self.log_text.yview) + sb_x = ttk.Scrollbar(txt_frame, orient="horizontal", command=self.log_text.xview) + sb_y.grid(row=0, column=1, sticky="ns") + sb_x.grid(row=1, column=0, sticky="ew") + self.log_text.configure(yscrollcommand=sb_y.set, xscrollcommand=sb_x.set) + + self.log_text.tag_config("banner", foreground=ACCENT) + self.log_text.tag_config("info", foreground=FG_DIM) + self.log_text.tag_config("db", foreground=ACCENT) + self.log_text.tag_config("shm", foreground=YELLOW) + self.log_text.tag_config("wal", foreground=YELLOW) + self.log_text.tag_config("error", foreground=RED) + self.log_text.tag_config("done", foreground=ACCENT) + self.log_text.tag_config("normal", foreground=FG) + + prog_frame = tk.Frame(frame, bg=BG_PANEL) + prog_frame.grid(row=2, column=0, sticky="ew", padx=10, pady=(0, 6)) + prog_frame.columnconfigure(0, weight=1) + + style = ttk.Style(self) + style.theme_use("default") + style.configure("Scan.Horizontal.TProgressbar", + troughcolor=BG_INPUT, background=ACCENT, + darkcolor=ACCENT, lightcolor=ACCENT, + bordercolor=BG_INPUT, troughrelief="flat", relief="flat") + style.configure("Vertical.TScrollbar", + troughcolor=BG_INPUT, background=BG_PANEL, + arrowcolor=FG_DIM, bordercolor=BG_INPUT) + style.configure("Horizontal.TScrollbar", + troughcolor=BG_INPUT, background=BG_PANEL, + arrowcolor=FG_DIM, bordercolor=BG_INPUT) + + self.progress = ttk.Progressbar(prog_frame, style="Scan.Horizontal.TProgressbar", + orient="horizontal", length=400, mode="determinate") + self.progress.grid(row=0, column=0, sticky="ew") + + self.prog_label = tk.Label(prog_frame, text="", font=SANS_SM, + bg=BG_PANEL, fg=FG_DIM, width=14, anchor="e") + self.prog_label.grid(row=0, column=1, padx=(8, 0)) + + def _build_statusbar(self): + bar = tk.Frame(self, bg=BG_PANEL, padx=18, pady=6) + bar.grid(row=2, column=0, sticky="ew") + bar.columnconfigure(1, weight=1) + + self.status_var = tk.StringVar(value="Ready") + tk.Label(bar, textvariable=self.status_var, + font=SANS_SM, bg=BG_PANEL, fg=FG_DIM, anchor="w").grid(row=0, column=0, sticky="w") + + pills = tk.Frame(bar, bg=BG_PANEL) + pills.grid(row=0, column=1, sticky="e") + + self._stat_vars = {} + for key, label, color in [ + ("db", "DBs", ACCENT), + ("wal", "WALs", YELLOW), + ("shm", "SHMs", YELLOW), + ("err", "Errors", RED), + ]: + v = tk.StringVar(value="-") + self._stat_vars[key] = v + pill = tk.Frame(pills, bg=BG, padx=8, pady=2) + pill.pack(side="left", padx=3) + tk.Label(pill, textvariable=v, font=(_MONO, 10, "bold"), + bg=BG, fg=color).pack(side="left") + tk.Label(pill, text=f" {label}", font=SANS_SM, + bg=BG, fg=FG_DIM).pack(side="left") + + def _build_path_row(self, parent, row, label_text, attr, browse_cmd, placeholder): + row_frame = tk.Frame(parent, bg=BG) + row_frame.grid(row=row, column=0, sticky="ew", pady=(0, 6)) + row_frame.columnconfigure(1, weight=1) + + tk.Label(row_frame, text=label_text, font=SANS_MD, + bg=BG, fg=FG, width=12, anchor="w").grid(row=0, column=0, sticky="w") + + entry = tk.Entry(row_frame, font=MONO, bg=BG_INPUT, fg=FG, + insertbackground=ACCENT, relief="flat", bd=0, + highlightthickness=1, + highlightcolor=ACCENT, highlightbackground=BG_PANEL) + entry.grid(row=0, column=1, sticky="ew", ipady=5, padx=(6, 6)) + entry.insert(0, placeholder) + entry.config(fg=FG_DIM) + + def _focus_in(e, ph=placeholder, en=entry): + if en.get() == ph: + en.delete(0, "end") + en.config(fg=FG) + + def _focus_out(e, ph=placeholder, en=entry): + if not en.get(): + en.insert(0, ph) + en.config(fg=FG_DIM) + + entry.bind("", _focus_in) + entry.bind("", _focus_out) + setattr(self, attr, entry) + + tk.Button(row_frame, text="Browse...", font=SANS_SM, + bg=BG_PANEL, fg=ACCENT, + activebackground=ACCENT_DIM, activeforeground=BG, + relief="flat", bd=0, padx=10, pady=5, + cursor="hand2", command=browse_cmd).grid(row=0, column=2, sticky="e") + + def _on_src_type_change(self): + ph = "Path to scan" + if self.input_path.get() not in (ph, ""): + self.input_path.delete(0, "end") + self.input_path.insert(0, ph) + self.input_path.config(fg=FG_DIM) + + def _browse_source(self): + if self.src_type.get() == "archive": + path = filedialog.askopenfilename( + title="Select archive", + filetypes=[ + ("Supported archives", "*.zip *.tar *.tar.gz *.tgz"), + ("All files", "*.*"), + ] + ) + else: + path = filedialog.askdirectory(title="Select source folder") + if path: + self.input_path.delete(0, "end") + self.input_path.insert(0, path) + self.input_path.config(fg=FG) + + def _browse_output(self): + path = filedialog.askdirectory(title="Select output folder") + if path: + self.output_path.delete(0, "end") + self.output_path.insert(0, path) + self.output_path.config(fg=FG) + + def _show_about(self, *args): + # 1. Check if an active instance already exists in memory + if hasattr(self, "_about_window") and self._about_window and tk.Toplevel.winfo_exists(self._about_window): + self._about_window.lift() + self._about_window.focus_set() + return + + # 2. Hard execution gate check to stop instantaneous double-firing + if hasattr(self, "_building_about") and self._building_about: + return + self._building_about = True + + try: + self._about_window = tk.Toplevel(self) + win = self._about_window + + win.title("About SQLiteWalker") + win.configure(bg=BG) + win.resizable(False, False) + win.transient(self) + _set_window_icon(win) + + content = tk.Frame(win, bg=BG, padx=26, pady=22) + content.grid(row=0, column=0, sticky="nsew") + content.columnconfigure(1, weight=1) + + tk.Label(content, text="SQLiteWalker", font=HEADER, + bg=BG, fg=ACCENT).grid(row=0, column=0, columnspan=2, sticky="w") + tk.Label(content, text="Python script to walk a folder, zip or tar file looking for SQLite databases", + font=SANS_MD, bg=BG, fg=FG).grid(row=1, column=0, columnspan=2, sticky="w", pady=(4, 12)) + + tk.Frame(content, bg=BG_PANEL, height=1).grid(row=2, column=0, columnspan=2, sticky="ew", pady=(0, 12)) + + rows = [ + ("Version", VERSION, False), + ("Author", "@KevinPagano3 | @stark4n6", False), + ("GitHub", "https://github.com/stark4n6/SQLiteWalker", True), + ("Website", "https://startme.stark4n6.com", True), + ("GUI contributor", "@Mipa97", False), + ("Runtime", f"{_SYS} | Python {sys.version.split()[0]}", False), + ] + + def _open_url(url): + try: + webbrowser.open_new_tab(url) + except Exception: + pass + + for row_idx, (label, value, is_url) in enumerate(rows, 3): + tk.Label(content, text=label, font=SANS_SM, bg=BG, fg=FG_DIM, + width=15, anchor="w").grid(row=row_idx, column=0, sticky="nw", pady=2) + + if is_url: + lbl_val = tk.Label(content, text=value, font=SANS_SM, bg=BG, fg=ACCENT, + anchor="w", wraplength=340, justify="left", cursor="hand2") + lbl_val.grid(row=row_idx, column=1, sticky="w", pady=2) + + lbl_val.bind("", lambda e, url=value: _open_url(url)) + lbl_val.bind("", lambda e, lbl=lbl_val: lbl.config(font=(_SANS, 9, "underline"))) + lbl_val.bind("", lambda e, lbl=lbl_val: lbl.config(font=SANS_SM)) + else: + tk.Label(content, text=value, font=SANS_SM, bg=BG, fg=FG, + anchor="w", wraplength=340, justify="left").grid(row=row_idx, column=1, sticky="w", pady=2) + + btn_row = tk.Frame(content, bg=BG) + btn_row.grid(row=3 + len(rows), column=0, columnspan=2, sticky="e", pady=(16, 0)) + tk.Button(btn_row, text="Close", font=SANS_SM, + bg=BG_PANEL, fg=ACCENT, + activebackground=ACCENT_DIM, activeforeground=BG, + relief="flat", bd=0, padx=18, pady=5, + cursor="hand2", command=win.destroy).pack() + + win.bind("", lambda _e: win.destroy()) + win.update_idletasks() + x = self.winfo_rootx() + (self.winfo_width() - win.winfo_width()) // 2 + y = self.winfo_rooty() + (self.winfo_height() - win.winfo_height()) // 2 + win.geometry(f"+{max(x, 0)}+{max(y, 0)}") + win.grab_set() + win.focus_set() + + finally: + self._building_about = False + + def _log(self, text, tag="normal"): + self.log_text.config(state="normal") + self.log_text.insert("end", text, tag) + self.log_text.see("end") + self.log_text.config(state="disabled") + + def _clear_log(self): + self.log_text.config(state="normal") + self.log_text.delete("1.0", "end") + self.log_text.config(state="disabled") + self._log(ASCII_BANNER, "banner") + + def _thread_log(self, text, tag="normal"): + self.after(0, self._log, text, tag) + + def _thread_progress(self, current, total): + def _up(): + if total: + pct = int(current / total * 100) + self.progress["value"] = pct + self.prog_label.config(text=f"{current} / {total}") + else: + self.progress["value"] = 0 + self.prog_label.config(text=f"{current} scanned") + self.after(0, _up) + + def _thread_live_count(self, key, current_val): + def _up(): + if key in self._stat_vars: + self._stat_vars[key].set(str(current_val)) + self.after(0, _up) + + def _open_last_output(self): + if self._last_output and os.path.isdir(self._last_output): + _open_folder(self._last_output) + + def _start_scan(self): + if self._scanning: + return + + placeholders = {"Path to scan", "Folder where results will be saved"} + inp = self.input_path.get().strip() + out = self.output_path.get().strip() + + if inp in placeholders or not inp: + messagebox.showerror("Missing input", "Please select a source file or folder.") + return + if out in placeholders or not out: + messagebox.showerror("Missing output", "Please select an output folder.") + return + if not os.path.exists(inp): + messagebox.showerror("Not found", f"Source path does not exist:\n{inp}") + return + if not os.path.exists(out): + messagebox.showerror("Not found", f"Output folder does not exist:\n{out}") + return + + self._scanning = True + self.open_btn.config(state="disabled") + self.scan_btn.config(state="disabled", text="Scanning...", bg=ACCENT_DIM, fg=BG) + self.status_var.set("Scanning...") + self.progress["value"] = 0 + self.prog_label.config(text="") + for v in self._stat_vars.values(): + v.set("0") + + self._log("\n" + "-" * 60 + "\n", "info") + self._log(f"Source : {inp}\n", "info") + self._log(f"Dest : {out}\n", "info") + self._log("-" * 60 + "\n", "info") + + def _run_scan_safely(): + try: + run_scan( + input_path=inp, + output_path=out, + src_type_choice=self.src_type.get(), + quiet_mode=self.quiet_var.get(), + log_cb=self._thread_log, + progress_cb=self._thread_progress, + live_count_cb=self._thread_live_count, + done_cb=self._scan_done + ) + except Exception as e: + self._thread_log(f"\nERROR: Scan failed: {e}\n", "error") + self._thread_log(traceback.format_exc(), "error") + self._scan_failed(str(e)) + + threading.Thread(target=_run_scan_safely, daemon=True).start() + + def _scan_failed(self, message): + def _update(): + self._scanning = False + self.scan_btn.config(state="normal", text="Run Scan", bg=ACCENT, fg=BG) + self.open_btn.config(state="disabled", fg=FG_DIM) + self.progress["value"] = 0 + self.prog_label.config(text="Failed") + self.status_var.set(f"Scan failed: {message}") + self.after(0, _update) + + def _scan_done(self, count, shm_count, wal_count, error_count, elapsed, out_folder): + def _update(): + self._scanning = False + self._last_output = out_folder + + self.scan_btn.config(state="normal", text="Run Scan", bg=ACCENT, fg=BG) + self.open_btn.config(state="normal", fg=ACCENT) + self.progress["value"] = 100 + self.prog_label.config(text="Done") + + self._stat_vars["db"].set(str(count)) + self._stat_vars["wal"].set(str(wal_count)) + self._stat_vars["shm"].set(str(shm_count)) + self._stat_vars["err"].set(str(error_count)) + self.status_var.set(f"Scan complete | {elapsed}s | {out_folder}") + + self._log("\n" + "-" * 60 + "\n", "info") + self._log("JOB FINISHED\n", "done") + self._log(f" Runtime : {elapsed}s\n", "done") + self._log(f" DBs : {count}\n", "done") + self._log(f" SHMs : {shm_count}\n","done") + self._log(f" WALs : {wal_count}\n","done") + if error_count: + self._log(f" Errors : {error_count} (see error_list.tsv / db_list.db)\n", "error") + self._log(f" Output : {out_folder}\n", "done") + self._log("-" * 60 + "\n", "info") + self.after(0, _update) + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def main(): + # Setup argument parser to support standard execution or headless CLI sweeps + # Disable automatic -h handling so we can intercept it cleanly parser = argparse.ArgumentParser( - description=f"SQLiteWalker v{VERSION} by @KevinPagano3 | @stark4n6 | https://github.com/stark4n6/SQLiteWalker" + description="SQLiteWalker: Walk a folder or archive sequentially mapping out SQLite files.", + add_help=False ) - parser.add_argument('-i', '--input_path', required=True, type=str, action="store", - help='Input path: folder, .zip, .tar, .tar.gz, or .tgz') - parser.add_argument('-o', '--output_path', required=True, type=str, action="store", - help='Output folder path') - parser.add_argument('-q', '--quiet_mode', required=False, action="store_true", - help='Suppress per-file console output') - args = parser.parse_args() - - input_path = args.input_path - output_path = os.path.abspath(args.output_path) - quiet_mode = args.quiet_mode - - if not os.path.exists(input_path): - parser.error("INPUT path does not exist.") - if not os.path.exists(output_path): - parser.error("OUTPUT folder does not exist.") - - # Windows: prefix paths to handle >260-char paths (done again inside run_scan, - # but doing it here ensures the argparse validation above works on long paths too) - if is_platform_windows(): - if len(input_path) > 1 and input_path[1] == ":": - input_path = "\\\\?\\" + input_path.replace("/", "\\") - - print(ascii_art) - print() - print(f"Source : {input_path}") - print(f"Destination : {output_path}") - print("-" * max(len(f"Source : {input_path}"), 40)) + parser.add_argument("-i", "--source", help="Path to input file or directory to scan.") + parser.add_argument("-o", "--output", help="Path to base output directory where compilation directory is written.") + parser.add_argument("-t", "--type", choices=["folder", "archive"], help="Specify source target mapping context.") + parser.add_argument("-h", "--help", action="store_true", help="Show this help message and exit.") - if quiet_mode: - print("Quiet mode enabled.") - print("These aren't the logs you're looking for.") - print() - - run_scan(input_path, output_path, quiet_mode) + # Check for CLI switches explicitly + cli_switches = ["-i", "--source", "-o", "--output", "-t", "--type", "-h", "--help"] + + if any(arg in sys.argv for arg in cli_switches): + args = parser.parse_args() + + # If help is requested, print it and exit immediately before GUI initialization + if args.help: + parser.print_help() + sys.exit(0) + + # If they didn't ask for help but missed a required parameter + if not args.source or not args.output or not args.type: + print("ERROR: When utilizing CLI switches, all arguments (-i, -o, -t) must be specified.\n") + parser.print_help() + sys.exit(1) + + run_in_cli_mode(args) + else: + # No switches provided; fallback to initializing interactive graphical loop cleanly + app = SQLiteWalkerApp() + app.mainloop() if __name__ == "__main__": diff --git a/SQLiteWalkerGUI.py b/SQLiteWalkerGUI.py deleted file mode 100644 index 042cbbe..0000000 --- a/SQLiteWalkerGUI.py +++ /dev/null @@ -1,1045 +0,0 @@ -import csv -import os -import shutil -import sqlite3 -import subprocess -import sys -import time -import threading -import traceback -import tkinter as tk -from tkinter import ttk, filedialog, messagebox -import zipfile # ZIP File support -import base64 -import platform as _platform_mod -import tempfile -import tarfile # TAR File Support -import re - -VERSION = "0.6.0" - -ASCII_BANNER = ( - " SQLiteWalker v" + VERSION + "\n" - " https://github.com/stark4n6/SQLiteWalker\n" - " @KevinPagano3 | @stark4n6\n" -) - -# --------------------------------------------------------------------------- -# Platform -# --------------------------------------------------------------------------- - -_SYS = _platform_mod.system() # "Windows", "Darwin", or "Linux" - -def _is_windows(): return _SYS == "Windows" -def _is_mac(): return _SYS == "Darwin" -def _is_linux(): return _SYS == "Linux" - -# Font families per OS - Segoe/Courier don't exist on macOS or Linux -if _is_windows(): - _SANS, _MONO = "Segoe UI", "Courier New" -elif _is_mac(): - _SANS, _MONO = "SF Pro Text", "Menlo" # SF Pro falls back to Helvetica Neue -else: - _SANS, _MONO = "DejaVu Sans", "DejaVu Sans Mono" - -MONO = (_MONO, 9) -SANS_SM = (_SANS, 9) -SANS_MD = (_SANS, 10) -HEADER = (_MONO, 14, "bold") - -# --------------------------------------------------------------------------- -# Palette -# --------------------------------------------------------------------------- - -BG = "#1a1d23" -BG_PANEL = "#21252e" -BG_INPUT = "#161920" -ACCENT = "#00d4aa" -ACCENT_DIM = "#007a63" -FG = "#d0d6e0" -FG_DIM = "#6b7280" -RED = "#f87171" -YELLOW = "#fbbf24" - -# --------------------------------------------------------------------------- -# Embedded icon - 32x32 "SW" PNG + ICO, no external files needed -# --------------------------------------------------------------------------- - -_ICON_PNG_B64 = ( - "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAC70lEQVR4nM2XTUgUYRjHfzPOzLru" - "ru4mfh78oA5+XbqI2KXoEGL0QWCXIIIiQokOHRI6VAR18GSHDhUEWZBR0CkijIiITlGQeihl19QN" - "dW21VdfZ3ZkOs6677exX7KrPZWffeeb9/57/O8z7vLDNIeT8xLdhPe39tp6c5sw+OZPwf4JkTvpH" - "uLarP2367KtbOYGkB4gTzyScFiQNRGqAqHiuwilBUkCIhRRPmCPFO5QMkEfxbCASAQogngnCfAm2" - "MDYBClj9Rpi5IKVKjlS5WDrXjdrWgGa3IgZViub9uG4MIXl9LJ85RKBnPyUjn3EOPANgqe8oK90d" - "yJNeKnoHAVjtasd/8TiWr5OUX7mXpCPGE8VXv3j1FMHOFnbdHKLmxDXK+x8gTXjRLTIAyqgHALWl" - "PvaM2mxchxqr0UssxlirMaaMuU1dMH0HtFIboT21COEI8qQXYT2E/H0a18AwsvtXdEIP6DrhmnI0" - "lwPdaiHUWI00NQeCgNpUlwCojHlMnTYFENQQaBq6LDF39xJL5w8TbG9CVzZXTAysIf2cj1WpNteB" - "IGB//j4mrLkchGvKQdeRx6dyAAiqOJ68BSBS6WTl2D4Wr59m7v5lwg1VsbyNqtSW+lilxZ/GkWYW" - "jLGo/ZJnDnElmD0AgOPxCBV9d7A/fYc86TVgKsr4c/LAJsCoOwFAmp5HXF5FGXWjNtWx3tYIgCW6" - "/jkBAMgTs5Q+fE1F7yD2Fx8A0K2WJAdCu2tRm+pi/5VRN7pVYe3gXmOeMXP7UwJotmJ8t88S7GhG" - "c9nRbMVEKp1GNV9+xPKkWR/i7wC6VIRuVeIAjF/Nbk0ANQvT74AQikAozNKFI2hlNhAEiub9OB69" - "wfbyY0KuMu4h2NkaFTIqlWYWEP0BNKcd0R9A8vpSAmxukVvwJYTk7XkH7QVRoqSWKo9h1pzsIAeg" - "oC6kas2SHSgARLq+0HwJ8giRqSndwW25CUQ2IPk9mKQByRh5O5rlCpLj4XTb4y/cQz1QNZiA/wAA" - "AABJRU5ErkJggg==" -) - -_ICON_ICO_B64 = ( - "AAABAAIAEBAAAAAAIAAfAwAAJgAAACAgAAAAACAAKAMAAEUDAACJUE5HDQoaCgAAAA1JSERSAAAAEAAA" - "ABAIBgAAAB/z/2EAAALmSURBVHicpZNPaFx1EMc/83tv962bP7jb1mSzIQ2hJiTWppRUixYNVNEu" - "0VJse1QwrVAk4L+D5LJ4UQoVRMnJCp5XDwqyWERTi9CGVlFJk0YsWNNi2yTbbfZl973dt7/xsNaD" - "eOuchpn5fhmG+Qh3Q1UQ0VY+t4lqMIVVDwAjIcnER8ija/+ddVuFvEHEMpt32b1v2pxffDX5w8ID" - "EkWttutS3Tty3PpnZ7jw7buIRC3NO1b+dVN1Wfz8y47TP+XaTn2F+8eNCP1nO4Gov9vdODpB5Zld" - "RYYPHWiZqAiFgsPhwYTMLxVSH36RS376dWi703FcR9QIqIKCNJvqXFutb0zmvNuvHyzq8OARPvst" - "QABKn+U7PpjWPpJBtmur9qZ6tCc7oL3prGa7+zWb6dfe+zOa2TasfXhB54m3lPKZvACCXtxs5i4t" - "dL10Ih1lNplw14MizSb3nb5I8NhDxC8vI1FE7fHttBe+pzY+qqbRsOXjz5ea40+MGPzKVPLMr1tM" - "2de19yYl9vt1Ygt/YvyA2pM7qI3vINy5jfU3DtHMpFk/mhO57Wvb2fktVFemDBhPmhYJGphShTuv" - "vUA4NojUGyTOLVIf3krUkyZRnKP67G5kIyD+yxXUNWCtZ7AWjEC9wea3P6bjVBH/xaep7n+ExLlL" - "hKMDRP3dJL/5Ef/gXpyVMs6dKhgDFgyGUEWwnUkqR8axnUkSsz8TW1rGvVXGKVVwVtdJXFgCY4hd" - "+Quph1gvBtiwdcTz8wtdk++n1VpjU+3i3CxjqgEad9F4DJoWqUdomwdBXWlP2pufvFmye7aPGJGx" - "VTvUN+Mfm3Bil6/W3WtrSCNqCRWkFiKNCByDNCzu8krdf+U5xw71zYiMrRotFBxS6ZP+UzuL1ckJ" - "z7m+EmKtYgQcB43H0JgLUVOdqzfC6ss5z983WiSVPqmFgnPPr+y2xHmDSMRs/kDl2P7pjT1D/wfT" - "LfvwQAumkcPRXQDlXnH+G6L4fYekNVANAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAACAAAAA" - "gCAYAAABzenr0AAAC70lEQVR4nM2XTUgUYRjHfzPOzLruru4mfh78oA5+XbqI2KXoEGL0QWCXIIIi" - "QokOHRI6VAR18GSHDhUEWZBR0CkijIiITlGQeihl19QNdW21VdfZ3ZkOs6677exX7KrPZWffeeb9" - "/57/O8z7vLDNIeT8xLdhPe39tp6c5sw+OZPwf4JkTvpHuLarP2367KtbOYGkB4gTzyScFiQNRGqA" - "qHiuwilBUkCIhRRPmCPFO5QMkEfxbCASAQogngnCfAm2MDYBClj9Rpi5IKVKjlS5WDrXjdrWgGa3" - "IgZViub9uG4MIXl9LJ85RKBnPyUjn3EOPANgqe8oK90dyJNeKnoHAVjtasd/8TiWr5OUX7mXpCPG" - "E8VXv3j1FMHOFnbdHKLmxDXK+x8gTXjRLTIAyqgHALWlPvaM2mxchxqr0UssxlirMaaMuU1dMH0H" - "tFIboT21COEI8qQXYT2E/H0a18AwsvtXdEIP6DrhmnI0lwPdaiHUWI00NQeCgNpUlwCojHlMnTYF" - "ENQQaBq6LDF39xJL5w8TbG9CVzZXTAysIf2cj1WpNteBIGB//j4mrLkchGvKQdeRx6dyAAiqOJ68" - "BSBS6WTl2D4Wr59m7v5lwg1VsbyNqtSW+lilxZ/GkWYWjLGo/ZJnDnElmD0AgOPxCBV9d7A/fYc8" - "6TVgKsr4c/LAJsCoOwFAmp5HXF5FGXWjNtWx3tYIgCW6/jkBAMgTs5Q+fE1F7yD2Fx8A0K2WJAdC" - "u2tRm+pi/5VRN7pVYe3gXmOeMXP7UwJotmJ8t88S7GhGc9nRbMVEKp1GNV9+xPKkWR/i7wC6VIRu" - "VeIAjF/Nbk0ANQvT74AQikAozNKFI2hlNhAEiub9OB69wfbyY0KuMu4h2NkaFTIqlWYWEP0BNKcd" - "0R9A8vpSAmxukVvwJYTk7XkH7QVRoqSWKo9h1pzsIAegoC6kas2SHSgARLq+0HwJ8giRqSndwW25" - "CUQ2IPk9mKQByRh5O5rlCpLj4XTb4y/cQz1QNZiA/wAAAABJRU5ErkJggg==" -) - - -def _set_window_icon(root): - # Windows needs a real .ico file on disk; write to temp and clean up immediately - # macOS/Linux accept a PhotoImage via iconphoto() - try: - if _is_windows(): - tmp = tempfile.NamedTemporaryFile(suffix=".ico", delete=False) - tmp.write(base64.b64decode(_ICON_ICO_B64)) - tmp.close() - root.iconbitmap(tmp.name) - os.unlink(tmp.name) - else: - img = tk.PhotoImage(data=_ICON_PNG_B64) - root.iconphoto(True, img) - root._icon_ref = img # keep ref so GC doesn't collect it - except Exception: - pass # icon is cosmetic - never crash over it - - -def _open_folder(path): - # Each OS has its own "open folder" verb - try: - if _is_windows(): - os.startfile(path) - elif _is_mac(): - subprocess.Popen(["open", path]) - else: - subprocess.Popen(["xdg-open", path]) - except Exception: - pass - - -# --------------------------------------------------------------------------- -# SQLite helpers -# --------------------------------------------------------------------------- - -# Windows long-path prefix: paths >260 chars need the \\?\ URI escape -def open_sqlite_db_readonly(path): - if _is_windows(): - if path.startswith("\\\\?\\UNC\\"): - path = "%5C%5C%3F%5C" + path[4:] - elif path.startswith("\\\\?\\"): - path = "%5C%5C%3F%5C" + path[4:] - elif path.startswith("\\\\"): - path = "%5C%5C%3F%5C\\UNC" + path[1:] - else: - path = "%5C%5C%3F%5C" + path - return sqlite3.connect(f"file:{path}?mode=ro", uri=True) - -WINDOWS_RESERVED_NAMES = { - "CON", "PRN", "AUX", "NUL", - "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", -} - - -def sanitize_archive_member_name(member_name): - member_name = member_name.replace("\\", "/") - - if member_name.startswith("/") or re.match(r"^[A-Za-z]:", member_name): - raise RuntimeError( - f"Unsafe absolute archive path blocked: {member_name}" - ) - - safe_parts = [] - for part in member_name.split("/"): - if part in ("", ".", ".."): - raise RuntimeError( - f"Unsafe archive path component blocked: {member_name}" - ) - - safe_part = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", part) - safe_part = safe_part.rstrip(" .") - - if not safe_part: - safe_part = "_" - - stem = safe_part.split(".", 1)[0].upper() - if stem in WINDOWS_RESERVED_NAMES: - safe_part = f"_{safe_part}" - - safe_parts.append(safe_part) - - return os.path.join(*safe_parts) - - -def unique_dest_path(dest_path): - if not os.path.exists(dest_path): - return dest_path - - folder, filename = os.path.split(dest_path) - stem, ext = os.path.splitext(filename) - - for suffix_num in range(1, 1000): - candidate = os.path.join(folder, f"{stem}-{suffix_num:03d}{ext}") - if not os.path.exists(candidate): - return candidate - - raise RuntimeError( - f"Could not create a unique output path for {filename}" - ) - - -def safe_archive_dest_path(member_name, destination): - dest_root = os.path.abspath(destination) - safe_member_name = sanitize_archive_member_name(member_name) - dest_path = os.path.abspath(os.path.join(dest_root, safe_member_name)) - - try: - common_path = os.path.commonpath([dest_root, dest_path]) - except ValueError: - common_path = "" - - if common_path != dest_root: - raise RuntimeError( - f"Unsafe archive path blocked: {member_name}" - ) - - return unique_dest_path(dest_path) - - -def safe_tar_write_file(member, source, destination, initial_data=b""): - if member.islnk() or member.issym(): - raise RuntimeError( - f"Unsafe TAR link blocked: {member.name}" - ) - - dest_path = safe_archive_dest_path(member.name, destination) - os.makedirs(os.path.dirname(dest_path), exist_ok=True) - - with open(dest_path, "wb") as out: - if initial_data: - out.write(initial_data) - shutil.copyfileobj(source, out) - - return dest_path - - -def safe_zip_extract(zip_archive, member, destination): - dest_path = safe_archive_dest_path(member.filename, destination) - - os.makedirs(os.path.dirname(dest_path), exist_ok=True) - with zip_archive.open(member) as source, open(dest_path, "wb") as out: - shutil.copyfileobj(source, out) - - return dest_path - - -def create_output_folder(output_path, base, splitter): - output_ts = time.strftime("%Y%m%d-%H%M%S") - - for suffix_num in range(1000): - suffix = "" if suffix_num == 0 else f"-{suffix_num:03d}" - out_folder = output_path + base + output_ts + suffix - - try: - os.makedirs(out_folder + splitter + "db_out") - return out_folder - except FileExistsError: - continue - - raise RuntimeError( - f"Could not create a unique output folder for {base}{output_ts}" - ) - -# --------------------------------------------------------------------------- -# Core scan - runs entirely in a worker thread, never touches tkinter directly -# --------------------------------------------------------------------------- - -def run_scan(input_path, output_path, quiet_mode, log_cb, progress_cb, done_cb): - base = "SQLiteWalker_Out_" - data_list = [] - error_list = [] - data_headers = ("File Name", "Export Path", "Tables") - error_headers = ("File Name", "Export Path", "Error") - count = error_count = wal_count = shm_count = 0 - splitter = "\\" if _is_windows() else "/" - - start_time = time.time() - - # Prefix Windows paths to handle >260-char paths - if _is_windows(): - if len(input_path) > 1 and input_path[1] == ":": - input_path = "\\\\?\\" + input_path.replace("/", "\\") - if len(output_path) > 1 and output_path[1] == ":": - output_path = "\\\\?\\" + output_path.replace("/", "\\") - if not output_path.endswith("\\"): - output_path += "\\" - - out_folder = create_output_folder(output_path, base, splitter) - - _, basename = os.path.split(input_path) - basename_lower = basename.lower() - - if os.path.isfile(input_path): - if basename_lower.endswith(".zip"): - archive_type = "zip" - - elif basename_lower.endswith((".tar", ".tar.gz", ".tgz")): - archive_type = "tar" - - else: - log_cb("Input file is not a supported archive (.zip / .tar / .tar.gz / .tgz). Aborting.\n", "error") - done_cb(0, 0, 0, 0, 0, out_folder) - return - - log_cb(f"Opening {archive_type.upper()}: {input_path}\n", "info") - if archive_type == "zip": - archive = zipfile.ZipFile(input_path, "r") - files = archive.infolist() - total = len(files) - - else: - archive = tarfile.open(input_path, "r|*") - files = archive - total = 0 - - for idx, entry in enumerate(files, 1): - progress_cb(idx, total) - file = entry.filename if archive_type == "zip" else entry.name - file_name = file.rsplit("/", 1) - - if file.endswith(("-shm", "-wal")): - try: - if archive_type == "zip": - if entry.is_dir(): - continue - new_path = safe_zip_extract( - archive, - entry, - out_folder + splitter + "db_out" - ) - else: - if not entry.isfile(): - continue - - f = archive.extractfile(entry) - - if f is None: - continue - - with f: - new_path = safe_tar_write_file( - entry, - f, - out_folder + splitter + "db_out" - ) - - except RuntimeError as e: - log_cb(f" BLOCKED ARCHIVE ENTRY: {e}\n", "error") - continue - - if file.startswith("/"): - file = file[1:] - if _is_windows(): - new_path = new_path.replace("/", "\\") - if file.endswith("-shm"): - shm_count += 1 - log_cb(f" SHM {shm_count}: {file}\n", "shm") - else: - wal_count += 1 - log_cb(f" WAL {wal_count}: {file}\n", "wal") - dest = new_path[4:] if _is_windows() else new_path - data_list.append((file_name[-1], dest, "")) - - else: - db = None - new_path = None - - if archive_type == "zip": - if entry.is_dir(): - continue - - with archive.open(entry) as f: - header = f.read(100) - - # SQLite magic bytes: "SQLite format 3\x00" - if not header.startswith(b"\x53\x51\x4c\x69\x74\x65\x20\x66\x6f\x72\x6d\x61\x74\x20\x33\x00"): - continue - - try: - new_path = safe_zip_extract( - archive, - entry, - out_folder + splitter + "db_out" - ) - - except RuntimeError as e: - log_cb(f" BLOCKED ZIP ENTRY: {e}\n", "error") - continue - - else: - if not entry.isfile(): - continue - - f = archive.extractfile(entry) - - if f is None: - continue - - with f: - header = f.read(100) - - # SQLite magic bytes: "SQLite format 3\x00" - if not header.startswith(b"\x53\x51\x4c\x69\x74\x65\x20\x66\x6f\x72\x6d\x61\x74\x20\x33\x00"): - continue - - try: - new_path = safe_tar_write_file( - entry, - f, - out_folder + splitter + "db_out", - header - ) - - except RuntimeError as e: - log_cb(f" BLOCKED TAR ENTRY: {e}\n", "error") - continue - - try: - if file.startswith("/"): - file = file[1:] - if _is_windows(): - new_path = new_path.replace("/", "\\") - db = open_sqlite_db_readonly(new_path) - cursor = db.cursor() - cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") - tables_list = [r[0] for r in cursor.fetchall()] - dest = new_path[4:] if _is_windows() else new_path - data_list.append((file_name[-1], dest, tables_list)) - count += 1 - log_cb(f" DB {count}: {file} [{len(tables_list)} tables]\n", "db") - except sqlite3.Error as e: - log_cb(f" ERROR: {file} - {e}\n", "error") - dest = new_path[4:] if _is_windows() else new_path - error_list.append((file_name[-1], dest, e)) - error_count += 1 - finally: - try: db.close() - except Exception: pass - - archive.close() - - elif os.path.isdir(input_path): - log_cb(f"Walking folder: {input_path}\n", "info") - # Collect all paths up front so we have a real total for the progress bar - all_files = [(r, fn) for r, _, fns in os.walk(input_path) for fn in fns] - total = max(len(all_files), 1) - - for idx, (root, file) in enumerate(all_files, 1): - progress_cb(idx, total) - src = os.path.join(root, file) - - if file.endswith(("-shm", "-wal")): - rel_path = os.path.relpath(root, input_path) - if rel_path == ".": - dest_dir = out_folder - else: - dest_dir = os.path.join(out_folder, rel_path) - - dest_file = os.path.join(dest_dir, file) - os.makedirs(dest_dir, exist_ok=True) - shutil.copy2(src, dest_file) - if file.endswith("-shm"): - shm_count += 1 - log_cb(f" SHM {shm_count}: {file}\n", "shm") - else: - wal_count += 1 - log_cb(f" WAL {wal_count}: {file}\n", "wal") - data_list.append((file, dest_file, "")) - continue - - if not os.path.isfile(src) or os.path.getsize(src) <= 100: - continue - try: - with open(src, "r", encoding="ISO-8859-1") as f: - hdr = f.read(100) - except Exception: - continue - if not hdr.startswith("SQLite format 3"): - continue - - db = None - try: - db = open_sqlite_db_readonly(src) - cursor = db.cursor() - cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") - tables_list = [r[0] for r in cursor.fetchall()] - - rel_path = os.path.relpath(root, input_path) - if rel_path == ".": - dest_dir = out_folder - else: - dest_dir = os.path.join(out_folder, rel_path) - dest_file = os.path.join(dest_dir, file) - os.makedirs(dest_dir, exist_ok=True) - shutil.copy2(src, dest_file) - - data_list.append((file, dest_file, tables_list)) - count += 1 - log_cb(f" DB {count}: {file} [{len(tables_list)} tables]\n", "db") - except sqlite3.Error as e: - log_cb(f" ERROR: {file} - {e}\n", "error") - error_list.append((file, src, e)) - error_count += 1 - finally: - try: - if db: db.close() - except Exception: pass - - else: - log_cb("Input path is not a file or folder. Aborting.\n", "error") - done_cb(0, 0, 0, 0, 0, out_folder) - return - - # Write results TSV; errors get a separate file only when there are any - tsv_path = out_folder + splitter + "db_list.tsv" - with open(tsv_path, "w", newline="") as f_out: - w = csv.writer(f_out, delimiter="\t") - w.writerow(data_headers) - for row in data_list: - w.writerow(row) - - if error_count > 0: - err_path = out_folder + splitter + "error_list.tsv" - with open(err_path, "w", newline="") as f_out: - w = csv.writer(f_out, delimiter="\t") - w.writerow(error_headers) - for row in error_list: - w.writerow(row) - - elapsed = round(time.time() - start_time, 2) - done_cb(count, shm_count, wal_count, error_count, elapsed, out_folder) - - -# --------------------------------------------------------------------------- -# GUI -# --------------------------------------------------------------------------- - -class SQLiteWalkerApp(tk.Tk): - def __init__(self): - super().__init__() - self.title(f"SQLiteWalker v{VERSION}") - self.configure(bg=BG) - self.minsize(780, 580) - self.resizable(True, True) - - _set_window_icon(self) - - self._scanning = False - self._last_output = None # set after each successful scan - - self._build_ui() - - self._log(ASCII_BANNER, "banner") - self._log(f" Platform : {_SYS}\n", "info") - self._log(f" Python : {sys.version.split()[0]}\n\n", "info") - - # ------------------------------------------------------------------ - # Layout - # ------------------------------------------------------------------ - - def _build_ui(self): - self.columnconfigure(0, weight=1) - self.rowconfigure(1, weight=1) - - self._build_topbar() - self._build_body() - self._build_statusbar() - - def _build_topbar(self): - bar = tk.Frame(self, bg=BG_PANEL, pady=10, padx=18) - bar.grid(row=0, column=0, sticky="ew") - bar.columnconfigure(1, weight=1) - - tk.Label(bar, text="SQLiteWalker", font=HEADER, - bg=BG_PANEL, fg=ACCENT).grid(row=0, column=0, sticky="w") - tk.Label(bar, text=f"v{VERSION} ", - font=SANS_SM, bg=BG_PANEL, fg=FG_DIM).grid(row=0, column=1, sticky="w", padx=(10, 0)) - tk.Button(bar, text="About", font=SANS_SM, - bg=BG_PANEL, fg=FG_DIM, - activebackground=BG_PANEL, activeforeground=ACCENT, - relief="flat", bd=0, cursor="hand2", - command=self._show_about).grid(row=0, column=2, sticky="e") - - def _build_body(self): - body = tk.Frame(self, bg=BG) - body.grid(row=1, column=0, sticky="nsew", padx=18, pady=(12, 0)) - body.columnconfigure(0, weight=1) - body.rowconfigure(4, weight=1) # row 4 = log panel, stretches with window - - # Source type: radio pair avoids the old "are you picking a zip?" messagebox - src_row = tk.Frame(body, bg=BG) - src_row.grid(row=0, column=0, sticky="w", pady=(0, 4)) - tk.Label(src_row, text="Source type", font=SANS_MD, - bg=BG, fg=FG, width=12, anchor="w").pack(side="left") - self.src_type = tk.StringVar(value="folder") - for val, lbl in [ - ("folder", "Folder"), - ("archive", "Archive (.zip / .tar / .tar.gz / .tgz)"), - ]: - tk.Radiobutton(src_row, text=lbl, - variable=self.src_type, - value=val, - font=SANS_SM, - bg=BG, - fg=FG, - selectcolor=BG_INPUT, - activebackground=BG, - activeforeground=ACCENT, - relief="flat", - bd=0, - command=self._on_src_type_change).pack( - side="left", padx=(0, 14)) - - self._build_path_row(body, 1, "Source", "input_path", self._browse_source, "Path to scan") - self._build_path_row(body, 2, "Output Folder", "output_path", self._browse_output, "Folder where results will be saved") - - # Options row - quiet mode on the left, action buttons on the right - opts = tk.Frame(body, bg=BG) - opts.grid(row=3, column=0, sticky="ew", pady=(4, 10)) - - self.quiet_var = tk.BooleanVar(value=True) # always quiet; only hits are logged - - # "Open Output" is disabled until at least one scan completes - self.open_btn = tk.Button(opts, text="Open Output", - font=SANS_SM, bg=BG_PANEL, fg=FG_DIM, - activebackground=ACCENT_DIM, activeforeground=BG, - relief="flat", bd=0, padx=12, pady=5, - cursor="hand2", state="disabled", - command=self._open_last_output) - self.open_btn.pack(side="right", padx=(6, 0)) - - self.scan_btn = tk.Button(opts, text="Run Scan", - font=(_SANS, 10, "bold"), - bg=ACCENT, fg=BG, - activebackground=ACCENT_DIM, activeforeground=BG, - disabledforeground=BG, - relief="flat", bd=0, padx=18, pady=5, - cursor="hand2", command=self._start_scan) - self.scan_btn.pack(side="right") - - self._build_log_panel(body) - - def _build_log_panel(self, parent): - frame = tk.Frame(parent, bg=BG_PANEL) - frame.grid(row=4, column=0, sticky="nsew") - frame.rowconfigure(1, weight=1) - frame.columnconfigure(0, weight=1) - - # Header row with "Log" label and a Clear button - hdr = tk.Frame(frame, bg=BG_PANEL, padx=10, pady=6) - hdr.grid(row=0, column=0, sticky="ew") - tk.Label(hdr, text="Log", font=SANS_SM, bg=BG_PANEL, fg=FG_DIM).pack(side="left") - tk.Button(hdr, text="Clear", font=SANS_SM, - bg=BG_PANEL, fg=FG_DIM, relief="flat", bd=0, - activebackground=BG_PANEL, activeforeground=ACCENT, - cursor="hand2", command=self._clear_log).pack(side="right") - - # Text widget + scrollbars - txt_frame = tk.Frame(frame, bg=BG_PANEL) - txt_frame.grid(row=1, column=0, sticky="nsew", padx=(10, 0), pady=(0, 4)) - txt_frame.rowconfigure(0, weight=1) - txt_frame.columnconfigure(0, weight=1) - - self.log_text = tk.Text(txt_frame, bg=BG_INPUT, fg=FG, font=MONO, - wrap="none", relief="flat", bd=0, - insertbackground=ACCENT, state="disabled", spacing1=1) - self.log_text.grid(row=0, column=0, sticky="nsew") - - sb_y = ttk.Scrollbar(txt_frame, orient="vertical", command=self.log_text.yview) - sb_x = ttk.Scrollbar(txt_frame, orient="horizontal", command=self.log_text.xview) - sb_y.grid(row=0, column=1, sticky="ns") - sb_x.grid(row=1, column=0, sticky="ew") - self.log_text.configure(yscrollcommand=sb_y.set, xscrollcommand=sb_x.set) - - self.log_text.tag_config("banner", foreground=ACCENT) - self.log_text.tag_config("info", foreground=FG_DIM) - self.log_text.tag_config("db", foreground=ACCENT) - self.log_text.tag_config("shm", foreground=YELLOW) - self.log_text.tag_config("wal", foreground=YELLOW) - self.log_text.tag_config("error", foreground=RED) - self.log_text.tag_config("done", foreground=ACCENT) - self.log_text.tag_config("normal", foreground=FG) - - # Progress bar - determinate because we pre-count files before walking - # NOTE: pady=(0,6) on a Frame() constructor triggers "bad screen distance" - # on some Tk versions - use grid(pady=...) instead - prog_frame = tk.Frame(frame, bg=BG_PANEL) - prog_frame.grid(row=2, column=0, sticky="ew", padx=10, pady=(0, 6)) - prog_frame.columnconfigure(0, weight=1) - - style = ttk.Style(self) - style.theme_use("default") - style.configure("Scan.Horizontal.TProgressbar", - troughcolor=BG_INPUT, background=ACCENT, - darkcolor=ACCENT, lightcolor=ACCENT, - bordercolor=BG_INPUT, troughrelief="flat", relief="flat") - style.configure("Vertical.TScrollbar", - troughcolor=BG_INPUT, background=BG_PANEL, - arrowcolor=FG_DIM, bordercolor=BG_INPUT) - style.configure("Horizontal.TScrollbar", - troughcolor=BG_INPUT, background=BG_PANEL, - arrowcolor=FG_DIM, bordercolor=BG_INPUT) - - self.progress = ttk.Progressbar(prog_frame, style="Scan.Horizontal.TProgressbar", - orient="horizontal", length=400, mode="determinate") - self.progress.grid(row=0, column=0, sticky="ew") - - self.prog_label = tk.Label(prog_frame, text="", font=SANS_SM, - bg=BG_PANEL, fg=FG_DIM, width=14, anchor="e") - self.prog_label.grid(row=0, column=1, padx=(8, 0)) - - def _build_statusbar(self): - bar = tk.Frame(self, bg=BG_PANEL, padx=18, pady=6) - bar.grid(row=2, column=0, sticky="ew") - bar.columnconfigure(1, weight=1) - - self.status_var = tk.StringVar(value="Ready") - tk.Label(bar, textvariable=self.status_var, - font=SANS_SM, bg=BG_PANEL, fg=FG_DIM, anchor="w").grid(row=0, column=0, sticky="w") - - pills = tk.Frame(bar, bg=BG_PANEL) - pills.grid(row=0, column=1, sticky="e") - - self._stat_vars = {} - for key, label, color in [ - ("db", "DBs", ACCENT), - ("wal", "WALs", ACCENT), - ("shm", "SHMs", ACCENT), - ("err", "Errors", RED), - ]: - v = tk.StringVar(value="-") - self._stat_vars[key] = v - pill = tk.Frame(pills, bg=BG, padx=8, pady=2) - pill.pack(side="left", padx=3) - tk.Label(pill, textvariable=v, font=(_MONO, 10, "bold"), - bg=BG, fg=color).pack(side="left") - tk.Label(pill, text=f" {label}", font=SANS_SM, - bg=BG, fg=FG_DIM).pack(side="left") - - def _build_path_row(self, parent, row, label_text, attr, browse_cmd, placeholder): - row_frame = tk.Frame(parent, bg=BG) - row_frame.grid(row=row, column=0, sticky="ew", pady=(0, 6)) - row_frame.columnconfigure(1, weight=1) - - tk.Label(row_frame, text=label_text, font=SANS_MD, - bg=BG, fg=FG, width=12, anchor="w").grid(row=0, column=0, sticky="w") - - entry = tk.Entry(row_frame, font=MONO, bg=BG_INPUT, fg=FG, - insertbackground=ACCENT, relief="flat", bd=0, - highlightthickness=1, - highlightcolor=ACCENT, highlightbackground=BG_PANEL) - entry.grid(row=0, column=1, sticky="ew", ipady=5, padx=(6, 6)) - entry.insert(0, placeholder) - entry.config(fg=FG_DIM) - - # Placeholder behaviour: clear on focus, restore if left empty - def _focus_in(e, ph=placeholder, en=entry): - if en.get() == ph: - en.delete(0, "end") - en.config(fg=FG) - - def _focus_out(e, ph=placeholder, en=entry): - if not en.get(): - en.insert(0, ph) - en.config(fg=FG_DIM) - - entry.bind("", _focus_in) - entry.bind("", _focus_out) - setattr(self, attr, entry) - - tk.Button(row_frame, text="Browse...", font=SANS_SM, - bg=BG_PANEL, fg=ACCENT, - activebackground=ACCENT_DIM, activeforeground=BG, - relief="flat", bd=0, padx=10, pady=5, - cursor="hand2", command=browse_cmd).grid(row=0, column=2, sticky="e") - - # ------------------------------------------------------------------ - # Source type toggle - # ------------------------------------------------------------------ - - def _on_src_type_change(self): - # Clear the path field when the user switches between folder/archive - ph = "Path to scan" - if self.input_path.get() not in (ph, ""): - self.input_path.delete(0, "end") - self.input_path.insert(0, ph) - self.input_path.config(fg=FG_DIM) - - # ------------------------------------------------------------------ - # Browse dialogs - # ------------------------------------------------------------------ - - def _browse_source(self): - if self.src_type.get() == "archive": - path = filedialog.askopenfilename( - title="Select archive", - filetypes=[ - ("Supported archives", "*.zip *.tar *.tar.gz *.tgz"), - ("All files", "*.*"), - ] - ) - else: - path = filedialog.askdirectory( - title="Select source folder" - ) - if path: - self.input_path.delete(0, "end") - self.input_path.insert(0, path) - self.input_path.config(fg=FG) - - def _browse_output(self): - path = filedialog.askdirectory(title="Select output folder") - if path: - self.output_path.delete(0, "end") - self.output_path.insert(0, path) - self.output_path.config(fg=FG) - - # ------------------------------------------------------------------ - # About dialog - # ------------------------------------------------------------------ - - def _show_about(self): - win = tk.Toplevel(self) - win.title("About SQLiteWalker") - win.configure(bg=BG) - win.resizable(False, False) - win.transient(self) - _set_window_icon(win) - - content = tk.Frame(win, bg=BG, padx=26, pady=22) - content.grid(row=0, column=0, sticky="nsew") - content.columnconfigure(1, weight=1) - - tk.Label(content, text="SQLiteWalker", font=HEADER, - bg=BG, fg=ACCENT).grid(row=0, column=0, columnspan=2, sticky="w") - tk.Label(content, text="Python script to walk a folder, zip or tar file looking for SQLite databases", - font=SANS_MD, bg=BG, fg=FG).grid(row=1, column=0, columnspan=2, sticky="w", pady=(4, 12)) - - tk.Frame(content, bg=BG_PANEL, height=1).grid( - row=2, column=0, columnspan=2, sticky="ew", pady=(0, 12) - ) - - rows = [ - ("Version", VERSION), - ("Author", "@KevinPagano3 | @stark4n6"), - ("GitHub", "https://github.com/stark4n6/SQLiteWalker"), - ("Website", "startme.stark4n6.com"), - ("GUI contributor", "@Mipa97"), - ("Runtime", f"{_SYS} | Python {sys.version.split()[0]}"), - ] - - for row_idx, (label, value) in enumerate(rows, 3): - tk.Label(content, text=label, font=SANS_SM, bg=BG, fg=FG_DIM, - width=15, anchor="w").grid(row=row_idx, column=0, sticky="nw", pady=2) - tk.Label(content, text=value, font=SANS_SM, bg=BG, fg=FG, - anchor="w", wraplength=340, justify="left").grid( - row=row_idx, column=1, sticky="w", pady=2 - ) - - btn_row = tk.Frame(content, bg=BG) - btn_row.grid(row=3 + len(rows), column=0, columnspan=2, sticky="e", pady=(16, 0)) - tk.Button(btn_row, text="Close", font=SANS_SM, - bg=BG_PANEL, fg=ACCENT, - activebackground=ACCENT_DIM, activeforeground=BG, - relief="flat", bd=0, padx=18, pady=5, - cursor="hand2", command=win.destroy).pack() - - win.bind("", lambda _e: win.destroy()) - win.update_idletasks() - x = self.winfo_rootx() + (self.winfo_width() - win.winfo_width()) // 2 - y = self.winfo_rooty() + (self.winfo_height() - win.winfo_height()) // 2 - win.geometry(f"+{max(x, 0)}+{max(y, 0)}") - win.grab_set() - win.focus_set() - - # ------------------------------------------------------------------ - # Log helpers - # ------------------------------------------------------------------ - - def _log(self, text, tag="normal"): - self.log_text.config(state="normal") - self.log_text.insert("end", text, tag) - self.log_text.see("end") - self.log_text.config(state="disabled") - - def _clear_log(self): - self.log_text.config(state="normal") - self.log_text.delete("1.0", "end") - self.log_text.config(state="disabled") - self._log(ASCII_BANNER, "banner") - - # Thread-safe: worker calls these, .after() marshals to the main thread - def _thread_log(self, text, tag="normal"): - self.after(0, self._log, text, tag) - - def _thread_progress(self, current, total): - def _up(): - if total: - pct = int(current / total * 100) - self.progress["value"] = pct - self.prog_label.config(text=f"{current} / {total}") - else: - self.progress["value"] = 0 - self.prog_label.config(text=f"{current} scanned") - self.after(0, _up) - - # ------------------------------------------------------------------ - # Open output folder - # ------------------------------------------------------------------ - - def _open_last_output(self): - if self._last_output and os.path.isdir(self._last_output): - _open_folder(self._last_output) - - # ------------------------------------------------------------------ - # Scan lifecycle - # ------------------------------------------------------------------ - - def _start_scan(self): - if self._scanning: - return - - placeholders = {"Path to scan", "Folder where results will be saved"} - inp = self.input_path.get().strip() - out = self.output_path.get().strip() - - if inp in placeholders or not inp: - messagebox.showerror("Missing input", "Please select a source file or folder.") - return - if out in placeholders or not out: - messagebox.showerror("Missing output", "Please select an output folder.") - return - if not os.path.exists(inp): - messagebox.showerror("Not found", f"Source path does not exist:\n{inp}") - return - if not os.path.exists(out): - messagebox.showerror("Not found", f"Output folder does not exist:\n{out}") - return - - self._scanning = True - self.open_btn.config(state="disabled") - self.scan_btn.config(state="disabled", text="Scanning...", bg=ACCENT_DIM, fg=BG) - self.status_var.set("Scanning...") - self.progress["value"] = 0 - self.prog_label.config(text="") - for v in self._stat_vars.values(): - v.set("-") - - self._log("\n" + "-" * 60 + "\n", "info") - self._log(f"Source : {inp}\n", "info") - self._log(f"Dest : {out}\n", "info") - self._log("-" * 60 + "\n", "info") - - def _run_scan_safely(): - try: - run_scan( - inp, - out, - self.quiet_var.get(), - self._thread_log, - self._thread_progress, - self._scan_done - ) - except Exception as e: - self._thread_log(f"\nERROR: Scan failed: {e}\n", "error") - self._thread_log(traceback.format_exc(), "error") - self._scan_failed(str(e)) - - threading.Thread( - target=_run_scan_safely, - daemon=True, - ).start() - - def _scan_failed(self, message): - def _update(): - self._scanning = False - self.scan_btn.config(state="normal", text="Run Scan", bg=ACCENT, fg=BG) - self.open_btn.config(state="disabled", fg=FG_DIM) - self.progress["value"] = 0 - self.prog_label.config(text="Failed") - self.status_var.set(f"Scan failed: {message}") - - self.after(0, _update) - - def _scan_done(self, count, shm_count, wal_count, error_count, elapsed, out_folder): - # Called from the worker thread - must schedule UI updates via after() - def _update(): - self._scanning = False - self._last_output = out_folder - - self.scan_btn.config(state="normal", text="Run Scan", bg=ACCENT, fg=BG) - self.open_btn.config(state="normal", fg=ACCENT) - self.progress["value"] = 100 - self.prog_label.config(text="Done") - - self._stat_vars["db"].set(str(count)) - self._stat_vars["wal"].set(str(wal_count)) - self._stat_vars["shm"].set(str(shm_count)) - self._stat_vars["err"].set(str(error_count)) - self.status_var.set(f"Scan complete | {elapsed}s | {out_folder}") - - self._log("\n" + "-" * 60 + "\n", "info") - self._log("JOB FINISHED\n", "done") - self._log(f" Runtime : {elapsed}s\n", "done") - self._log(f" DBs : {count}\n", "done") - self._log(f" SHMs : {shm_count}\n","done") - self._log(f" WALs : {wal_count}\n","done") - if error_count: - self._log(f" Errors : {error_count} (see error_list.tsv)\n", "error") - self._log(f" Output : {out_folder}\n", "done") - self._log("-" * 60 + "\n", "info") - - self.after(0, _update) - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - -def main(): - app = SQLiteWalkerApp() - app.mainloop() - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/logo.png b/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7570a70d9baad4297c74b3d65de0979766f3651c GIT binary patch literal 21541 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 E0fF`;iU0rr literal 0 HcmV?d00001