Files
2026-06-26 16:54:55 +08:00

490 lines
14 KiB
Python

"""
文件扫描模块
提供目录大小扫描、文件列表等功能
"""
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