""" 文件扫描模块 提供目录大小扫描、文件列表等功能 """ import os import time from typing import List, Dict, Optional, Callable, Tuple from dataclasses import dataclass, field from datetime import datetime from utils import format_size, is_virtual_filesystem, run_command @dataclass class FileItem: """文件/目录信息""" name: str # 名称 path: str # 完整路径 is_dir: bool # 是否为目录 size: int # 大小(字节) modified_time: datetime # 修改时间 permissions: str # 权限字符串 owner: str # 所有者 group: str # 所属组 @property def size_formatted(self) -> str: return format_size(self.size) @property def modified_time_formatted(self) -> str: return self.modified_time.strftime("%Y-%m-%d %H:%M") @property def icon(self) -> str: if self.is_dir: return "📁" elif self.name.endswith(('.log', '.log.gz', '.log.1', '.log.2')): return "📋" elif self.name.endswith(('.gz', '.tar', '.zip', '.7z', '.rar')): return "📦" elif self.name.endswith(('.conf', '.cfg', '.ini', '.yaml', '.yml', '.json')): return "⚙️" elif self.name.endswith(('.sh', '.bash', '.py', '.pl')): return "📜" elif self.name.endswith(('.txt', '.md', '.rst')): return "📄" else: return "📄" @dataclass class ScanProgress: """扫描进度信息""" current_path: str = "" scanned_dirs: int = 0 scanned_files: int = 0 total_size: int = 0 elapsed_time: float = 0.0 is_complete: bool = False class FileScanner: """文件扫描器""" # 虚拟文件系统目录列表 VIRTUAL_DIRS = [ '/proc', '/sys', '/dev', '/run', '/var/run', '/var/lock', '/tmp', ] # 系统日志文件模式(不包含子目录中的日志) SYSTEM_LOG_PATTERNS = [ '*.log', '*.log.*', '*.gz', 'syslog*', 'messages*', 'dmesg*', 'kern.log*', 'auth.log*', 'daemon.log*', 'debug*', 'boot.log*', 'faillog', 'lastlog', 'wtmp', 'btmp', 'utmp', ] def __init__(self, sudo_password: Optional[str] = None): self.sudo_password = sudo_password self._cancel_scan = False def scan_directory_sizes( self, path: str, skip_virtual: bool = True, callback: Optional[Callable[[ScanProgress], None]] = None ) -> Dict[str, int]: """ 扫描目录下每个子目录的大小 参数: path: 要扫描的目录路径 skip_virtual: 是否跳过虚拟文件系统目录 callback: 进度回调函数 返回: {目录路径: 大小} 字典 """ self._cancel_scan = False dir_sizes: Dict[str, int] = {} progress = ScanProgress() start_time = time.time() try: # 获取顶层子目录 entries = self._list_directory(path) for entry in entries: if self._cancel_scan: break entry_path = os.path.join(path, entry) # 跳过虚拟文件系统 if skip_virtual and self._is_virtual_dir(entry_path): continue # 跳过其他挂载点 if self._is_other_mountpoint(entry_path, path): continue # 计算目录大小 size = self._get_directory_size(entry_path, progress, callback) dir_sizes[entry_path] = size progress.total_size += size except PermissionError: pass except Exception as e: print(f"扫描目录时发生错误: {e}") progress.is_complete = True progress.elapsed_time = time.time() - start_time if callback: callback(progress) return dir_sizes def list_directory( self, path: str, show_hidden: bool = False, sort_by: str = 'size', reverse: bool = True ) -> List[FileItem]: """ 列出目录下的文件和子目录 参数: path: 目录路径 show_hidden: 是否显示隐藏文件 sort_by: 排序方式 (name, size, time) reverse: 是否逆序 返回: FileItem 列表 """ items: List[FileItem] = [] try: entries = self._list_directory(path) for entry in entries: # 跳过隐藏文件(除非要求显示) if not show_hidden and entry.startswith('.'): continue entry_path = os.path.join(path, entry) try: item = self._get_file_item(entry_path, entry) if item: items.append(item) except (PermissionError, OSError): # 跳过无权限访问的文件 pass except PermissionError: pass except Exception as e: print(f"列出目录时发生错误: {e}") # 排序 if sort_by == 'name': items.sort(key=lambda x: x.name.lower(), reverse=reverse) elif sort_by == 'size': items.sort(key=lambda x: x.size, reverse=reverse) elif sort_by == 'time': items.sort(key=lambda x: x.modified_time, reverse=reverse) # 目录排在前面 items.sort(key=lambda x: (not x.is_dir, -x.size if reverse else x.size)) return items def get_system_logs(self, mount_point: str) -> Tuple[List[FileItem], int]: """ 获取 /var/log 下的系统日志文件(不包含子目录中的日志) 参数: mount_point: 分区挂载点 返回: (日志文件列表, 总大小) """ log_dir = os.path.join(mount_point, 'var', 'log') if not os.path.exists(log_dir): return [], 0 logs: List[FileItem] = [] total_size = 0 try: entries = self._list_directory(log_dir) for entry in entries: entry_path = os.path.join(log_dir, entry) # 只处理文件,不处理子目录 if not os.path.isfile(entry_path): continue # 检查是否为日志文件 if self._is_log_file(entry): try: item = self._get_file_item(entry_path, entry) if item: logs.append(item) total_size += item.size except (PermissionError, OSError): pass except PermissionError: pass except Exception as e: print(f"获取系统日志时发生错误: {e}") # 按大小排序 logs.sort(key=lambda x: x.size, reverse=True) return logs, total_size def cancel_scan(self): """取消正在进行的扫描""" self._cancel_scan = True def _list_directory(self, path: str) -> List[str]: """列出目录内容""" try: return os.listdir(path) except PermissionError: # 尝试使用 sudo if self.sudo_password: stdout, _, returncode = run_command( ['ls', '-a', path], use_sudo=True, sudo_password=self.sudo_password ) if returncode == 0: return [f for f in stdout.strip().split('\n') if f and f not in ('.', '..')] return [] except OSError: return [] def _get_directory_size( self, path: str, progress: ScanProgress, callback: Optional[Callable[[ScanProgress], None]] = None ) -> int: """递归计算目录大小""" if self._cancel_scan: return 0 total_size = 0 try: entries = self._list_directory(path) for entry in entries: if self._cancel_scan: break entry_path = os.path.join(path, entry) # 跳过虚拟文件系统 if self._is_virtual_dir(entry_path): continue # 跳过其他挂载点 if self._is_other_mountpoint(entry_path, path): continue try: if os.path.islink(entry_path): # 跳过符号链接 continue elif os.path.isdir(entry_path): # 递归计算子目录 progress.current_path = entry_path progress.scanned_dirs += 1 if callback and progress.scanned_dirs % 100 == 0: callback(progress) total_size += self._get_directory_size(entry_path, progress, callback) else: # 获取文件大小 try: file_size = os.path.getsize(entry_path) total_size += file_size progress.scanned_files += 1 progress.total_size += file_size if callback and progress.scanned_files % 1000 == 0: callback(progress) except OSError: pass except PermissionError: # 无权限访问 pass except PermissionError: # 无权限访问目录,尝试使用 sudo if self.sudo_password: total_size = self._get_directory_size_sudo(path) return total_size def _get_directory_size_sudo(self, path: str) -> int: """使用 sudo 获取目录大小""" stdout, _, returncode = run_command( ['du', '-sb', path], use_sudo=True, sudo_password=self.sudo_password, timeout=60 ) if returncode == 0: try: return int(stdout.split()[0]) except (ValueError, IndexError): pass return 0 def _get_file_item(self, path: str, name: str) -> Optional[FileItem]: """获取文件/目录信息""" try: stat_info = os.lstat(path) # 获取所有者和组 try: import pwd import grp owner = pwd.getpwuid(stat_info.st_uid).pw_name group = grp.getgrgid(stat_info.st_gid).gr_name except (ImportError, KeyError): owner = str(stat_info.st_uid) group = str(stat_info.st_gid) # 获取权限字符串 permissions = self._format_permissions(stat_info.st_mode) # 如果是目录,获取目录大小(使用 du) if os.path.isdir(path): size = self._get_dir_size_fast(path) else: size = stat_info.st_size return FileItem( name=name, path=path, is_dir=os.path.isdir(path), size=size, modified_time=datetime.fromtimestamp(stat_info.st_mtime), permissions=permissions, owner=owner, group=group ) except (OSError, PermissionError): return None def _get_dir_size_fast(self, path: str) -> int: """快速获取目录大小(使用 du 命令)""" try: stdout, _, returncode = run_command( ['du', '-sb', path], use_sudo=True, sudo_password=self.sudo_password, timeout=10 ) if returncode == 0: return int(stdout.split()[0]) except Exception: pass return 0 def _format_permissions(self, mode: int) -> str: """格式化文件权限为字符串""" perms = [] # 文件类型 if os.path.isdir(mode): perms.append('d') elif os.path.islink(mode): perms.append('l') else: perms.append('-') # 所有者权限 perms.append('r' if mode & 0o400 else '-') perms.append('w' if mode & 0o200 else '-') perms.append('x' if mode & 0o100 else '-') # 组权限 perms.append('r' if mode & 0o040 else '-') perms.append('w' if mode & 0o020 else '-') perms.append('x' if mode & 0o010 else '-') # 其他用户权限 perms.append('r' if mode & 0o004 else '-') perms.append('w' if mode & 0o002 else '-') perms.append('x' if mode & 0o001 else '-') return ''.join(perms) def _is_virtual_dir(self, path: str) -> bool: """检查是否为虚拟文件系统目录""" path = os.path.normpath(path) for vdir in self.VIRTUAL_DIRS: if path == vdir or path.startswith(vdir + '/'): return True return False def _is_other_mountpoint(self, path: str, base_path: str) -> bool: """检查是否为其他挂载点""" try: # 如果路径和基础路径的设备号不同,说明是不同的挂载点 base_stat = os.stat(base_path) path_stat = os.stat(path) if base_stat.st_dev != path_stat.st_dev: return True except OSError: pass return False def _is_log_file(self, filename: str) -> bool: """检查是否为日志文件""" import fnmatch for pattern in self.SYSTEM_LOG_PATTERNS: if fnmatch.fnmatch(filename, pattern): return True # 检查文件扩展名 log_extensions = ['.log', '.log.gz', '.log.1', '.log.2', '.log.3'] for ext in log_extensions: if filename.endswith(ext): return True return False