在“个人网盘”应用中的路径选择框(移动文件和复制文件的功能需要用到),出现无法展开处于ISO镜像文件中的IMG镜像文件内目录的问题

问题涉及代码

该问题涉及了以下几种函数类型及该类型下的函数(带*为大型函数,因函数内容过多,位于最后):

  1. 路径解析与虚拟路径处理函数
    | 函数名 | 功能 |
    | _get_archive_ext() | 获取归档文件扩展名 |
    | split_virtual_path() | 将虚拟路径拆分为归档相对路径和内部路径 |
    | _is_archive_or_image() | 检查是否为支持的归档/镜像文件 |
    | is_virtual_path() | 检查路径是否包含 :: 分隔符 |
    | _nested_cache_key() | 生成嵌套归档缓存目录名 |
    | _nested_cache_dir() | 创建嵌套缓存目录 |
    | _get_or_extract_nested_inner() | 从外层归档提取内层归档到缓存 |
  2. 格式检测函数
    | 函数名 | 功能 |
    | _detect_image_format() | 按二进制签名检测镜像真实格式 |
    | _is_iso9660_file() | 判断文件是否真的是 ISO9660(可交给 pycdlib)|
  3. 提取函数
    | 函数名 | 功能 |
    | _extract_nested_archive() | 从外层归档提取内层归档 | *
    | _get_nested_archive_path() | 处理多层嵌套虚拟路径 |
    | _extract_virtual_path_item() | 从归档(支持多层嵌套)中提取目标内容 | *
    | _extract_archive_item() | 从外层压缩包/镜像中提取指定路径的文件到临时目录 | *
    | list_nested_virtual_directory() | 处理嵌套压缩包/镜像:提取内部归档并列出其内容 |
  4. 目录列表函数
    | 函数名 | 功能 |
    | list_virtual_directory() | 列出虚拟目录(压缩包/镜像内部)的文件和文件夹 | *
  5. API 路由
    | 函数名 | 功能 |
    | api_dir_tree() | 普通目录树 API |
    | api_dir_tree_with_path_route() | 路径选择树 API 路由 |
    | _api_dir_tree_nested() | 处理多层嵌套虚拟路径 | *
    | api_dir_tree_with_path() | 路径选择树 API 核心逻辑 | *
  6. ISO/IMG 专用提取函数
    | 函数名 | 功能 |
    | _extract_iso_item() | 从 ISO/镜像文件中提取指定项 | *
    | _extract_iso_to_dir() | 使用 pycdlib 或 pytsk3 将 ISO/镜像文件内容提取到临时目录 | *

问题表现

  • 在进入程序的前端后(html),通过点击文件的移动和复制按钮打开路径选择框,在通过小箭头展开ISO镜像文件中的IMG镜像文件时,发现展开后的内容空白
  • 环境因素排除:所有涉及的库已正确安装,ISO镜像文件和IMG镜像文件完好无损坏,可以被直接正常访问
  • IMG镜像文件目录展开问题补充:在物理目录中的IMG镜像文件可以被正常展开目录,不存在该问题
  • 问题于2天前被测试发现,本人已经实在找不出可能的原因,报错库是pycdlib,推测可能的3种原因:
    1. 路径的处理逻辑问题
    2. 从ISO镜像文件中提取IMG镜像文件时出现问题
    3. IMG镜像文件在有虚拟目录的路径中时有原来的目录展开逻辑失效

问题报错

  • 程序报错日志(因编辑器原因导致显示不完全一致,但核心日志都显示正确)

    �失败: Valid ISO9660 filesystems must have at least one PVD
    INFO:werkzeug:::ffff:127.0.0.1 - - [28/Aug/2026 16:41:11] “GET /api/dir_tree?path=BOOT/DOS71INS.IMG::MS-DOS%207.10.iso::BOOT HTTP/1.1” 200 -
    INFO:main:Request from ::ffff:127.0.0.1
    INFO:main:[开发模式] 脚本位置: d:\个人系统数据\桌面\天星网盘\app.py
    INFO:main:运行时目录: d:\个人系统数据\桌面\天星网盘
    DEBUG:main:api_dir_tree_with_path called: path=BOOT/DOS71INS.IMG::MS-DOS 7.10.iso::BOOT/DOS71INS.IMG::, user=第三名尔阳
    INFO:main:[缓存状态] nested_f072c8b771bc414c86d58754bf07172a 标记为使用中
    DEBUG:main:extract_archive_item: archive_path=d:\个人系统数据\桌面\天星网盘\uploads\第三名尔阳\BOOT/DOS71INS.IMG, inner_path=MS-DOS 7.10.iso, temp_dir=C:\Users\HUAWEI\AppData\Local\Temp\天星网盘cache\nested_f072c8b771bc414c86d58754bf07172a, ext=.img, target=C:\Users\HUAWEI\AppData\Local\Temp\天星网盘_cache\nested_f072c8b771bc414c86d58754bf07172a\MS-DOS 7.10.iso
    DEBUG:__main
    :pycdlib 打开失败: Valid ISO9660 filesystems must have at least one PVD
    INFO:main:[缓存状态] nested_801db0ea92f0491db22fa08b29dab950 标记为使用中
    DEBUG:main:_extract_archive_item: archive_path=C:\Users\HUAWEI\AppData\Local\Temp\天星网盘_cache\nested_f072c8b771bc414c86d58754bf07172a\MS-DOS 7.10.iso, inner_path=BOOT/DOS71INS.IMG, temp_dir=C:\Users\HUAWEI\AppData\Local\Temp\天星网盘_cache\nested_801db0ea92f0491db22fa08b29dab950, ext=.iso, target=C:\Users\HUAWEI\AppData\Local\Temp\天星网盘_cache\nested_801db0ea92f0491db22fa08b29dab950\DOS71INS.IMG
    INFO:werkzeug:::ffff:127.0.0.1 - - [28/Aug/2026 16:41:13] “[35m[1mGET /api/dir_tree?path=BOOT/DOS71INS.IMG::MS-DOS%207.10.iso::BOOT/DOS71INS.IMG:: HTTP/1.1[0m” 500 -

  • 浏览器network报错
    请求网址:http://127.0.0.1:8000/api/dir_tree?path=1%2FMS-DOS%207.10.iso%3A%3ABOOT%2FDOS71INS.IMG%3A%3A
    响应:{“error”: “\u89e3\u6790 ISO \u5931\u8d25\uff1aValid ISO9660 filesystems must have at least one PVD”}

涉及所有函数内容

路径解析与虚拟路径处理函数

def _get_archive_ext(archive_path):
    """获取归档文件的扩展名,特殊处理 .tar.gz/.tar.bz2 等复合扩展名和 ISO 9660 ;1 后缀"""
    lower = archive_path.lower()
    if lower.endswith('.tar.gz') or lower.endswith('.tgz'):
        return '.tar.gz'
    if lower.endswith('.tar.bz2') or lower.endswith('.tbz'):
        return '.tbz'
    # ISO 9660 版本后缀 ;1 会被 splitext 误识别为扩展名
    if lower.endswith(';1'):
        base = archive_path[:-2]  # 去掉 ;1
        return os.path.splitext(base)[1].lower()
    return os.path.splitext(archive_path)[1].lower()
def split_virtual_path(path):
    """将虚拟路径拆分为归档/镜像文件相对路径和内部路径。
    支持嵌套归档/镜像(例如 outer.zip::inner.tar::file.txt 解析为 ['outer.zip', 'inner.tar', 'file.txt'])
    返回 (archive_rel, inner) 的形式,其中 inner 是路径。
    如果是多层嵌套,内部归档会通过 _extract_nested_archive 提取后处理。
    """
    if not path:
        return '', ''
    normalized = path.strip('/')
    # 只保留末尾最后一组 :: 表示"归档根目录"标记
    # 例如 'middle.zip::innermost.zip::' -> 'middle.zip::innermost.zip::' (保留)
    # 例如 'middle.zip::innermost.zip::subdir/' -> 'middle.zip::innermost.zip::subdir/' (保留)
    original_stripped = normalized.rstrip('/')
    has_trailing_colon = original_stripped.endswith('::')
    if has_trailing_colon:
        normalized = original_stripped[:-2]
    if not normalized:
        # 处理纯 '::' 或 '' 的情况
        if path.strip('/').endswith('::'):
            return '', ''
        return '', ''
    # 只按第一个 :: 拆分
    if '::' in normalized:
        idx = normalized.index('::')
        archive_rel = normalized[:idx].strip('/')
        inner = normalized[idx+2:].strip('/')
        # 如果原始路径末尾有 ::,说明 inner 后面还有一个归档根目录标记
        if has_trailing_colon and inner:
            inner += '::'
        elif has_trailing_colon and not inner:
            # 只有单个归档名 + :: 的情况,如 'archive.zip::'
            return archive_rel, ''
        return archive_rel, inner
    # 没有 :: 的情况
    if has_trailing_colon:
        # 如 'archive.zip::' 已经被处理为 normalized = 'archive.zip'
        return normalized, ''
    return normalized, ''
def _is_archive_or_image(filename):
    """检查是否为支持的归档/镜像文件"""
    archive_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.7z', '.rar'}
    image_exts = {'.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'}
    ext = os.path.splitext(filename)[1].lower()
    return ext in archive_exts or ext in image_exts
def is_virtual_path(path):
    """检查路径是否为虚拟路径(包含 :: 分隔符表示进入归档/镜像内部)"""
    return '::' in (path or '')
def _nested_cache_key(archive_full, inner_path):
    """根据外层归档路径 + mtime + size + 内部路径 + 代码版本号生成 16 位 sha1 哈希作为缓存子目录名"""
    try:
        mtime = os.path.getmtime(archive_full)
        size = os.path.getsize(archive_full)
    except OSError:
        mtime = 0
        size = 0
    key_str = f"{_NESTED_CACHE_VERSION}|{archive_full}|{mtime}|{size}|{inner_path}"
    return hashlib.sha1(key_str.encode('utf-8')).hexdigest()[:16]
def _nested_cache_dir(user_dir):
    """在 user_dir/.nested_cache/ 下创建隐藏缓存目录,注册进缓存管理系统,
    由 GC 线程按 300 秒无活动规则自动回收"""
    cache_dir = os.path.join(user_dir, '.nested_cache')
    os.makedirs(cache_dir, exist_ok=True)
    mark_cache_in_use(cache_dir)
    return cache_dir
def _get_or_extract_nested_inner(user_dir, archive_full, archive_rel, inner_check, inner_filename):
    """从外层归档中提取内层归档到 user_dir/.nested_cache/<hash>/ 下。
    首次访问时提取;后续访问直接复用缓存,不再反复解压。
    返回 (extracted_path, cache_subdir)。失败时抛出异常。"""
    cache_base = _nested_cache_dir(user_dir)
    cache_key = _nested_cache_key(archive_full, inner_check)
    cache_subdir = os.path.join(cache_base, cache_key)
    os.makedirs(cache_subdir, exist_ok=True)
    mark_cache_in_use(cache_subdir)

    # 检查缓存是否已存在
    extracted = _find_extracted_file(cache_subdir, inner_filename)
    if not extracted:
        extracted = _find_extracted_file(cache_subdir, inner_check)
    if extracted:
        refresh_cache_activity(cache_subdir)
        return extracted, cache_subdir

    # 首次访问:从外层归档提取
    try:
        _extract_archive_item(archive_full, inner_check, cache_subdir)
    except Exception as e:
        logger.warning(f"提取虚拟目录中的归档失败: {inner_check}, error: {e}")
        raise

    extracted = _find_extracted_file(cache_subdir, inner_filename)
    if not extracted:
        extracted = _find_extracted_file(cache_subdir, inner_check)
    if not extracted:
        raise FileNotFoundError(f"嵌套归档不存在: {inner_filename}")

    return extracted, cache_subdir

格式检测函数

def _detect_image_format(file_path):
    """按二进制签名检测镜像真实格式,而不是信任扩展名。

    判定依据(两种格式二进制结构完全不兼容):
      - ISO9660: 卷描述符从逻辑扇区16开始(2048B扇区时偏移0x8000),
                 PVD 类型字节(0x01)在 0x8000,标识符 "CD001" 在 0x8001~0x8005。
      - FAT/MBR: 引导扇区第0字节为跳转指令 0xEB/0xE9,偏移510-511为 0x55AA。

    返回:
      'iso9660' -> 可安全交给 pycdlib
      'fat'     -> 磁盘镜像,必须交给 pytsk3
      None      -> 无法识别,同样交给 pytsk3 尝试
    把 FAT 镜像交给 pycdlib 必然抛出
    "Valid ISO9660 filesystems must have at least one PVD"。
    """
    try:
        if not file_path or not os.path.isfile(file_path):
            return None
        size = os.path.getsize(file_path)
        if size < 512:
            return None
        with open(file_path, 'rb') as f:
            # 1) ISO9660:类型字节必须为 0x01(PVD),否则 pycdlib 仍会报缺 PVD
            if size >= 0x8006:
                f.seek(0x8000)
                vd = f.read(6)
                if vd[0] == 0x01 and vd[1:6] == b'CD001':
                    return 'iso9660'
            # 2) FAT / MBR:跳转指令 + 0x55AA 签名
            f.seek(0)
            head = f.read(512)
            if len(head) >= 512 and head[510:512] == b'\x55\xAA' \
                    and head[0] in (0xEB, 0xE9):
                return 'fat'
            return None
    except Exception:
        return None
def _is_iso9660_file(file_path):
    """轻量判断:该文件是否真的是 ISO9660(可安全交给 pycdlib 打开)"""
    return _detect_image_format(file_path) == 'iso9660'

def _find_extracted_file(temp_dir, expected_name):
    """
    在临时目录中查找提取后的文件,支持:
    - 精确匹配
    - 空格/+号互换匹配
    - 大小关感不敏感匹配
    - ISO 9660 ;1 后缀忽略匹配
    - 递归搜索子目录(支持嵌套归档查找)
    """
    if not expected_name:
        return None
    base = os.path.basename(expected_name)

    # 1. 精确匹配(直接路径)
    path = os.path.join(temp_dir, expected_name.replace('/', os.sep))
    if os.path.exists(path):
        return path

    # 2. 精确匹配(basename)
    path = os.path.join(temp_dir, base)
    if os.path.exists(path):
        return path

    # 3. 空格/+号互换匹配
    for variant in [base.replace('+', ' '), base.replace(' ', '+')]:
        path = os.path.join(temp_dir, variant)
        if os.path.exists(path):
            return path

    # 4. 大小写不敏感匹配(根目录)
    base_lower = base.lower()
    try:
        for f in os.listdir(temp_dir):
            if f.lower() == base_lower:
                return os.path.join(temp_dir, f)
    except Exception:
        pass

    # 5. ISO ;1 后缀匹配 (忽略大小写及 ;1 后缀)
    base_normalized = base_lower.replace(';1', '')
    try:
        for f in os.listdir(temp_dir):
            f_normalized = f.lower().replace(';1', '')
            if f_normalized == base_normalized:
                return os.path.join(temp_dir, f)
    except Exception:
        pass

    # 6. 递归搜索子目录(修复4层+虚拟目录和移动/复制到嵌套归档的bug)
    try:
        for root, dirs, files in os.walk(temp_dir):
            for f in files:
                if f == base or f.lower() == base_lower:
                    return os.path.join(root, f)
                f_normalized = f.lower().replace(';1', '')
                if f_normalized == base_normalized:
                    return os.path.join(root, f)
            # 也检查目录名(因为 _find_extracted_file 有时用于查找目录路径中的归档)
            for d in dirs:
                d_path = os.path.join(root, d)
                if expected_name.replace('/', os.sep) == d_path or expected_name.replace('/', os.sep) == d_path.replace('\\', os.sep):
                    return d_path
    except Exception:
        pass

    return None

提取函数

def _extract_nested_archive(user_dir, archive_rel, inner_name):
    """
    从外层归档/镜像中提取内层归档/镜像文件到临时目录。
    返回临时文件的绝对路径。
    """
    archive_full = os.path.join(user_dir, archive_rel)
    ext = _get_archive_ext(archive_full)
    temp_dir = create_cache_dir(prefix='nested_archive_')
    temp_file = os.path.join(temp_dir, os.path.basename(inner_name))

    try:
        if ext == '.zip':
            with zipfile.ZipFile(archive_full, 'r') as zf:
                use_gbk = False
                has_fpx_utf8 = False
                for info in zf.infolist():
                    if info.flag_bits & 0x0800:
                        has_fpx_utf8 = True
                        break
                if not has_fpx_utf8:
                    for info in zf.infolist():
                        fn = info.filename
                        if isinstance(fn, str):
                            if any(ord(c) > 127 and ord(c) < 256 for c in fn):
                                try:
                                    fn.encode('latin-1').decode('gbk')
                                    use_gbk = True
                                    break
                                except Exception:
                                    pass
                        elif isinstance(fn, bytes):
                            use_gbk = True
                            break
                matched = False
                for info in zf.infolist():
                    fn = info.filename
                    if use_gbk:
                        try:
                            fn = fn.encode('latin-1').decode('gbk')
                        except Exception:
                            try:
                                fn = fn.encode('cp437').decode('gbk')
                            except Exception:
                                pass
                    if fn == inner_name or fn.rstrip('/') == inner_name:
                        with zf.open(info.filename) as src, open(temp_file, 'wb') as dst:
                            shutil.copyfileobj(src, dst)
                        matched = True
                        break
                if not matched:
                    raise FileNotFoundError(f'内层归档 {inner_name} 未找到')
        elif ext in ('.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.tbz'):
            with tarfile.open(archive_full, 'r') as tf:
                for member in tf.getmembers():
                    if member.name == inner_name or member.name.rstrip('/') == inner_name:
                        if member.isfile():
                            src = tf.extractfile(member)
                            if src:
                                with open(temp_file, 'wb') as dst:
                                    shutil.copyfileobj(src, dst)
                                break
                else:
                    raise FileNotFoundError(f'内层归档 {inner_name} 未找到')
        elif ext == '.7z' and HAS_PY7ZR:
            with py7zr.SevenZipFile(archive_full, mode='r') as zf:
                zf.extract(targets=[inner_name], path=temp_dir)
                extracted = os.path.join(temp_dir, inner_name)
                if os.path.exists(extracted):
                    if extracted != temp_file:
                        os.rename(extracted, temp_file)
                    temp_file = extracted
                else:
                    raise FileNotFoundError(f'内层归档 {inner_name} 提取失败')
        elif ext == '.rar' and HAS_RARFILE:
            with rarfile.RarFile(archive_full) as rf:
                rf.extract(inner_name, temp_dir)
                extracted = os.path.join(temp_dir, inner_name)
                if os.path.exists(extracted):
                    if extracted != temp_file:
                        os.rename(extracted, temp_file)
                    temp_file = extracted
                else:
                    raise FileNotFoundError(f'内层归档 {inner_name} 提取失败')
        elif ext in ('.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'):
            _nested_extracted = False

            # 尝试 pycdlib(仅对 .iso 启用;.img 是 FAT/NTFS,pycdlib 打不开会抛 PVD 错误)
            if HAS_PYCDLIB and _is_iso9660_file(archive_full):
                try:
                    iso = pycdlib.PyCdlib()
                    iso.open(archive_full)
                    inner_lower = inner_name.lower()
                    inner_lower_no_ver = inner_lower.replace(';1', '')
                    actual_path = None

                    # 精确大小写不敏感匹配
                    for dir_path, dirs, files in iso.walk(iso_path='/'):
                        for f in files:
                            full_path = (dir_path.rstrip('/') + '/' + f).lstrip('/')
                            if full_path.lower() == inner_lower:
                                actual_path = full_path
                                break
                        if actual_path:
                            break

                    # 去掉 ;1 版本后缀后匹配
                    if not actual_path:
                        for dir_path, dirs, files in iso.walk(iso_path='/'):
                            for f in files:
                                full_path = (dir_path.rstrip('/') + '/' + f).lstrip('/')
                                full_lower = full_path.lower().replace(';1', '')
                                if full_lower == inner_lower_no_ver or full_lower == inner_lower:
                                    actual_path = full_path
                                    break
                            if actual_path:
                                break

                    # Rock Ridge
                    if not actual_path and iso.has_rock_ridge():
                        for dir_path, dirs, files in iso.walk(rr_path='/'):
                            for f in files:
                                full_path = (dir_path.rstrip('/') + '/' + f).lstrip('/')
                                if full_path.lower() == inner_lower or full_path.lower().replace(';1', '') == inner_lower_no_ver:
                                    actual_path = f"rr://{full_path}"
                                    break
                            if actual_path:
                                break

                    # Joliet
                    if not actual_path and iso.has_joliet():
                        for dir_path, dirs, files in iso.walk(joliet_path='/'):
                            for f in files:
                                full_path = (dir_path.rstrip('/') + '/' + f).lstrip('/')
                                if full_path.lower() == inner_lower or full_path.lower().replace(';1', '') == inner_lower_no_ver:
                                    actual_path = f"joliet://{full_path}"
                                    break
                            if actual_path:
                                break

                    if not actual_path:
                        raise FileNotFoundError(f"ISO中未找到: {inner_name}")

                    os.makedirs(os.path.dirname(temp_file), exist_ok=True)
                    with open(temp_file, 'wb') as dst_fp:
                        if actual_path.startswith('rr://'):
                            iso_fp = actual_path[5:]
                            iso.get_file_from_iso_fp(dst_fp, rr_path=iso_fp)
                        elif actual_path.startswith('joliet://'):
                            iso_fp = actual_path[9:]
                            iso.get_file_from_iso_fp(dst_fp, joliet_path=iso_fp)
                        else:
                            iso_path_arg = '/' + actual_path if not actual_path.startswith('/') else actual_path
                            iso.get_file_from_iso_fp(dst_fp, iso_path=iso_path_arg)
                    _nested_extracted = True
                    iso.close()
                except FileNotFoundError:
                    raise
                except Exception:
                    pass

            # 统一落到 pytsk3 回退(pytsk3 可读 FAT/NTFS/ISO9660/分区表镜像)
            if not _nested_extracted and not HAS_PYTSK3:
                raise FileNotFoundError(f'无法从镜像 {archive_full} 中提取 {inner_name}')
            if not _nested_extracted and HAS_PYTSK3:
                try:
                    img = pytsk3.Img_Info(archive_full)
                    try:
                        fs = pytsk3.FS_Info(img)
                    except Exception:
                        # 整盘镜像(带 MBR/分区表),尝试第一个分区
                        fs = None
                        try:
                            vs = pytsk3.Volume_Info(img)
                            for part in vs:
                                # 跳过扩展分区/空分区
                                if part.len <= 0:
                                    continue
                                desc = (part.desc.decode('utf-8', errors='replace') if isinstance(part.desc, bytes) else str(part.desc)) if part.desc else ''
                                if desc.lower().startswith(('extended', 'unallocated', '')) and part.start == 0:
                                    # 空描述且 start==0 时可能是整个盘,但 VS 存在通常是分区表;继续找第一个可分区
                                    pass
                                try:
                                    fs = pytsk3.FS_Info(img, offset=part.start * 512)
                                    break
                                except Exception:
                                    fs = None
                                    continue
                        except Exception:
                            fs = None
                    if fs is not None:
                        _open_matched = None
                        target_lower = inner_name.lower().rstrip(';1')
                        # 根目录精确打开
                        for try_path in (
                            '/' + inner_name,
                            '/' + inner_name.upper(),
                            '/' + inner_name.lower(),
                            '/' + target_lower,
                        ):
                            try:
                                _open_matched = fs.open(try_path)
                                break
                            except Exception:
                                _open_matched = None
                        # 找不到就遍历根目录(兼容 FAT 短文件名/LFN)
                        if _open_matched is None:
                            try:
                                root = fs.open_dir(path='/')
                                for entry in root:
                                    try:
                                        en = entry.info.name.name
                                        ename = en.decode('utf-8', errors='replace') if isinstance(en, bytes) else str(en)
                                    except Exception:
                                        continue
                                    if not ename or ename in ('.', '..'):
                                        continue
                                    if ename.lower().rstrip(';1') == target_lower:
                                        try:
                                            _open_matched = entry
                                            break
                                        except Exception:
                                            _open_matched = None
                            except Exception:
                                _open_matched = None
                        if _open_matched is not None and getattr(_open_matched.info, 'meta', None):
                            ftype = _open_matched.info.meta.type
                            if ftype == pytsk3.TSK_FS_META_TYPE_DIR:
                                raise IsADirectoryError(inner_name)
                            size = _open_matched.info.meta.size or 0
                            os.makedirs(os.path.dirname(temp_file), exist_ok=True)
                            with open(temp_file, 'wb') as dst:
                                offset = 0
                                while offset < size:
                                    chunk = _open_matched.read_random(offset, min(65536, size - offset))
                                    if not chunk:
                                        break
                                    dst.write(chunk)
                                    offset += len(chunk)
                            _nested_extracted = True
                            try:
                                _open_matched.close()
                            except Exception:
                                pass
                        try:
                            fs.close()
                        except Exception:
                            pass
                    try:
                        img.close()
                    except Exception:
                        pass
                except IsADirectoryError:
                    raise
                except Exception:
                    pass
            if not _nested_extracted:
                raise FileNotFoundError(f'无法从镜像 {archive_full} 中提取 {inner_name}')
        else:
            raise NotImplementedError('不支持的归档格式')

        if not os.path.exists(temp_file):
            raise FileNotFoundError(f'内层归档提取后文件不存在')
        return temp_file
    except Exception:
        # 清理临时目录
        try:
            shutil.rmtree(temp_dir, ignore_errors=True)
        except Exception:
            pass
        raise
def _get_nested_archive_path(user_dir, virtual_path):
    """
    处理嵌套归档/镜像路径,返回 (temp_archive_path, inner_path, temp_dir)。
    temp_archive_path: 最内层归档/镜像的临时文件路径(或原始路径如果不嵌套)
    inner_path: 最内层归档中的内部路径
    temp_dir: 临时目录(调用者负责清理,或者 None 表示不需要清理)
    如果不需要嵌套处理,返回 (archive_full, inner, None)
    """
    parts = [p.strip('/\\') for p in virtual_path.split('::')]
    parts = [p for p in parts if p]

    if len(parts) <= 2:
        # 简单情况:只有一个归档
        archive_rel, inner = split_virtual_path(virtual_path)
        return os.path.join(user_dir, archive_rel), inner, None

    # 多层嵌套:从外向内逐层提取
    temp_dir = None
    current_archive_rel = parts[0]
    current_archive_path = os.path.join(user_dir, current_archive_rel)

    for i in range(1, len(parts) - 1):
        inner_name = parts[i]
        extracted_path = _extract_nested_archive(user_dir, current_archive_rel, inner_name)
        # 此时 extracted_path 是一个临时文件,需要更新 current_archive_rel
        # 但 _extract_nested_archive 依赖 archive_rel 相对于 user_dir 的路径
        # 所以需要用更通用的方式处理后续层级
        if temp_dir is None:
            temp_dir = os.path.dirname(extracted_path)
        # 对于后续层级,需要将当前归档作为"新的user_dir"处理
        # 简化处理:直接提取最外层,然后逐层处理
        current_archive_rel = extracted_path
        current_archive_path = extracted_path

    inner = parts[-1] if len(parts) > 2 else ''
    return current_archive_path, inner, temp_dir
def _extract_virtual_path_item(archive_full, inner_path, temp_dir):
    """
    从归档(支持多层嵌套)中提取目标内容到临时目录。
    返回提取后的文件/目录的绝对路径。
    支持多重虚拟路径:archive_full 是最外层归档,inner_path 可能包含 :: 表示嵌套。
    """
    # 递归处理嵌套:如果 inner_path 包含 ::,先提取内层归档再递归
    if '::' in inner_path:
        nested_archive_path, final_path = split_virtual_path(inner_path)
        nested_temp = create_cache_dir(prefix='nested_extract_')
        try:
            _extract_archive_item(archive_full, nested_archive_path, nested_temp)
            nested_archive_file = _find_extracted_file(nested_temp, nested_archive_path)
            if not nested_archive_file:
                raise FileNotFoundError(f'嵌套归档不存在: {nested_archive_path}')
            result = _extract_virtual_path_item(nested_archive_file, final_path, temp_dir)
            # 先返回结果,再清理临时目录(确保返回值中的文件已被复制到temp_dir)
            return result
        finally:
            # 从缓存管理中移除,并立即清理
            try:
                remove_cache_entry(nested_temp)
                shutil.rmtree(nested_temp, ignore_errors=True)
            except Exception:
                pass

    # 单层提取
    ext = _get_archive_ext(archive_full)
    try:
        if ext == '.zip':
            with zipfile.ZipFile(archive_full, 'r') as zf:
                for name in zf.namelist():
                    if name == inner_path or name.startswith(inner_path + '/'):
                        zf.extract(name, temp_dir)
        elif ext in ('.tar', '.tgz', '.tar.gz'):
            with tarfile.open(archive_full, 'r') as tf:
                for member in tf.getmembers():
                    if member.name == inner_path or member.name.startswith(inner_path + '/'):
                        tf.extract(member, temp_dir)
        elif ext in ('.bz2', '.tbz'):
            with tarfile.open(archive_full, 'r') as tf:
                for member in tf.getmembers():
                    if member.name == inner_path or member.name.startswith(inner_path + '/'):
                        tf.extract(member, temp_dir)
        elif ext in ('.iso', '.img') and HAS_PYCDLIB and _is_iso9660_file(archive_full):
            _extract_iso_item(archive_full, inner_path, temp_dir)
        elif ext == '.7z' and HAS_PY7ZR:
            with py7zr.SevenZipFile(archive_full, mode='r') as zf:
                for name in zf.getnames():
                    if name == inner_path or name.startswith(inner_path + '/'):
                        zf.extract(targets=[name], path=temp_dir)
        elif ext == '.rar' and HAS_RARFILE:
            with rarfile.RarFile(archive_full) as rf:
                for name in rf.namelist():
                    if name == inner_path or name.startswith(inner_path + '/'):
                        rf.extract(name, temp_dir)
        elif ext in ('.dmg', '.wim', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.iso') and HAS_PYTSK3:
            # 使用 pytsk3 提取镜像文件
            img = pytsk3.Img_Info(archive_full)
            fs = pytsk3.FS_Info(img)
            open_path = inner_path if inner_path.startswith('/') else '/' + inner_path
            base_target = os.path.join(temp_dir, inner_path) if inner_path else temp_dir
            try:
                def _tsk_entry_name(e):
                    """获取条目的文件名,优先长文件名"""
                    try:
                        n = e.info.name.name
                        short_name = n.decode('utf-8') if isinstance(n, bytes) else n
                    except Exception:
                        return None
                    # FAT 文件系统:通过遍历 name 链查找长文件名
                    if short_name and short_name in ('.', '..'):
                        return short_name
                    try:
                        # 尝试从 meta 中获取长文件名
                        if hasattr(e, 'meta') and e.meta:
                            meta = e.meta
                            # pytsk3 的 FAT 实现中,长文件名通过 name 链的后继条目存储
                            # 我们尝试遍历 find 或 name_info 寻找长文件名
                            # 注意:pytsk3 不直接暴露 LFN,但 open_dir 返回的 entry 已经有完整的 name
                        # 返回短文件名(可能已经是扩展属性中的长文件名)
                        return short_name
                    except Exception:
                        return short_name
                def _tsk_extract_recursive(tsk_path, local_dir):
                    try:
                        dirobj = fs.open_dir(path=tsk_path)
                        entries = list(dirobj)  # Force iteration
                    except Exception:
                        return
                    for entry in entries:
                        name = _tsk_entry_name(entry)
                        if not name or name in ('.', '..'):
                            continue
                        if name.startswith('$') and name.upper() in ('$FAT1', '$FAT2', '$FAT12', '$MBR', '$ORPHANFILES'):
                            continue
                        try:
                            is_dir = entry.info.meta.type == pytsk3.TSK_FS_META_TYPE_DIR
                        except Exception:
                            continue
                        if is_dir:
                            child_tsk = tsk_path.rstrip('/') + '/' + name
                            child_local = os.path.join(local_dir, name)
                            os.makedirs(child_local, exist_ok=True)
                            _tsk_extract_recursive(child_tsk, child_local)
                        else:
                            file_tsk = tsk_path.rstrip('/') + '/' + name
                            local_file = os.path.join(local_dir, name)
                            try:
                                src = fs.open(file_tsk)
                                file_size = src.info.meta.size
                                with open(local_file, 'wb') as dst:
                                    total_read = 0
                                    while total_read < file_size:
                                        data = src.read(65536)
                                        if not data:
                                            break
                                        dst.write(data)
                                        total_read += len(data)
                                src.close()
                                if total_read != file_size:
                                    try:
                                        os.remove(local_file)
                                    except Exception:
                                        pass
                            except Exception:
                                try:
                                    if os.path.exists(local_file):
                                        os.remove(local_file)
                                except Exception:
                                    pass
                # 关键修复:先检查是否为目录,避免 fs.open() 对目录路径失败
                is_directory = False
                try:
                    test_dir = fs.open_dir(path=open_path)
                    is_directory = True
                    # 关闭测试打开的目录
                    try:
                        test_dir.close()
                    except Exception:
                        pass
                except Exception:
                    is_directory = False

                if is_directory:
                    # 目录:直接递归提取
                    os.makedirs(base_target, exist_ok=True)
                    _tsk_extract_recursive(open_path, base_target)
                else:
                    # 文件:使用 fs.open() 提取
                    try:
                        f = fs.open(open_path)
                        os.makedirs(base_target, exist_ok=True)
                        dst_path = os.path.join(base_target, os.path.basename(inner_path) if inner_path else 'file')
                        with open(dst_path, 'wb') as dst:
                            while True:
                                data = f.read(65536)
                                if not data:
                                    break
                                dst.write(data)
                        try:
                            f.close()
                        except Exception:
                            pass
                    except Exception as e:
                        logger.warning(f"[pytsk3] 提取文件失败:{open_path} - {e}")
                        # 尝试作为目录再次提取(fallback)
                        try:
                            dirobj = fs.open_dir(path=open_path)
                            os.makedirs(base_target, exist_ok=True)
                            _tsk_extract_recursive(open_path, base_target)
                            try:
                                dirobj.close()
                            except Exception:
                                pass
                        except Exception:
                            pass
            except (FileNotFoundError, IsADirectoryError):
                raise
            finally:
                try:
                    fs.close()
                except Exception:
                    pass
                try:
                    img.close()
                except Exception:
                    pass
        else:
            raise NotImplementedError(f'不支持的归档格式: {ext}')
    except Exception as e:
        logger.warning(f"_extract_virtual_path_item 提取失败: {e}")
        raise

    # 确保返回的是实际文件系统路径,不包含::
    extracted = os.path.join(temp_dir, inner_path) if inner_path else temp_dir
    # 移除可能的::字符(安全保证)
    extracted = extracted.replace('::', '')
    if not os.path.exists(extracted):
        base = os.path.basename(inner_path) if inner_path else ''
        alt = os.path.join(temp_dir, base)
        if os.path.exists(alt):
            extracted = alt
    # 最后的检查:确保提取路径不包含非法字符
    if '::' in extracted:
        extracted = os.path.join(temp_dir, os.path.basename(inner_path))
    return extracted
def _extract_archive_item(archive_path, inner_path, temp_dir):
    """从外层压缩包/镜像中提取指定路径的文件到临时目录"""
    ext = _get_archive_ext(archive_path)
    # 尝试查找已提取的文件,如果不存在则使用默认目标路径
    target = _find_extracted_file(temp_dir, inner_path)
    if not target:
        target = os.path.join(temp_dir, os.path.basename(inner_path))
    try:
        logger.debug(f"_extract_archive_item: archive_path={archive_path}, inner_path={inner_path}, temp_dir={temp_dir}, ext={ext}, target={target}")
    except Exception:
        pass

    if ext == '.zip':
        with zipfile.ZipFile(archive_path, 'r') as zf:
            # 尝试用原始路径和GBK解码后的路径来匹配
            found = False
            all_infos = zf.infolist()
            for info in all_infos:
                raw_fn = info.filename
                decoded_fn = decode_archive_name(raw_fn)
                if raw_fn == inner_path or decoded_fn == inner_path or raw_fn.rstrip('/') == inner_path or decoded_fn.rstrip('/') == inner_path:
                    with zf.open(info.filename) as src, open(target, 'wb') as dst:
                        shutil.copyfileobj(src, dst)
                    found = True
                    break
            if not found:
                # fallback1: 尝试原始 extract
                try:
                    zf.extract(inner_path, temp_dir)
                    found = True
                except KeyError:
                    pass
            if not found:
                # fallback2: 不区分大小写匹配
                inner_lower = inner_path.lower()
                inner_base_lower = os.path.basename(inner_path).lower()
                for info in all_infos:
                    raw_fn = info.filename
                    decoded_fn = decode_archive_name(raw_fn)
                    if raw_fn.lower() == inner_lower or decoded_fn.lower() == inner_lower:
                        with zf.open(info.filename) as src, open(target, 'wb') as dst:
                            shutil.copyfileobj(src, dst)
                        found = True
                        break
                if not found:
                    # fallback3: 仅按basename匹配(用于子目录路径不匹配的情况)
                    for info in all_infos:
                        raw_fn = info.filename
                        decoded_fn = decode_archive_name(raw_fn)
                        if os.path.basename(raw_fn).lower() == inner_base_lower or os.path.basename(decoded_fn).lower() == inner_base_lower:
                            with zf.open(info.filename) as src, open(target, 'wb') as dst:
                                shutil.copyfileobj(src, dst)
                            found = True
                            break
    elif ext in ('.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.tbz'):
        with tarfile.open(archive_path, 'r') as tf:
            # 尝试匹配编码后的文件名
            found = False
            all_members = tf.getmembers()
            for member in all_members:
                decoded_name = decode_archive_name(member.name)
                if member.name == inner_path or decoded_name == inner_path or member.name.rstrip('/') == inner_path or decoded_name.rstrip('/') == inner_path:
                    tf.extract(member, temp_dir)
                    found = True
                    break
            if not found:
                # fallback1
                try:
                    member = tf.getmember(inner_path)
                    tf.extract(member, temp_dir)
                    found = True
                except KeyError:
                    pass
            if not found:
                # fallback2: 不区分大小写匹配
                inner_lower = inner_path.lower()
                inner_base_lower = os.path.basename(inner_path).lower()
                for member in all_members:
                    decoded_name = decode_archive_name(member.name)
                    if member.name.lower() == inner_lower or decoded_name.lower() == inner_lower:
                        tf.extract(member, temp_dir)
                        found = True
                        break
                if not found:
                    # fallback3: 仅按basename匹配
                    for member in all_members:
                        decoded_name = decode_archive_name(member.name)
                        if os.path.basename(member.name).lower() == inner_base_lower or os.path.basename(decoded_name).lower() == inner_base_lower:
                            tf.extract(member, temp_dir)
                            found = True
                            break
    elif ext == '.7z' and HAS_PY7ZR:
        with py7zr.SevenZipFile(archive_path, mode='r') as zf:
            zf.extractall(path=temp_dir, filters=[{'id': py7zr.FILTER_LZMA2, 'preset': 9}])
            # 只保留目标文件
            if os.path.exists(os.path.join(temp_dir, inner_path)):
                shutil.move(os.path.join(temp_dir, inner_path), target)
    elif ext == '.rar' and HAS_RARFILE:
        with rarfile.RarFile(archive_path) as rf:
            rf.extract(inner_path, temp_dir)
    elif ext == '.iso':
        # ISO 9660 镜像:pycdlib 优先,失败时 pytsk3 回退(混合镜像场景)
        _mirror_extracted = False

        # 尝试1:使用 pycdlib(仅对 .iso 启用;.img 不是 ISO9660,pycdlib 会抛 PVD 错误)
        if HAS_PYCDLIB and not _mirror_extracted and _is_iso9660_file(archive_path):
            try:
                _iso = pycdlib.PyCdlib()
                _iso.open(archive_path)
                try:
                    _inner_lower = inner_path.lower()
                    _inner_lower_no_ver = _inner_lower.replace(';1', '')
                    _actual_path = None

                    for _dir_path, _dirs, _files in _iso.walk(iso_path='/'):
                        for _f in _files:
                            _full_iso_path = (_dir_path.rstrip('/') + '/' + _f).lstrip('/')
                            if _full_iso_path.lower() == _inner_lower:
                                _actual_path = _full_iso_path
                                break
                        if _actual_path:
                            break
                    if not _actual_path:
                        for _dir_path, _dirs, _files in _iso.walk(iso_path='/'):
                            for _f in _files:
                                _full_iso_path = (_dir_path.rstrip('/') + '/' + _f).lstrip('/')
                                _full_lower = _full_iso_path.lower().replace(';1', '')
                                if _full_lower == _inner_lower_no_ver or _full_lower == _inner_lower:
                                    _actual_path = _full_iso_path
                                    break
                            if _actual_path:
                                break
                    if not _actual_path and _iso.has_rock_ridge():
                        for _dir_path, _dirs, _files in _iso.walk(rr_path='/'):
                            for _f in _files:
                                _full_rr_path = (_dir_path.rstrip('/') + '/' + _f).lstrip('/')
                                if _full_rr_path.lower() == _inner_lower or _full_rr_path.lower().replace(';1', '') == _inner_lower_no_ver:
                                    _actual_path = f"rr://{_full_rr_path}"
                                    break
                            if _actual_path:
                                break
                    if not _actual_path and _iso.has_joliet():
                        for _dir_path, _dirs, _files in _iso.walk(joliet_path='/'):
                            for _f in _files:
                                _full_j_path = (_dir_path.rstrip('/') + '/' + _f).lstrip('/')
                                if _full_j_path.lower() == _inner_lower or _full_j_path.lower().replace(';1', '') == _inner_lower_no_ver:
                                    _actual_path = f"joliet://{_full_j_path}"
                                    break
                            if _actual_path:
                                break

                    if not _actual_path:
                        raise FileNotFoundError(f"ISO中未找到: {inner_path}")

                    os.makedirs(os.path.dirname(target), exist_ok=True)
                    with open(target, 'wb') as _dst_fp:
                        if _actual_path.startswith('rr://'):
                            _iso_fp = _actual_path[5:]
                            _iso.get_file_from_iso_fp(_dst_fp, rr_path=_iso_fp)
                        elif _actual_path.startswith('joliet://'):
                            _iso_fp = _actual_path[9:]
                            _iso.get_file_from_iso_fp(_dst_fp, joliet_path=_iso_fp)
                        else:
                            _iso_path_arg = '/' + _actual_path if not _actual_path.startswith('/') else _actual_path
                            _iso.get_file_from_iso_fp(_dst_fp, iso_path=_iso_path_arg)
                    _mirror_extracted = True
                except FileNotFoundError:
                    raise
                except Exception as _pycdlib_exc:
                    logger.debug(f"pycdlib 提取失败: {_pycdlib_exc}")
                finally:
                    _iso.close()
            except FileNotFoundError:
                raise
            except Exception as _pycdlib_exc:
                logger.debug(f"pycdlib 打开失败: {_pycdlib_exc}")

        # 尝试1b:pycdlib 失败时 pytsk3 回退(混合/混合ISO镜像)
        if not _mirror_extracted and HAS_PYTSK3:
            try:
                _img = pytsk3.Img_Info(archive_path)
                _fs = None
                try:
                    _fs = pytsk3.FS_Info(_img)
                except Exception:
                    _fs = None
                if _fs is None:
                    try:
                        _vs = pytsk3.Volume_Info(_img)
                        for _part in _vs:
                            if _part.len <= 0:
                                continue
                            try:
                                _fs = pytsk3.FS_Info(_img, offset=_part.start * 512)
                                break
                            except Exception:
                                _fs = None
                    except Exception:
                        _fs = None
                if _fs is not None:
                    _target_lower = inner_path.lower().rstrip(';1')
                    _f = None
                    # 尝试直接路径打开
                    for _try in ('/' + inner_path, '/' + inner_path.upper(), '/' + inner_path.lower(), '/' + _target_lower):
                        try:
                            _f = _fs.open(_try)
                            break
                        except Exception:
                            _f = None
                    # 如果直接路径失败,尝试递归搜索子目录
                    if _f is None:
                        def _find_file_recursive_iso(fs_obj, dir_path, target_path_lower, current_path='/'):
                            """递归搜索目录树查找目标文件,通过路径后缀匹配"""
                            try:
                                dir_obj = fs_obj.open_dir(path=dir_path)
                            except Exception:
                                return None
                            for entry in dir_obj:
                                try:
                                    en = entry.info.name.name
                                    enm = en.decode('utf-8', errors='replace') if isinstance(en, bytes) else str(en)
                                except Exception:
                                    continue
                                if not enm or enm in ('.', '..'):
                                    continue
                                entry_path_lower = (current_path.rstrip('/') + '/' + enm).lower().lstrip('/')
                                # 检查路径后缀匹配
                                if entry_path_lower == target_path_lower or entry_path_lower.endswith('/' + target_path_lower):
                                    if hasattr(entry.info, 'meta') and entry.info.meta:
                                        if entry.info.meta.type != pytsk3.TSK_FS_META_TYPE_DIR:
                                            return entry
                                # 如果是目录,递归搜索
                                try:
                                    if hasattr(entry.info, 'meta') and entry.info.meta:
                                        if entry.info.meta.type == pytsk3.TSK_FS_META_TYPE_DIR:
                                            sub_path = dir_path.rstrip('/') + '/' + enm
                                            found = _find_file_recursive_iso(fs_obj, sub_path, target_path_lower, current_path.rstrip('/') + '/' + enm)
                                            if found:
                                                return found
                                except Exception:
                                    pass
                            return None
                        # 从根目录开始递归搜索
                        _f = _find_file_recursive_iso(_fs, '/', _target_lower)
                    if _f is not None and getattr(_f.info, 'meta', None):
                        if _f.info.meta.type == pytsk3.TSK_FS_META_TYPE_DIR:
                            raise IsADirectoryError(inner_path)
                        os.makedirs(os.path.dirname(target), exist_ok=True)
                        with open(target, 'wb') as _dst:
                            _offset = 0
                            _size = _f.info.meta.size or 0
                            while _offset < _size:
                                _data = _f.read_random(_offset, min(65536, _size - _offset))
                                if not _data:
                                    break
                                _dst.write(_data)
                                _offset += len(_data)
                        _mirror_extracted = True
                        try:
                            _f.close()
                        except Exception:
                            pass
                    try:
                        _fs.close()
                    except Exception:
                        pass
                try:
                    _img.close()
                except Exception:
                    pass
            except (FileNotFoundError, IsADirectoryError):
                raise
            except Exception as _pytsk3_exc:
                logger.debug(f"pytsk3 打开 ISO 失败: {_pytsk3_exc}")

        if not _mirror_extracted:
            raise FileNotFoundError(f'无法从镜像 {archive_path} 中提取 {inner_path}')
    elif ext in ('.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'):
        # 磁盘镜像:先尝试 pycdlib(如果文件实际是 ISO9660),失败再回退 pytsk3
        _mirror_extracted = False
        # 尝试 1:pycdlib(仅当文件实际是 ISO9660 时)
        if HAS_PYCDLIB and not _mirror_extracted and _is_iso9660_file(archive_path):
            try:
                _iso = pycdlib.PyCdlib()
                _iso.open(archive_path)
                try:
                    _inner_lower = inner_path.lower()
                    _inner_lower_no_ver = _inner_lower.replace(';1', '')
                    _actual_path = None
                    for _dir_path, _dirs, _files in _iso.walk(iso_path='/'):
                        for _f in _files:
                            _full_iso_path = (_dir_path.rstrip('/') + '/' + _f).lstrip('/')
                            if _full_iso_path.lower() == _inner_lower:
                                _actual_path = _full_iso_path
                                break
                        if _actual_path:
                            break
                    if not _actual_path:
                        for _dir_path, _dirs, _files in _iso.walk(iso_path='/'):
                            for _f in _files:
                                _full_iso_path = (_dir_path.rstrip('/') + '/' + _f).lstrip('/')
                                _full_lower = _full_iso_path.lower().replace(';1', '')
                                if _full_lower == _inner_lower_no_ver or _full_lower == _inner_lower:
                                    _actual_path = _full_iso_path
                                    break
                            if _actual_path:
                                break
                    if not _actual_path and _iso.has_rock_ridge():
                        for _dir_path, _dirs, _files in _iso.walk(rr_path='/'):
                            for _f in _files:
                                _full_rr_path = (_dir_path.rstrip('/') + '/' + _f).lstrip('/')
                                if _full_rr_path.lower() == _inner_lower or _full_rr_path.lower().replace(';1', '') == _inner_lower_no_ver:
                                    _actual_path = f"rr://{_full_rr_path}"
                                    break
                            if _actual_path:
                                break
                    if not _actual_path and _iso.has_joliet():
                        for _dir_path, _dirs, _files in _iso.walk(joliet_path='/'):
                            for _f in _files:
                                _full_j_path = (_dir_path.rstrip('/') + '/' + _f).lstrip('/')
                                if _full_j_path.lower() == _inner_lower or _full_j_path.lower().replace(';1', '') == _inner_lower_no_ver:
                                    _actual_path = f"joliet://{_full_j_path}"
                                    break
                            if _actual_path:
                                break
                    if not _actual_path:
                        raise FileNotFoundError(f"镜像中未找到:{inner_path}")
                    os.makedirs(os.path.dirname(target), exist_ok=True)
                    with open(target, 'wb') as _dst_fp:
                        if _actual_path.startswith('rr://'):
                            _iso.get_file_from_iso_fp(_dst_fp, rr_path=_actual_path[5:])
                        elif _actual_path.startswith('joliet://'):
                            _iso.get_file_from_iso_fp(_dst_fp, joliet_path=_actual_path[9:])
                        else:
                            _iso_path_arg = '/' + _actual_path if not _actual_path.startswith('/') else _actual_path
                            _iso.get_file_from_iso_fp(_dst_fp, iso_path=_iso_path_arg)
                    _mirror_extracted = True
                except FileNotFoundError:
                    raise
                except Exception as _pycdlib_exc:
                    logger.debug(f"pycdlib 提取失败:{_pycdlib_exc}")
                finally:
                    _iso.close()
            except FileNotFoundError:
                raise
            except Exception as _pycdlib_exc:
                logger.debug(f"pycdlib 打开失败:{_pycdlib_exc}")
        # 尝试 2:pytsk3 回退
        if not _mirror_extracted and HAS_PYTSK3:
            try:
                _img = pytsk3.Img_Info(archive_path)
                _fs = None
                try:
                    _fs = pytsk3.FS_Info(_img)
                except Exception:
                    _fs = None
                if _fs is None:
                    # 整盘镜像,尝试分区表
                    try:
                        _vs = pytsk3.Volume_Info(_img)
                        for _part in _vs:
                            if _part.len <= 0:
                                continue
                            try:
                                _fs = pytsk3.FS_Info(_img, offset=_part.start * 512)
                                break
                            except Exception:
                                _fs = None
                    except Exception:
                        _fs = None
                if _fs is not None:
                    _target_lower = inner_path.lower().rstrip(';1')
                    _f = None
                    for _try in ('/' + inner_path, '/' + inner_path.upper(), '/' + inner_path.lower(), '/' + _target_lower):
                        try:
                            _f = _fs.open(_try)
                            break
                        except Exception:
                            _f = None
                    if _f is None:
                        # 先遍历根目录兼容 FAT 短文件名/LFN
                        try:
                            _root = _fs.open_dir(path='/')
                            for _e in _root:
                                try:
                                    _en = _e.info.name.name
                                    _enm = _en.decode('utf-8', errors='replace') if isinstance(_en, bytes) else str(_en)
                                except Exception:
                                    continue
                                if not _enm or _enm in ('.', '..'):
                                    continue
                                if _enm.lower().rstrip(';1') == _target_lower:
                                    _f = _e
                                    break
                        except Exception:
                            _f = None
                    if _f is None:
                        # 根目录未找到,递归搜索整个目录树
                        def _find_file_recursive_img(fs_obj, dir_path, target_path_lower, current_path='/'):
                            """递归搜索目录树查找目标文件,通过路径后缀匹配"""
                            try:
                                dir_obj = fs_obj.open_dir(path=dir_path)
                            except Exception:
                                return None
                            for entry in dir_obj:
                                try:
                                    en = entry.info.name.name
                                    enm = en.decode('utf-8', errors='replace') if isinstance(en, bytes) else str(en)
                                except Exception:
                                    continue
                                if not enm or enm in ('.', '..'):
                                    continue
                                entry_path_lower = (current_path.rstrip('/') + '/' + enm).lower().lstrip('/')
                                # 检查路径后缀匹配
                                if entry_path_lower == target_path_lower or entry_path_lower.endswith('/' + target_path_lower):
                                    if hasattr(entry.info, 'meta') and entry.info.meta:
                                        if entry.info.meta.type != pytsk3.TSK_FS_META_TYPE_DIR:
                                            return entry
                                # 如果是目录,递归搜索
                                try:
                                    if hasattr(entry.info, 'meta') and entry.info.meta:
                                        if entry.info.meta.type == pytsk3.TSK_FS_META_TYPE_DIR:
                                            sub_path = dir_path.rstrip('/') + '/' + enm
                                            found = _find_file_recursive_img(fs_obj, sub_path, target_path_lower, current_path.rstrip('/') + '/' + enm)
                                            if found:
                                                return found
                                except Exception:
                                    pass
                            return None
                        _f = _find_file_recursive_img(_fs, '/', _target_lower)
                    if _f is not None and getattr(_f.info, 'meta', None):
                        if _f.info.meta.type == pytsk3.TSK_FS_META_TYPE_DIR:
                            raise IsADirectoryError(inner_path)
                        os.makedirs(os.path.dirname(target), exist_ok=True)
                        with open(target, 'wb') as _dst:
                            _offset = 0
                            _size = _f.info.meta.size or 0
                            while _offset < _size:
                                _data = _f.read_random(_offset, min(65536, _size - _offset))
                                if not _data:
                                    break
                                _dst.write(_data)
                                _offset += len(_data)
                        _mirror_extracted = True
                        try:
                            _f.close()
                        except Exception:
                            pass
                    try:
                        _fs.close()
                    except Exception:
                        pass
                try:
                    _img.close()
                except Exception:
                    pass
            except (FileNotFoundError, IsADirectoryError):
                raise
            except Exception as _pytsk3_exc:
                logger.debug(f"pytsk3 提取文件失败: {_pytsk3_exc}")

        if not _mirror_extracted:
            raise FileNotFoundError(f'无法从镜像 {archive_path} 中提取 {inner_path}')

    if not os.path.exists(target):
        # 尝试不带前缀路径
        base = os.path.basename(inner_path)
        if os.path.exists(os.path.join(temp_dir, inner_path)):
            shutil.move(os.path.join(temp_dir, inner_path), target)
        elif os.path.exists(os.path.join(temp_dir, base)):
            pass  # 已经存在
        else:
            try:
                logger.debug(f"_extract_archive_item failed to find target: archive_path={archive_path}, inner_path={inner_path}, temp_dir={temp_dir}")
                logger.debug(f"temp_dir contents: {os.listdir(temp_dir) if os.path.exists(temp_dir) else 'missing'}")
            except Exception:
                pass
            raise FileNotFoundError(f'无法提取嵌套归档: {inner_path}')
def list_nested_virtual_directory(user_dir, outer_archive_path, nested_path, original_virtual_path=None, remaining_inner=''):
    """处理嵌套压缩包/镜像:提取内部归档并列出其内容

    Args:
        user_dir: 用户目录
        outer_archive_path: 外层归档完整路径
        nested_path: 内部归档在outer中的路径(如 "subdir/inner.tar")
        original_virtual_path: 最原始的完整虚拟路径(用于生成正确的item路径,不能被覆盖)
        remaining_inner: 嵌套归档内部还有剩余路径时传入(如 "subdir2")
    """
    nested_ext = os.path.splitext(nested_path)[1].lower()

    temp_dir = create_cache_dir(prefix='nested_')
    try:
        _extract_archive_item(outer_archive_path, nested_path, temp_dir)
        extracted = os.path.join(temp_dir, os.path.basename(nested_path))
        logger.debug(f"nested extraction: outer={outer_archive_path}, nested_path={nested_path}, temp_dir={temp_dir}, expected_extracted={extracted}")
        if not os.path.exists(extracted):
            # 尝试使用 _find_extracted_file 查找(支持子目录递归搜索)
            extracted = _find_extracted_file(temp_dir, nested_path)

        if not extracted:
            try:
                logger.debug(f"temp_dir contents: {os.listdir(temp_dir)}")
            except Exception:
                pass
            raise FileNotFoundError(f'嵌套归档提取失败: {nested_path}')

        # path_prefix 构建:必须保持从最原始路径到当前嵌套归档的完整路径链
        # original_virtual_path 是最顶层的完整路径 (如 'A::B::C::D::')
        # remaining_inner 是从当前层开始还有多少层未处理 (如 '')
        # 通过 remaining_inner 计算已处理的层数,从而确定 path_prefix 应该包含多少段
        if original_virtual_path:
            parts = [p.strip('/') for p in original_virtual_path.split('::') if p]
            remaining_parts = [p.strip('/') for p in remaining_inner.split('::') if p] if remaining_inner else []
            # 已处理层数 = 总层数 - 剩余层数
            processed_count = len(parts) - len(remaining_parts)
            if processed_count >= 1 and processed_count <= len(parts):
                # path_prefix 包含到当前层止的所有段 (processed_count-1 就是已处理层数减1)
                path_prefix = '::'.join(parts[:processed_count])
            else:
                path_prefix = parts[0] if parts else None
        else:
            path_prefix = None

        archive_name = os.path.basename(nested_path)
        # 关键修复:判断 archive_name 是否为归档/镜像文件
        # 如果是普通文件夹,不应该加 :: 后缀
        nested_ext = os.path.splitext(archive_name)[1].lower()
        archive_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.7z', '.rar'}
        image_exts = {'.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'}
        is_nested_archive = nested_ext in archive_exts or nested_ext in image_exts

        # 用 temp_dir 作为 user_dir,构建 virtual_path
        # 只有当 archive_name 是归档/镜像文件时才加 :: 后缀
        if is_nested_archive:
            inner_vp = archive_name + '::' + remaining_inner if remaining_inner else archive_name + '::'
        else:
            # 普通文件夹:使用 / 分隔符连接路径
            inner_vp = (archive_name + '/' + remaining_inner) if remaining_inner else archive_name
        result = list_virtual_directory(temp_dir, inner_vp, temp_dir=temp_dir, path_prefix=path_prefix, original_virtual_path=original_virtual_path)
        return result
    except Exception:
        raise
    finally:
        try:
            if temp_dir and os.path.exists(temp_dir):
                remove_cache_entry(temp_dir)
                shutil.rmtree(temp_dir, ignore_errors=True)
        except Exception:
            pass

目录列表函数

def list_virtual_directory(user_dir, virtual_path, temp_dir=None, path_prefix=None, original_virtual_path=None):
    """列出虚拟目录(压缩包/镜像内部)的文件和文件夹

    支持嵌套压缩包/镜像:如果 inner 路径指向的是压缩包/镜像文件,则提取并打开

    Args:
        user_dir: 用户目录
        virtual_path: 虚拟路径
        temp_dir: 如果是嵌套归档,这是临时目录路径(用于后续清理)
        path_prefix: 路径前缀,用于嵌套归档生成正确的item路径
        original_virtual_path: 最顶层的原始虚拟路径(用于深度嵌套归档保持正确的path_prefix)
    """
    # 保存原始路径(第一层调用的原始路径)
    if original_virtual_path is None:
        original_virtual_path = virtual_path

    archive_rel, inner = split_virtual_path(virtual_path)
    archive_full = os.path.join(user_dir, archive_rel)

    # 调试信息:记录正在打开的归档/镜像及其内部路径(便于诊断无法访问或解析失败的问题)
    try:
        logger.debug(f"list_virtual_directory: user_dir={user_dir}, archive_rel={archive_rel}, archive_full={archive_full}, inner={inner}, temp_dir={temp_dir}, path_prefix={path_prefix}")
    except Exception:
        pass

    if not is_safe_path(user_dir, archive_full):
        raise ValueError('路径不安全')
    if not os.path.exists(archive_full) or not os.path.isfile(archive_full):
        raise FileNotFoundError('文件不存在')

    # 检查 inner 路径是否指向嵌套的压缩包/镜像
    # inner 可能包含 ::(如 "subdir/inner.tar::subdir2"),需要先取第一个 :: 之前的部分
    nested_archive_path = None
    inner_check = inner
    if '::' in inner:
        inner_check = inner.split('::', 1)[0]
    if inner_check:
        inner_ext = os.path.splitext(inner_check)[1].lower()
        nested_archive_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.7z', '.rar'}
        nested_image_exts = {'.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'}
        if inner_ext in nested_archive_exts or inner_ext in nested_image_exts:
            nested_archive_path = inner_check

    if nested_archive_path:
        # 嵌套压缩包/镜像:先提取到临时目录,再打开
        remaining = ''
        if '::' in inner:
            remaining = inner.split('::', 1)[1]
        return list_nested_virtual_directory(user_dir, archive_full, nested_archive_path, original_virtual_path=original_virtual_path, remaining_inner=remaining)

    ext = os.path.splitext(archive_full)[1].lower()
    items = []
    image_exts = {'.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'}

    # 构建完整的虚拟路径前缀
    virtual_prefix = (path_prefix + '::') if path_prefix else (archive_rel + '::')

    if ext == '.zip':
        archive_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.7z', '.rar'}
        image_exts = {'.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'}
        try:
            with zipfile.ZipFile(archive_full, 'r') as zf:
                prefix = (inner + '/') if inner else ''
                prefix_gbk = prefix  # 尝试用GBK编码后的prefix匹配
                try:
                    prefix_gbk = prefix.encode('utf-8').decode('gbk') if prefix else ''
                except Exception:
                    pass
                # 获取所有文件信息用于后续匹配
                all_infos = zf.infolist()
                all_names = zf.namelist()
                # 过滤时同时尝试原始prefix和GBK编码的prefix
                names = []
                used_prefix = prefix  # 记录实际匹配的prefix
                for n in all_names:
                    if n == prefix:
                        continue
                    if n.startswith(prefix):
                        names.append((n, prefix))
                    elif prefix_gbk and n.startswith(prefix_gbk):
                        names.append((n, prefix_gbk))
                seen = set()
                # 构建目录 mtime 和 size 映射,用于显示目录信息
                dir_mtime_map = {}
                dir_size_map = {}
                for info in all_infos:
                    raw_fn = info.filename
                    decoded_fn = decode_archive_name(raw_fn)
                    if '/' in decoded_fn:
                        dir_part = '/'.join(decoded_fn.split('/')[:-1])
                        dir_prefix_decoded = decode_archive_name(inner) if inner else ''
                        if dir_part.startswith(dir_prefix_decoded):
                            if dir_prefix_decoded and dir_part == dir_prefix_decoded:
                                continue
                            rel_dir = dir_part[len(dir_prefix_decoded):].lstrip('/') if dir_prefix_decoded else dir_part
                            if not rel_dir:
                                continue
                            # 递归累加到所有上级目录
                            parts_list = rel_dir.split('/')
                            for i in range(len(parts_list)):
                                ancestor = '/'.join(parts_list[:i+1])
                                if ancestor and ancestor not in dir_mtime_map:
                                    try:
                                        dir_mtime_map[ancestor] = datetime(*info.date_time).strftime('%Y-%m-%d %H:%M')
                                    except Exception:
                                        dir_mtime_map[ancestor] = '-'
                                if not raw_fn.endswith('/'):
                                    dir_size_map[ancestor] = dir_size_map.get(ancestor, 0) + info.file_size
                logger.info(f"[list_virtual_directory ZIP] dir_size_map: {dir_size_map}")
                logger.info(f"[list_virtual_directory ZIP] dir_mtime_map: {dir_mtime_map}")
                logger.info(f"[list_virtual_directory ZIP] inner='{inner}', prefix='{prefix}'")
                for n_item in names:
                    if isinstance(n_item, tuple):
                        n, matched_prefix = n_item
                    else:
                        n, matched_prefix = n_item, prefix
                    rest = n[len(matched_prefix):] if matched_prefix else n
                    parts = rest.split('/')
                    name = parts[0]
                    # 始终使用统一解码策略修复可能的编码问题
                    name = decode_archive_name(name)
                    if name in seen:
                        continue
                    seen.add(name)
                    is_dir = (len(parts) > 1) or n.endswith('/')
                    item_path = virtual_prefix + ('/'.join([inner, name]).lstrip('/') if inner else name)
                    # 获取大小和修改时间
                    size = '-'
                    mtime = '-'
                    can_expand = is_dir
                    # 目录也尝试获取mtime/size(从该目录下的文件推断)
                    if is_dir:
                        if name in dir_mtime_map:
                            mtime = dir_mtime_map[name]
                        if name in dir_size_map:
                            size = format_file_size(dir_size_map[name])
                        elif size == '-':
                            size = '0 B'
                        logger.info(f"[list_virtual_directory ZIP] dir item '{name}': size={size}, mtime={mtime}")
                        # 目录也可能是可展开的(如果是压缩包名作为目录)
                        file_ext = os.path.splitext(name.rstrip('/'))[1].lower()
                        if file_ext in archive_exts or file_ext in image_exts:
                            can_expand = True
                    else:
                        # 查找匹配的文件信息获取大小和修改时间
                        matched = False
                        for info in all_infos:
                            full_name = info.filename
                            fn_matched_prefix = prefix
                            if prefix_gbk and full_name.startswith(prefix_gbk):
                                fn_matched_prefix = prefix_gbk
                            fn_rest = full_name[len(fn_matched_prefix):] if fn_matched_prefix else full_name
                            fn_display = decode_archive_name(fn_rest)
                            # 始终尝试原始名和解码名匹配
                            if fn_rest == rest or fn_display == rest or fn_rest == name or fn_display == name:
                                size = format_file_size(info.file_size)
                                try:
                                    mtime = datetime(*info.date_time).strftime('%Y-%m-%d %H:%M')
                                except Exception:
                                    mtime = '-'
                                file_ext = os.path.splitext(name)[1].lower()
                                if file_ext in archive_exts or file_ext in image_exts:
                                    can_expand = True
                                matched = True
                                break
                        if not matched:
                            for full_name in all_names:
                                fn_matched_prefix = prefix
                                if prefix_gbk and full_name.startswith(prefix_gbk):
                                    fn_matched_prefix = prefix_gbk
                                fn_rest = full_name[len(fn_matched_prefix):] if fn_matched_prefix else full_name
                                fn_display = decode_archive_name(fn_rest)
                                if fn_rest == rest or fn_display == rest or fn_rest == name or fn_display == name:
                                    try:
                                        info = zf.getinfo(full_name)
                                        size = format_file_size(info.file_size)
                                        try:
                                            mtime = datetime(*info.date_time).strftime('%Y-%m-%d %H:%M')
                                        except Exception:
                                            mtime = '-'
                                        file_ext = os.path.splitext(name)[1].lower()
                                        if file_ext in archive_exts or file_ext in image_exts:
                                            can_expand = True
                                    except Exception:
                                        pass
                                    break
                    file_ext = os.path.splitext(name)[1].lower()
                    is_archive_item = file_ext in archive_exts
                    is_image_item = file_ext in image_exts
                    items.append({'name': name, 'path': item_path, 'is_dir': is_dir, 'size': size, 'mtime': mtime, 'is_archive': is_archive_item, 'is_image': is_image_item, 'can_expand': can_expand, 'can_write': True})
        except Exception as e:
            raise Exception(f'读取ZIP失败: {str(e)}')

    elif ext in ('.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.tbz'):
        archive_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.7z', '.rar'}
        image_exts_list = {'.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'}
        try:
            with tarfile.open(archive_full, 'r') as tf:
                prefix = (inner + '/') if inner else ''
                prefix_gbk = prefix
                try:
                    prefix_gbk = prefix.encode('utf-8').decode('gbk') if prefix else ''
                except Exception:
                    pass
                # 过滤时同时尝试原始prefix和GBK编码的prefix
                members_list = []
                for m in tf.getmembers():
                    if m.name == prefix:
                        continue
                    if m.name.startswith(prefix):
                        members_list.append((m, prefix))
                    elif prefix_gbk and m.name.startswith(prefix_gbk):
                        members_list.append((m, prefix_gbk))
                seen = set()
                # 构建目录mtime/size映射(使用解码后的文件名)
                dir_mtime_map = {}
                dir_size_map = {}
                for m, mprefix in members_list:
                    raw_rest = m.name[len(mprefix):] if mprefix else m.name
                    decoded_rest = decode_archive_name(raw_rest)
                    if '/' in decoded_rest:
                        dir_part = '/'.join(decoded_rest.split('/')[:-1])
                        top_dir = dir_part.split('/')[0]
                        if top_dir not in dir_mtime_map:
                            try:
                                dir_mtime_map[top_dir] = datetime.fromtimestamp(m.mtime).strftime('%Y-%m-%d %H:%M')
                            except Exception:
                                dir_mtime_map[top_dir] = '-'
                        if m.isfile():
                            dir_size_map[top_dir] = dir_size_map.get(top_dir, 0) + m.size
                for m, mprefix in members_list:
                    raw_rest = m.name[len(mprefix):] if mprefix else m.name
                    raw_parts = raw_rest.split('/')
                    name = decode_archive_name(raw_parts[0])
                    decoded_parts = decode_archive_name(raw_rest).split('/')
                    if name in seen:
                        continue
                    seen.add(name)
                    is_dir = (len(decoded_parts) > 1) or m.isdir()
                    item_path = virtual_prefix + ('/'.join([inner, name]).lstrip('/') if inner else name)
                    size = format_file_size(m.size) if m.isfile() else '-'
                    try:
                        mtime = datetime.fromtimestamp(m.mtime).strftime('%Y-%m-%d %H:%M') if hasattr(m, 'mtime') else '-'
                    except Exception:
                        if is_dir and name in dir_mtime_map:
                            mtime = dir_mtime_map[name]
                        else:
                            mtime = '-'
                    if is_dir and name in dir_size_map:
                        size = format_file_size(dir_size_map[name])
                    # 检测嵌套压缩包/镜像
                    can_expand = is_dir
                    if not is_dir:
                        file_ext = os.path.splitext(name)[1].lower()
                        if file_ext in archive_exts or file_ext in image_exts_list:
                            can_expand = True
                    if is_dir:
                        file_ext = os.path.splitext(name)[1].lower()
                        if file_ext in archive_exts or file_ext in image_exts_list:
                            can_expand = True
                    file_ext = os.path.splitext(name)[1].lower()
                    is_archive_item = file_ext in archive_exts
                    is_image_item = file_ext in image_exts_list
                    items.append({'name': name, 'path': item_path, 'is_dir': is_dir, 'size': size, 'mtime': mtime, 'is_archive': is_archive_item, 'is_image': is_image_item, 'can_expand': can_expand, 'can_write': True})
        except Exception as e:
            raise Exception(f'读取TAR失败: {str(e)}')

    elif ext == '.7z' and HAS_PY7ZR:
        archive_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.7z', '.rar'}
        image_exts = {'.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'}
        try:
            with py7zr.SevenZipFile(archive_full, mode='r') as zf:
                prefix = (inner + '/') if inner else ''
                prefix_gbk = prefix
                try:
                    prefix_gbk = prefix.encode('utf-8').decode('gbk') if prefix else ''
                except Exception:
                    pass
                # 过滤时同时尝试原始prefix和GBK编码的prefix
                names = []
                for n in zf.getnames():
                    if n == prefix:
                        continue
                    if n.startswith(prefix):
                        names.append((n, prefix))
                    elif prefix_gbk and n.startswith(prefix_gbk):
                        names.append((n, prefix_gbk))
                seen = set()
                # 构建文件信息查找表
                file_info_map = {}
                try:
                    archive_info = zf.archive_info
                    if hasattr(archive_info, 'file_info_list'):
                        for fi in archive_info.file_info_list:
                            fn = fi.get('filename', '')
                            file_info_map[fn] = fi
                except Exception:
                    pass
                # 也尝试从 headers 构建
                if not file_info_map:
                    try:
                        headers = zf.headers
                        if hasattr(headers, 'files_info'):
                            for fh in headers.files_info:
                                if hasattr(fh, 'filename'):
                                    fn = fh.filename
                                    info = {'uncompressed': fh.uncompressed, 'm_time': fh.m_time}
                                    if hasattr(fh, 'm_time') and hasattr(fh, 'uncompressed'):
                                        file_info_map[fn] = info
                    except Exception:
                        pass
                # 构建目录mtime/size映射(从子文件推断)
                dir_mtime_map = {}
                dir_size_map = {}
                for full_fn, fi in file_info_map.items():
                    matched_p = prefix
                    if prefix_gbk and full_fn.startswith(prefix_gbk):
                        matched_p = prefix_gbk
                    if full_fn.startswith(matched_p) and full_fn != matched_p:
                        rest = full_fn[len(matched_p):] if matched_p else full_fn
                        if '/' in rest and not full_fn.endswith('/'):
                            dir_part = '/'.join(rest.split('/')[:-1])
                            top_dir = dir_part.split('/')[0]
                            if top_dir not in dir_mtime_map:
                                m_time = fi.get('m_time', None) if isinstance(fi, dict) else None
                                if m_time:
                                    try:
                                        dir_mtime_map[top_dir] = datetime.fromtimestamp(m_time).strftime('%Y-%m-%d %H:%M')
                                    except Exception:
                                        dir_mtime_map[top_dir] = '-'
                                else:
                                    dir_mtime_map[top_dir] = '-'
                            uncompressed_val = None
                            if isinstance(fi, dict):
                                uncompressed_val = fi.get('uncompressed', None)
                            else:
                                uncompressed_val = getattr(fi, 'uncompressed', None)
                            if isinstance(uncompressed_val, (list, tuple)):
                                size_value = uncompressed_val[0] if uncompressed_val else 0
                            else:
                                size_value = uncompressed_val or 0
                            if size_value:
                                dir_size_map[top_dir] = dir_size_map.get(top_dir, 0) + size_value
                # 如果没有 file_info_map,尝试从 names 列表中获取大小
                if not file_info_map:
                    for n_item in names:
                        if isinstance(n_item, tuple):
                            full_fn, mprefix = n_item
                        else:
                            full_fn, mprefix = n_item, prefix
                        if full_fn.startswith(prefix) and full_fn != prefix and not full_fn.endswith('/'):
                            rel = full_fn[len(mprefix):] if mprefix else full_fn
                            if '/' in rel:
                                top_dir = rel.split('/')[0]
                                try:
                                    info = zf.getinfo(full_fn)
                                    dir_size_map[top_dir] = dir_size_map.get(top_dir, 0) + info.file_size
                                except Exception:
                                    pass
                for n_item in names:
                    if isinstance(n_item, tuple):
                        n, matched_prefix = n_item
                    else:
                        n, matched_prefix = n_item, prefix
                    rest = n[len(matched_prefix):] if matched_prefix else n
                    parts = rest.split('/')
                    name = decode_archive_name(parts[0])
                    if name in seen:
                        continue
                    seen.add(name)
                    is_dir = (len(parts) > 1) or n.endswith('/')
                    item_path = virtual_prefix + ('/'.join([inner, name]).lstrip('/') if inner else name)
                    size = '-'
                    mtime = '-'
                    can_expand = is_dir
                    if is_dir:
                        # 目录尝试从映射表获取mtime/size
                        if name in dir_mtime_map:
                            mtime = dir_mtime_map[name]
                        if name in dir_size_map:
                            size = format_file_size(dir_size_map[name])
                        file_ext = os.path.splitext(name)[1].lower()
                        if file_ext in archive_exts or file_ext in image_exts:
                            can_expand = True
                    else:
                        # 尝试从查找表获取文件信息——用 rest(去prefix后的完整路径)匹配
                        matched_info = None
                        for full_fn, fi in file_info_map.items():
                            fn_rest = full_fn[len(prefix):] if prefix else full_fn
                            if fn_rest == rest or fn_rest == name:
                                matched_info = fi
                                break
                        if matched_info:
                            fi = matched_info
                            uncompressed = fi.get('uncompressed', fi.get('uncompressed', None)) if isinstance(fi, dict) else None
                            if uncompressed and uncompressed[0]:
                                size = format_file_size(uncompressed[0])
                            m_time = fi.get('m_time', None) if isinstance(fi, dict) else None
                            if m_time:
                                try:
                                    mtime = datetime.fromtimestamp(m_time).strftime('%Y-%m-%d %H:%M')
                                except Exception:
                                    mtime = '-'
                        # 如果查找表没有,尝试从 headers 直接获取
                        if size == '-':
                            try:
                                headers = zf.headers
                                for fh in headers.files_info:
                                    if hasattr(fh, 'filename'):
                                        fh_rest = fh.filename[len(prefix):] if prefix else fh.filename
                                        if fh_rest == rest or fh_rest == name:
                                            if hasattr(fh, 'uncompressed') and fh.uncompressed:
                                                size = format_file_size(fh.uncompressed[0]) if fh.uncompressed[0] else '-'
                                            if hasattr(fh, 'm_time') and fh.m_time:
                                                try:
                                                    mtime = datetime.fromtimestamp(fh.m_time).strftime('%Y-%m-%d %H:%M')
                                                except Exception:
                                                    mtime = '-'
                                            break
                            except Exception:
                                pass
                        # 检测是否为可展开的压缩包/镜像
                        file_ext = os.path.splitext(name)[1].lower()
                        if file_ext in archive_exts or file_ext in image_exts:
                            can_expand = True
                    file_ext = os.path.splitext(name)[1].lower()
                    is_archive_item = file_ext in archive_exts
                    is_image_item = file_ext in image_exts
                    items.append({'name': name, 'path': item_path, 'is_dir': is_dir, 'size': size, 'mtime': mtime, 'is_archive': is_archive_item, 'is_image': is_image_item, 'can_expand': can_expand, 'can_write': False})
        except Exception as e:
            raise Exception(f'读取7z失败: {str(e)}')

    elif ext == '.rar' and HAS_RARFILE:
        archive_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.7z', '.rar'}
        image_exts = {'.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'}
        try:
            with rarfile.RarFile(archive_full) as rf:
                prefix = (inner + '/') if inner else ''
                prefix_gbk = prefix
                try:
                    prefix_gbk = prefix.encode('utf-8').decode('gbk') if prefix else ''
                except Exception:
                    pass
                infolist = rf.infolist()
                # 过滤时同时尝试原始prefix和GBK编码的prefix
                names = []
                for n in rf.namelist():
                    if n == prefix:
                        continue
                    if n.startswith(prefix):
                        names.append((n, prefix))
                    elif prefix_gbk and n.startswith(prefix_gbk):
                        names.append((n, prefix_gbk))
                seen = set()
                # 构建目录 mtime/size 映射
                dir_mtime_map = {}
                dir_size_map = {}
                for rarinfo in infolist:
                    rar_fn = rarinfo.filename
                    decoded_fn = decode_archive_name(rar_fn)
                    if '/' in decoded_fn:
                        dir_prefix_decoded = decode_archive_name(inner) if inner else ''
                        decoded_rest = decoded_fn[len(dir_prefix_decoded):].lstrip('/') if dir_prefix_decoded else decoded_fn
                        dir_part = '/'.join(decoded_rest.split('/')[:-1])
                        top_dir = dir_part.split('/')[0] if dir_part else ''
                        if top_dir:
                            if top_dir not in dir_mtime_map:
                                try:
                                    dir_mtime_map[top_dir] = datetime(*rarinfo.date_time).strftime('%Y-%m-%d %H:%M')
                                except Exception:
                                    dir_mtime_map[top_dir] = '-'
                            if not rarinfo.is_dir():
                                dir_size_map[top_dir] = dir_size_map.get(top_dir, 0) + rarinfo.file_size
                for n_item in names:
                    if isinstance(n_item, tuple):
                        n, mprefix = n_item
                    else:
                        n, mprefix = n_item, prefix
                    rest = n[len(mprefix):] if mprefix else n
                    parts = rest.split('/')
                    name = parts[0]
                    # 始终使用统一解码策略
                    name = decode_archive_name(name)
                    if name in seen:
                        continue
                    seen.add(name)
                    is_dir = (len(parts) > 1) or n.endswith('/')
                    item_path = virtual_prefix + ('/'.join([inner, name]).lstrip('/') if inner else name)
                    size = '-'
                    mtime = '-'
                    can_expand = is_dir
                    if is_dir:
                        if name in dir_mtime_map:
                            mtime = dir_mtime_map[name]
                        if name in dir_size_map:
                            size = format_file_size(dir_size_map[name])
                        file_ext = os.path.splitext(name)[1].lower()
                        if file_ext in archive_exts or file_ext in image_exts:
                            can_expand = True
                    else:
                        try:
                            for rarinfo in infolist:
                                rar_fn = rarinfo.filename
                                rar_rest = rar_fn[len(prefix):] if prefix else rar_fn
                                display_name = decode_archive_name(rar_rest)
                                if (rar_rest == rest or display_name == rest) and not rarinfo.is_dir():
                                    size = format_file_size(rarinfo.file_size)
                                    try:
                                        mtime = datetime(*rarinfo.date_time).strftime('%Y-%m-%d %H:%M')
                                    except Exception:
                                        mtime = '-'
                                    break
                        except Exception:
                            pass
                        file_ext = os.path.splitext(name)[1].lower()
                        if file_ext in archive_exts or file_ext in image_exts:
                            can_expand = True
                    file_ext = os.path.splitext(name)[1].lower()
                    is_archive_item = file_ext in archive_exts
                    is_image_item = file_ext in image_exts
                    items.append({'name': name, 'path': item_path, 'is_dir': is_dir, 'size': size, 'mtime': mtime, 'is_archive': is_archive_item, 'is_image': is_image_item, 'can_expand': can_expand, 'can_write': False})
        except Exception as e:
            raise Exception(f'读取RAR失败: {str(e)}')

    elif ext in image_exts:
        img_writeable = has_image_write_tools()
        archive_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.7z', '.rar'}
        # pycdlib 打开失败时自动回退到 pytsk3
        _img_pycdlib_parsed = False
        if HAS_PYCDLIB and _is_iso9660_file(archive_full):
            try:
                iso = pycdlib.PyCdlib()
                iso.open(archive_full)
                try:
                    use_rr = iso.has_rock_ridge()
                    use_joliet = iso.has_joliet()
                    if use_rr:
                        walk_kwargs = {'rr_path': '/' + inner if inner else '/'}
                    elif use_joliet:
                        walk_kwargs = {'joliet_path': '/' + inner if inner else '/'}
                    else:
                        walk_kwargs = {'iso_path': '/' + inner if inner else '/'}
                    # 可用的路径系统列表(按优先级)
                    walk_path_key = list(walk_kwargs.keys())[0] if walk_kwargs else 'iso_path'
                    path_types = []
                    if use_rr:
                        path_types.append('rr_path')
                    if use_joliet:
                        path_types.append('joliet_path')
                    path_types.append('iso_path')
                    if walk_path_key in path_types:
                        path_types.remove(walk_path_key)
                        path_types.insert(0, walk_path_key)
                    def iso_stat(path_str, is_directory=False):
                        """尝试用多种方式获取 ISO 文件 stat,返回 (size, mtime) 元组"""
                        path_variants = set()
                        path_variants.add(path_str)
                        if not path_str.startswith('/'):
                            path_variants.add('/' + path_str)
                        if is_directory:
                            dir_total = 0
                            dir_newest_mtime = None
                            for ptype in path_types:
                                for pval in path_variants:
                                    try:
                                        for wd, wdirs, wfiles in iso.walk(**{ptype: pval}):
                                            for wf in wfiles:
                                                child = (wd.rstrip('/') + '/' + wf).lstrip('/')
                                                try:
                                                    bio = io.BytesIO()
                                                    iso.get_file_from_iso_fp(bio, **{ptype: child})
                                                    fs = bio.tell()
                                                    if fs > 0:
                                                        dir_total += fs
                                                except Exception:
                                                    pass
                                    except Exception:
                                        pass
                                if dir_total > 0:
                                    break
                            return (dir_total if dir_total > 0 else 0, dir_newest_mtime)
                        for ptype in path_types:
                            for pval in path_variants:
                                try:
                                    stat_res = iso.stat(**{ptype: pval})
                                    s = getattr(stat_res, 'size', None) or getattr(stat_res, 'st_size', None)
                                    t = getattr(stat_res, 'mtime', None) or getattr(stat_res, 'st_mtime', None)
                                    if s is not None and s > 0:
                                        return (s, t)
                                    return (s, None)
                                except Exception:
                                    pass
                        if not is_directory:
                            for ptype in path_types:
                                for pval in path_variants:
                                    try:
                                        bio = io.BytesIO()
                                        iso.get_file_from_iso_fp(bio, **{ptype: pval})
                                        size = bio.tell()
                                        if size >= 0:
                                            return (size, None)
                                    except Exception:
                                        continue
                        return None
                    iso_stats_cache = {}
                    iso_fs_mtime = os.path.getmtime(archive_full)
                    def get_iso_item_stat(path_str, is_directory=False):
                        if path_str in iso_stats_cache:
                            return iso_stats_cache[path_str]
                        result = iso_stat(path_str, is_directory)
                        iso_stats_cache[path_str] = result
                        return result
                    try:
                        for walk_p, walk_dirs, walk_files in iso.walk(**walk_kwargs):
                            wp_clean = walk_p.rstrip('/')
                            for wf in walk_files:
                                full_path = ('/' + walk_p.lstrip('/') + '/' + wf) if walk_p != '/' else '/' + wf
                                get_iso_item_stat(full_path, is_directory=False)
                            for wd in walk_dirs:
                                full_path = ('/' + walk_p.lstrip('/') + '/' + wd) if walk_p != '/' else '/' + wd
                                get_iso_item_stat(full_path, is_directory=True)
                            break
                    except Exception:
                        pass
                    for p, dirs, files in iso.walk(**walk_kwargs):
                        start = '/' + inner if inner else '/'
                        if p.rstrip('/') == start.rstrip('/'):
                            for d in dirs:
                                dir_iso_fp = p.rstrip('/') + '/' + d
                                dir_s_stat, dir_t_stat = 0, None
                                dir_size = '-'
                                dir_mtime = '-'
                                try:
                                    ds = get_iso_item_stat(dir_iso_fp, is_directory=True)
                                    if ds:
                                        dir_s_stat, dir_t_stat = ds[0] or 0, ds[1]
                                except Exception:
                                    pass
                                if dir_t_stat and dir_t_stat > 0:
                                    try:
                                        dir_mtime = datetime.fromtimestamp(dir_t_stat).strftime('%Y-%m-%d %H:%M')
                                    except Exception:
                                        pass
                                if dir_mtime == '-':
                                    dir_mtime = datetime.fromtimestamp(iso_fs_mtime).strftime('%Y-%m-%d %H:%M')
                                if dir_s_stat == 0:
                                    try:
                                        for ptype in path_types:
                                            try:
                                                for sub_p, sub_d, sub_ff in iso.walk(**{ptype: dir_iso_fp}):
                                                    for sf in sub_ff:
                                                        try:
                                                            bio_calc = io.BytesIO()
                                                            iso.get_file_from_iso_fp(bio_calc, **{ptype: (sub_p.rstrip('/') + '/' + sf).lstrip('/')})
                                                            dir_s_stat += bio_calc.tell()
                                                        except Exception:
                                                            pass
                                                if dir_s_stat > 0:
                                                    break
                                            except Exception:
                                                continue
                                    except Exception:
                                        pass
                                if dir_s_stat > 0:
                                    dir_size = format_file_size(dir_s_stat)
                                item_path = virtual_prefix + ('/'.join([inner, d]).lstrip('/') if inner else d)
                                file_ext = os.path.splitext(d)[1].lower()
                                can_expand_dir = file_ext in archive_exts or file_ext in image_exts
                                if not can_expand_dir:
                                    can_expand_dir = True
                                is_archive_d = file_ext in archive_exts
                                is_image_d = file_ext in image_exts
                                items.append({'name': d, 'path': item_path, 'is_dir': True, 'size': dir_size, 'mtime': dir_mtime, 'is_archive': is_archive_d, 'is_image': is_image_d, 'can_expand': can_expand_dir, 'can_write': img_writeable})
                            for f in files:
                                item_path = virtual_prefix + ('/'.join([inner, f]).lstrip('/') if inner else f)
                                size = '-'
                                f_mtime = '-'
                                f_s = 0
                                f_t = None
                                try:
                                    iso_fp = p.rstrip('/') + '/' + f
                                    stat = get_iso_item_stat(iso_fp, is_directory=False)
                                    if stat:
                                        f_s = stat[0] if stat[0] else 0
                                        if f_s:
                                            size = format_file_size(f_s)
                                        f_t = stat[1]
                                except Exception as e:
                                    logger.warning(f"ISO get_iso_item_stat failed for {f}: {e}")
                                if f_t and f_t > 0:
                                    try:
                                        f_mtime = datetime.fromtimestamp(f_t).strftime('%Y-%m-%d %H:%M')
                                    except Exception:
                                        pass
                                if f_mtime == '-':
                                    try:
                                        f_mtime = datetime.fromtimestamp(iso_fs_mtime).strftime('%Y-%m-%d %H:%M')
                                    except Exception as e:
                                        logger.warning(f"ISO mtime fallback failed for {f}: {e}, iso_fs_mtime={iso_fs_mtime}")
                                        pass
                                file_ext = os.path.splitext(f)[1].lower()
                                can_expand = file_ext in archive_exts or file_ext in image_exts
                                is_archive_f = file_ext in archive_exts
                                is_image_f = file_ext in image_exts
                                items.append({'name': f, 'path': item_path, 'is_dir': False, 'size': size, 'mtime': f_mtime, 'is_archive': is_archive_f, 'is_image': is_image_f, 'can_expand': can_expand, 'can_write': img_writeable})
                            break
                    _img_pycdlib_parsed = True
                except Exception as _e:
                    logger.warning(f"pycdlib failed, falling back to pytsk3: {_e}")
                finally:
                    try:
                        iso.close()
                    except Exception:
                        pass
            except Exception as _e:
                logger.warning(f"pycdlib open failed, falling back to pytsk3: {_e}")


        # pytsk3 回退分支:当 pycdlib 不适用或失败时启用
        if not _img_pycdlib_parsed:
            parsed = False
            walk_path = '/' + inner if inner else '/'  # 定义 walk_path 作用域
            if HAS_PYTSK3:
                try:
                    iso_fs_mtime = os.path.getmtime(archive_full)
                    logger.info(f"[pytsk3] Attempting to open image: {archive_full} ({os.path.getsize(archive_full)} bytes)")
                    img_size = os.path.getsize(archive_full)
                    if img_size == 0:
                        raise ValueError(f"Image file is empty ({archive_full})")
                    img = pytsk3.Img_Info(archive_full)
                    logger.info(f"[pytsk3] Img_Info created successfully")
                    fs = pytsk3.FS_Info(img)
                    logger.info(f"[pytsk3] FS_Info created successfully, walk_path={walk_path}, fs_type={getattr(fs, 'fs_type', 'unknown')}")
                    def calc_tsk_dir_size(path_str):
                        total = 0
                        try:
                            subdir = fs.open_dir(path=path_str)
                            for subentry in subdir:
                                try:
                                    sub_name = subentry.info.name.name.decode('utf-8') if isinstance(subentry.info.name.name, bytes) else subentry.info.name.name
                                except Exception:
                                    continue
                                if sub_name in ['.', '..']:
                                    continue
                                try:
                                    if subentry.info.meta.type == pytsk3.TSK_FS_META_TYPE_DIR:
                                        sub_path = path_str.rstrip('/') + '/' + sub_name
                                        total += calc_tsk_dir_size(sub_path)
                                    elif hasattr(subentry.info.meta, 'size') and subentry.info.meta.size is not None:
                                        total += subentry.info.meta.size
                                except Exception:
                                    pass
                        except Exception:
                            pass
                        return total
                    try:
                        dirobj = fs.open_dir(path=walk_path)
                    except Exception as e:
                        logger.warning(f"[pytsk3] open_dir failed for walk_path='{walk_path}': {e}, trying alternative formats")
                        dirobj = None
                        # 尝试备用路径格式
                        alt_paths = []
                        if inner:
                            # 尝试不带前导/的路径
                            alt_paths.append(inner)
                            # 尝试双斜杠
                            alt_paths.append('/' + inner.lstrip('/'))
                            # 尝试空路径(根目录)
                            alt_paths.append('/')
                        for alt_path in alt_paths:
                            try:
                                dirobj = fs.open_dir(path=alt_path)
                                logger.info(f"[pytsk3] open_dir succeeded with alt_path='{alt_path}'")
                                break
                            except Exception as e2:
                                logger.debug(f"[pytsk3] open_dir failed for alt_path='{alt_path}': {e2}")
                                dirobj = None
                    if dirobj is not None:
                        def _get_entry_name(entry):
                            try:
                                n = entry.info.name.name
                                short_name = n.decode('utf-8') if isinstance(n, bytes) else str(n)

                                if hasattr(entry.info.meta, 'name'):
                                    meta = entry.info.meta
                                    try:
                                        name_iter = getattr(meta, 'name', None)
                                        while name_iter:
                                            try:
                                                name_bytes = getattr(name_iter, 'name', None)
                                                if name_bytes:
                                                    decoded = name_bytes.decode('utf-8') if isinstance(name_bytes, bytes) else str(name_bytes)
                                                    if decoded and decoded not in ('.', '..'):
                                                        parts = decoded.split('.', 1)
                                                        base = parts[0]
                                                        ext = parts[1] if len(parts) > 1 else ''
                                                        if len(base) > 8 or len(ext) > 3 or base != base.upper():
                                                            return decoded
                                                name_iter = getattr(name_iter, 'next', None)
                                            except Exception:
                                                break
                                    except Exception:
                                        pass
                                return short_name
                            except Exception:
                                return None

                        for entry in dirobj:
                            name = _get_entry_name(entry)
                            if not name or name in ['.', '..']:
                                continue
                            if name.startswith('$') and name.upper() in ('$FAT1', '$FAT2', '$FAT12', '$MBR', '$ORPHANFILES'):
                                continue
                            try:
                                is_dir = entry.info.meta.type == pytsk3.TSK_FS_META_TYPE_DIR
                            except Exception:
                                is_dir = False
                            # 尝试获取文件大小和修改时间
                            size = '-'
                            mtime = '-'
                            try:
                                meta = entry.info.meta
                                meta_size = getattr(meta, 'size', None)

                                if meta_size is not None and not is_dir:
                                    try:
                                        size = format_file_size(int(meta_size))
                                    except (TypeError, ValueError):
                                        pass
                                elif is_dir:
                                    dir_path = walk_path.rstrip('/') + '/' + name if walk_path != '/' else '/' + name
                                    total_size = calc_tsk_dir_size(dir_path)
                                    if total_size:
                                        size = format_file_size(total_size)
                            except Exception:
                                pass

                            try:
                                meta = entry.info.meta
                                def get_timestamp(ts):
                                    if ts is None:
                                        return None
                                    if hasattr(ts, 'secs'):
                                        return ts.secs if ts.secs > 0 else None
                                    if isinstance(ts, (int, float)) and ts > 0:
                                        return int(ts)
                                    return None

                                ts = get_timestamp(getattr(meta, 'mtime', None))
                                if ts:
                                    mtime = datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')
                                if mtime == '-':
                                    ts = get_timestamp(getattr(meta, 'crtime', None))
                                    if ts:
                                        mtime = datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')
                                if mtime == '-':
                                    ts = get_timestamp(getattr(meta, 'ctime', None))
                                    if ts:
                                        mtime = datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')
                                # 如果仍然无法获取,使用镜像文件自身的修改时间
                                if mtime == '-':
                                    try:
                                        mtime = datetime.fromtimestamp(iso_fs_mtime).strftime('%Y-%m-%d %H:%M')
                                    except Exception:
                                        pass
                            except Exception:
                                pass
                            item_path = virtual_prefix + ('/'.join([inner, name]).lstrip('/') if inner else name)
                            can_expand = is_dir
                            file_ext = os.path.splitext(name)[1].lower()
                            if file_ext in archive_exts or file_ext in image_exts:
                                can_expand = True
                            is_archive_item = file_ext in archive_exts
                            is_image_item = file_ext in image_exts
                            items.append({'name': name, 'path': item_path, 'is_dir': is_dir, 'size': size, 'mtime': mtime, 'is_archive': is_archive_item, 'is_image': is_image_item, 'can_expand': can_expand, 'can_write': img_writeable})
                        parsed = True
                except Exception as e:
                    logger.warning(f"pytsk3 failed to open image {archive_full}: {e}\n{_tb.format_exc()}")
                    try:
                        if HAS_PYTSK3:
                            test_img = pytsk3.Img_Info(archive_full)
                            try:
                                test_fs = pytsk3.FS_Info(test_img)
                                walk_path_test = '/' + inner if inner else '/'
                                error_detail = f'pytsk3 can open image but {walk_path_test} not found in filesystem (fs_type={test_fs.info.ftype})'
                                test_fs.close()
                            except Exception as e2:
                                error_detail = f'pytsk3 cannot open filesystem on image: {e2}'
                            test_img.close()
                            return jsonify({'items': [], 'unsupported': True, 'error_detail': error_detail}), 500
                    except Exception:
                        pass
                    return jsonify({'items': [], 'unsupported': True}), 500

            if not parsed:
                try:
                    if HAS_PYTSK3:
                        test_img = pytsk3.Img_Info(archive_full)
                        test_fs = pytsk3.FS_Info(test_img)
                        walk_path_test = '/' + inner if inner else '/'
                        error_detail = f'pytsk3 can open image but {walk_path_test} not found in filesystem (fs_type={test_fs.fs_typ_str})'
                        try:
                            test_fs.close()
                        except Exception:
                            pass
                        try:
                            test_img.close()
                        except Exception:
                            pass
                    else:
                        error_detail = 'pytsk3 not installed'
                except Exception as diag_e:
                    error_detail = f'pytsk3 error: {diag_e}'
                logger.warning(f"无法打开镜像 {archive_full}: {error_detail}")
                raise NotImplementedError(f'无法打开此镜像文件: {error_detail}')

    else:
        raise NotImplementedError('不支持的归档/镜像格式')

    return items

API 路由

def api_dir_tree():
    return api_dir_tree_with_path(parse_request_path())
def api_dir_tree_with_path_route():
    """路径选择树API - 与普通目录列表类似,但过滤掉普通文件只保留可展开项"""
    return api_dir_tree_with_path(parse_request_path())
def _api_dir_tree_nested(path, user_dir, pre_extracted_path=None, pre_extracted_temp_dir=None, virtual_prefix_override=None, from_userdir_cache=False, _extracted_set=None, _pre_orig_archive_rel=None):
    """处理多层嵌套虚拟路径(如 outer.zip::inner.tar.gz::subdir)
    pre_extracted_path: 如果提供,跳过 parts[0] 的提取,直接使用该路径作为当前归档。
    pre_extracted_temp_dir: 如果提供了 pre_extracted_path,传入创建该文件的临时目录,以便统一清理。
    virtual_prefix_override: 如果提供,使用此值作为虚拟路径前缀(而非从 parts 推导),配合 pre_extracted_path 使用。
    _extracted_set: 已提取的文件路径集合,用于避免重复提取(归档链解析时已提取的文件不再重复提取)。
    _pre_orig_archive_rel: 如果提供,当 pre_extracted_path 存在时,用作 _orig_archive_rel(而非从 parts[0] 推导的缓存路径)。
    """
    parts = [p.strip('/\\') for p in path.split('::')]
    parts = [p for p in parts if p]

    # 关键修复:拆分形如 BOOT/DOS71INS.IMG 的父目录+归档组合段
    all_nested_exts = {'.zip','.tar','.tgz','.tar.gz','.gz','.bz2','.7z','.rar',
                        '.iso','.img','.vmdk','.vhd','.vhdx','.qcow2','.dmg','.wim'}
    _np = [parts[0]] if parts else []
    for _p in parts[1:]:
        _pe = _get_archive_ext(_p)
        if '/' in _p and _pe in all_nested_exts:
            _ls = _p.rfind('/'); _pa = _p[:_ls]; _fn = _p[_ls+1:]
            if _pa: _np.append(_pa)
            _np.append(_fn)
        else:
            _np.append(_p)
    parts = _np

    if pre_extracted_path:
        _pre_base = os.path.basename(pre_extracted_path)
        for _pi, _pseg in enumerate(parts):
            if os.path.basename(_pseg) == _pre_base:
                parts = parts[_pi + 1:]
                break

    archive_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.7z', '.rar'}
    image_exts = {'.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'}

    temp_dirs = []
    try:
        if pre_extracted_path:
            # 外层归档已由调用者提取到临时目录,直接使用
            current_archive_path = pre_extracted_path
        else:
            current_archive_path = os.path.join(user_dir, parts[0])

        # 将预提取的临时目录加入清理列表
        if pre_extracted_temp_dir and pre_extracted_temp_dir not in temp_dirs:
            temp_dirs.append(pre_extracted_temp_dir)

        # 确定有多少层归档需要提取
        # parts[0] 是最外层归档,parts[1..N] 依次是嵌套归档或内部路径
        # 关键:判断 parts[-1] 是否是归档文件 - 如果是,说明需要提取所有parts作为归档层
        last_part_ext = _get_archive_ext(parts[-1])
        last_is_archive = last_part_ext in archive_exts or last_part_ext in image_exts

        # 统一归档链提取:从 i=1 向右扫,归档段提取为下一层,父目录段拼进 inner_name 并追加入 vp 后缀
        _i = 0 if pre_extracted_path else 1
        _vp_suffix_parts = []
        while _i < len(parts):
            _pex = _get_archive_ext(parts[_i])
            if _pex in archive_exts or _pex in image_exts:
                _pp = []; _j = _i - 1
                while _j >= 1 and not (_get_archive_ext(parts[_j]) in archive_exts or _get_archive_ext(parts[_j]) in image_exts):
                    _pp.insert(0, parts[_j]); _j -= 1
                _inner_name = '/'.join(_pp + [parts[_i]]) if _pp else parts[_i]
                # vp 后缀只追加"父目录/归档名::"——父目录/目录段已经通过前面的 _more 分支追加过了
                # 这里 _pp 是这个归档名前面的父目录段,需要用 '/' 连起来
                _vp_path_to_archive = '/'.join(_pp + [parts[_i]]) if _pp else parts[_i]
                _vp_suffix_parts.append(_vp_path_to_archive + '::')
                # 检查是否已在归档链解析中提取过(避免重复提取)
                _already_extract = None
                if _extracted_set:
                    for _ep in _extracted_set:
                        if os.path.basename(_ep) == parts[_i] or os.path.basename(_ep) == _inner_name:
                            _already_extract = _ep
                            break
                if _already_extract and os.path.exists(_already_extract):
                    current_archive_path = _already_extract
                else:
                    _td = create_cache_dir(prefix='nested_')
                    temp_dirs.append(_td)
                    try:
                        _extract_archive_item(current_archive_path, _inner_name, _td)
                    except Exception:
                        return jsonify({'error': f'提取嵌套归档失败: {_inner_name}'}), 500
                    _ex = _find_extracted_file(_td, parts[_i])
                    if not _ex: _ex = _find_extracted_file(_td, _inner_name)
                    if not _ex:
                        return jsonify({'error': f'嵌套归档不存在: {_inner_name}'}), 404
                    current_archive_path = _ex
                _i += 1
            else:
                _more = False
                for _k in range(_i+1, len(parts)):
                    _kex = _get_archive_ext(parts[_k])
                    if _kex in archive_exts or _kex in image_exts:
                        # 注意:这里不要把 parts[_i] 追加到 _vp_suffix_parts,
                        # 因为上面归档分支已经把"父目录/归档名"作为整体追加过了。
                        # 只需要 _i 前进到下一段,循环继续即可。
                        _more = True; break
                if _more:
                    _i += 1
                else:
                    break
        inner_path = '/'.join(parts[_i:]) if _i < len(parts) else ''
        ext = _get_archive_ext(current_archive_path)
        items = []
        # 计算可写性:ZIP/TAR直接可写;7z/RAR/ISO可通过提取-重打包修改
        can_write = ext in ('.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2')
        if not can_write:
            can_write = ext in ('.7z', '.rar') and (HAS_PY7ZR or HAS_RARFILE)
        # ISO в嵌套路径中,即使不能直接写入,也可以提取-修改-重打包
        if ext == '.iso' and HAS_PYCDLIB:
            can_write = True
        # IMG 文件通过 pytsk3 处理,如果有镜像写入工具则可写
        if ext == '.img' and has_image_write_tools():
            can_write = True

        # 关键修复:构建虚拟路径前缀时区分归档/镜像文件和普通文件夹
        # 只有归档/镜像文件之间用 :: 连接,普通文件夹用 / 连接
        archive_parts = []
        folder_parts = []
        for i, part in enumerate(parts):
            # 判断每个 part 是否是归档/镜像文件
            part_ext = _get_archive_ext(part)
            is_part_archive = part_ext in archive_exts or part_ext in image_exts
            if is_part_archive:
                archive_parts.append(part)
            else:
                # 普通文件夹及其后续部分都视为文件夹路径
                folder_parts = parts[i:]
                break

        # 构建前缀:归档部分用 :: 连接
        # 关键修复:virtual_prefix 只包含归档链(不含文件夹路径),inner_path 保留子目录路径
        # 这样归档处理器(pytsk3/pycdlib等)能正确打开到子目录位置
        original_inner_path = inner_path  # 保存原始 inner_path 用于 ZIP 内部查找
        if virtual_prefix_override:
            virtual_prefix = virtual_prefix_override
            # 方案2修复:override 只包含进入本层之前已知的前缀;
            # 本层循环中新提取的更深层归档段必须追加,
            # 否则三层及以上的 item_path 会与父级重叠、无法继续下钻
            if _vp_suffix_parts:
                virtual_prefix += ''.join(_vp_suffix_parts)
            if virtual_prefix and not virtual_prefix.endswith('::'):
                virtual_prefix = virtual_prefix + '::'
            # 关键修复:当 pre_extracted_path 由调用者提供时,virtual_prefix 可能包含
            # 缓存目录路径(如 .nested_cache/xxx/),需要替换为原始归档链路径,
            # 防止缓存路径泄漏到前端导致后续请求无法正确解析
            if _pre_orig_archive_rel and pre_extracted_path:
                _cache_prefix = os.path.relpath(pre_extracted_path, user_dir).replace('\\', '/')
                if virtual_prefix.startswith(_cache_prefix + '::'):
                    virtual_prefix = _pre_orig_archive_rel + '::' + virtual_prefix[len(_cache_prefix) + 2:]
                elif virtual_prefix == _cache_prefix + '::':
                    virtual_prefix = _pre_orig_archive_rel + '::'
        elif archive_parts:
            virtual_prefix = parts[0] + '::'
            # 追加嵌套归档链的后缀(如 'BOOT/DOS71INS.IMG::')
            # _vp_suffix_parts 在归档链提取循环中构建,包含了正确的父目录+归档名
            if _vp_suffix_parts:
                virtual_prefix += ''.join(_vp_suffix_parts)
            # 不再将 folder_parts 追加到 virtual_prefix,也不再清空 inner_path
            # inner_path 已经包含子目录路径(如 'subdir'),供归档处理器定位
        else:
            # 没有归档部分(不应该发生,因为 parts[0] 应该是归档)
            virtual_prefix = parts[0] + '::' if parts else ''

        # 处理ZIP
        if ext == '.zip':
            try:
                with zipfile.ZipFile(current_archive_path, 'r') as zf:
                    # 关键修复:使用 original_inner_path 进行内部查找,而不是 inner_path
                    prefix = (original_inner_path + '/') if original_inner_path else ''
                    all_infos = zf.infolist()
                    all_names = zf.namelist()
                    names = [n for n in all_names if n.startswith(prefix) and n != prefix]
                    seen = set()
                    dir_mtime_map = {}
                    dir_size_map = {}

                    for info in all_infos:
                        raw_fn = info.filename
                        decoded_fn = decode_archive_name(raw_fn)
                        if '/' in decoded_fn:
                            dir_part = '/'.join(decoded_fn.split('/')[:-1])
                            if dir_part.startswith(prefix) and dir_part != prefix:
                                rel_dir = dir_part[len(prefix):] if prefix else dir_part
                                # 递归累加到所有上级目录
                                parts_list = rel_dir.split('/')
                                for i in range(len(parts_list)):
                                    ancestor = '/'.join(parts_list[:i+1])
                                    if ancestor and ancestor not in dir_mtime_map:
                                        try:
                                            dir_mtime_map[ancestor] = datetime(*info.date_time).strftime('%Y-%m-%d %H:%M')
                                        except Exception:
                                            dir_mtime_map[ancestor] = '-'
                                    if not raw_fn.endswith('/'):
                                        dir_size_map[ancestor] = dir_size_map.get(ancestor, 0) + info.file_size

                    for n in names:
                        rest = n[len(prefix):] if prefix else n
                        rest_parts = rest.split('/')
                        name = rest_parts[0]
                        # 始终使用统一解码策略
                        name = decode_archive_name(name)

                        if name in seen:
                            continue
                        seen.add(name)
                        is_dir = (len(rest_parts) > 1) or n.endswith('/')
                        # 关键修复:正确使用 inner_path 构建 item 路径
                        item_inner = '/'.join([inner_path, name]).lstrip('/') if inner_path else name
                        item_path = virtual_prefix + item_inner
                        size = '-'
                        mtime = '-'
                        can_expand_item = is_dir

                        if is_dir:
                            if name in dir_mtime_map:
                                mtime = dir_mtime_map[name]
                            if name in dir_size_map:
                                size = format_file_size(dir_size_map[name])
                            elif size == '-':
                                size = '0 B'
                        else:
                            matched = False
                            for info in all_infos:
                                full_name = info.filename
                                fn_rest = full_name[len(prefix):] if prefix else full_name
                                fn_display = decode_archive_name(fn_rest)
                                if (fn_rest == rest or fn_display == rest or fn_rest == name or fn_display == name):
                                    size = format_file_size(info.file_size)
                                    try:
                                        mtime = datetime(*info.date_time).strftime('%Y-%m-%d %H:%M')
                                    except Exception:
                                        mtime = '-'
                                    matched = True
                                    break
                            if not matched:
                                for full_name in all_names:
                                    fn_rest = full_name[len(prefix):] if prefix else full_name
                                    fn_display = decode_archive_name(fn_rest)
                                    if fn_rest == rest or fn_display == rest or fn_rest == name or fn_display == name:
                                        try:
                                            info = zf.getinfo(full_name)
                                            size = format_file_size(info.file_size)
                                            try:
                                                mtime = datetime(*info.date_time).strftime('%Y-%m-%d %H:%M')
                                            except Exception:
                                                mtime = '-'
                                        except Exception:
                                            pass
                                        break

                        file_ext = os.path.splitext(name)[1].lower()
                        if file_ext in archive_exts or file_ext in image_exts:
                            can_expand_item = True

                        # 关键修复:is_archive 和 is_image 只对文件设置,目录始终为 False
                        is_archive_item = (not is_dir) and (file_ext in archive_exts)
                        is_image_item = (not is_dir) and (file_ext in image_exts)
                        items.append({
                            'name': name, 'path': item_path, 'is_dir': is_dir,
                            'size': size, 'mtime': mtime, 'is_archive': is_archive_item,
                            'is_image': is_image_item, 'can_expand': can_expand_item,
                            'can_write': can_write
                        })
            except Exception as e:
                return jsonify({'error': f'读取ZIP失败: {str(e)}'}), 500

        elif ext in ('.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.tbz'):
            try:
                with tarfile.open(current_archive_path, 'r') as tf:
                    # 关键修复:使用 original_inner_path 构建 prefix
                    prefix = (original_inner_path + '/') if original_inner_path else ''
                    members = [m for m in tf.getmembers() if m.name.startswith(prefix) and m.name != prefix]
                    seen = set()
                    dir_mtime_map = {}
                    dir_size_map = {}
                    for m in members:
                        raw_rest = m.name[len(prefix):] if prefix else m.name
                        decoded_rest = decode_archive_name(raw_rest)
                        if '/' in decoded_rest:
                            dir_part = '/'.join(decoded_rest.split('/')[:-1])
                            # 递归累加到所有上级目录
                            if dir_part:
                                parts_list = dir_part.split('/')
                                for i in range(len(parts_list)):
                                    ancestor = '/'.join(parts_list[:i+1])
                                    if ancestor not in dir_mtime_map:
                                        try:
                                            dir_mtime_map[ancestor] = datetime.fromtimestamp(m.mtime).strftime('%Y-%m-%d %H:%M')
                                        except Exception:
                                            dir_mtime_map[ancestor] = '-'
                                if m.isfile():
                                    for i in range(len(parts_list)):
                                        ancestor = '/'.join(parts_list[:i+1])
                                        dir_size_map[ancestor] = dir_size_map.get(ancestor, 0) + m.size
                    for m in members:
                        raw_rest = m.name[len(prefix):]
                        raw_parts = raw_rest.split('/')
                        name = decode_archive_name(raw_parts[0])
                        decoded_parts = decode_archive_name(raw_rest).split('/')
                        if name in seen:
                            continue
                        seen.add(name)
                        is_dir = (len(decoded_parts) > 1) or m.isdir()
                        # 关键修复:正确使用 inner_path 构建 item 路径
                        item_inner = '/'.join([inner_path, name]).lstrip('/') if inner_path else name
                        item_path = virtual_prefix + item_inner
                        size = format_file_size(m.size) if m.isfile() else '-'
                        try:
                            mtime = datetime.fromtimestamp(m.mtime).strftime('%Y-%m-%d %H:%M')
                        except Exception:
                            if is_dir and name in dir_mtime_map:
                                mtime = dir_mtime_map[name]
                            else:
                                mtime = '-'
                        if is_dir:
                            for i in range(len(decoded_parts) - 1, 0, -1):
                                ancestor = '/'.join(decoded_parts[:i+1])
                                if ancestor in dir_size_map:
                                    size = format_file_size(dir_size_map[ancestor])
                                    break
                            if size == '-':
                                size = '0 B'
                        can_expand_item = is_dir
                        file_ext = os.path.splitext(name)[1].lower()
                        if file_ext in archive_exts or file_ext in image_exts:
                            can_expand_item = True
                        # 关键修复:is_archive 和 is_image 只对文件设置,目录始终为 False
                        is_archive_item = (not is_dir) and (file_ext in archive_exts)
                        is_image_item = (not is_dir) and (file_ext in image_exts)
                        items.append({
                            'name': name, 'path': item_path, 'is_dir': is_dir,
                            'size': size, 'mtime': mtime, 'is_archive': is_archive_item,
                            'is_image': is_image_item, 'can_expand': can_expand_item,
                            'can_write': can_write
                        })
            except Exception as e:
                return jsonify({'error': f'读取TAR失败: {str(e)}'}), 500

        elif ext == '.7z' and HAS_PY7ZR:
            try:
                with py7zr.SevenZipFile(current_archive_path, mode='r') as zf:
                    # 关键修复:使用 original_inner_path 构建 prefix
                    prefix = (original_inner_path + '/') if original_inner_path else ''
                    names = [n for n in zf.getnames() if n.startswith(prefix) and n != prefix]
                    seen = set()
                    file_info_map = {}
                    try:
                        archive_info = zf.archive_info
                        if hasattr(archive_info, 'file_info_list'):
                            for fi in archive_info.file_info_list:
                                fn = fi.get('filename', '')
                                file_info_map[fn] = fi
                    except Exception:
                        pass
                    if not file_info_map:
                        try:
                            headers = zf.headers
                            if hasattr(headers, 'files_info'):
                                for fh in headers.files_info:
                                    if hasattr(fh, 'filename'):
                                        fn = fh.filename
                                        file_info_map[fn] = {'uncompressed': fh.uncompressed, 'm_time': fh.m_time}
                        except Exception:
                            pass
                    dir_mtime_map = {}
                    dir_size_map = {}
                    for full_fn, fi in file_info_map.items():
                        if full_fn.startswith(prefix) and full_fn != prefix:
                            rest = full_fn[len(prefix):]
                            if '/' in rest:
                                dir_part = '/'.join(rest.split('/')[:-1])
                                # 递归累加到所有上级目录
                                if dir_part:
                                    parts_list = dir_part.split('/')
                                    m_time = fi.get('m_time', None) if isinstance(fi, dict) else None
                                    for i in range(len(parts_list)):
                                        ancestor = '/'.join(parts_list[:i+1])
                                        if ancestor and ancestor not in dir_mtime_map:
                                            if m_time:
                                                try:
                                                    dir_mtime_map[ancestor] = datetime.fromtimestamp(m_time).strftime('%Y-%m-%d %H:%M')
                                                except Exception:
                                                    dir_mtime_map[ancestor] = '-'
                                            else:
                                                dir_mtime_map[ancestor] = '-'
                                    # 累加文件大小到目录
                                    uncompressed_val = fi.get('uncompressed', None) if isinstance(fi, dict) else None
                                    if isinstance(uncompressed_val, (list, tuple)):
                                        size_value = uncompressed_val[0] if uncompressed_val else 0
                                    else:
                                        size_value = uncompressed_val or 0
                                    if size_value:
                                        for i in range(len(parts_list)):
                                            ancestor = '/'.join(parts_list[:i+1])
                                            dir_size_map[ancestor] = dir_size_map.get(ancestor, 0) + size_value
                    for n in names:
                        rest = n[len(prefix):]
                        rest_parts = rest.split('/')
                        name = decode_archive_name(rest_parts[0])
                        if name in seen:
                            continue
                        seen.add(name)
                        is_dir = (len(rest_parts) > 1) or n.endswith('/')
                        item_inner = '/'.join([inner_path, name]).lstrip('/') if inner_path else name
                        item_path = virtual_prefix + item_inner
                        size = '-'
                        mtime = '-'
                        can_expand_item = is_dir
                        if is_dir:
                            if name in dir_mtime_map:
                                mtime = dir_mtime_map[name]
                            if name in dir_size_map:
                                size = format_file_size(dir_size_map[name])
                            elif size == '-':
                                size = '0 B'
                        else:
                            matched_info = None
                            for full_fn, fi in file_info_map.items():
                                fn_rest = full_fn[len(prefix):] if prefix else full_fn
                                if fn_rest == rest or fn_rest == name:
                                    matched_info = fi
                                    break
                            if matched_info:
                                uncompressed = matched_info.get('uncompressed', None) if isinstance(matched_info, dict) else None
                                if uncompressed and uncompressed[0]:
                                    size = format_file_size(uncompressed[0])
                                m_time = matched_info.get('m_time', None) if isinstance(matched_info, dict) else None
                                if m_time:
                                    try:
                                        mtime = datetime.fromtimestamp(m_time).strftime('%Y-%m-%d %H:%M')
                                    except Exception:
                                        mtime = '-'
                            if size == '-':
                                try:
                                    headers = zf.headers
                                    for fh in headers.files_info:
                                        if hasattr(fh, 'filename') and (fh.filename == rest or fh.filename == name):
                                            if hasattr(fh, 'uncompressed') and fh.uncompressed:
                                                size = format_file_size(fh.uncompressed[0]) if fh.uncompressed[0] else '-'
                                            if hasattr(fh, 'm_time') and fh.m_time:
                                                try:
                                                    mtime = datetime.fromtimestamp(fh.m_time).strftime('%Y-%m-%d %H:%M')
                                                except Exception:
                                                    mtime = '-'
                                            break
                                except Exception:
                                    pass
                        file_ext = os.path.splitext(name)[1].lower()
                        if file_ext in archive_exts or file_ext in image_exts:
                            can_expand_item = True
                        is_archive_item = file_ext in archive_exts
                        is_image_item = file_ext in image_exts
                        items.append({
                            'name': name, 'path': item_path, 'is_dir': is_dir,
                            'size': size, 'mtime': mtime, 'is_archive': is_archive_item,
                            'is_image': is_image_item, 'can_expand': can_expand_item,
                            'can_write': can_write
                        })
            except Exception as e:
                return jsonify({'error': f'读取7z失败: {str(e)}'}), 500

        elif ext == '.rar' and HAS_RARFILE:
            try:
                with rarfile.RarFile(current_archive_path) as rf:
                    # 关键修复:使用 original_inner_path 进行内部查找
                    prefix = (original_inner_path + '/') if original_inner_path else ''
                    infolist = rf.infolist()
                    # Use decode_archive_name for robust decoding; avoid ad-hoc encode/decode heuristics
                    use_gbk = False
                    names = [n for n in rf.namelist() if n.startswith(prefix) and n != prefix]
                    seen = set()
                    dir_mtime_map = {}
                    dir_size_map = {}
                    for rarinfo in infolist:
                        rar_fn = rarinfo.filename
                        decoded_fn = decode_archive_name(rar_fn)
                        if '/' in decoded_fn:
                            rest = decoded_fn[len(prefix):] if prefix else decoded_fn
                            dir_part = '/'.join(rest.split('/')[:-1])
                            top_dir = dir_part.split('/')[0] if dir_part else ''
                            if top_dir and top_dir not in dir_mtime_map:
                                try:
                                    dir_mtime_map[top_dir] = datetime(*rarinfo.date_time).strftime('%Y-%m-%d %H:%M')
                                except Exception:
                                    dir_mtime_map[top_dir] = '-'
                            if not rarinfo.is_dir():
                                dir_size_map[top_dir] = dir_size_map.get(top_dir, 0) + rarinfo.file_size
                    for n in names:
                        rest = n[len(prefix):]
                        rest_parts = rest.split('/')
                        name = rest_parts[0]
                        # Decode entry name robustly
                        name = decode_archive_name(name)
                        if name in seen:
                            continue
                        seen.add(name)
                        is_dir = (len(rest_parts) > 1) or n.endswith('/')
                        # 关键修复:正确使用 inner_path 构建 item 路径
                        item_inner = '/'.join([inner_path, name]).lstrip('/') if inner_path else name
                        item_path = virtual_prefix + item_inner
                        size = '-'
                        mtime = '-'
                        can_expand_item = is_dir
                        if is_dir:
                            if name in dir_mtime_map:
                                mtime = dir_mtime_map[name]
                            if name in dir_size_map:
                                size = format_file_size(dir_size_map[name])
                        else:
                            try:
                                for rarinfo in infolist:
                                    rar_fn = rarinfo.filename
                                    rar_rest = rar_fn[len(prefix):] if prefix else rar_fn
                                    display_name = decode_archive_name(rar_rest) if use_gbk else rar_rest
                                    if (rar_rest == rest or display_name == rest or rar_rest == name or display_name == name) and not rarinfo.is_dir():
                                        size = format_file_size(rarinfo.file_size)
                                        try:
                                            mtime = datetime(*rarinfo.date_time).strftime('%Y-%m-%d %H:%M')
                                        except Exception:
                                            mtime = '-'
                                        break
                            except Exception:
                                pass
                        file_ext = os.path.splitext(name)[1].lower()
                        if file_ext in archive_exts or file_ext in image_exts:
                            can_expand_item = True
                        is_archive_item = file_ext in archive_exts
                        is_image_item = file_ext in image_exts
                        items.append({
                            'name': name, 'path': item_path, 'is_dir': is_dir,
                            'size': size, 'mtime': mtime, 'is_archive': is_archive_item,
                            'is_image': is_image_item, 'can_expand': can_expand_item,
                            'can_write': can_write
                        })
            except Exception as e:
                return jsonify({'error': f'读取RAR失败: {str(e)}'}), 500

        elif ext in ('.iso', '.img'):
            # 统一处理 ISO/IMG:pycdlib 仅处理 .iso(ISO 9660),.img 直接用 pytsk3(FAT/NTFS)
            # 与 list_virtual_directory 保持一致:IMG 文件通常是原始磁盘镜像,不是 ISO 9660 格式
            _iso_parsed = False
            if HAS_PYCDLIB and _is_iso9660_file(current_archive_path):
                try:
                    iso = pycdlib.PyCdlib()
                    iso.open(current_archive_path)
                    use_rr = iso.has_rock_ridge()
                    use_joliet = iso.has_joliet()

                    # 关键修复:根据 current_archive_path 是否被修改以及 folder_parts 来判断 iso_inner_path
                    # 如果 current_archive_path 是原始路径且 original_inner_path 非空(folder_parts 为空),使用 original_inner_path
                    # 如果 current_archive_path 已被修改(提取后的文件),iso_inner_path 应该为空
                    # 但如果 folder_parts 非空,original_inner_path 保存了文件夹路径,需要在虚拟路径前缀中体现
                    original_archive_path = os.path.join(user_dir, parts[0])
                    # 只要调用者已经通过 pre_extracted_path 提供了当前处理的归档(说明经过了从外到内的提取)
                    # 就应该从当前 current_archive_path 的根目录开始浏览;inner_path 只反映
                    # 在当前归档内部的子目录位置(由 folder_parts / original_inner_path 决定)
                    if pre_extracted_path and not folder_parts:
                        iso_inner_path = ''
                    elif original_inner_path:
                        # original_inner_path 非空,表示需要浏览 ISO 的特定子目录
                        iso_inner_path = original_inner_path
                    else:
                        iso_inner_path = ''
                    iso_start_path = '/' + iso_inner_path if iso_inner_path else '/'
                    # 使用 ISO9660 主视图遍历(兼容性最好,始终有目录/文件列表)
                    # Rock Ridge / Joliet 仅用于 stat 和文件读取的长文件名支持
                    walk_kwargs = {'iso_path': iso_start_path}

                    def iso_stat(path_str):
                        """尝试用多种方式获取 ISO 文件 stat"""
                        try:
                            return iso.stat(rr_path=path_str)
                        except Exception:
                            pass
                        try:
                            return iso.stat(joliet_path=path_str)
                        except Exception:
                            pass
                        try:
                            return iso.stat(iso_path=path_str)
                        except Exception:
                            pass
                        # 最后尝试读取文件内容获取大小
                        try:
                            out_fp = io.BytesIO()
                            iso.get_file_from_iso_fp(iso_path=path_str.lstrip('/'), outfp=out_fp)
                            return type('StatResult', (), {'size': len(out_fp.getvalue()), 'mtime': os.path.getmtime(current_archive_path)})()
                        except Exception:
                            pass
                        try:
                            out_fp = io.BytesIO()
                            iso.get_file_from_iso_fp(rr_path=path_str.lstrip('/'), outfp=out_fp)
                            return type('StatResult', (), {'size': len(out_fp.getvalue()), 'mtime': os.path.getmtime(current_archive_path)})()
                        except Exception:
                            pass
                        try:
                            out_fp = io.BytesIO()
                            iso.get_file_from_iso_fp(joliet_path=path_str.lstrip('/'), outfp=out_fp)
                            return type('StatResult', (), {'size': len(out_fp.getvalue()), 'mtime': os.path.getmtime(current_archive_path)})()
                        except Exception:
                            pass
                        return None

                    def iso_walk(path_str):
                        if use_rr:
                            return iso.walk(rr_path=path_str)
                        if use_joliet:
                            return iso.walk(joliet_path=path_str)
                        return iso.walk(iso_path=path_str)

                    for p, dirs, files in iso.walk(**walk_kwargs):
                        start = '/' + iso_inner_path if iso_inner_path else '/'
                        if p.rstrip('/') == start.rstrip('/'):
                            for d in dirs:
                                # 去掉 ISO 版本后缀(目录一般没有 ;1,但统一处理更稳妥)
                                d_clean = d[:-2] if d.lower().endswith(';1') else d
                                item_inner = '/'.join([inner_path, d_clean]).lstrip('/') if inner_path else d_clean
                                item_path = virtual_prefix + item_inner
                                dir_mtime = '-'
                                dir_size = '-'
                                try:
                                    dir_iso_fp = p.rstrip('/') + '/' + d
                                    dir_stat = iso_stat(dir_iso_fp)
                                    if dir_stat:
                                        if hasattr(dir_stat, 'size') and dir_stat.size:
                                            dir_size = format_file_size(dir_stat.size)
                                        else:
                                            total_size = 0
                                            try:
                                                for sub_p, _, sub_files in iso_walk(dir_iso_fp):
                                                    for sub_f in sub_files:
                                                        sub_fp = sub_p.rstrip('/') + '/' + sub_f
                                                        sub_stat = iso_stat(sub_fp)
                                                        if sub_stat and hasattr(sub_stat, 'size') and sub_stat.size:
                                                            total_size += sub_stat.size
                                            except Exception:
                                                total_size = 0
                                            if total_size:
                                                dir_size = format_file_size(total_size)
                                        if hasattr(dir_stat, 'mtime') and dir_stat.mtime is not None:
                                            try:
                                                dir_mtime = datetime.fromtimestamp(dir_stat.mtime).strftime('%Y-%m-%d %H:%M')
                                            except Exception:
                                                pass
                                except Exception:
                                    pass
                                if dir_mtime == '-':
                                    dir_mtime = _get_iso_fallback_mtime(dir_mtime, current_archive_path)
                                file_ext = os.path.splitext(d_clean)[1].lower()
                                can_expand_d = file_ext in archive_exts or file_ext in image_exts
                                if not can_expand_d:
                                    can_expand_d = True
                                is_archive_d = file_ext in archive_exts
                                is_image_d = file_ext in image_exts
                                items.append({
                                    'name': d_clean, 'path': item_path, 'is_dir': True,
                                    'size': dir_size if dir_size != '-' else '0 B',
                                    'mtime': _get_iso_fallback_mtime(dir_mtime, current_archive_path),
                                    'is_archive': is_archive_d,
                                    'is_image': is_image_d, 'can_expand': can_expand_d,
                                    'can_write': can_write
                                })
                            for f in files:
                                # 去掉 ISO9660 的版本号后缀 ;1(显示长/干净文件名)
                                f_clean = f[:-2] if f.lower().endswith(';1') else f
                                item_inner = '/'.join([inner_path, f_clean]).lstrip('/') if inner_path else f_clean
                                item_path = virtual_prefix + item_inner
                                size = '-'
                                f_mtime = '-'
                                try:
                                    iso_fp = p.rstrip('/') + '/' + f  # 内部访问仍使用 ISO 原始文件名(含 ;1)
                                    stat = iso_stat(iso_fp)
                                    if stat:
                                        if hasattr(stat, 'size') and stat.size:
                                            size = format_file_size(stat.size)
                                        elif hasattr(stat, 'size'):
                                            size = '0 B'
                                        if hasattr(stat, 'mtime') and stat.mtime is not None:
                                            try:
                                                f_mtime = datetime.fromtimestamp(stat.mtime).strftime('%Y-%m-%d %H:%M')
                                            except Exception:
                                                pass
                                except Exception:
                                    pass
                                if size == '-':
                                    try:
                                        iso_fp = p.rstrip('/') + '/' + f
                                        out_fp = io.BytesIO()
                                        for kwargs in [
                                            {'iso_path': iso_fp.lstrip('/')},
                                            {'rr_path': iso_fp.lstrip('/')},
                                            {'joliet_path': iso_fp.lstrip('/')},
                                        ]:
                                            try:
                                                iso.get_file_from_iso_fp(outfp=out_fp, **kwargs)
                                                fdata = out_fp.getvalue()
                                                size = format_file_size(len(fdata)) if fdata else '0 B'
                                                break
                                            except Exception:
                                                out_fp = io.BytesIO()
                                                continue
                                    except Exception:
                                        pass
                                if size == '-':
                                    size = '0 B'
                                f_mtime = _get_iso_fallback_mtime(f_mtime, current_archive_path)
                                file_ext = os.path.splitext(f_clean)[1].lower()
                                can_expand = file_ext in archive_exts or file_ext in image_exts
                                is_archive_f = file_ext in archive_exts
                                is_image_f = file_ext in image_exts
                                items.append({
                                    'name': f_clean, 'path': item_path, 'is_dir': False,
                                    'size': size, 'mtime': f_mtime, 'is_archive': is_archive_f,
                                    'is_image': is_image_f, 'can_expand': can_expand,
                                    'can_write': can_write
                                })
                        break
                    iso.close()
                    _iso_parsed = True
                except Exception as _e:
                    print(f'!!! ISO BRANCH EXC: {_e!r}', flush=True)
                    pass

        if ext in ('.iso', '.img') and not _iso_parsed and HAS_PYTSK3:
            # pytsk3 回退:当 pycdlib 无法读取时(如 FAT/NTFS 文件系统的镜像、含分区表的整盘镜像)
            archive_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.7z', '.rar'}
            image_exts = {'.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'}
            _iso_parsed = False
            parsed = False
            try:
                img = pytsk3.Img_Info(current_archive_path)
                fs = None
                try:
                    fs = pytsk3.FS_Info(img)
                except Exception:
                    fs = None
                if fs is None:
                    # 尝试分区表(整盘镜像)
                    try:
                        vs = pytsk3.Volume_Info(img)
                        for part in vs:
                            if part.len <= 0:
                                continue
                            try:
                                fs = pytsk3.FS_Info(img, offset=part.start * 512)
                                break
                            except Exception:
                                fs = None
                    except Exception:
                        fs = None
                if fs is not None:
                    walk_path = '/' + original_inner_path if original_inner_path else '/'
                    dirobj = None
                    # 尝试多种路径变体打开目录
                    path_variants = [walk_path]
                    if original_inner_path:
                        # 添加大小写变体
                        path_variants.append('/' + original_inner_path.upper())
                        path_variants.append('/' + original_inner_path.lower())
                        # 如果路径包含子目录,尝试递归查找
                        if '/' in original_inner_path.strip('/'):
                            # 从根目录开始递归查找目标目录
                            def _find_dir_recursive(fs_obj, target_path):
                                """递归搜索目录树查找目标目录,通过路径后缀匹配"""
                                target_parts = [p.lower() for p in target_path.strip('/').split('/') if p]
                                if not target_parts:
                                    try:
                                        return fs_obj.open_dir(path='/')
                                    except Exception:
                                        return None
                                # BFS搜索所有目录
                                dirs_to_check = [('/')]
                                while dirs_to_check:
                                    current_path = dirs_to_check.pop(0)
                                    try:
                                        dir_obj = fs_obj.open_dir(path=current_path)
                                    except Exception:
                                        continue
                                    for entry in dir_obj:
                                        try:
                                            en = entry.info.name.name
                                            enm = en.decode('utf-8', errors='replace') if isinstance(en, bytes) else str(en)
                                        except Exception:
                                            continue
                                        if not enm or enm in ('.', '..'):
                                            continue
                                        if enm.startswith('$') and enm.upper() in ('$FAT1', '$FAT2', '$FAT12', '$MBR', '$ORPHANFILES'):
                                            continue
                                        try:
                                            if hasattr(entry.info, 'meta') and entry.info.meta:
                                                if entry.info.meta.type == pytsk3.TSK_FS_META_TYPE_DIR:
                                                    sub_path = current_path.rstrip('/') + '/' + enm
                                                    # 构建当前目录的路径部分
                                                    sub_parts = [p.lower() for p in sub_path.strip('/').split('/') if p]
                                                    # 检查路径后缀匹配
                                                    if len(sub_parts) >= len(target_parts):
                                                        match = True
                                                        offset = len(sub_parts) - len(target_parts)
                                                        for i, tp in enumerate(target_parts):
                                                            if sub_parts[offset + i] != tp:
                                                                match = False
                                                                break
                                                        if match:
                                                            return fs_obj.open_dir(path=sub_path)
                                                    dirs_to_check.append(sub_path)
                                        except Exception:
                                            pass
                                return None
                            # 尝试递归查找目录
                            dirobj = _find_dir_recursive(fs, original_inner_path)
                    # 如果递归查找也失败,尝试直接打开各种路径变体
                    if dirobj is None:
                        for pv in path_variants:
                            try:
                                dirobj = fs.open_dir(path=pv)
                                if dirobj is not None:
                                    break
                            except Exception:
                                dirobj = None
                    if dirobj is not None:
                        def _img_entry_name(entry):
                            try:
                                n = entry.info.name.name
                                name = n.decode('utf-8') if isinstance(n, bytes) else n
                            except Exception:
                                return None
                            if name and name in ('.', '..'):
                                return name
                            try:
                                if hasattr(entry.info, 'meta') and entry.info.meta:
                                    meta = entry.info.meta
                                    if hasattr(meta, 'name'):
                                        current = meta.name
                                        visited = set()
                                        while current:
                                            try:
                                                n = current.name
                                                if n in visited:
                                                    break
                                                visited.add(n)
                                                decoded = n.decode('utf-8', errors='replace') if isinstance(n, bytes) else str(n)
                                                if decoded and decoded not in ('.', '..'):
                                                    parts_name = decoded.split('.', 1)
                                                    base = parts_name[0]
                                                    ext_part = parts_name[1] if len(parts_name) > 1 else ''
                                                    if len(base) > 8 or len(ext_part) > 3 or base != base.upper() or ext_part != ext_part.upper():
                                                        return decoded
                                            except Exception:
                                                break
                                            if hasattr(current, 'next') and current.next:
                                                current = current.next
                                            else:
                                                break
                            except Exception:
                                pass
                            return name

                        for entry in dirobj:
                            name = _img_entry_name(entry)
                            if not name or name in ('.', '..'):
                                continue
                            if name.startswith('$') and name.upper() in ('$FAT1', '$FAT2', '$FAT12', '$MBR', '$ORPHANFILES'):
                                continue
                            try:
                                is_dir = entry.info.meta.type == pytsk3.TSK_FS_META_TYPE_DIR
                            except Exception:
                                is_dir = False
                            size = '-'
                            mtime = '-'
                            try:
                                meta = entry.info.meta
                                def get_timestamp(ts):
                                    if ts is None:
                                        return None
                                    if hasattr(ts, 'secs'):
                                        return ts.secs if ts.secs > 0 else None
                                    if isinstance(ts, (int, float)) and ts > 0:
                                        return int(ts)
                                    return None
                                ts = get_timestamp(getattr(meta, 'mtime', None))
                                if ts:
                                    mtime = datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')
                                if mtime == '-':
                                    ts = get_timestamp(getattr(meta, 'crtime', None))
                                    if ts:
                                        mtime = datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')
                                if mtime == '-':
                                    ts = get_timestamp(getattr(meta, 'ctime', None))
                                    if ts:
                                        mtime = datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')
                            except Exception:
                                pass
                            try:
                                meta = entry.info.meta
                                meta_size = getattr(meta, 'size', None)
                                if meta_size is not None and not is_dir:
                                    try:
                                        size = format_file_size(int(meta_size))
                                    except (TypeError, ValueError):
                                        pass
                            except Exception:
                                pass
                            item_path = virtual_prefix + ('/'.join([inner_path, name]).lstrip('/') if inner_path else name)
                            can_expand = is_dir
                            file_ext = os.path.splitext(name)[1].lower()
                            if file_ext in archive_exts or file_ext in image_exts:
                                can_expand = True
                            is_archive_item = file_ext in archive_exts
                            is_image_item = file_ext in image_exts
                            items.append({'name': name, 'path': item_path, 'is_dir': is_dir, 'size': size, 'mtime': mtime, 'is_archive': is_archive_item, 'is_image': is_image_item, 'can_expand': can_expand, 'can_write': True})
                        parsed = True
                        try:
                            dirobj.close()
                        except Exception:
                            pass
                try:
                    if fs is not None:
                        fs.close()
                except Exception:
                    pass
                try:
                    img.close()
                except Exception:
                    pass
            except Exception:
                parsed = False
            if not parsed:
                return jsonify({'items': [], 'unsupported': True})

        return jsonify({'items': items, 'current_path': path})
    finally:
        for d in temp_dirs:
            try:
                if from_userdir_cache or (d.startswith(os.path.join(user_dir, '.nested_cache'))):
                    # user_dir 内部缓存:不立即删除,刷新活动时间,交给后台 GC 线程回收
                    refresh_cache_activity(d)
                else:
                    remove_cache_entry(d)
                    shutil.rmtree(d, ignore_errors=True)
            except Exception:
                pass
def api_dir_tree_with_path(path):
    if 'username' not in session:
        return jsonify({'error': '未登录'}), 401

    # 如果调用者已传入 path,则使用它;否则从 request.args 获取
    if not path:
        path = request.args.get('path', '').strip('/').replace('\\', '/')
    user_dir = ensure_user_upload_dir(session['username'])
    logger.debug(f"api_dir_tree_with_path called: path={path}, user={session.get('username')}")

    # 处理虚拟路径(archive_path::inner/path)
    if '::' in path:
        # 使用 split_virtual_path 统一解析
        archive_rel, inner = split_virtual_path(path)
        archive_rel = archive_rel.strip('/')
        archive_full = os.path.join(user_dir, archive_rel)

        # 安全路径检查
        if not is_safe_path(user_dir, archive_full):
            return jsonify({'error': '路径不安全'}), 403

        # 保存原始虚拟路径前缀,用于 item_path 构建(防止链式解析后 cache 路径泄漏到前端)
        _orig_archive_rel = archive_rel

        # 同时保存一条虚拟链路径,用于后续构建 orig_vp。这个值在重排序/链提取中
        # 始终使用用户可见的归档名(不会变成缓存目录路径)
        _virtual_chain = archive_rel

        # 归档链解析:当 archive_rel 存在且 inner 包含 :: 时,
        # 需要迭代提取归档链中的每一层,直到找到最终的内层归档
        # 关键修复:当 archive_rel 不是物理文件时(如用户在 ISO 内部浏览 IMG),
        # 第一段指向虚拟路径中的文件(如 BOOT/DOS71INS.IMG),真正的物理文件在 inner 中。
        # 此时需要重新排序归档链,找到最外层的物理归档文件作为起点。
        nested_all_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.7z', '.rar', '.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'}
        _chain_extracted = []

        # 只在 archive_full 不存在时,才从 inner 段中寻找物理归档文件
        # 当 archive_full 已存在时,它就是正确的最外层归档(如从 ISO 内部浏览时)
        # 不要因为有同名物理文件就在 inner 中触发不必要的重排序
        archive_exists = archive_full is not None and os.path.exists(archive_full)
        if not archive_exists and '::' in inner:
            _inner_segs = inner.split('::')
            _inner_segs = [s for s in _inner_segs if s]
            _reorder_found = False
            _original_archive_rel = archive_rel  # 保存原始的 archive_rel(如 BOOT/DOS71INS.IMG)
            for _si, _seg in enumerate(_inner_segs):
                _seg_path = os.path.join(user_dir, _seg)
                if os.path.exists(_seg_path):
                    _before = _inner_segs[:_si]
                    if _before:
                        _pre = '/'.join(_before)
                        archive_rel = _pre + '/' + _seg
                        archive_full = os.path.join(user_dir, archive_rel)
                    else:
                        archive_rel = _seg
                        archive_full = _seg_path
                    _after = _inner_segs[_si+1:]
                    # 关键修复:重排序后,把原始的 archive_rel 保留在 inner 中
                    # 例如:BOOT/DOS71INS.IMG::MS-DOS 7.10.iso:: -> MS-DOS 7.10.iso::BOOT/DOS71INS.IMG::
                    inner_parts = [_original_archive_rel] + _after
                    inner = '/'.join(inner_parts) if inner_parts else ''
                    if inner and not inner.endswith('::'):
                        inner += '::'
                    _orig_archive_rel = archive_rel
                    _reorder_found = True
                    # 提取 archive_rel 中 '/' 之前的归档段(它们也是虚拟的,需要从物理归档中逐层提取)
                    if _before:
                        _cur_arc = archive_full
                        _cur_rel = archive_rel
                        _cur_inner = '/'.join(_before)
                        _reorder_extracted = []
                        for _bi, _bseg in enumerate(_before):
                            _bex = _get_archive_ext(_bseg)
                            if _bex in nested_all_exts:
                                _bfn = _bseg.rsplit('/', 1)[-1] if '/' in _bseg else _bseg
                                _btd = _nested_cache_dir(user_dir)
                                _bkey = _nested_cache_key(_cur_arc, _cur_inner)
                                _bsub = os.path.join(_btd, _bkey)
                                os.makedirs(_bsub, exist_ok=True)
                                mark_cache_in_use(_bsub)
                                try:
                                    _bexist = _find_extracted_file(_bsub, _bfn)
                                    if not _bexist:
                                        _extract_archive_item(_cur_arc, _cur_inner, _bsub)
                                        _bexist = _find_extracted_file(_bsub, _bfn)
                                except Exception as _be:
                                    logger.warning(f"重排序提取失败: {_bseg}, error: {_be}")
                                    break
                                if _bexist:
                                    _reorder_extracted.append(_bexist)
                                    _cur_arc = _bexist
                        if _reorder_extracted:
                            archive_full = _reorder_extracted[-1]
                            archive_rel = os.path.relpath(archive_full, user_dir).replace('\\', '/')
                            _orig_archive_rel = archive_rel
                            _chain_extracted.extend(_reorder_extracted)
                    break
            if not _reorder_found:
                return jsonify({'error': '不存在'}), 404

        # 保存最原始的物理归档路径(在重排序/链提取之前),
        # 用于构建正确的虚拟路径前缀,防止缓存路径泄漏到前端
        _virtual_chain = _orig_archive_rel

        if os.path.exists(archive_full) and '::' in inner:
            _current_archive = archive_full
            _current_rel = archive_rel
            _current_inner = inner
            _extracted_files = set()
            _max_iterations = 10
            _iter = 0
            # 跟踪从原始物理归档开始的虚拟链路径,用于构建正确的 orig_vp
            while _iter < _max_iterations and '::' in _current_inner:
                _iter += 1
                _chk = _current_inner.split('::', 1)[0]
                _chk_ext = _get_archive_ext(_chk)
                if _chk_ext not in nested_all_exts:
                    break
                # 从当前归档中提取 _chk
                _chk_filename = _chk
                if '/' in _chk:
                    _ls = _chk.rfind('/')
                    _chk_filename = _chk[_ls+1:]
                _td = _nested_cache_dir(user_dir)
                _key = _nested_cache_key(_current_archive, _chk)
                _subdir = os.path.join(_td, _key)
                os.makedirs(_subdir, exist_ok=True)
                mark_cache_in_use(_subdir)
                try:
                    _existing = _find_extracted_file(_subdir, _chk_filename)
                    if not _existing:
                        _existing = _find_extracted_file(_subdir, _chk)
                    if _existing:
                        refresh_cache_activity(_subdir)
                    else:
                        _extract_archive_item(_current_archive, _chk, _subdir)
                        _existing = _find_extracted_file(_subdir, _chk_filename)
                        if not _existing:
                            _existing = _find_extracted_file(_subdir, _chk)
                        if not _existing:
                            _existing = _find_extracted_file(_subdir, _chk_filename)
                except Exception as _e:
                    logger.warning(f"归档链解析提取失败: {_chk}, error: {_e}")
                    break
                if not _existing:
                    break
                _extracted_files.add(_existing)
                # 更新虚拟链:用"原始虚拟路径::当前提取段"构建正确的前缀
                _virtual_chain = f"{_virtual_chain}::{_chk}"
                _rem = _current_inner.split('::', 1)[1]
                _current_inner = _rem
                _current_archive = _existing
            if _current_archive != archive_full:
                archive_full = _current_archive
                archive_rel = _current_rel
                inner = _current_inner
                _chain_extracted = list(_extracted_files)

        if not os.path.exists(archive_full):
            return jsonify({'error': '不存在'}), 404

        # 检查 inner 是否指向嵌套归档
        inner_check = inner
        if '::' in inner:
            inner_check = inner.split('::', 1)[0]
        remaining_inner = ''
        if '::' in inner:
            remaining_inner = inner.split('::', 1)[1]

        if inner_check:
            # 关键修复:当 inner_check 包含 '/' 时(如 'subdir/xxx.img'),
            # 说明归档文件在虚拟目录中,需要先提取再处理
            inner_parent = ''
            inner_filename = inner_check
            if '/' in inner_check:
                last_slash = inner_check.rfind('/')
                inner_parent = inner_check[:last_slash]
                inner_filename = inner_check[last_slash+1:]

            nested_ext = _get_archive_ext(inner_filename)
            nested_all_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.7z', '.rar', '.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'}
            if nested_ext in nested_all_exts or remaining_inner:
                # 归档/镜像在虚拟路径里:无论是否在子目录,都统一走 nested 处理。
                # inner_parent 有值:归档在外层归档的子目录里,需要先提取;
                # inner_parent 为空:归档在外层归档根目录,直接提取 basename 即可。
                try:
                    extracted, nested_temp = _get_or_extract_nested_inner(
                        user_dir, archive_full, archive_rel, inner_check, inner_filename)
                except Exception as e:
                    logger.warning(f"提取虚拟目录中的归档失败: {inner_check}, error: {e}")
                    return jsonify({'error': f'提取嵌套归档失败: {inner_filename}'}), 500

                extracted_rel = os.path.relpath(extracted, user_dir).replace('\\', '/')
                nested_vp = extracted_rel
                if remaining_inner:
                    nested_vp = nested_vp + '::' + remaining_inner
                # 构建 orig_vp 的 archive 部分:使用用户可见的虚拟链路径
                # _virtual_chain 从 _orig_archive_rel 开始,在链式归档提取过程中
                # 累加了所有经过的归档路径(如 "MS-DOS 7.10.iso::BOOT/DOS71INS.IMG")
                # 没有链式提取时,_virtual_chain 等于 _orig_archive_rel(即物理归档路径)
                _orig_for_vp = _virtual_chain
                orig_vp = f"{_orig_for_vp}::{inner_check}::"
                return _api_dir_tree_nested(nested_vp, user_dir,
                                           pre_extracted_path=extracted,
                                           pre_extracted_temp_dir=nested_temp,
                                           virtual_prefix_override=orig_vp,
                                           from_userdir_cache=True,
                                           _pre_orig_archive_rel=_orig_for_vp)

            ext = _get_archive_ext(archive_full)
            # 方案2补充:最内层文件扩展名不可识别时,按签名纠正格式
            _known_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.tbz',
                   '.7z', '.rar', '.iso', '.img', '.vmdk', '.vhd', '.vhdx',
                   '.qcow2', '.dmg', '.wim'}
            if ext not in _known_exts and _is_iso9660_file(archive_full):
                ext = '.iso'
            items = []

        # 预计算可写性(在虚拟路径各分支中都需要使用)
        _archive_writable = ext in ('.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2')
        _image_writable = ext in ('.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim') and has_image_write_tools()

        # ZIP 支持
        if ext == '.zip':
            archive_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.7z', '.rar'}
            image_exts = {'.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'}
            try:
                with zipfile.ZipFile(archive_full, 'r') as zf:
                    prefix = (inner + '/') if inner else ''
                    all_infos = zf.infolist()
                    all_names = zf.namelist()
                    names = [n for n in all_names if n.startswith(prefix) and n != prefix]
                    seen = set()
                    # 构建目录 mtime 和 size 映射
                    dir_mtime_map = {}
                    dir_size_map = {}
                    for info in all_infos:
                        raw_fn = info.filename
                        decoded_fn = decode_archive_name(raw_fn)
                        if '/' in decoded_fn:
                            rel = decoded_fn[len(prefix):] if prefix else decoded_fn
                            dir_part = '/'.join(rel.split('/')[:-1])
                            # 递归累加到所有上级目录
                            if dir_part:
                                parts_list = dir_part.split('/')
                                for i in range(len(parts_list)):
                                    ancestor = '/'.join(parts_list[:i+1])
                                    if ancestor and ancestor not in dir_mtime_map:
                                        try:
                                            dir_mtime_map[ancestor] = datetime(*info.date_time).strftime('%Y-%m-%d %H:%M')
                                        except Exception:
                                            dir_mtime_map[ancestor] = '-'
                                # 非目录条目才计入大小
                                if not raw_fn.endswith('/'):
                                    for i in range(len(parts_list)):
                                        ancestor = '/'.join(parts_list[:i+1])
                                        dir_size_map[ancestor] = dir_size_map.get(ancestor, 0) + info.file_size
                    for n in names:
                        rest = n[len(prefix):]
                        parts = rest.split('/')
                        name = parts[0]
                        # Decode entry name robustly
                        name = decode_archive_name(name)
                        if name in seen:
                            continue
                        seen.add(name)
                        is_dir = (len(parts) > 1) or n.endswith('/')
                        item_path = f"{_orig_archive_rel}::{('/'.join([inner, name]).lstrip('/') if inner else name)}"
                        size = '-'
                        mtime = '-'
                        can_expand = is_dir
                        if is_dir:
                            if name in dir_mtime_map:
                                mtime = dir_mtime_map[name]
                            if name in dir_size_map:
                                size = format_file_size(dir_size_map[name])
                            elif size == '-':
                                size = '0 B'
                            logger.info(f"ZIP dir item '{name}': size={size}, mtime={mtime}")
                            file_ext = os.path.splitext(name.rstrip('/'))[1].lower()
                            if file_ext in archive_exts or file_ext in image_exts:
                                can_expand = True
                        else:
                            matched = False
                            for info in all_infos:
                                full_name = info.filename
                                fn_rest = full_name[len(prefix):] if prefix else full_name
                                fn_display = decode_archive_name(fn_rest)
                                if fn_rest == rest or fn_display == rest:
                                    size = format_file_size(info.file_size)
                                    try:
                                        mtime = datetime(*info.date_time).strftime('%Y-%m-%d %H:%M')
                                    except Exception:
                                        mtime = '-'
                                    file_ext = os.path.splitext(name)[1].lower()
                                    if file_ext in archive_exts or file_ext in image_exts:
                                        can_expand = True
                                    matched = True
                                    break
                            if not matched:
                                for full_name in all_names:
                                    fn_rest = full_name[len(prefix):] if prefix else full_name
                                    fn_display = decode_archive_name(fn_rest)
                                    if fn_rest == rest or fn_display == rest:
                                        try:
                                            info = zf.getinfo(full_name)
                                            size = format_file_size(info.file_size)
                                            try:
                                                mtime = datetime(*info.date_time).strftime('%Y-%m-%d %H:%M')
                                            except Exception:
                                                mtime = '-'
                                            file_ext = os.path.splitext(name)[1].lower()
                                            if file_ext in archive_exts or file_ext in image_exts:
                                                can_expand = True
                                        except Exception:
                                            pass
                                        break
                        file_ext = os.path.splitext(name)[1].lower()
                        is_archive_item = file_ext in archive_exts
                        is_image_item = file_ext in image_exts
                        items.append({'name': name, 'path': item_path, 'is_dir': is_dir, 'size': size, 'mtime': mtime, 'is_archive': is_archive_item, 'is_image': is_image_item, 'can_expand': can_expand, 'can_write': _archive_writable or _image_writable})
            except Exception as e:
                return jsonify({'error': f'读取 ZIP 失败: {str(e)}'}), 500

        # tar / tar.gz / tgz / bz2 支持 (在 api_dir_tree_with_path 中)
        elif ext in ('.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.tbz'):
            archive_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.7z', '.rar'}
            image_exts = {'.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'}
            try:
                with tarfile.open(archive_full, 'r') as tf:
                    prefix = (inner + '/') if inner else ''
                    members = [m for m in tf.getmembers() if m.name.startswith(prefix) and m.name != prefix]
                    seen = set()
                    # 构建目录mtime/size映射(使用解码后的文件名)
                    dir_mtime_map = {}
                    dir_size_map = {}
                    for m in members:
                        raw_rest = m.name[len(prefix):] if prefix else m.name
                        decoded_rest = decode_archive_name(raw_rest)
                        if '/' in decoded_rest:
                            dir_part = '/'.join(decoded_rest.split('/')[:-1])
                            # 递归累加到所有上级目录
                            if dir_part:
                                parts_list = dir_part.split('/')
                                for i in range(len(parts_list)):
                                    ancestor = '/'.join(parts_list[:i+1])
                                    if ancestor not in dir_mtime_map:
                                        try:
                                            dir_mtime_map[ancestor] = datetime.fromtimestamp(m.mtime).strftime('%Y-%m-%d %H:%M')
                                        except Exception:
                                            dir_mtime_map[ancestor] = '-'
                                if m.isfile():
                                    for i in range(len(parts_list)):
                                        ancestor = '/'.join(parts_list[:i+1])
                                        dir_size_map[ancestor] = dir_size_map.get(ancestor, 0) + m.size
                    for m in members:
                        raw_rest = m.name[len(prefix):]
                        raw_parts = raw_rest.split('/')
                        name = decode_archive_name(raw_parts[0])
                        decoded_parts = decode_archive_name(raw_rest).split('/')
                        if name in seen:
                            continue
                        seen.add(name)
                        is_dir = (len(decoded_parts) > 1) or m.isdir()
                        item_path = f"{_orig_archive_rel}::{('/'.join([inner, name]).lstrip('/') if inner else name)}"
                        size = format_file_size(m.size) if m.isfile() else '-'
                        try:
                            mtime = datetime.fromtimestamp(m.mtime).strftime('%Y-%m-%d %H:%M') if hasattr(m, 'mtime') else '-'
                        except Exception:
                            if is_dir and name in dir_mtime_map:
                                mtime = dir_mtime_map[name]
                            else:
                                mtime = '-'
                        if is_dir:
                            for i in range(len(decoded_parts) - 1, 0, -1):
                                ancestor = '/'.join(decoded_parts[:i+1])
                                if ancestor in dir_size_map:
                                    size = format_file_size(dir_size_map[ancestor])
                                    break
                            if size == '-':
                                size = '0 B'
                        can_expand = is_dir
                        if not is_dir:
                            file_ext = os.path.splitext(name)[1].lower()
                            if file_ext in archive_exts or file_ext in image_exts:
                                can_expand = True
                        # 目录如果是压缩包名也应该可展开
                        if is_dir:
                            file_ext = os.path.splitext(name)[1].lower()
                            if file_ext in archive_exts or file_ext in image_exts:
                                can_expand = True
                        file_ext = os.path.splitext(name)[1].lower()
                        is_archive_item = file_ext in archive_exts
                        is_image_item = file_ext in image_exts
                        items.append({'name': name, 'path': item_path, 'is_dir': is_dir, 'size': size, 'mtime': mtime, 'is_archive': is_archive_item, 'is_image': is_image_item, 'can_expand': can_expand, 'can_write': _archive_writable or _image_writable})
            except Exception as e:
                return jsonify({'error': f'读取 TAR 失败: {str(e)}'}), 500

        # 7z 支持(如果可用)(在 api_dir_tree_with_path 中)
        elif ext == '.7z' and HAS_PY7ZR:
            archive_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.7z', '.rar'}
            image_exts = {'.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'}
            try:
                with py7zr.SevenZipFile(archive_full, mode='r') as zf:
                    prefix = (inner + '/') if inner else ''
                    names = [n for n in zf.getnames() if n.startswith(prefix) and n != prefix]
                    seen = set()
                    # 构建文件信息查找表
                    file_info_map = {}
                    try:
                        archive_info = zf.archive_info
                        if hasattr(archive_info, 'file_info_list'):
                            for fi in archive_info.file_info_list:
                                fn = fi.get('filename', '')
                                file_info_map[fn] = fi
                    except Exception:
                        pass
                    # 构建目录 mtime 和 size 映射
                    dir_mtime_map = {}
                    dir_size_map = {}
                    for full_fn, fi in file_info_map.items():
                        if full_fn.startswith(prefix) and full_fn != prefix:
                            rest = full_fn[len(prefix):]
                            if '/' in rest:
                                dir_part = '/'.join(rest.split('/')[:-1])
                                # 递归累加到所有上级目录
                                if dir_part:
                                    parts_list = dir_part.split('/')
                                    m_time = fi.get('m_time', None) if isinstance(fi, dict) else None
                                    for i in range(len(parts_list)):
                                        ancestor = '/'.join(parts_list[:i+1])
                                        if ancestor and ancestor not in dir_mtime_map:
                                            if m_time:
                                                try:
                                                    dir_mtime_map[ancestor] = datetime.fromtimestamp(m_time).strftime('%Y-%m-%d %H:%M')
                                                except Exception:
                                                    dir_mtime_map[ancestor] = '-'
                                            else:
                                                dir_mtime_map[ancestor] = '-'
                                    # 累加文件大小到目录
                                    uncompressed_val = fi.get('uncompressed', None) if isinstance(fi, dict) else None
                                    if isinstance(uncompressed_val, (list, tuple)):
                                        size_value = uncompressed_val[0] if uncompressed_val else 0
                                    else:
                                        size_value = uncompressed_val or 0
                                    if size_value:
                                        for i in range(len(parts_list)):
                                            ancestor = '/'.join(parts_list[:i+1])
                                            dir_size_map[ancestor] = dir_size_map.get(ancestor, 0) + size_value
                    for n in names:
                        rest = n[len(prefix):]
                        parts = rest.split('/')
                        name = decode_archive_name(parts[0])
                        if name in seen:
                            continue
                        seen.add(name)
                        is_dir = (len(parts) > 1) or n.endswith('/')
                        item_path = f"{_orig_archive_rel}::{('/'.join([inner, name]).lstrip('/') if inner else name)}"
                        size = '-'
                        mtime = '-'
                        can_expand = is_dir
                        if is_dir:
                            if name in dir_mtime_map:
                                mtime = dir_mtime_map[name]
                            if name in dir_size_map:
                                size = format_file_size(dir_size_map[name])
                            elif size == '-':
                                size = '0 B'
                            file_ext = os.path.splitext(name)[1].lower()
                            if file_ext in archive_exts or file_ext in image_exts:
                                can_expand = True
                        else:
                            matched_rest = rest
                            if rest in file_info_map:
                                fi = file_info_map[rest]
                                uncompressed = fi.get('uncompressed', None) if isinstance(fi, dict) else None
                                if uncompressed and uncompressed[0]:
                                    size = format_file_size(uncompressed[0])
                                m_time = fi.get('m_time', None) if isinstance(fi, dict) else None
                                if m_time:
                                    try:
                                        mtime = datetime.fromtimestamp(m_time).strftime('%Y-%m-%d %H:%M')
                                    except Exception:
                                        mtime = '-'
                            if size == '-' and name in file_info_map:
                                fi = file_info_map[name]
                                uncompressed = fi.get('uncompressed', None) if isinstance(fi, dict) else None
                                if uncompressed and uncompressed[0]:
                                    size = format_file_size(uncompressed[0])
                                m_time = fi.get('m_time', None) if isinstance(fi, dict) else None
                                if m_time:
                                    try:
                                        mtime = datetime.fromtimestamp(m_time).strftime('%Y-%m-%d %H:%M')
                                    except Exception:
                                        mtime = '-'
                            if size == '-':
                                try:
                                    headers = zf.headers
                                    for fh in headers.files_info:
                                        if hasattr(fh, 'filename') and (fh.filename == rest or fh.filename == name):
                                            if hasattr(fh, 'uncompressed') and fh.uncompressed:
                                                size = format_file_size(fh.uncompressed[0]) if fh.uncompressed[0] else '-'
                                            if hasattr(fh, 'm_time') and fh.m_time:
                                                try:
                                                    mtime = datetime.fromtimestamp(fh.m_time).strftime('%Y-%m-%d %H:%M')
                                                except Exception:
                                                    mtime = '-'
                                            break
                                except Exception:
                                    pass
                            file_ext = os.path.splitext(name)[1].lower()
                            if file_ext in archive_exts or file_ext in image_exts:
                                can_expand = True
                        file_ext = os.path.splitext(name)[1].lower()
                        is_archive_item = file_ext in archive_exts
                        is_image_item = file_ext in image_exts
                        items.append({'name': name, 'path': item_path, 'is_dir': is_dir, 'size': size, 'mtime': mtime, 'is_archive': is_archive_item, 'is_image': is_image_item, 'can_expand': can_expand, 'can_write': _archive_writable or _image_writable})
            except Exception as e:
                return jsonify({'error': f'读取 7z 失败: {str(e)}'}), 500

        # RAR 支持(如果可用)- api_dir_tree_with_path
        elif ext == '.rar' and HAS_RARFILE:
            archive_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.7z', '.rar'}
            image_exts = {'.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'}
            try:
                with rarfile.RarFile(archive_full) as rf:
                    prefix = (inner + '/') if inner else ''
                    infolist = rf.infolist()
                    # 增强编码检测
                    use_gbk = False
                    try:
                        for info in infolist:
                            fn = info.filename
                            if isinstance(fn, str):
                                try:
                                    fn.encode('utf-8')
                                except UnicodeEncodeError:
                                    use_gbk = True
                                    break
                                if any(ord(c) > 127 and ord(c) < 256 for c in fn):
                                    try:
                                        fn.encode('latin-1').decode('gbk')
                                        use_gbk = True
                                        break
                                    except Exception:
                                        pass
                    except Exception:
                        pass
                    names = [n for n in rf.namelist() if n.startswith(prefix) and n != prefix]
                    seen = set()
                    # 构建目录 mtime 和 size 映射
                    dir_mtime_map = {}
                    dir_size_map = {}
                    for rarinfo in infolist:
                        rar_fn = rarinfo.filename
                        decoded_fn = decode_archive_name(rar_fn)
                        if '/' in decoded_fn:
                            rest = decoded_fn[len(prefix):] if prefix else decoded_fn
                            dir_part = '/'.join(rest.split('/')[:-1])
                            # 递归累加到所有上级目录
                            if dir_part:
                                parts_list = dir_part.split('/')
                                for i in range(len(parts_list)):
                                    ancestor = '/'.join(parts_list[:i+1])
                                    if ancestor and ancestor not in dir_mtime_map:
                                        try:
                                            dir_mtime_map[ancestor] = datetime(*rarinfo.date_time).strftime('%Y-%m-%d %H:%M')
                                        except Exception:
                                            dir_mtime_map[ancestor] = '-'
                                if not rarinfo.is_dir():
                                    for i in range(len(parts_list)):
                                        ancestor = '/'.join(parts_list[:i+1])
                                        dir_size_map[ancestor] = dir_size_map.get(ancestor, 0) + rarinfo.file_size
                    for n in names:
                        rest = n[len(prefix):]
                        parts = rest.split('/')
                        name = parts[0]
                        # Decode entry name robustly
                        name = decode_archive_name(name)
                        if name in seen:
                            continue
                        seen.add(name)
                        is_dir = (len(parts) > 1) or n.endswith('/')
                        item_path = f"{_orig_archive_rel}::{('/'.join([inner, name]).lstrip('/') if inner else name)}"
                        size = '-'
                        mtime = '-'
                        can_expand = is_dir
                        if is_dir:
                            if name in dir_mtime_map:
                                mtime = dir_mtime_map[name]
                            if name in dir_size_map:
                                size = format_file_size(dir_size_map[name])
                            elif size == '-':
                                size = '0 B'
                            file_ext = os.path.splitext(name)[1].lower()
                            if file_ext in archive_exts or file_ext in image_exts:
                                can_expand = True
                        else:
                            try:
                                for rarinfo in infolist:
                                    rar_fn = rarinfo.filename
                                    rar_rest = rar_fn[len(prefix):] if prefix else rar_fn
                                    display_name = decode_archive_name(rar_rest)
                                    if (rar_rest == rest or display_name == rest or rar_rest == name or display_name == name) and not rarinfo.is_dir():
                                        size = format_file_size(rarinfo.file_size)
                                        try:
                                            mtime = datetime(*rarinfo.date_time).strftime('%Y-%m-%d %H:%M')
                                        except Exception:
                                            mtime = '-'
                                        break
                            except Exception:
                                pass
                            file_ext = os.path.splitext(name)[1].lower()
                            if file_ext in archive_exts or file_ext in image_exts:
                                can_expand = True
                        file_ext = os.path.splitext(name)[1].lower()
                        is_archive_item = file_ext in archive_exts
                        is_image_item = file_ext in image_exts
                        items.append({'name': name, 'path': item_path, 'is_dir': is_dir, 'size': size, 'mtime': mtime, 'is_archive': is_archive_item, 'is_image': is_image_item, 'can_expand': can_expand, 'can_write': _archive_writable or _image_writable})
            except Exception as e:
                return jsonify({'error': f'读取 RAR 失败: {str(e)}'}), 500

        else:
            # 镜像或未支持的格式
            image_exts = {'.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'}
            archive_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.7z', '.rar'}
            if ext in image_exts:
                # 按二进制签名判断真实格式,而不是按扩展名。
                _iso_ok = False
                if HAS_PYCDLIB and _is_iso9660_file(archive_full):
                    try:
                        iso = pycdlib.PyCdlib()
                        iso.open(archive_full)
                        use_rr = iso.has_rock_ridge()
                        use_joliet = iso.has_joliet()
                        if use_rr:
                            walk_kwargs = {'rr_path': '/' + inner if inner else '/'}
                        elif use_joliet:
                            walk_kwargs = {'joliet_path': '/' + inner if inner else '/'}
                        else:
                            walk_kwargs = {'iso_path': '/' + inner if inner else '/'}

                        def iso_stat(fp):
                            for kwargs in [{'rr_path': fp}, {'joliet_path': fp}, {'iso_path': fp}]:
                                try:
                                    stat_res = iso.stat(**kwargs)
                                    s = getattr(stat_res, 'size', None) or getattr(stat_res, 'st_size', None)
                                    t = getattr(stat_res, 'mtime', None) or getattr(stat_res, 'st_mtime', None)
                                    if s is not None or t is not None:
                                        return (s, t)
                                except Exception:
                                    pass
                            return None

                        def iso_walk_dir(path_str):
                            if use_rr:
                                return iso.walk(rr_path=path_str)
                            if use_joliet:
                                return iso.walk(joliet_path=path_str)
                            return iso.walk(iso_path=path_str)

                        for p, dirs, files in iso.walk(**walk_kwargs):
                            start = '/' + inner if inner else '/'
                            if p.rstrip('/') == start.rstrip('/'):
                                for d in dirs:
                                    # 去掉 ISO ;1 版本号
                                    d_clean = d[:-2] if d.lower().endswith(';1') else d
                                    item_path = f"{_orig_archive_rel}::{('/'.join([inner, d_clean]).lstrip('/') if inner else d_clean)}"
                                    dir_mtime = '-'
                                    dir_size = '-'
                                    try:
                                        dir_iso_fp = p.rstrip('/') + '/' + d
                                        stat_result = iso_stat(dir_iso_fp)
                                        if stat_result:
                                            dir_s, dir_t = stat_result
                                            if dir_t:
                                                dir_mtime = datetime.fromtimestamp(dir_t).strftime('%Y-%m-%d %H:%M')
                                            if dir_s:
                                                dir_size = format_file_size(dir_s)
                                            else:
                                                total_size = 0
                                                for sub_p, _, sub_files in iso_walk_dir(dir_iso_fp):
                                                    for sub_f in sub_files:
                                                        sub_fp = sub_p.rstrip('/') + '/' + sub_f
                                                        sub_stat = iso_stat(sub_fp)
                                                        if sub_stat and sub_stat[0]:
                                                            total_size += sub_stat[0]
                                                if total_size:
                                                    dir_size = format_file_size(total_size)
                                    except Exception:
                                        pass
                                    if dir_mtime == '-':
                                        try:
                                            dir_mtime = datetime.fromtimestamp(os.path.getmtime(archive_full)).strftime('%Y-%m-%d %H:%M')
                                        except Exception:
                                            pass
                                    file_ext = os.path.splitext(d_clean)[1].lower()
                                    can_expand_d = file_ext in archive_exts or file_ext in image_exts
                                    if not can_expand_d:
                                        can_expand_d = True
                                    is_archive_d = file_ext in archive_exts
                                    is_image_d = file_ext in image_exts
                                    items.append({'name': d_clean, 'path': item_path, 'is_dir': True, 'size': dir_size if dir_size != '-' else '0 B', 'mtime': dir_mtime, 'is_archive': is_archive_d, 'is_image': is_image_d, 'can_expand': can_expand_d, 'can_write': _archive_writable or _image_writable})
                                for f in files:
                                    f_clean = f[:-2] if f.lower().endswith(';1') else f
                                    item_path = f"{_orig_archive_rel}::{('/'.join([inner, f_clean]).lstrip('/') if inner else f_clean)}"
                                    size = '-'
                                    f_mtime = '-'
                                    try:
                                        iso_fp = p.rstrip('/') + '/' + f
                                        stat_result = iso_stat(iso_fp)
                                        if stat_result:
                                            f_s, f_t = stat_result
                                            if f_s:
                                                size = format_file_size(f_s)
                                            if f_t:
                                                f_mtime = datetime.fromtimestamp(f_t).strftime('%Y-%m-%d %H:%M')
                                        elif size == '-':
                                            try:
                                                bio = iso.get_file_from_iso_fp(iso_fp)
                                                size_bytes = bio.getbuffer().nbytes
                                                if size_bytes > 0:
                                                    size = format_file_size(size_bytes)
                                            except Exception:
                                                pass
                                    except Exception:
                                        pass
                                    if f_mtime == '-':
                                        try:
                                            f_mtime = datetime.fromtimestamp(os.path.getmtime(archive_full)).strftime('%Y-%m-%d %H:%M')
                                        except Exception:
                                            pass
                                    file_ext = os.path.splitext(f_clean)[1].lower()
                                    can_expand = file_ext in archive_exts or file_ext in image_exts
                                    is_archive_f = file_ext in archive_exts
                                    is_image_f = file_ext in image_exts
                                    items.append({'name': f_clean, 'path': item_path, 'is_dir': False, 'size': size, 'mtime': f_mtime, 'is_archive': is_archive_f, 'is_image': is_image_f, 'can_expand': can_expand, 'can_write': _archive_writable or _image_writable})
                                break
                        iso.close()
                        _iso_ok = True
                    except Exception:
                        _iso_ok = False
                        # 不直接 500,回退到 pytsk3
                if _iso_ok:
                    pass
                elif HAS_PYTSK3:
                    parsed = False
                    try:
                        img = pytsk3.Img_Info(archive_full)
                        fs = None
                        try:
                            fs = pytsk3.FS_Info(img)
                        except Exception:
                            fs = None
                        # 整盘镜像(含 MBR/分区表):尝试第一个分区
                        if fs is None:
                            try:
                                vs = pytsk3.Volume_Info(img)
                                for part in vs:
                                    if part.len <= 0:
                                        continue
                                    try:
                                        fs = pytsk3.FS_Info(img, offset=part.start * 512)
                                        break
                                    except Exception:
                                        fs = None
                            except Exception:
                                fs = None
                        if fs is None:
                            parsed = False
                        else:
                            walk_path = '/' + inner if inner else '/'
                            try:
                                dirobj = fs.open_dir(path=walk_path)
                            except Exception:
                                dirobj = None
                            if dirobj is not None:
                                # Helper function to get entry name and check LFN
                                def _img_entry_name(entry):
                                    """获取镜像条目的文件名,优先长文件名"""
                                    try:
                                        n = entry.info.name.name
                                        name = n.decode('utf-8') if isinstance(n, bytes) else n
                                    except Exception:
                                        return None
                                    if name and name in ('.', '..'):
                                        return name
                                    try:
                                        # 尝试遍历 name 链获取长文件名
                                        if hasattr(entry.info, 'meta') and entry.info.meta:
                                            meta = entry.info.meta
                                            if hasattr(meta, 'name'):
                                                current = meta.name
                                                visited = set()
                                                while current:
                                                    try:
                                                        n = current.name
                                                        if n in visited:
                                                            break
                                                        visited.add(n)
                                                        decoded = n.decode('utf-8', errors='replace') if isinstance(n, bytes) else str(n)
                                                        if decoded and decoded not in ('.', '..'):
                                                            parts = decoded.split('.', 1)
                                                            base = parts[0]
                                                            ext = parts[1] if len(parts) > 1 else ''
                                                            # 长文件名特征:名称>8字符 或 扩展名>3字符 或 包含小写
                                                            if len(base) > 8 or len(ext) > 3 or base != base.upper() or ext != ext.upper():
                                                                return decoded
                                                    except Exception:
                                                        break
                                                    if hasattr(current, 'next') and current.next:
                                                        current = current.next
                                                    else:
                                                        break
                                    except Exception:
                                        pass
                                    return name

                                for entry in dirobj:
                                    name = _img_entry_name(entry)
                                    if not name or name in ('.', '..'):
                                        continue
                                    # 过滤 pytsk3 的系统/元数据条目($MBR/$FAT1/$FAT2/$OrphanFiles)
                                    if name.startswith('$') and name.upper() in ('$FAT1', '$FAT2', '$FAT12', '$MBR', '$ORPHANFILES'):
                                        continue
                                    try:
                                        is_dir = entry.info.meta.type == pytsk3.TSK_FS_META_TYPE_DIR
                                    except Exception:
                                        is_dir = False
                                    size = '-'
                                    mtime = '-'
                                    try:
                                        meta = entry.info.meta
                                        def get_timestamp(ts):
                                            if ts is None:
                                                return None
                                            if hasattr(ts, 'secs'):
                                                return ts.secs if ts.secs > 0 else None
                                            if isinstance(ts, (int, float)) and ts > 0:
                                                return int(ts)
                                            return None
                                        ts = get_timestamp(getattr(meta, 'mtime', None))
                                        if ts:
                                            mtime = datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')
                                        if mtime == '-':
                                            ts = get_timestamp(getattr(meta, 'crtime', None))
                                            if ts:
                                                mtime = datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')
                                        if mtime == '-':
                                            ts = get_timestamp(getattr(meta, 'ctime', None))
                                            if ts:
                                                mtime = datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')
                                    except Exception:
                                        pass

                                    try:
                                        meta = entry.info.meta
                                        meta_size = getattr(meta, 'size', None)
                                        if meta_size is not None and not is_dir:
                                            try:
                                                size = format_file_size(int(meta_size))
                                            except (TypeError, ValueError):
                                                pass
                                    except Exception:
                                        pass
                                    item_path = f"{_orig_archive_rel}::{('/'.join([inner, name]).lstrip('/') if inner else name)}"
                                    can_expand = is_dir
                                    file_ext = os.path.splitext(name)[1].lower()
                                    if file_ext in archive_exts or file_ext in image_exts:
                                        can_expand = True
                                    is_archive_item = file_ext in archive_exts
                                    is_image_item = file_ext in image_exts
                                    img_can_write = True
                                    items.append({'name': name, 'path': item_path, 'is_dir': is_dir, 'size': size, 'mtime': mtime, 'is_archive': is_archive_item, 'is_image': is_image_item, 'can_expand': can_expand, 'can_write': img_can_write})
                                parsed = True
                    except Exception:
                        parsed = parsed or False
                    finally:
                        try:
                            if fs is not None:
                                fs.close()
                        except Exception:
                            pass
                        try:
                            img.close()
                        except Exception:
                            pass

                    if not parsed:
                        return jsonify({'items': [], 'unsupported': True})
                else:
                    return jsonify({'items': [], 'unsupported': True})

        for it in items:
            it['can_write'] = _archive_writable or _image_writable
        return jsonify({'items': items, 'current_path': path})

    # 普通目录(物理)
    target_dir = os.path.join(user_dir, path) if path else user_dir
    if not is_safe_path(user_dir, target_dir):
        return jsonify({'error': '路径不安全'}), 403
    if not os.path.exists(target_dir):
        return jsonify({'error': '不存在'}), 404
    # 如果 path 指向一个文件(归档/镜像),尝试展开其内部
    # 注意:如果 path 已经包含 :: 则说明已进入虚拟路径,不应再次处理
    if os.path.isfile(target_dir) and '::' not in path and path.count('::') < 1:
        ext = os.path.splitext(target_dir)[1].lower()
        archive_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.7z', '.rar'}
        image_exts = {'.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'}
        if ext in archive_exts or ext in image_exts:
            # 将 path 转为虚拟路径格式并重新调用
            return api_dir_tree_with_path(path + '::')
        return jsonify({'items': [], 'current_path': path})

    items = []
    for item in os.listdir(target_dir):
        if item.startswith('.'):
            continue
        item_path = os.path.join(target_dir, item)
        rel_path = os.path.join(path, item).replace('\\', '/') if path else item
        if os.path.isfile(item_path):
            size = os.path.getsize(item_path)
            mtime = datetime.fromtimestamp(os.path.getmtime(item_path)).strftime('%Y-%m-%d %H:%M')
            ext = os.path.splitext(item)[1].lower()
            archive_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.7z', '.rar'}
            image_exts = {'.iso', '.img', '.vmdk', '.vhd', '.vhdx', '.qcow2', '.dmg', '.wim'}
            is_archive = ext in archive_exts
            is_image = ext in image_exts
            # 仅当服务器具备对应解析库时才标记可展开
            can_expand = False
            if is_archive:
                if ext in ('.zip',):
                    can_expand = True
                elif ext in ('.tar', '.tgz', '.tar.gz', '.gz', '.bz2', '.tbz'):
                    can_expand = True
                elif ext == '.7z' and HAS_PY7ZR:
                    can_expand = True
                elif ext == '.rar' and HAS_RARFILE:
                    can_expand = True
            elif is_image:
                if ext == '.iso' and HAS_PYCDLIB:
                    can_expand = True
                elif HAS_PYTSK3:
                    can_expand = True

            # 路径选择框只显示可展开的归档/镜像文件,普通文件不显示
            # /api/dir_tree 应该显示所有文件,不跳过

            # 是否可写(默认仅对常见归档开启写入,对镜像需额外工具支持)
            can_write = False
            writable_exts = {'.zip', '.tar', '.tgz', '.tar.gz', '.gz', '.bz2'}
            if is_archive and ext in writable_exts:
                can_write = True
            elif is_image and has_image_write_tools():
                can_write = True

            items.append({
                'name': item,
                'path': rel_path,
                'is_dir': False,
                'size': format_file_size(size),
                'mtime': mtime,
                'is_archive': is_archive,
                'is_image': is_image,
                'can_expand': can_expand,
                'can_write': can_write
            })
        else:
            # 目录显示大小和修改时间
            dir_size = 0
            dir_mtime = 0
            try:
                dir_mtime = os.path.getmtime(item_path)
                # 计算目录大小(可选,只计算直接子项)
                for root, dirs, files in os.walk(item_path):
                    for f in files:
                        try:
                            fp = os.path.join(root, f)
                            dir_size += os.path.getsize(fp)
                        except (OSError, FileNotFoundError):
                            pass
            except (OSError, FileNotFoundError):
                dir_mtime = 0

            items.append({
                'name': item,
                'path': rel_path,
                'is_dir': True,
                'size': format_file_size(dir_size) if dir_size > 0 else '-',
                'mtime': datetime.fromtimestamp(dir_mtime).strftime('%Y-%m-%d %H:%M') if dir_mtime else '-',
                'is_archive': False,
                'is_image': False,
                'can_expand': True,
                'can_write': True
            })

    items.sort(key=lambda x: (not x['is_dir'], x['name'].lower()))
    return jsonify({'items': items, 'current_path': path})

ISO/IMG 专用提取函数

def _extract_iso_item(iso_path, inner_path, temp_dir):
    """从ISO/镜像文件中提取指定路径的项到临时目录,支持pycdlib和pytsk3回退"""
    # 先尝试 pycdlib
    if HAS_PYCDLIB and _is_iso9660_file(iso_path):
        try:
            iso = pycdlib.PyCdlib()
            iso.open(iso_path)
            try:
                inner_lower = inner_path.lower()
                use_rr = iso.has_rock_ridge()
                use_joliet = iso.has_joliet()

                # 按优先级构建路径系统列表:Rock Ridge > Joliet > ISO标准路径
                path_systems = []
                if use_rr:
                    path_systems.append('rr_path')
                if use_joliet:
                    path_systems.append('joliet_path')
                path_systems.append('iso_path')

                def _strip_version_number(filename):
                    """移除ISO 9660版本号后缀(如 ;1)"""
                    semicolon_idx = filename.rfind(';')
                    if semicolon_idx >= 0:
                        return filename[:semicolon_idx]
                    return filename

                def _path_matches(walked_path, filename, inner_lower):
                    """检查路径是否匹配目标inner_path"""
                    full_path = (walked_path.rstrip('/') + '/' + filename).lstrip('/')
                    full_path_clean = _strip_version_number(full_path)
                    return full_path_clean.lower() == inner_lower or full_path_clean.lower().startswith(inner_lower + '/')

                def _build_dst_name(walked_path, filename, inner_lower, inner_path_orig):
                    """构建目标文件名"""
                    full_path = (walked_path.rstrip('/') + '/' + filename).lstrip('/')
                    full_path_clean = _strip_version_number(full_path)
                    if full_path_clean.lower() == inner_lower:
                        return inner_path_orig
                    else:
                        sub_after_prefix = full_path_clean[len(inner_path_orig):]
                        return inner_path_orig + sub_after_prefix.replace(os.sep, '/')

                # 依次尝试每种路径系统,找到匹配的文件即停止
                for path_type in path_systems:
                    iso_to_dst = {}
                    try:
                        for dir_path, dirs, files in iso.walk(**{path_type: '/'}):
                            for f in files:
                                if _path_matches(dir_path, f, inner_lower):
                                    dst_name = _build_dst_name(dir_path, f, inner_lower, inner_path)
                                    full_iso_path = (dir_path.rstrip('/') + '/' + f).lstrip('/')
                                    iso_to_dst['/' + full_iso_path] = (dst_name, path_type)
                                    dst_path = os.path.join(temp_dir, dst_name)
                                    os.makedirs(os.path.dirname(dst_path), exist_ok=True)
                    except Exception:
                        continue

                    if iso_to_dst:
                        # 找到匹配文件,执行提取
                        for iso_fp, (dst_name, pt) in iso_to_dst.items():
                            dst_path = os.path.join(temp_dir, dst_name)
                            os.makedirs(os.path.dirname(dst_path), exist_ok=True)
                            with open(dst_path, 'wb') as fp:
                                iso.get_file_from_iso_fp(fp, **{pt: iso_fp})
                        return

                # 所有路径系统都未找到匹配文件
                raise FileNotFoundError(f"ISO中未找到: {inner_path}")
            finally:
                iso.close()
        except Exception as _e:
            logger.warning(f"pycdlib extract failed, falling back to pytsk3: {_e}")

    # pytsk3 回退
    if HAS_PYTSK3:
        try:
            img = pytsk3.Img_Info(iso_path)
            fs = pytsk3.FS_Info(img)
            try:
                # 尝试打开目标路径
                walk_path = '/' + inner_path
                dirobj = fs.open_dir(path=walk_path)
                # 是目录,提取所有文件
                for entry in dirobj:
                    try:
                        name = entry.info.name.name.decode('utf-8') if isinstance(entry.info.name.name, bytes) else entry.info.name.name
                    except Exception:
                        continue
                    if name in ['.', '..']:
                        continue
                    dst_name = os.path.join(temp_dir, inner_path, name) if inner_path else os.path.join(temp_dir, name)
                    os.makedirs(os.path.dirname(dst_name), exist_ok=True)
                    if entry.info.meta.type != pytsk3.TSK_FS_META_TYPE_DIR:
                        try:
                            meta_size = getattr(entry.info.meta, 'size', None)
                            if hasattr(meta_size, '__int__'):
                                meta_size = int(meta_size)
                            if meta_size and meta_size > 0:
                                with open(dst_name, 'wb') as fp:
                                    entry.get_file().write_to_fd(fp.fileno())
                        except Exception:
                            pass
                return
            except Exception as _e:
                logger.warning(f"pytsk3 open_dir failed for {walk_path}, trying as file: {_e}")
                # 尝试作为文件提取
                try:
                    walk_path = '/' + inner_path
                    obj = fs.open_file(path=walk_path)
                    dst_name = os.path.join(temp_dir, inner_path)
                    os.makedirs(os.path.dirname(dst_name), exist_ok=True)
                    with open(dst_name, 'wb') as fp:
                        obj.get_file().write_to_fd(fp.fileno())
                    return
                except Exception:
                    pass
            finally:
                try:
                    fs.close()
                except Exception:
                    pass
                try:
                    img.close()
                except Exception:
                    pass
        except Exception as _e:
            logger.warning(f"pytsk3 fallback also failed: {_e}")

    raise Exception(f"无法从镜像文件提取 {inner_path}: pycdlib 和 pytsk3 均失败")
def _extract_iso_to_dir(iso_path, temp_dir):
    """使用pycdlib或pytsk3将ISO/镜像文件内容提取到临时目录"""
    # 先尝试 pycdlib
    if HAS_PYCDLIB and _is_iso9660_file(iso_path):
        try:
            iso = pycdlib.PyCdlib()
            iso.open(iso_path)
            try:
                for dir_path, dirs, files in iso.walk(iso_path='/'):
                    rel_dir = dir_path.lstrip('/').rstrip('/')
                    if rel_dir:
                        target_dir = os.path.join(temp_dir, rel_dir.replace('/', os.sep))
                        os.makedirs(target_dir, exist_ok=True)
                    for f in files:
                        src_iso_path = (dir_path.rstrip('/') + '/' + f).lstrip('/')
                        if rel_dir:
                            dst_path = os.path.join(temp_dir, rel_dir.replace('/', os.sep), f)
                        else:
                            dst_path = os.path.join(temp_dir, f)
                        os.makedirs(os.path.dirname(dst_path), exist_ok=True)
                        with open(dst_path, 'wb') as fp:
                            iso.get_file_from_iso_fp(fp, iso_path='/' + src_iso_path)
            finally:
                iso.close()
            return
        except Exception as _e:
            logger.warning(f"pycdlib extract to dir failed, falling back to pytsk3: {_e}")

    # pytsk3 回退
    if HAS_PYTSK3:
        try:
            img = pytsk3.Img_Info(iso_path)
            fs = pytsk3.FS_Info(img)
            try:
                def _walk_tsk_dir(path_str):
                    try:
                        dirobj = fs.open_dir(path=path_str)
                        for entry in dirobj:
                            try:
                                name = entry.info.name.name.decode('utf-8') if isinstance(entry.info.name.name, bytes) else entry.info.name.name
                            except Exception:
                                continue
                            if name in ['.', '..']:
                                continue
                            child_path = path_str.rstrip('/') + '/' + name
                            if entry.info.meta.type == pytsk3.TSK_FS_META_TYPE_DIR:
                                rel = child_path.lstrip('/')
                                if rel:
                                    os.makedirs(os.path.join(temp_dir, rel), exist_ok=True)
                                _walk_tsk_dir(child_path)
                            else:
                                rel = child_path.lstrip('/')
                                dst_path = os.path.join(temp_dir, rel)
                                os.makedirs(os.path.dirname(dst_path), exist_ok=True)
                                try:
                                    meta_size = getattr(entry.info.meta, 'size', None)
                                    if hasattr(meta_size, '__int__'):
                                        meta_size = int(meta_size)
                                    if meta_size and meta_size > 0:
                                        with open(dst_path, 'wb') as fp:
                                            entry.get_file().write_to_fd(fp.fileno())
                                except Exception:
                                    pass
                    except Exception:
                        pass
                _walk_tsk_dir('/')
                return
            finally:
                try:
                    fs.close()
                except Exception:
                    pass
                try:
                    img.close()
                except Exception:
                    pass
        except Exception as _e:
            logger.warning(f"pytsk3 fallback extract to dir also failed: {_e}")

    raise Exception(f"无法从镜像文件提取内容: pycdlib 和 pytsk3 均失败")
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!