#!/usr/bin/python3

# Copyright 2024 The Fuse-Archive Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import argparse
import errno
import hashlib
import logging
import os
import pprint
import random
import stat
import subprocess
import sys
import tempfile
import time
import unicodedata
from contextlib import contextmanager

sys.setrecursionlimit(3000)

parser = argparse.ArgumentParser()
parser.add_argument('--fast', action='store_true', help='skip slow tests')
parser.add_argument('-v',
                    '--verbose',
                    action='store_true',
                    help='enable debug logging')
args = parser.parse_args()

logging.getLogger().setLevel('DEBUG' if args.verbose else 'INFO')
is_fast = args.fast

# Directory of this test program.
script_dir = os.path.dirname(os.path.realpath(__file__))

# Directory containing the archives to mount.
data_dir = os.path.join(script_dir, 'data')

# Path of the FUSE mounter.
mount_program = os.path.join(script_dir, '..', 'out', 'fuse-archive')

sr = subprocess.run([mount_program, '--version'],
                    capture_output=True,
                    encoding='UTF-8')


def GetFuseMajorVersion():
    for line in sr.stdout.split('\n'):
        if 'FUSE library version' in line:
            # Handle "FUSE library version 3.x" and "FUSE library version: 3.x"
            version_str = line.split('version')[-1].strip(': ').split()[0]
            return int(version_str.split('.')[0])
    return 0


fuse_major_version = GetFuseMajorVersion()
logging.info(f'FUSE major version: {fuse_major_version}')

on_mac = sys.platform.startswith('darwin')
on_linux = sys.platform.startswith('linux')

# On macOS, using the default TMPDIR causes Finder to use CPU excessively.
tmp_dir_base = '/tmp' if on_mac else None

has_memcache = not on_mac
if not has_memcache:
    logging.info('Will skip tests relying on memcache')

has_xattrs = not on_mac
if not has_memcache:
    logging.info('Will skip tests for xattrs')

has_holes = not on_mac and fuse_major_version >= 3
if not has_holes:
    logging.info('Will skip tests for holes')

# Computes the MD5 hash of the given file.
# Returns the MD5 hash as an hexadecimal string.
# Throws OSError if the file cannot be read.
def md5(path):
    h = hashlib.md5()
    with open(path, 'rb') as f:
        while chunk := f.read(4096):
            h.update(chunk)
    return h.hexdigest()


# Gets the hole map of the given file.
def GetHoleMap(path):
    m = []
    try:
        fd = os.open(path, os.O_RDONLY)
        try:
            i = 0
            while True:
                j = os.lseek(fd, i, os.SEEK_HOLE)
                if j < i: break
                m.append(j)
                i = os.lseek(fd, j, os.SEEK_DATA)
                if i <= j: break
                m.append(i)
        finally:
            os.close(fd)
    except OSError as e:
        if e.errno != errno.ENXIO:
            LogError(f'Cannot get holes from {path!r}: {e}');
    return m


# Walks the given directory.
# Returns a dict representing all the files and directories.
def GetTree(root, use_md5=True):
    result = {}

    def scan(path, st):
        mode = st.st_mode
        line = {
            'ino': st.st_ino,
            'mode': stat.filemode(mode),
            'nlink': st.st_nlink,
            'uid': st.st_uid,
            'gid': st.st_gid,
            'atime': st.st_atime_ns,
            'mtime': st.st_mtime_ns,
            'ctime': st.st_ctime_ns,
        }

        key = os.path.relpath(path, root)

        if on_mac:
            # macOS VFS returns NFD-normalised filenames; normalise to NFC so
            # tree keys match the NFC names stored in archives.
            key = unicodedata.normalize('NFC', key)

        result[key] = line

        if stat.S_ISREG(mode):
            line['size'] = st.st_size
            line['blocks'] = st.st_blocks
            if has_holes and st.st_blocks * 512 < st.st_size:
                logging.debug(f'Sparse file: {path!r}')
                line['holes'] = GetHoleMap(path)
            try:
                if use_md5:
                    line['md5'] = md5(path)
            except OSError as e:
                line['errno'] = e.errno
        elif stat.S_ISLNK(mode):
            line['target'] = os.readlink(path)
        elif stat.S_ISBLK(mode) or stat.S_ISCHR(mode):
            line['rdev'] = st.st_rdev
        elif stat.S_ISDIR(mode):
            try:
                for entry in list(os.scandir(path)):
                    scan(entry.path, entry.stat(follow_symlinks=False))
            except OSError as e:
                line['errno'] = e.errno

        if has_xattrs:
            try:
                attrs = os.listxattr(path, follow_symlinks=False)
                xattr_dict = dict()
                for attr in attrs:
                    try:
                        value = os.getxattr(path, attr, follow_symlinks=False)
                        xattr_dict[attr] = value.decode('utf-8')
                    except OSError as e:
                        xattr_dict[attr] = f'<error: {e.errno}>'
                if xattr_dict:
                    logging.debug(f"Path {path} has xattrs: {xattr_dict}")
                line['xattr'] = xattr_dict
            except OSError as e:
                line['xattr'] = e.errno

    st = os.stat(root, follow_symlinks=False)

    # On some systems, the mount point is not immediately functional.
    while st.st_ino == 0:
        time.sleep(0.1)
        st = os.stat(root, follow_symlinks=False)

    scan(root, st)
    return result


# Total number of errors.
error_count = 0


# Logs the given error.
def LogError(msg):
    logging.error(msg)
    global error_count
    error_count += 1


# Compares got_tree with want_tree. If strict is True, checks that got_tree
# doesn't contain any extra entries that aren't in want_tree.
def CheckTree(got_tree, want_tree, strict=False):
    for name, want_entry in want_tree.items():
        try:
            got_entry = got_tree.pop(name)
            for key, want_value in want_entry.items():
                if not has_xattrs and key == 'xattr':
                    continue

                if on_mac and key == 'rdev':
                    # macFUSE re-encodes device numbers using BSD packing, so
                    # raw rdev values differ from Linux. Skip on macOS.
                    continue

                if not has_holes and key == 'holes':
                    continue

                got_value = got_entry.get(key)

                if key in ('atime', 'mtime',
                           'ctime') and want_value % 1000000000 == 0:
                    got_value //= 1000000000
                    want_value //= 1000000000

                if got_value != want_value:
                    LogError(
                        f'Mismatch for {name!r}[{key}] got: {got_value!r}, want:'
                        f' {want_value!r}')
        except KeyError:
            LogError(f'Missing entry {name!r}')

    if strict and got_tree:
        LogError(f'Found {len(got_tree)} unexpected entries: {got_tree}')


def CanRun(args):
    try:
        subprocess.run(args, capture_output=True, check=True)
        logging.debug(f'Can run {args!r}')
        return True
    except FileNotFoundError as e:
        logging.debug(f'Cannot run {args!r}: {e}')
        logging.info(f'Will skip tests relying on {args[0]}')
        return False


has_base64 = CanRun(['base64', '--version'])
has_brotli = CanRun(['brotli', '--version'])
has_bzip2 = CanRun(['bzip2', '--help'])

# BSD compress (macOS) is always present but does not support -V, --help, or
# -h. On Linux, ncompress may or may not be installed and does support -V.
has_compress = on_mac or CanRun(['compress', '-V'])

# On macOS, even if the `gpg` program is present, libarchive can't  reach
# gpg-agent's FD / socket.
has_gpg = not on_mac and CanRun(['gpg', '--version'])

has_gzip = CanRun(['gzip', '--version'])
has_lrzip = CanRun(['lrzip', '--version'])
has_lz4 = CanRun(['lz4', '--version'])
has_lzip = CanRun(['lzip', '--version'])
has_lzma = CanRun(['lzma', '--version'])
has_lzop = CanRun(['lzop', '--version'])
has_xz = CanRun(['xz', '--version'])
has_zstd = CanRun(['zstd', '--version'])
has_tar = CanRun(['tar', '--version'])


def HasLib(name):
    if ' ' + name + '/' in sr.stdout:
        return True
    logging.info(f'Will skip tests relying on {name}')
    return False


has_bz2lib = HasLib('bz2lib')
has_liblz4 = HasLib('liblz4')
has_liblzma = HasLib('liblzma')
has_libzstd = HasLib('libzstd')
has_zlib = HasLib('zlib')
has_nettle = HasLib('nettle')
has_openssl = HasLib('openssl')

# https://github.com/google/fuse-archive/issues/59
env = os.environ.copy()
# MALLOC_PERTURB_ makes lrzip much slower
# https://github.com/ckolivas/lrzip/issues/266
if not is_fast and not has_lrzip:
    env['MALLOC_PERTURB_'] = '170'


def Unmount(mount_point):
    # Linux: -l (lazy) detaches immediately even if mount is busy.
    # macOS: -l is unsupported; -f (force) is the closest equivalent.
    if on_mac:
        subprocess.run(['umount', '-f', mount_point], check=True)
    else:
        subprocess.run(['umount', '-l', mount_point], check=True)


@contextmanager
def MountArchive(zip_names, options=[], password='', env=env):
    with tempfile.TemporaryDirectory(dir=tmp_dir_base) as mount_point:
        if type(zip_names) is not list: zip_names = [zip_names]
        zip_paths = [
            os.path.join(script_dir, 'data', zip_name)
            for zip_name in zip_names
        ]
        logging.debug(f'Mounting {zip_paths!r} on {mount_point!r}...')
        command = [mount_program, *options]
        if args.verbose: command.append('-v')
        command.extend([*zip_paths, mount_point])
        logging.debug(f'Command: {command}')
        if 'MOUNT_WRAPPER' in env:
            command = env['MOUNT_WRAPPER'].split() + command
        subprocess.run(
            command,
            check=True,
            capture_output=True,
            input=password,
            encoding='UTF-8',
            env=env,
        )
        try:
            logging.debug(f'Mounted archive {zip_paths!r} on {mount_point!r}')
            yield mount_point
        finally:
            logging.debug(f'Unmounting {zip_paths!r} from {mount_point!r}...')
            Unmount(mount_point)
            logging.debug(f'Unmounted {zip_paths!r} from {mount_point!r}')


# Mounts the given archive(s), walks the mounted archive and unmounts.
# Returns a pair where:
# - member 0 is a dict representing the mounted archive.
# - member 1 is the result of os.statvfs
#
# Throws subprocess.CalledProcessError if the archive cannot be mounted.
def MountArchiveAndGetTree(zip_names,
                           options=[],
                           password='',
                           use_md5=True,
                           get_tree=True,
                           env=env):
    with MountArchive(zip_names, options=options, password=password, env=env) as mount_point:
        tree = GetTree(mount_point, use_md5=use_md5) if get_tree else None
        return tree, os.statvfs(mount_point)


# Mounts the given archive(s), checks the mounted archive tree and unmounts.
# Logs an error if the archive cannot be mounted.
def MountArchiveAndCheckTree(
    zip_names,
    want_tree,
    want_blocks=None,
    want_inodes=None,
    options=[],
    password='',
    strict=True,
    use_md5=True,
    env=env):
    s = f'Test {zip_names!r}'
    if options: s += f', options = {" ".join(options)!r}'
    if password: s += f', password = {password!r}'
    logging.info(s)
    try:
        got_tree, st = MountArchiveAndGetTree(zip_names,
                                              options=options,
                                              password=password,
                                              use_md5=use_md5,
                                              env=env,
                                              get_tree=want_tree is not None)

        want_block_size = 512
        if st.f_bsize < want_block_size:
            LogError('Mismatch for st.f_bsize: '
                     f'got: {st.f_bsize}, want at least: {want_block_size}')
        if st.f_frsize != want_block_size:
            LogError('Mismatch for st.f_frsize: '
                     f'got: {st.f_frsize}, want: {want_block_size}')

        want_name_max = 255
        if st.f_namemax != want_name_max:
            LogError('Mismatch for st.f_namemax: '
                     f'got: {st.f_namemax}, want: {want_name_max}')

        if want_blocks is not None and st.f_blocks != want_blocks:
            LogError(
                f'Mismatch for st.f_blocks: got: {st.f_blocks}, want: {want_blocks}'
            )

        if want_inodes is not None and st.f_files != want_inodes:
            LogError(
                f'Mismatch for st.f_files: got: {st.f_files}, want: {want_inodes}'
            )

        if want_tree is not None: CheckTree(got_tree, want_tree, strict=strict)
    except subprocess.CalledProcessError as e:
        LogError(f'Cannot test {zip_names!r}: {e.stderr}')


# Try to mount the given archive(s), and expects an error.
# Logs an error if the archive can be mounted, or if the returned error code doesn't match.
def CheckArchiveMountingError(zip_names,
                              want_error_code,
                              options=[],
                              password='',
                              env=env):
    s = f'Test {zip_names!r}'
    if options: s += f', options = {" ".join(options)!r}'
    if password: s += f', password = {password!r}'
    logging.info(s)
    try:
        got_tree, _ = MountArchiveAndGetTree(zip_names,
                                             options=options,
                                             password=password,
                                             env=env)
        LogError(f'Want error, Got tree: {got_tree}')
    except subprocess.CalledProcessError as e:
        if e.returncode != want_error_code:
            LogError(
                f'Want error: {want_error_code}, Got error: {e.returncode} in {e}'
            )


def GenerateReferenceData():
    for zip_name in os.listdir(os.path.join(script_dir, 'data')):
        tree, _ = MountArchiveAndGetTree(zip_name, password='password')
        all_zips[zip_name] = tree

    pprint.pprint(all_zips, compact=True, sort_dicts=False)


# Tests most of the archive files in data_dir using default mounting options.
def TestArchiveWithOptions(options=[]):
    want_tree = {'.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2}}

    zip_names = ['empty.tar']

    if has_gzip or has_zlib:
        zip_names += ['empty.tar.gz', 'empty.tgz']

    for zip_name in zip_names:
        MountArchiveAndCheckTree(zip_name, want_tree, options=options)

    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 4},
        'artificial': {'mode': 'drwxr-xr-x'},
        'artificial/0.bytes': {'mode': '-rw-r--r--', 'mtime': 1580883024000000000, 'size': 0, 'md5': 'd41d8cd98f00b204e9800998ecf8427e'},
        'github-tags.json': {'mode': '-rw-r--r--', 'mtime': 1597241062000000000, 'size': 853, 'md5': 'b2d7993ed99c65296bf95824c57b4fdc'},
        'hello.sh': {'mode': '-rwxr-xr-x', 'mtime': 1620022795000000000, 'size': 693, 'md5': '72d710dd3766a67401a79f8d3df3114c'},
        'non-ascii': {'mode': 'drwxr-xr-x'},
        'non-ascii/αβ.txt': {'mode': '-rw-r--r--', 'mtime': 1620022605000000000, 'size': 104, 'md5': '3369a4163a436de59e23daedd371b5f0'},
        'non-ascii/😻.txt': {'mode': '-rw-r--r--', 'mtime': 1620022983000000000, 'size': 151, 'md5': '5d18e0e461374191825c6e7898af5634'},
        'pjw-thumbnail.png': {'mode': '-rw-r--r--', 'mtime': 1580883024000000000, 'size': 208, 'md5': 'f7017e60a0af6d7ad3128c149624aac5'},
        'romeo.txt': {'mode': '-rw-r--r--', 'mtime': 1580883024000000000, 'size': 942, 'md5': '80f1521c4533d017df063c623b75cde3'},
        'romeo.txt.gz': {'mode': '-rw-r--r--', 'mtime': 1580883024000000000, 'size': 558, 'md5': 'f261bc929b34f58d8138413ed6252f2d'},
    }

    zip_names = [
        'archive.rar', 'archive.cbr', 'archive.tar', 'archive.tar.uu',
        'archive.xxx', '--help'
    ]

    if has_zlib or has_gzip:
        zip_names += [
            'archive.rar.gz', 'archive.tar.gz', 'archive.tgz', 'archive.rpm',
            'archive.spm'
        ]

    if has_zlib:
        zip_names += [
            'archive.zip', 'archive.aab', 'archive.apk', 'archive.cbz',
            'archive.ear', 'archive.epub', 'archive.ipa', 'archive.jar',
            'archive.war', 'archive.whl', 'archive.xpi'
        ]

    if has_bz2lib or has_bzip2:
        zip_names += [
            'archive.tar.bzip2', 'archive.tar.bz2', 'archive.tar.bz',
            'archive.tb2', 'archive.tbz', 'archive.tbz2', 'archive.tz2'
        ]

    if has_liblz4 or has_lz4:
        zip_names += ['archive.tar.lz4', 'archive.tlz4']

    if has_liblzma:
        zip_names += ['archive.7z']

    if has_liblzma or has_lzip:
        zip_names += ['archive.tar.lz', 'archive.tlzip', 'lz_is_lzip.tlz']

    if has_liblzma or has_lzma:
        zip_names += [
            'archive.tar.lzma', 'archive.tlz', 'archive.tlzma',
            'lz_is_lzma.tlz'
        ]

    if has_liblzma or has_xz:
        zip_names += ['archive.tar.xz', 'archive.txz', 'compressed.tar']

    if has_libzstd or has_zstd:
        zip_names += [
            'archive.tar.zst', 'archive.tzs', 'archive.tzst', 'archive.tzstd'
        ]

    if has_base64:
        zip_names += ['archive.tar.b64']

    if has_brotli:
        zip_names += ['archive.tar.br', 'archive.tbr']

    if has_compress:
        zip_names += [
            'archive.tar.Z', 'archive.taz', 'archive.taZ', 'archive.tz'
        ]

    if has_lrzip:
        zip_names += ['archive.tar.lrz', 'archive.tlrz']

    if has_lzop:
        zip_names += ['archive.tar.lzo', 'archive.tlzo', 'archive.tlzop']

    if has_gpg:
        zip_names += ['archive.tar.gpg', 'archive.tar.pgp', 'archive.tar.asc']

    for zip_name in zip_names:
        MountArchiveAndCheckTree(zip_name, want_tree, options=options)

    if has_zlib:
        MountArchiveAndCheckTree('archive.tar.gz.uu',
                                 want_tree,
                                 options=[*options, '-o', 'maxfilters=2'])

    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
        'romeo.txt': {'mode': '-rw-r--r--', 'size': 942, 'md5': '80f1521c4533d017df063c623b75cde3'},
    }

    zip_names = ['romeo.txt.uu']

    if has_liblzma or has_lzip:
        zip_names += ['romeo.txt.lz', 'romeo.txt.lzip']

    if has_zlib or has_gzip:
        zip_names += ['romeo.txt.gz', 'romeo.txt.gzip']

    if has_bz2lib or has_bzip2:
        zip_names += ['romeo.txt.bz2', 'romeo.txt.bz', 'romeo.txt.bzip2']

    if has_bz2lib:
        zip_names += ['romeo.bzip2.zip']

    if has_liblz4 or has_lz4:
        zip_names += ['romeo.txt.lz4']

    if has_liblzma:
        zip_names += ['romeo.lzma.zip', 'romeo.xz.zip']

    if has_liblzma or has_lzma:
        zip_names += ['romeo.txt.lzma']

    if has_liblzma or has_xz:
        zip_names += ['romeo.txt.xz']

    if has_libzstd or has_zstd:
        zip_names += ['romeo.txt.zst', 'romeo.txt.zstd']

    if has_base64:
        zip_names += ['romeo.txt.b64', 'romeo.txt.base64']

    if has_brotli:
        zip_names += ['romeo.txt.br', 'romeo.txt.brotli']

    if has_compress:
        zip_names += ['romeo.txt.Z']

    if has_lrzip:
        zip_names += ['romeo.txt.lrz', 'romeo.txt.lrzip']

    if has_lzop:
        zip_names += ['romeo.txt.lzo', 'romeo.txt.lzop']

    if has_gpg:
        zip_names += ['romeo.txt.gpg', 'romeo.txt.pgp', 'romeo.txt.asc']

    for zip_name in zip_names:
        MountArchiveAndCheckTree(zip_name, want_tree, options=options)

    if has_zlib:
        MountArchiveAndCheckTree('romeo.txt.gz.uu',
                                 want_tree,
                                 options=[*options, '-o', 'maxfilters=2'])

    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 4},
        'artificial': {'mode': 'drwxr-xr-x'},
        'artificial/0.bytes': {'mode': '-rw-r--r--', 'mtime': 1580883024000000000, 'size': 0, 'md5': 'd41d8cd98f00b204e9800998ecf8427e'},
        'github-tags.json': {'mode': '-rw-r--r--', 'mtime': 1597241062000000000, 'size': 853, 'md5': 'b2d7993ed99c65296bf95824c57b4fdc'},
        'hello.sh': {'mode': '-rwxr-xr-x', 'mtime': 1620022795000000000, 'size': 693, 'md5': '72d710dd3766a67401a79f8d3df3114c'},
        'non-ascii': {'mode': 'drwxr-xr-x'},
        'non-ascii/αβ.txt': {'mode': '-rw-r--r--', 'mtime': 1620022605000000000, 'size': 104, 'md5': '3369a4163a436de59e23daedd371b5f0'},
        # No non-BMP characters in filenames.
        # 'non-ascii/😻.txt': {'mode': '-rw-r--r--', 'mtime': 1620022983000000000, 'size': 151, 'md5': '5d18e0e461374191825c6e7898af5634'},
        'pjw-thumbnail.png': {'mode': '-rw-r--r--', 'mtime': 1580883024000000000, 'size': 208, 'md5': 'f7017e60a0af6d7ad3128c149624aac5'},
        'romeo.txt': {'mode': '-rw-r--r--', 'mtime': 1580883024000000000, 'size': 942, 'md5': '80f1521c4533d017df063c623b75cde3'},
        'romeo.txt.gz': {'mode': '-rw-r--r--', 'mtime': 1580883024000000000, 'size': 558, 'md5': 'f261bc929b34f58d8138413ed6252f2d'},
    }

    zip_names = ['archive.iso', 'archive.iso9660']

    if has_gzip or has_zlib:
        zip_names += ['archive.iso.gz']

    for zip_name in zip_names:
        MountArchiveAndCheckTree(zip_name, want_tree, options=options)

    if has_zlib:
        want_tree = {
            '.': {'mode': 'drwxr-xr-x'},
            '-': {'mode': '-rw-r--r--', 'size': 305, 'md5': 'c60b77c7b1cad939d1dee69925b2e47b'},
            'second.txt': {'mode': '-rw-r--r--', 'size': 320, 'md5': 'da1344f8f5f2e52fae7671250d81376e'},
            }

        MountArchiveAndCheckTree('data_descriptor.zip',
                                 want_tree,
                                 options=options,
                                 use_md5=True)
    else:
        want_tree = {
            '.': {'mode': 'drwxr-xr-x'},
            '-': {'mode': '-rw-r--r--', 'size': 305},
            'second.txt': {'mode': '-rw-r--r--', 'size': 320},
            }

        MountArchiveAndCheckTree('data_descriptor.zip',
                                 want_tree,
                                 options=[*options, '-o', 'nocache'],
                                 use_md5=False)

    if has_zlib:
        # This should not be mistaken for an mtree archive.
        # https://github.com/google/fuse-archive/issues/43
        want_tree = {
            '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
            'test.csv': {'mode': '-rw-r--r--', 'mtime': 1739773077000000000, 'size': 88, 'md5': '9359ea183fa52719372753e6ca34e3b1'},
        }
        MountArchiveAndCheckTree('test.csv.gz', want_tree, options=options)
    elif has_gzip:
        # This should not be mistaken for an mtree archive.
        # https://github.com/google/fuse-archive/issues/43
        want_tree = {
            '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
            'test.csv': {'mode': '-rw-r--r--', 'size': 88, 'md5': '9359ea183fa52719372753e6ca34e3b1'},
        }
        MountArchiveAndCheckTree('test.csv.gz', want_tree, options=options)

    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
        # Mysterious extra file
        # https://github.com/libarchive/libarchive/issues/2524
        'data': {'mode': '-rw-r--r--', 'size': 0, 'md5': 'd41d8cd98f00b204e9800998ecf8427e'},
        '0.bytes': {'mode': '-rw-r--r--', 'size': 0, 'md5': 'd41d8cd98f00b204e9800998ecf8427e'},
        'github-tags.json': {'mode': '-rw-r--r--', 'size': 853, 'md5': 'b2d7993ed99c65296bf95824c57b4fdc'},
        'hello.sh': {'mode': '-rw-r--r--', 'size': 693, 'md5': '72d710dd3766a67401a79f8d3df3114c'},
        'αβ.txt': {'mode': '-rw-r--r--', 'size': 104, 'md5': '3369a4163a436de59e23daedd371b5f0'},
        '😻.txt': {'mode': '-rw-r--r--', 'size': 151, 'md5': '5d18e0e461374191825c6e7898af5634'},
        'pjw-thumbnail.png': {'mode': '-rw-r--r--', 'size': 208, 'md5': 'f7017e60a0af6d7ad3128c149624aac5'},
        'romeo.txt': {'mode': '-rw-r--r--', 'size': 942, 'md5': '80f1521c4533d017df063c623b75cde3'},
        'romeo.txt.gz': {'mode': '-rw-r--r--', 'size': 558, 'md5': 'f261bc929b34f58d8138413ed6252f2d'},
    }

    zip_names = ['archive.a', 'archive.ar']

    if has_gzip or has_zlib:
        zip_names += ['archive.a.gz', 'archive.ar.gz']

    for zip_name in zip_names:
        MountArchiveAndCheckTree(zip_name, want_tree, options=options)

    want_tree = {
        # Don't check mtime for this archive.
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 4},
        'artificial': {'mode': 'drwxr-xr-x'},
        'artificial/0.bytes': {'mode': '-rw-r--r--', 'size': 0, 'md5': 'd41d8cd98f00b204e9800998ecf8427e'},
        'github-tags.json': {'mode': '-rw-r--r--', 'size': 853, 'md5': 'b2d7993ed99c65296bf95824c57b4fdc'},
        'hello.sh': {'mode': '-rw-r--r--', 'size': 693, 'md5': '72d710dd3766a67401a79f8d3df3114c'},
        'non-ascii': {'mode': 'drwxr-xr-x'},
        'non-ascii/αβ.txt': {'mode': '-rw-r--r--', 'size': 104, 'md5': '3369a4163a436de59e23daedd371b5f0'},
        'non-ascii/😻.txt': {'mode': '-rw-r--r--', 'size': 151, 'md5': '5d18e0e461374191825c6e7898af5634'},
        'pjw-thumbnail.png': {'mode': '-rw-r--r--', 'size': 208, 'md5': 'f7017e60a0af6d7ad3128c149624aac5'},
        'romeo.txt': {'mode': '-rw-r--r--', 'size': 942, 'md5': '80f1521c4533d017df063c623b75cde3'},
        'romeo.txt.gz': {'mode': '-rw-r--r--', 'size': 558, 'md5': 'f261bc929b34f58d8138413ed6252f2d'},
    }

    zip_names = ['archive.cab']

    if has_gzip or has_zlib:
        zip_names += ['archive.cab.gz']

    for zip_name in zip_names:
        MountArchiveAndCheckTree(zip_name, want_tree, options=options)

    want_trees = {
        # This should be handled as a RAR, and not as a ZIP.
        # https://github.com/libarchive/libarchive/issues/2249
        'archive.zip.rar': {
            '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
            'archive.zip': {'ino': 2, 'mode': '-rw-r--r--', 'mtime': 1739230765000000000, 'size': 3480, 'md5': 'e43a4ee1eb970d00b6c0ebf6e25347d5'},
        },
        'archive.tar.gz.uu': {
            '.': {'mode': 'drwxr-xr-x', 'nlink': 2},
            'archive.tar.gz': {'mode': '-rw-r--r--', 'size': 2672, 'md5': '76e1a227c44858cb0e05969b89f03987'},
        },
        'mixed-paths.zip': {
            '.': {'mode': 'drwxr-xr-x'},
            "Quote ' (1)": {'md5': '3ca0f2a7d572f3ad256fcd13e39bd8da'},
            "Quote ' (2)": {'md5': '60e20f9b84bf0fe96e7d226341aaf72d'},
            "Quote '": {'md5': '71e103fda7cffdb58b4aa44fa540efce'},
            '  (1)': {'md5': 'fc520ede1760d4e64770b48ba8f859fb'},
            '  (2)': {'md5': '9d0c61de9c0cdc3aec1221d3b00f6af1'},
            ' ': {'md5': '4ecf69a9cb2cce4469fbea4cab35277d'},
            'data': {'md5': 'd4afca8308970d15340aa4f83fcd1503'},
            'data (1)': {'md5': 'ce7bda717aef111cace3a9687b474b15'},
            'data (2)': {'md5': 'dbb5fe07c92222392511fc3ce4ca0240'},
            ' ←Space (1)': {'md5': '42f00634c4115c5f67d9be6a1cfa414c'},
            ' ←Space (2)': {'md5': 'a4506a1c46eaab6a62de9d5ddb80666b'},
            ' ←Space': {'md5': '9f407c9493c1ccc2458ab776b0e3efcf'},
            '$HOME (1)': {'md5': 'c41dc9371b2be069bd8e013c428ace13'},
            '$HOME (2)': {'md5': 'e40b01b5ff1cc66197d3428919a18da4'},
            '$HOME': {'md5': 'aedeaeef1d21c8bb6afe8c1252f7d3c8'},
            '%TMP% (1)': {'md5': '8ecc4766c79654383ed3e1ec4cefd5ce'},
            '%TMP% (2)': {'md5': 'edb312c5d6d077b0a27f5bf1e7117206'},
            '%TMP%': {'md5': 'a0e10b52aca6cc8b673d6eeb72f2f95f'},
            '- (1)': {'md5': '704efc6cc347fcdf01585e2ee91c80fa'},
            '- (2)': {'md5': '6c561d06c7890a70215c3f253cb6bb02'},
            '-': {'md5': '0015ecf55d7da11c150a666e0583c9fa'},
            '.' * 251 + ' (1)': {'md5': 'd73c3af2bb73f18416eae424d2bb1226'},
            '.' * 251 + ' (2)': {'md5': 'ab6a815c651781866cf22f96699954cb'},
            '.' * 251 + ' (3)': {'md5': '35eef262524368b8c99d28d1b9018629'},
            '.' * 251 + ' (4)': {'md5': '804600f4aa79357fe896a10773bf78fa'},
            '.' * 251 + ' (5)': {'md5': '707b9ac9459e6dda9da9abd0957a21bc'},
            '.' * 255: {'md5': 'cc3b7dadd6280c4586748318e2ee6b1e'},
            '... (1)': {'md5': '24b8fb13b27ae0e9d0da9cc411c279c6'},
            '... (2)': {'md5': '05e8526f15d1bee44e8e0f526fb93f3a'},
            '... (3)': {'md5': 'd554ecc8f6a782ab311a81f38b095ef5'},
            '... (4)': {'md5': '7a51680c3febd5563773fe9a8090ee73'},
            '... (5)': {'md5': '66a818b0b78bd22bc33f330de5101c00'},
            '...': {'md5': 'b246079eb0c241c0ad50a30873701b7d'},
            '.... (1)': {'md5': 'bf4189b1f38be5379b3c8e330e14ef00'},
            '.... (2)': {'md5': 'c0ddf361e9cce0a3d1ce734023c99852'},
            '....': {'md5': '51cd1872e35401de1969f1c53c32332a'},
            '...Three (1)': {'md5': '205a4fa1968f1a05d90a5810d7daea5a'},
            '...Three (2)': {'md5': 'fed163fdc049f5ba0347e27de9e3b75e'},
            '...Three (3)': {'md5': 'be8c7e5c8149543bfb0b899a63dfd3a5'},
            '...Three (4)': {'md5': 'a32371c48bdd02833ececf98ab629ff1'},
            '...Three (5)': {'md5': '2cdb1981a75f35b3e2c7b35ad04aa461'},
            '...Three': {'md5': 'ea9951c389c3bcd6355df418e6338d86'},
            '..Two (1)': {'md5': 'e2bafe9b9dab8502c8dd91a7cd304aca'},
            '..Two (2)': {'md5': '6372fc10e660a6bf413b9fe3e52cf6df'},
            '..Two': {'md5': '3676540e9ef52c8fec43c1705e547270'},
            '.One (1)': {'md5': '695b92c19227b154a9ad4c7454e60954'},
            '.One (2)': {'md5': '69ef0885b6ec5b8434bf67767b061924'},
            '.One': {'md5': '39c89ab7f825d93e11273cee816983d1'},
            '.foo (1).txt': {'md5': '8ff4eea96f318c3cbe8e6a713c8ad8af'},
            '.foo (2).txt': {'md5': '1c758452430d22a7bb54798873ea854f'},
            '.foo (3).txt': {'md5': '88a416d7c9817c188596d3dcb553c9ca'},
            '.foo (4).txt': {'md5': '568de72385d3ccc1d21fa2ccf4a1517f'},
            '.foo (5).txt': {'md5': '634a6c97ee76c4032c06f0769f0601b6'},
            '.foo.txt': {'md5': '52a1b1c7d65f4d9cdd41425f861150f7'},
            '?': {'md5': 'e3f500a4fad52d4b13d9f5c58b42714b'},
            '? (1)': {'md5': '23158c8ba1b968745324a3634906c7e7'},
            '? (2)': {'md5': 'e6ade0e8b4f62d8d7ea74abd60f18027'},
            '? (3)': {'md5': '3e9c3a7b046474add2da5aa3397c3170'},
            '? (4)': {'md5': '72878fc9a1ae28aef32afff602158d4a'},
            '? (5)': {'md5': 'ebee9acee0ac49c76eb2de67051592c0'},
            'Over The Top (1)': {'md5': 'd597b0c4199d9bdf2e0f7400174d7ebe'},
            'Over The Top (2)': {'md5': '3ce3fb4e0e1c86b1b07f992678a04664'},
            'Over The Top': {'md5': '991393263550ac730626a64f139eddb3'},
            'AUX (1)': {'md5': '7fdc6b9a4f48109c19455764fad5f7a0'},
            'AUX (2)': {'md5': '6b02202ccdafa51eae1f6425d608709c'},
            'AUX': {'md5': '3f69a27bc959159d6911d1d2f44ecfb5'},
            'Ampersand & (1)': {'md5': '7ddd78b542607296a9a5e93745b82297'},
            'Ampersand & (2)': {'md5': '31184f350acc30d15ffac762acde7304'},
            'Ampersand &': {'md5': '255622e1d13e054c5b0e9365c9a723bf'},
            'Angle <> (1)': {'md5': '36fbea036273dc1917a3e7e3b573dd22'},
            'Angle <> (2)': {'md5': 'c98dc6a537865f0896db87452784280b'},
            'Angle <>': {'md5': '1c5cbe4d86c73de115eb48de5cf0eeea'},
            'At @ (1)': {'md5': 'dccf5a36da117a1b9ea52f6aa1d46dca'},
            'At @ (2)': {'md5': '40d05f890dcda5552b7b87f1b1223b1a'},
            'At @': {'md5': 'cabcc9ad22fd30b2fe3220785474d9d8'},
            'At The Top (1)': {'md5': 'ba54e1aca97bad73f835c5d1c858417c'},
            'At The Top (2)': {'md5': '2d56b90a936b4f2b7f9ba2e4ef1fbd83'},
            'At The Top': {'md5': '9ef02862743242d23ce6ed223c38b707'},
            'Backslash ': {'mode': 'drwxr-xr-x'},
            'Backspace \x08 (1)': {'md5': 'a14e44a642e9037c3c298348093ec380'},
            'Backspace \x08 (2)': {'md5': 'a52f6c6706cfe008497efe714eb2a5ff'},
            'Backspace \x08': {'md5': '5d2071685575754babe374981552164b'},
            'Backtick ` (1)': {'md5': 'b92aae294d43c3e081397788bcdeda77'},
            'Backtick ` (2)': {'md5': '714d2283f6384ffabcf1c403ad0ebb3e'},
            'Backtick `': {'md5': '44a52fddfbaf7c0c213c20192744afd5'},
            'Bell \x07 (1)': {'md5': 'd42111a8d9d715d6ca13f870c03bb136'},
            'Bell \x07 (2)': {'md5': 'a9e9d87ea96b4f4e8797820c7ac19df1'},
            'Bell \x07': {'md5': 'aafdd252197512170d856086452836a9'},
            'C:': {'mode': 'drwxr-xr-x'},
            'C:/Temp': {'mode': 'drwxr-xr-x'},
            'C:/Temp/File (1)': {'md5': 'ca11bb68c069615a4b9b6eecedae436b'},
            'C:/Temp/File (2)': {'md5': 'de6cf0f9e21e500d452ca1731148a774'},
            'C:/Temp/File': {'md5': '09e89cca300f6ad14c9c614fb95c33b0'},
            'CASE (1)': {'md5': '00b78d79abf97077fbf025f3355fddb2'},
            'CASE (2)': {'md5': '53e44ae7ecdff196ff18881fae2a4c31'},
            'CASE': {'md5': 'e92836e4b17a39306f51d13ead0a09e4'},
            'CLOCK$ (1)': {'md5': '4a89d1ec340b7984fce1828f32b17a1b'},
            'CLOCK$ (2)': {'md5': 'a10ee44c95468f79c4d76acbc431f2d9'},
            'CLOCK$': {'md5': '306be585c84e6ed5aab5070e9846121d'},
            'COM1 (1)': {'md5': '7126112f4c33e7530fffcf7278a5e719'},
            'COM1 (2)': {'md5': '619a4330a4dd8433f2b27ceb9e2c575c'},
            'COM1': {'md5': 'a613354228a8154a5c980526c82d7efe'},
            'COM9 (1)': {'md5': '7c904dcc4bb875a99e403d6a41d97c2a'},
            'COM9 (2)': {'md5': 'c0a1e5c28a2359f61cc4531df14d0892'},
            'COM9': {'md5': '413d4b59327743bb2d8ce59f41fd1f41'},
            'CON (1)': {'md5': 'a6a5ee67a986dc6270311f631c2d403d'},
            'CON (2)': {'md5': '66673cda9a9e98f3ab3f7194ead33958'},
            'CON': {'md5': '407f3ff633ac1822974ce5e1a07ac9e5'},
            'Café (1)': {'md5': 'b7ce2be1dfb8cf535bccf1036284900c'},
            'Café (2)': {'md5': '637f113e67522f774879f6f63f3093da'},
            'Café (3)': {'md5': 'e9e59d2978d1ffcfc11274fcedea35d6'},
            'Café (4)': {'md5': 'f60233605389c3fc8ba841d202860c38'},
            'Café (5)': {'md5': '4f51f74e22dd4e3a77f965d77daffb4b'},
            'Café': {'md5': '080f684e7afffcc022c53359c1b6d968'},
            'Caret ^ (1)': {'md5': '6ecbe85c819de33f1a1566fc6a49b148'},
            'Caret ^ (2)': {'md5': '4037bf95d8b2a026056c6be3cb88f16d'},
            'Caret ^': {'md5': '327e8a9bae15530ada822c20e6e875f2'},
            'Carriage Return \r (1)': {'md5': '1657ca6d389450c66dcb3737ade632d4'},
            'Carriage Return \r (2)': {'md5': 'f5f79fbe6bd369bb344d2acb1500f3a0'},
            'Carriage Return \r': {'md5': '5b5600d7515e86d01364bc6f066cfc14'},
            'Case (1)': {'md5': 'b33984146a2bb8c8fc337362e91d1911'},
            'Case (2)': {'md5': '2653ca3273626a97a5b9155b83511e44'},
            'Case': {'md5': 'f39791e31c562ce31a325a188c405d02'},
            'Colon : (1)': {'md5': '662fcf045861ca1e9be6466f34f23846'},
            'Colon : (2)': {'md5': '30b9479836d7aab4428db84a4435de2b'},
            'Colon :': {'md5': 'bad98d8795bd453e675ae45cf511cb6f'},
            'Comma , (1)': {'md5': '741313ce46594c276d4dbf8c50a3c242'},
            'Comma , (2)': {'md5': 'dbe2d9051ca9c7ad197acdd39644c151'},
            'Comma ,': {'md5': '8e4e2a35ea3db7e1f0c71e9e260e3f2b'},
            'Curly {} (1)': {'md5': '5e75818b995fac62fd10046e918a6d68'},
            'Curly {} (2)': {'md5': '2526b1e0fdb6a56ef9c7104b0432295a'},
            'Curly {}': {'md5': '7f80122391b3c4f8af113d08784576bb'},
            'Dash - (1)': {'md5': 'c2fe85e07ed29f1907466647d8e7de73'},
            'Dash - (2)': {'md5': '394758376fd6bece3d8c523911d4802f'},
            'Dash -': {'md5': '367fcecd09ee039d04346ca9483f15b0'},
            'Delete \x7f (1)': {'md5': '63f936c1f9b6679f6448d6b9dd6907e9'},
            'Delete \x7f (2)': {'md5': '1e5f71a3210f3572ad57cd0f8db7c773'},
            'Delete \x7f': {'md5': '3ffe534d559861937f43e74252183f7d'},
            'Dollar $ (1)': {'md5': '785fcb195fd44a2d958ab601533aaa93'},
            'Dollar $ (2)': {'md5': 'ad033aafabafeeece964f7558f5e0110'},
            'Dollar $': {'md5': '8e567128bd3120c6b504f0ea1c078591'},
            'Dot . (1)': {'md5': 'd6bbeadd3e2949c9a97863faf7941fd1'},
            'Dot . (2)': {'md5': 'ba3e606d9d97dcaa1903c77b4caa62a7'},
            'Dot .': {'md5': 'f312d5c330e833d409ad05fe206b3099'},
            'Double quote " (1)': {'md5': '843b0fa6d0bd93d58b5a1a0960c4be2f'},
            'Double quote " (2)': {'md5': '94912b944529fe9ac74f1d17ffd685ed'},
            'Double quote "': {'md5': '7b20185b51dbce9398dfbe5b3c5c2f44'},
            'Empty': {'mode': 'drwxr-xr-x'},
            'Equal = (1)': {'md5': '83379a04a3ee4566eb3605bc3f5a4ab4'},
            'Equal = (2)': {'md5': '7891ccb015f7c6daec40bbd16c8075ec'},
            'Equal =': {'md5': '69b9497f9f34aba975699c833d037666'},
            'Escape \x1b (1)': {'md5': '9e3c4cefd408009286d5b418ae9988db'},
            'Escape \x1b (2)': {'md5': '8e7878beac19e997da1ef12bd481cb44'},
            'Escape \x1b': {'md5': '1c1ee3412c62f438d4403816482ed486'},
            'Euro € (1)': {'md5': '54dd622a6f10eed2cd59ed2a6f594ef2'},
            'Euro € (2)': {'md5': '3ee86efa36d8cf102288ad2bf936dfa8'},
            'Euro €': {'md5': '8e79df94fcaf890f6433ecf48152919d'},
            'Exclamation ! (1)': {'md5': '10e762da30d7793ee14e7bf0c154078b'},
            'Exclamation ! (2)': {'md5': '22520ad75ab8d3b28e90fd7a4fc2bfc8'},
            'Exclamation !': {'md5': '255abe4fe8bbb0b4f8411913673388fe'},
            'FileOrDir (1)': {'md5': '616efa472d51b7f5aacab007d9a635be'},
            'FileOrDir (2)': {'md5': 'be82b39076b03bcf3828ab09b1c34582'},
            'FileOrDir (3)': {'md5': 'e503e8e89925339aefb47443581ba4bc'},
            'FileOrDir': {'mode': 'drwxr-xr-x'},
            'Hash # (1)': {'md5': '37715cd1852064027118eb0e466d1172'},
            'Hash # (2)': {'md5': '38730629648b8780fe1f4f90738eb6a1'},
            'Hash #': {'md5': '2c33603f6b59836dc9dd61bbd6f47b6d'},
            'LPT1 (1)': {'md5': '695771298fcc161d5375e7ef65fe0cbf'},
            'LPT1 (2)': {'md5': '4c6478198627fe5d5d8ca588782502ea'},
            'LPT1': {'md5': 'e9aa40253c2cda7319f60e127b7a5d2b'},
            'LPT9 (1)': {'md5': '338ad2a4d01b83966a2d93b84991079c'},
            'LPT9 (2)': {'md5': 'a5ee3dd960f9c0457cc778afdc1bc45e'},
            'LPT9': {'md5': 'b5908fed9f25a430cc17f41058517fd7'},
            'Line Feed \n (1)': {'md5': '3e1c022c4be1b6d982289cbd6aeb9eba'},
            'Line Feed \n (2)': {'md5': 'fc8dfff4cc4757c29ab931d9e7f954e9'},
            'Line Feed \n': {'md5': '72af886a4aed8ad6885a7f786ec5b661'},
            'NUL (1)': {'md5': '95c40e86277b9e90a040c3b302d7562c'},
            'NUL (2)': {'md5': 'd6516225315fb534b075c396016ca039'},
            'NUL': {'md5': '485ca989764c13cf55f8ab3d839cfd1e'},
            'One. (1)': {'md5': 'a85a42df93c1b1365a6a593e07a3f80a'},
            'One. (2)': {'md5': 'a05d70a56ff44a1fa4b42a55d5a29c19'},
            'One.': {'md5': 'ab394e10e5ef36efedc9e415c2c3cb42'},
            'PRN (1)': {'md5': '193b412ab1a91011b5ea7accb3c146c2'},
            'PRN (2)': {'md5': 'dc5ef433ccf07082e793a71e17dd2b1f'},
            'PRN': {'md5': 'c6f96d5f3a7313541646d5d8b951dc0d'},
            'Percent % (1)': {'md5': 'c8dd7a81eaf6d6c8e817657aa45063ef'},
            'Percent % (2)': {'md5': '5dca4785fee3d3cf8b7a090555409e1a'},
            'Percent %': {'md5': 'bc402953f32f33d3e8c17360557bd294'},
            'Pipe | (1)': {'md5': '8f2eaf2f601cfc28087f0826c0d0415d'},
            'Pipe | (2)': {'md5': '715f63380171ee77dd2057cff284edd7'},
            'Pipe |': {'md5': '6dfa11c10b119a6f0bea267804707f59'},
            'Plus + (1)': {'md5': '00a706dba456a2da0c8175498c1c2e0a'},
            'Plus + (2)': {'md5': '9dfada89dcbd35eb95e6d6882f4eb79b'},
            'Plus +': {'md5': '2517d92c6a28e82e53086558f599f7a3'},
            'Question ? (1)': {'md5': '2b04a6c5990e28e981052c2a1b487891'},
            'Question ? (2)': {'md5': '96ae8325f90df4cfb401b3d40c65417d'},
            'Question ?': {'md5': '4762c2deeccd2243cbe2c750f6027608'},
            'Round () (1)': {'md5': '66a98a72d65a794b288469d1b9f5a9c7'},
            'Round () (2)': {'md5': '347ea63daa56787225c2575a277fefcf'},
            'Round ()': {'md5': 'ea2053ca1e8235aec047f2debca62161'},
            'Semicolon ; (1)': {'md5': '3ee36aec082f0d0c9f89145d4d3081f8'},
            'Semicolon ; (2)': {'md5': '7977cacb02ea2bbb75517dde267e6904'},
            'Semicolon ;': {'md5': '789f0142366e24b2ce19c7248b3c1103'},
            'Smile 🙂 (1)': {'md5': 'bafa29d272d040544572ab3b4e5cc497'},
            'Smile 🙂 (2)': {'md5': '581599352cd9dda6a541481952d06048'},
            'Smile 🙂': {'md5': 'a870c6b6877e97c950d18d95505a9769'},
            'Space→  (1)': {'md5': '2c8a1745d3f0add39eb277b539fdfeaa'},
            'Space→  (2)': {'md5': 'e272aa59f06c6b9f3af33c941e6b3c5a'},
            'Space→ ': {'md5': '20f9a99e85968869900467f65298c1ba'},
            'Square [] (1)': {'md5': 'bc066cc3c9934b31a337260efc99d1df'},
            'Square [] (2)': {'md5': 'fc77bf99b696d888e4ca937f8cf5097a'},
            'Square []': {'md5': '47a86abc13e9703ca7457ea9e29b83b1'},
            'Star * (1)': {'md5': '2a9362d8d04ce694c85e4d054fa72763'},
            'Star * (2)': {'md5': '341c4e1e9785c4658250c1a37e9fd04f'},
            'Star *': {'md5': '1e1d1d592ee97949db5fa6d1db01a06f'},
            'String Terminator \x9c (1)': {'md5': 'ba7f76d662af39dfbaeb3899c46978ef'},
            'String Terminator \x9c (2)': {'md5': '33abcf5fd9bff50dc5f97592a133d1d2'},
            'String Terminator \x9c': {'md5': 'ad901030332576dfb289e848d7ef5721'},
            'Tab \t (1)': {'md5': '76635ca0e84ce5af2d08804b81fe33e0'},
            'Tab \t (2)': {'md5': '3f92d47d30f56412f37b81aaf302d5eb'},
            'Tab \t': {'md5': 'f546955680b666f1f4ed2cddb173d142'},
            'Three... (1)': {'md5': '79d37b7eedc1aa7d1d54fdf906ea32f4'},
            'Three... (2)': {'md5': '17715f0b716cf0061fce3c6ac99fa035'},
            'Three... (3)': {'md5': '2cc2c8026d87a4a2ee0175d3977bd9db'},
            'Three... (4)': {'md5': 'cc8a892db5bbb6e00783911a977e49e2'},
            'Three... (5)': {'md5': 'd79667406b09137e02359a22570cc69f'},
            'Three...': {'md5': 'fd7443b6ef5da1fe8229a64ac462fbb9'},
            'Tilde ~ (1)': {'md5': 'b9170048f08dafa6d314ff685cf56396'},
            'Tilde ~ (2)': {'md5': '44a6e1123c523c089a1fb2f622a346d9'},
            'Tilde ~': {'md5': 'd644fb4311c7b1c581ed1cbca0913ff3'},
            'Two.. (1)': {'md5': '561eaf19d31ba9f2f3a93f4a1cf740cd'},
            'Two.. (2)': {'md5': '3bfdba5a9317da55733a3aa3db8788c8'},
            'Two..': {'md5': '8b25e8cb6343c03d3dceeb5287c4414f'},
            'One Level Up (1)': {'md5': '5cfff7eb216252fd9edd35ad58f65807'},
            'One Level Up (2)': {'md5': '8926cc7e8073e1079320f2e0b4b2a05c'},
            'One Level Up': {'md5': 'd530362d8793bd2213702f7a8b9eb391'},
            'Two Levels Up (1)': {'md5': 'c1c08ba600c42750bb25007bd93fcd37'},
            'Two Levels Up (2)': {'md5': '35bdc6589118dee115df941fd9775282'},
            'Two Levels Up': {'md5': 'fefd04175ab55cbf25f4e59a62b44c2a'},
            'Three Levels Up (1)': {'md5': '5d7122fa28bb1886d90cdbaee7b8b630'},
            'Three Levels Up (2)': {'md5': '69baf719bc3af25f12c86a2c146ab491'},
            'Three Levels Up': {'md5': '77798d1b2b8f820dbf742a6416d2fd51'},
            'Underscore _ (1)': {'md5': 'c23f32b919508169a496a093839f0e04'},
            'Underscore _ (2)': {'md5': 'cdf441502a50204b943e0a8f943e0668'},
            'Underscore _': {'md5': '3ef0593f0a008dd757bfc49dc75f3f9a'},
            'a' + '🙂' * 62 + ' (1)': {'md5': '6ca3d8755b658c8c0ffe1c1d43b61b2a'},
            'a' + '🙂' * 62 + ' (2)': {'md5': '56e595f226384b9413361d435b5f5e44'},
            'a' + '🙂' * 63: {'md5': 'e1397fa63e2d64195fcedad9348182e8'},
            'a': {'mode': 'drwxr-xr-x'},
            'a/? (1)': {'size': 34, 'md5': 'fea45576dee3469614a677cac21192e3'},
            'a/? (2)': {'size': 35, 'md5': '3e3d527abc6edc59608f027a1f12e581'},
            'a/? (3)': {'size': 35, 'md5': '71f25b9671fe15d79baab1f17c4872dc'},
            'a/? (4)': {'size': 36, 'md5': '48ca4b2216dca0ec1a5de383481b12a0'},
            'a/? (5)': {'size': 34, 'md5': 'c22db2a461a51236bf132906371a5d31'},
            'a/? (6)': {'size': 35, 'md5': 'd56fdea04fc2c01f6556ed8157e4ce5c'},
            'a/?': {'mode': 'drwxr-xr-x'},
            'a/?/b (1)': {'size': 37, 'md5': 'c5bb219b8e035b24e763e1a409e1e9e8'},
            'a/?/b (2)': {'size': 37, 'md5': '7dbda963a520c0bb261685ac28bba6dd'},
            'a/?/b (3)': {'size': 38, 'md5': 'cb1028cae1d77c324c186482ae9edff5'},
            'a/?/b (4)': {'size': 36, 'md5': '6a49956b8999b6ed64b30171994ac1cf'},
            'a/?/b (5)': {'size': 37, 'md5': 'c3552b56f73b2f0e290b38bdd1fb0c69'},
            'a/?/b': {'size': 36, 'md5': '01fa5cbef17c21be0d127cf70dad97e2'},
            'ab' + '🙂' * 62 + ' (1)': {'md5': '4db72400bf44ff1bf81231513083701d'},
            'ab' + '🙂' * 62 + ' (2)': {'md5': 'f7b12f040637a6dae4263bc2817b56eb'},
            'ab' + '🙂' * 63: {'md5': 'fd0d1895da329d89e2396f8300c4f61f'},
            'abc' + '🙂' * 62 + ' (1)': {'md5': '05937261559a83256ddb4d44480bb5c4'},
            'abc' + '🙂' * 62 + ' (2)': {'md5': '6ac381136adf1f350e667f4753e65f63'},
            'abc' + '🙂' * 63: {'md5': '4d222a84ac3bd28f6efd125337e125f6'},
            'abcd' + '🙂' * 61 + ' (1)': {'md5': 'c573d71784261e3c388d489c915b879a'},
            'abcd' + '🙂' * 61 + ' (2)': {'md5': '5f19970d9d6df57bd84b506e6d81807e'},
            'abcd' + '🙂' * 62: {'md5': '51ee3f362dab5fafdb377657487fa09c'},
            'case (1)': {'md5': 'cf53c713e71b4765ebbe560c8e826868'},
            'case (2)': {'md5': '0f94a61da6aaa86267cd006c6812582a'},
            'case': {'md5': '2c6d2601bfa42243878ef4ddd9940b42'},
            'dev': {'mode': 'drwxr-xr-x'},
            'dev/null (1)': {'md5': '0542f8d179a0a5812cca12f9d1a20768'},
            'dev/null (2)': {'md5': '146840ea79bf74bd86d1e73c3a166d4b'},
            'dev/null': {'md5': '2a62812a0e6f22b55507ef85c0e3e3e4'},
            'foo (1).tar.gz': {'md5': '108c35f79486c56c344d0f064e4a511a'},
            'foo (2).tar.gz': {'md5': '387ccc3f6b2333031dc50e733f7827b2'},
            'foo (3).tar.gz': {'md5': '711ab4fd640632037360f79cda1fdc2b'},
            'foo (4).tar.gz': {'md5': '82fd4edb638dae95d133c2c870bc09eb'},
            'foo (5).tar.gz': {'md5': '0e6b54d7b2997381121a0b943e8243b4'},
            'foo.a b (1)': {'md5': 'b9210e8eda5412bf89a20cdac2e0a104'},
            'foo.a b (2)': {'md5': 'aa32c2818b3e50eda6b8fa566d0fe927'},
            'foo.a b (3)': {'md5': '30ff74a98043c1d2c015210865d456d6'},
            'foo.a b (4)': {'md5': '5e9a95548b5b6b53061ec81719bedd8e'},
            'foo.a b (5)': {'md5': 'b10267c805c7c45a8c5a09c4f36377ea'},
            'foo.a b': {'md5': '786e84bde86ba0eb1606b4df422a791d'},
            'foo.tar.gz': {'md5': '06ab92061c8deeabf83b9aefcd0d59b0'},
            'server': {'mode': 'drwxr-xr-x'},
            'server/share': {'mode': 'drwxr-xr-x'},
            'server/share/file (1)': {'md5': 'bcfef6ccd938358b6db770eb1d3de17f'},
            'server/share/file (2)': {'md5': '422825a9e933d11cf4c2f5c0f03e8224'},
            'server/share/file': {'md5': '6bf0dd24273206e58f6dae18c4e0c5d6'},
            'u': {'mode': 'drwxr-xr-x'},
            'u/v': {'mode': 'drwxr-xr-x'},
            'u/v/w': {'mode': 'drwxr-xr-x'},
            'u/v/w/x': {'mode': 'drwxr-xr-x'},
            'u/v/w/x/y': {'mode': 'drwxr-xr-x'},
            'u/v/w/x/y/z (1)': {'md5': '92ca5594530ebbe26df838c6e789a669'},
            'u/v/w/x/y/z (2)': {'md5': '5780324871ddfae6538bd0d427e1c731'},
            'u/v/w/x/y/z': {'md5': '7913f1d77a8a8d35cb62da4e8cba336a'},
            '~ (1)': {'md5': 'db7971e041320de89f83b3caa3c11c7e'},
            '~ (2)': {'md5': '115d04c23c77c6490bd9aebe9cfde881'},
            '~': {'md5': '5dc8143d7f881a02622daaeabf714329'},
            '🏳\u200d🌈' * 22 + ' (1)': {'md5': 'e15a50f8eb3fccdc26132fa06fa1205f'},
            '🏳\u200d🌈' * 22 + ' (2)': {'md5': 'e5918d1b19a95d6a0724bca2b9e74878'},
            '🏳\u200d🌈' * 23: {'md5': '8c29470e347f44ec20cd684c0f192945'},
            '🙂' * 62 + ' (1)': {'md5': '0ca3c0471e4176f8e223d69ffb67e847'},
            '🙂' * 62 + ' (2)': {'md5': '6c191a9c421da46230b4367e4b8e08d9'},
            '🙂' * 63: {'md5': '9ba840cf76707d55db222c1b90cedbfb'},
        },
        'romeo.txt.gz.uu': {
            '.': {'mode': 'drwxr-xr-x'},
            'romeo.txt.gz': {'mode': '-rw-r--r--', 'size': 558, 'md5': 'f261bc929b34f58d8138413ed6252f2d'}
        },
        'dot-slash-foo.tar': {
            '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
            'foo': {'mode': '-rw-r--r--', 'mtime': 1641016352000000000, 'size': 0, 'md5': 'd41d8cd98f00b204e9800998ecf8427e'},
        },
    }

    for zip_name, want_tree in want_trees.items():
        MountArchiveAndCheckTree(zip_name, want_tree, options=options)


def TestSparse():
    if is_fast: return
    if not has_zlib and not has_gzip: return

    want_trees = {
        'zeroes-256mib.tar.gz': {
            '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
            # https://github.com/google/fuse-archive/issues/59
            'zeroes': {'mode': '-rw-r--r--', 'size': 268435456, 'md5': '1f5039e50bd66b290c56684d8550c6c2', 'blocks': 0, 'holes': [0]},
        },
        'sparse.tar.gz': {
            '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
            # https://github.com/google/fuse-archive/issues/40
            'sparse': {'mode': '-rw-r--r--', 'size': 1073741824, 'md5': '5e4001589ffa2c5135f413a13e6800ef', 'blocks': 1, 'holes': [0, 536870912, 536870917]},
        },
        'seek.tar.gz': {
            '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
            'complex_sparse': {'mode': '-rw-r--r--', 'size': 8192, 'md5': '8c16924f2d11167fd4e4215a89dbf435', 'blocks': 3, 'holes': [0, 2048, 3072, 5632, 6144]},
        },
    }

    options = ['precache']
    if has_memcache: options += ['memcache']
    for option in options:
        for zip_name, want_tree in want_trees.items():
            MountArchiveAndCheckTree(zip_name, want_tree, options=['-o', option])

    want_trees = {
        'zeroes-256mib.tar.gz': {
            '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
            # https://github.com/google/fuse-archive/issues/59
            'zeroes': {'mode': '-rw-r--r--', 'size': 268435456, 'md5': '1f5039e50bd66b290c56684d8550c6c2', 'blocks': 524288},
        },
        'sparse.tar.gz': {
            '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
            # https://github.com/google/fuse-archive/issues/40
            'sparse': {'mode': '-rw-r--r--', 'size': 1073741824, 'md5': '5e4001589ffa2c5135f413a13e6800ef', 'blocks': 2097152},
        },
        'seek.tar.gz': {
            '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
            'complex_sparse': {'mode': '-rw-r--r--', 'size': 8192, 'md5': '8c16924f2d11167fd4e4215a89dbf435', 'blocks': 16},
        },
    }

    for option in ['lazycache', 'nocache', 'noholes']:
        for zip_name, want_tree in want_trees.items():
            MountArchiveAndCheckTree(zip_name, want_tree, options=['-o', option])


def TestFilteredZip():
    zip_names = []
    if has_gpg:
        zip_names += ['archive.zip.gpg', 'archive.zip.pgp', 'archive.zip.asc']

    if has_gzip or has_zlib:
        zip_names += ['archive.zip.gz']

    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
        'archive.zip': {'ino': 2, 'mode': '-rw-r--r--', 'size': 3480, 'md5': 'e43a4ee1eb970d00b6c0ebf6e25347d5'},
    }

    for zip_name in zip_names:
        MountArchiveAndCheckTree(zip_name,
                                 want_tree,
                                 options=['-o', 'lazycache'])
        MountArchiveAndCheckTree(zip_name,
                                 want_tree,
                                 options=['-o', 'nocache'])

    zip_names = []
    if has_gpg:
        zip_names += ['archive.7z.gpg']

    if has_gzip or has_zlib:
        zip_names += ['archive.7z.gz']

    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
        'archive.7z': {'ino': 2, 'mode': '-rw-r--r--', 'size': 2486, 'md5': '168baf5e253d792233880badd945c1de'},
    }

    for zip_name in zip_names:
        MountArchiveAndCheckTree(zip_name,
                                 want_tree,
                                 options=['-o', 'lazycache'])
        MountArchiveAndCheckTree(zip_name,
                                 want_tree,
                                 options=['-o', 'nocache'])

    zip_names = []
    if has_gpg:
        if has_zlib:
            zip_names += [
                'archive.zip.gpg', 'archive.zip.pgp', 'archive.zip.asc'
            ]

        if has_liblzma:
            zip_names += ['archive.7z.gpg']

    if has_gzip or has_zlib:
        if has_zlib:
            zip_names += ['archive.zip.gz']

        if has_liblzma:
            zip_names += ['archive.7z.gz']

    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 4},
        'artificial': {'mode': 'drwxr-xr-x'},
        'artificial/0.bytes': {'mode': '-rw-r--r--', 'mtime': 1580883024000000000, 'size': 0, 'md5': 'd41d8cd98f00b204e9800998ecf8427e'},
        'github-tags.json': {'mode': '-rw-r--r--', 'mtime': 1597241062000000000, 'size': 853, 'md5': 'b2d7993ed99c65296bf95824c57b4fdc'},
        'hello.sh': {'mode': '-rwxr-xr-x', 'mtime': 1620022795000000000, 'size': 693, 'md5': '72d710dd3766a67401a79f8d3df3114c'},
        'non-ascii': {'mode': 'drwxr-xr-x'},
        'non-ascii/αβ.txt': {'mode': '-rw-r--r--', 'mtime': 1620022605000000000, 'size': 104, 'md5': '3369a4163a436de59e23daedd371b5f0'},
        'non-ascii/😻.txt': {'mode': '-rw-r--r--', 'mtime': 1620022983000000000, 'size': 151, 'md5': '5d18e0e461374191825c6e7898af5634'},
        'pjw-thumbnail.png': {'mode': '-rw-r--r--', 'mtime': 1580883024000000000, 'size': 208, 'md5': 'f7017e60a0af6d7ad3128c149624aac5'},
        'romeo.txt': {'mode': '-rw-r--r--', 'mtime': 1580883024000000000, 'size': 942, 'md5': '80f1521c4533d017df063c623b75cde3'},
        'romeo.txt.gz': {'mode': '-rw-r--r--', 'mtime': 1580883024000000000, 'size': 558, 'md5': 'f261bc929b34f58d8138413ed6252f2d'},
    }

    for zip_name in zip_names:
        MountArchiveAndCheckTree(zip_name, want_tree)


# Tests that specialized ZIP formats (like APK) remain opaque when filtered.
def TestFilteredOpaque():
    if not has_gpg: return

    # archive.apk.gpg is a symlink to archive.zip.gpg.
    # Because .apk is not in seekable_exts, it should NOT be exploded.
    zip_name = 'archive.apk.gpg'
    want_tree = {
        '.': {'mode': 'drwxr-xr-x'},
        'archive.apk': {'size': 3480},
    }
    # Use maxfilters=1 (implicit) as .gpg is the only filter.
    MountArchiveAndCheckTree(zip_name, want_tree, strict=True, use_md5=False)


def TestHardlinks(options=[]):
    zip_name = 'hardlinks.tar'

    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 3, 'mtime': 1727754916000000000},
        'Dir1': {'ino': 3, 'mode': 'drwxr-xr-x', 'nlink': 3, 'mtime': 1727754809000000000},
        'Dir1/Dir2': {'ino': 4, 'mode': 'drwxr-xr-x', 'nlink': 2, 'mtime': 1727754818000000000},
        'Dir1/Dir2/File': {'ino': 2, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'Dir1/File': {'ino': 2, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'File1': {'ino': 2, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'File2': {'ino': 2, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'File3': {'ino': 2, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'File4': {'ino': 2, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'File5': {'ino': 2, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'Symlink1': {'ino': 5, 'mode': 'lrwxr-xr-x', 'nlink': 3, 'mtime': 1727754873000000000, 'target': 'Target', 'xattr': {}},
        'Symlink2': {'ino': 5, 'mode': 'lrwxr-xr-x', 'nlink': 3, 'mtime': 1727754873000000000, 'target': 'Target', 'xattr': {}},
        'Symlink3': {'ino': 5, 'mode': 'lrwxr-xr-x', 'nlink': 3, 'mtime': 1727754873000000000, 'target': 'Target', 'xattr': {}},
    }

    MountArchiveAndCheckTree(zip_name,
                             want_tree,
                             want_blocks=15,
                             want_inodes=5,
                             options=options)

    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 4},
        'hardlinks': {'ino': 2, 'mode': 'drwxr-xr-x', 'nlink': 3, 'mtime': 1727754916000000000},
        'hardlinks/Dir1': {'ino': 4, 'mode': 'drwxr-xr-x', 'nlink': 3, 'mtime': 1727754809000000000},
        'hardlinks/Dir1/Dir2': {'ino': 5, 'mode': 'drwxr-xr-x', 'nlink': 2, 'mtime': 1727754818000000000},
        'hardlinks/Dir1/Dir2/File': {'ino': 3, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'hardlinks/Dir1/File': {'ino': 3, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'hardlinks/File1': {'ino': 3, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'hardlinks/File2': {'ino': 3, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'hardlinks/File3': {'ino': 3, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'hardlinks/File4': {'ino': 3, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'hardlinks/File5': {'ino': 3, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'hardlinks/Symlink1': {'ino': 6, 'mode': 'lrwxr-xr-x', 'nlink': 3, 'mtime': 1727754873000000000, 'target': 'Target', 'xattr': {}},
        'hardlinks/Symlink2': {'ino': 6, 'mode': 'lrwxr-xr-x', 'nlink': 3, 'mtime': 1727754873000000000, 'target': 'Target', 'xattr': {}},
        'hardlinks/Symlink3': {'ino': 6, 'mode': 'lrwxr-xr-x', 'nlink': 3, 'mtime': 1727754873000000000, 'target': 'Target', 'xattr': {}},
        'hardlinks (1)': {'ino': 7, 'mode': 'drwxr-xr-x', 'nlink': 3, 'mtime': 1727754916000000000},
        'hardlinks (1)/Dir1': {'ino': 9, 'mode': 'drwxr-xr-x', 'nlink': 3, 'mtime': 1727754809000000000},
        'hardlinks (1)/Dir1/Dir2': {'ino': 10, 'mode': 'drwxr-xr-x', 'nlink': 2, 'mtime': 1727754818000000000},
        'hardlinks (1)/Dir1/Dir2/File': {'ino': 8, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'hardlinks (1)/Dir1/File': {'ino': 8, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'hardlinks (1)/File1': {'ino': 8, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'hardlinks (1)/File2': {'ino': 8, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'hardlinks (1)/File3': {'ino': 8, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'hardlinks (1)/File4': {'ino': 8, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'hardlinks (1)/File5': {'ino': 8, 'mode': '-rw-r--r--', 'nlink': 7, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'hardlinks (1)/Symlink1': {'ino': 11, 'mode': 'lrwxr-xr-x', 'nlink': 3, 'mtime': 1727754873000000000, 'target': 'Target', 'xattr': {}},
        'hardlinks (1)/Symlink2': {'ino': 11, 'mode': 'lrwxr-xr-x', 'nlink': 3, 'mtime': 1727754873000000000, 'target': 'Target', 'xattr': {}},
        'hardlinks (1)/Symlink3': {'ino': 11, 'mode': 'lrwxr-xr-x', 'nlink': 3, 'mtime': 1727754873000000000, 'target': 'Target', 'xattr': {}},
    }

    MountArchiveAndCheckTree([zip_name, zip_name],
                             want_tree,
                             want_blocks=31,
                             want_inodes=11,
                             options=[*options, '-o', 'nomerge'])

    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 3, 'mtime': 1727754916000000000},
        'Dir1': {'ino': 3, 'mode': 'drwxr-xr-x', 'nlink': 3, 'mtime': 1727754809000000000},
        'Dir1/Dir2': {'ino': 4, 'mode': 'drwxr-xr-x', 'nlink': 2, 'mtime': 1727754818000000000},
        'File4': {'ino': 2, 'mode': '-rw-r--r--', 'nlink': 1, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'Symlink2': {'ino': 5, 'mode': 'lrwxr-xr-x', 'nlink': 1, 'mtime': 1727754873000000000, 'target': 'Target', 'xattr': {}},
    }

    MountArchiveAndCheckTree(zip_name,
                             want_tree,
                             want_blocks=7,
                             want_inodes=5,
                             options=[*options, '-o', 'nohardlinks'])

    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
        'File4': {'ino': 2, 'mode': '-rw-r--r--', 'nlink': 1, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'Symlink2': {'ino': 3, 'mode': 'lrwxr-xr-x', 'nlink': 1, 'mtime': 1727754873000000000, 'target': 'Target', 'xattr': {}},
    }

    MountArchiveAndCheckTree(zip_name,
                             want_tree,
                             want_blocks=5,
                             want_inodes=3,
                             options=[*options, '-o', 'nodirs'])

    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 4},
        'hardlinks': {'ino': 2, 'mode': 'drwxr-xr-x', 'nlink': 2},
        'hardlinks/File4': {'ino': 3, 'mode': '-rw-r--r--', 'nlink': 1, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'hardlinks/Symlink2': {'ino': 4, 'mode': 'lrwxr-xr-x', 'nlink': 1, 'mtime': 1727754873000000000, 'target': 'Target', 'xattr': {}},
        'hardlinks (1)': {'ino': 5, 'mode': 'drwxr-xr-x', 'nlink': 2},
        'hardlinks (1)/File4': {'ino': 6, 'mode': '-rw-r--r--', 'nlink': 1, 'mtime': 1727754740000000000, 'size': 35, 'blocks': 1, 'xattr': {}, 'md5': '972fc6414a197a62c6c84fe8da0cf5ca'},
        'hardlinks (1)/Symlink2': {'ino': 7, 'mode': 'lrwxr-xr-x', 'nlink': 1, 'mtime': 1727754873000000000, 'target': 'Target', 'xattr': {}},
    }

    MountArchiveAndCheckTree([zip_name, zip_name],
                             want_tree,
                             want_blocks=11,
                             want_inodes=7,
                             options=[*options, '-o', 'nomerge,nodirs'])

    if not has_gzip and not has_zlib: return

    # Tests hardlinks with extended attributes consistency.
    zip_name = 'xattr_hardlinks.tar.gz'
    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
        'original': {
            'ino': 2, 'mode': '-rw-r--r--', 'nlink': 2,
            'xattr': {'user.test_attr': 'test_value'},
        },
        'link': {
            'ino': 2, 'mode': '-rw-r--r--', 'nlink': 2,
            'xattr': {'user.test_attr': 'test_value'},
        },
    }

    MountArchiveAndCheckTree(zip_name,
                             want_tree,
                             options=options,
                             use_md5=False)


# Tests sparse file seeking logic.
def TestSeek(options=[]):
    if not has_gzip and not has_zlib: return
    if not has_holes:
        logging.info('Skipping TestSeek')
        return

    zip_name = 'seek.tar.gz'
    s = f'Test {zip_name!r}'
    if options: s += f', options = {" ".join(options)!r}'
    logging.info(s)
    with MountArchive(zip_name, options=options) as mount_point:
        path = os.path.join(mount_point, 'complex_sparse')

        # In lazycache mode, holes are not known until the file is read.
        if '-o' in options and 'lazycache' in options:
            with open(path, 'rb') as f:
                f.read()
            # Wait for any async processing or FUSE attribute cache
            time.sleep(1)

        fd = os.open(path, os.O_RDONLY)
        try:
            is_noholes = '-o' in options and 'noholes' in options
            is_nocache = '-o' in options and 'nocache' in options

            def check_seek(offset, whence, want):
                got = os.lseek(fd, offset, whence)
                if got != want:
                    LogError(
                        f'Seek mismatch for {whence} at {offset}: got {got}, want {want} (options={options!r})'
                    )

            def check_enxio(offset, whence):
                try:
                    os.lseek(fd, offset, whence)
                    LogError(
                        f'Seek {whence} at {offset} should fail with ENXIO (options={options!r})'
                    )
                except OSError as e:
                    if e.errno != errno.ENXIO:
                        LogError(
                            f'Seek {whence} at {offset} failed with wrong error: {e.errno} (options={options!r})'
                        )

            if is_noholes or is_nocache:
                # No holes reported
                check_seek(0, os.SEEK_DATA, 0)
                check_seek(2048, os.SEEK_DATA, 2048)
                check_seek(3072, os.SEEK_DATA, 3072)
                check_seek(5120, os.SEEK_DATA, 5120)
                check_seek(0, os.SEEK_HOLE, 8192)
                check_seek(2048, os.SEEK_HOLE, 8192)
                check_seek(3072, os.SEEK_HOLE, 8192)
                check_seek(5120, os.SEEK_HOLE, 8192)
            else:
                # Based on WriteBytesAndSkipHoles logic with min_hole_size=1024:
                # [0, 2048): HOLE
                # [2048, 3072): DATA ('A's)
                # [3072, 5120): HOLE
                # [5120, 5632): 512 NULs < 1024. Merged into the previous hole!
                # [5632, 6144): DATA ('B's)
                # [6144, 8192): HOLE
                # Resulting Holes: [0, 2048), [3072, 5632), [6144, 8192)
                # Resulting Data: [2048, 3072), [5632, 6144)

                check_seek(0, os.SEEK_DATA, 2048)
                check_seek(2048, os.SEEK_DATA, 2048)
                check_seek(3000, os.SEEK_DATA, 3000)
                check_seek(3072, os.SEEK_DATA, 5632)
                check_seek(5120, os.SEEK_DATA, 5632)
                check_seek(5632, os.SEEK_DATA, 5632)
                check_enxio(6144, os.SEEK_DATA)

                check_seek(0, os.SEEK_HOLE, 0)
                check_seek(1024, os.SEEK_HOLE, 1024)
                check_seek(2048, os.SEEK_HOLE, 3072)
                check_seek(5120, os.SEEK_HOLE, 5120)
                check_seek(5632, os.SEEK_HOLE, 6144)
                check_seek(7000, os.SEEK_HOLE, 7000)

            check_enxio(8192, os.SEEK_DATA)
            check_enxio(8192, os.SEEK_HOLE)
            check_enxio(9000, os.SEEK_DATA)
            check_enxio(9000, os.SEEK_HOLE)
        finally:
            os.close(fd)


# Tests multiple archives, nomerge and notrim options.
def TestMultiArchive(options=[]):
    if not has_gzip and not has_zlib: return

    zip1 = 'multi1.tar.gz'
    zip2 = 'multi2.tar.gz'
    zip_names = [zip1, zip2]

    # Default: merged and trimmed.
    # multi1 has a/b/c/file1
    # multi2 has a/b/d/file2
    # Common prefix is a/b/, so it should be trimmed.
    want_tree = {
        '.': {'mode': 'drwxr-xr-x'},
        'c': {'mode': 'drwxr-xr-x'},
        'c/file1': {'size': 6, 'md5': '5149d403009a139c7e085405ef762e1a'},
        'd': {'mode': 'drwxr-xr-x'},
        'd/file2': {'size': 6, 'md5': '3d709e89c8ce201e3c928eb917989aef'},
    }
    MountArchiveAndCheckTree(zip_names, want_tree, options=options)

    # notrim: merged but not trimmed.
    want_tree = {
        '.': {'mode': 'drwxr-xr-x'},
        'a': {'mode': 'drwxr-xr-x'},
        'a/b': {'mode': 'drwxr-xr-x'},
        'a/b/c': {'mode': 'drwxr-xr-x'},
        'a/b/c/file1': {'size': 6, 'md5': '5149d403009a139c7e085405ef762e1a'},
        'a/b/d': {'mode': 'drwxr-xr-x'},
        'a/b/d/file2': {'size': 6, 'md5': '3d709e89c8ce201e3c928eb917989aef'},
    }
    MountArchiveAndCheckTree(zip_names,
                             want_tree,
                             options=[*options, '-o', 'notrim'])

    # nomerge: not merged, and each archive is trimmed.
    want_tree = {
        '.': {'mode': 'drwxr-xr-x'},
        'multi1': {'mode': 'drwxr-xr-x'},
        'multi1/file1': {'size': 6, 'md5': '5149d403009a139c7e085405ef762e1a'},
        'multi2': {'mode': 'drwxr-xr-x'},
        'multi2/file2': {'size': 6, 'md5': '3d709e89c8ce201e3c928eb917989aef'},
    }
    MountArchiveAndCheckTree(zip_names,
                             want_tree,
                             options=[*options, '-o', 'nomerge'])

    # nomerge and notrim: not merged, not trimmed.
    want_tree = {
        '.': {'mode': 'drwxr-xr-x'},
        'multi1': {'mode': 'drwxr-xr-x'},
        'multi1/a': {'mode': 'drwxr-xr-x'},
        'multi1/a/b': {'mode': 'drwxr-xr-x'},
        'multi1/a/b/c': {'mode': 'drwxr-xr-x'},
        'multi1/a/b/c/file1': {'size': 6, 'md5': '5149d403009a139c7e085405ef762e1a'},
        'multi2': {'mode': 'drwxr-xr-x'},
        'multi2/a': {'mode': 'drwxr-xr-x'},
        'multi2/a/b': {'mode': 'drwxr-xr-x'},
        'multi2/a/b/d': {'mode': 'drwxr-xr-x'},
        'multi2/a/b/d/file2': {'size': 6, 'md5': '3d709e89c8ce201e3c928eb917989aef'},
    }
    MountArchiveAndCheckTree(zip_names,
                             want_tree,
                             options=[*options, '-o', 'nomerge,notrim'])

    # Merged with collisions.
    want_tree = {
        '.': {'mode': 'drwxr-xr-x'},
        'file1': {'size': 6, 'md5': '5149d403009a139c7e085405ef762e1a'},
        'file1 (1)': {'size': 6, 'md5': '5149d403009a139c7e085405ef762e1a'},
    }
    MountArchiveAndCheckTree([zip1, zip1], want_tree, options=options)


# Tests SUID, SGID and sticky bits with -o enforce_permissions.
def TestSpecialPermissions(options=[]):
    if not has_gzip and not has_zlib: return
    zip_name = 'special_perms.tar.gz'
    want_tree = {
        '.': {'mode': 'drwxr-xr-x'},
        'suid': {'mode': '-rwsr-xr-x'},
        'sgid': {'mode': '-rwxr-sr-x'},
        'suid_sgid': {'mode': '-rwsr-sr-x'},
        'sticky': {'mode': 'drwxr-xr-t'},
        'all': {'mode': '-rwsr-sr-t'},
    }
    # -o enforce_permissions tells fuse-archive to use the bits from the archive.
    MountArchiveAndCheckTree(zip_name,
                             want_tree,
                             options=[*options, '-o', 'enforce_permissions'],
                             use_md5=False)


# Tests dmask and fmask.
def TestMasks():
    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 24},
        'Dir000': {'mode': 'drwxr-xr-x'},
        'Dir001': {'mode': 'drwxr-xr-x'},
        'Dir002': {'mode': 'drwxr-xr-x'},
        'Dir003': {'mode': 'drwxr-xr-x'},
        'Dir004': {'mode': 'drwxr-xr-x'},
        'Dir005': {'mode': 'drwxr-xr-x'},
        'Dir006': {'mode': 'drwxr-xr-x'},
        'Dir007': {'mode': 'drwxr-xr-x'},
        'Dir010': {'mode': 'drwxr-xr-x'},
        'Dir020': {'mode': 'drwxr-xr-x'},
        'Dir030': {'mode': 'drwxr-xr-x'},
        'Dir040': {'mode': 'drwxr-xr-x'},
        'Dir050': {'mode': 'drwxr-xr-x'},
        'Dir060': {'mode': 'drwxr-xr-x'},
        'Dir070': {'mode': 'drwxr-xr-x'},
        'Dir100': {'mode': 'drwxr-xr-x'},
        'Dir200': {'mode': 'drwxr-xr-x'},
        'Dir300': {'mode': 'drwxr-xr-x'},
        'Dir400': {'mode': 'drwxr-xr-x'},
        'Dir500': {'mode': 'drwxr-xr-x'},
        'Dir600': {'mode': 'drwxr-xr-x'},
        'Dir700': {'mode': 'drwxr-xr-x'},
        'File000': {'mode': '-rw-r--r--'},
        'File001': {'mode': '-rwxr-xr-x'},
        'File002': {'mode': '-rw-r--r--'},
        'File003': {'mode': '-rwxr-xr-x'},
        'File004': {'mode': '-rw-r--r--'},
        'File005': {'mode': '-rwxr-xr-x'},
        'File006': {'mode': '-rw-r--r--'},
        'File007': {'mode': '-rwxr-xr-x'},
        'File010': {'mode': '-rwxr-xr-x'},
        'File020': {'mode': '-rw-r--r--'},
        'File030': {'mode': '-rwxr-xr-x'},
        'File040': {'mode': '-rw-r--r--'},
        'File050': {'mode': '-rwxr-xr-x'},
        'File060': {'mode': '-rw-r--r--'},
        'File070': {'mode': '-rwxr-xr-x'},
        'File100': {'mode': '-rwxr-xr-x'},
        'File200': {'mode': '-rw-r--r--'},
        'File300': {'mode': '-rwxr-xr-x'},
        'File400': {'mode': '-rw-r--r--'},
        'File500': {'mode': '-rwxr-xr-x'},
        'File600': {'mode': '-rw-r--r--'},
        'File700': {'mode': '-rwxr-xr-x'},
    }

    MountArchiveAndCheckTree('permissions.tar', want_tree, use_md5=False)

    want_tree = {
        '.': {'ino': 1, 'mode': 'drwx------', 'nlink': 24},
        'Dir000': {'mode': 'drwx------'},
        'Dir001': {'mode': 'drwx------'},
        'Dir002': {'mode': 'drwx------'},
        'Dir003': {'mode': 'drwx------'},
        'Dir004': {'mode': 'drwx------'},
        'Dir005': {'mode': 'drwx------'},
        'Dir006': {'mode': 'drwx------'},
        'Dir007': {'mode': 'drwx------'},
        'Dir010': {'mode': 'drwx------'},
        'Dir020': {'mode': 'drwx------'},
        'Dir030': {'mode': 'drwx------'},
        'Dir040': {'mode': 'drwx------'},
        'Dir050': {'mode': 'drwx------'},
        'Dir060': {'mode': 'drwx------'},
        'Dir070': {'mode': 'drwx------'},
        'Dir100': {'mode': 'drwx------'},
        'Dir200': {'mode': 'drwx------'},
        'Dir300': {'mode': 'drwx------'},
        'Dir400': {'mode': 'drwx------'},
        'Dir500': {'mode': 'drwx------'},
        'Dir600': {'mode': 'drwx------'},
        'Dir700': {'mode': 'drwx------'},
        'File000': {'mode': '-rw-r--r--'},
        'File001': {'mode': '-rwxr-xr-x'},
        'File002': {'mode': '-rw-r--r--'},
        'File003': {'mode': '-rwxr-xr-x'},
        'File004': {'mode': '-rw-r--r--'},
        'File005': {'mode': '-rwxr-xr-x'},
        'File006': {'mode': '-rw-r--r--'},
        'File007': {'mode': '-rwxr-xr-x'},
        'File010': {'mode': '-rwxr-xr-x'},
        'File020': {'mode': '-rw-r--r--'},
        'File030': {'mode': '-rwxr-xr-x'},
        'File040': {'mode': '-rw-r--r--'},
        'File050': {'mode': '-rwxr-xr-x'},
        'File060': {'mode': '-rw-r--r--'},
        'File070': {'mode': '-rwxr-xr-x'},
        'File100': {'mode': '-rwxr-xr-x'},
        'File200': {'mode': '-rw-r--r--'},
        'File300': {'mode': '-rwxr-xr-x'},
        'File400': {'mode': '-rw-r--r--'},
        'File500': {'mode': '-rwxr-xr-x'},
        'File600': {'mode': '-rw-r--r--'},
        'File700': {'mode': '-rwxr-xr-x'},
    }

    MountArchiveAndCheckTree('permissions.tar',
                             want_tree,
                             use_md5=False,
                             options=['-o', 'dmask=077'])

    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 24},
        'Dir000': {'mode': 'drwxr-xr-x'},
        'Dir001': {'mode': 'drwxr-xr-x'},
        'Dir002': {'mode': 'drwxr-xr-x'},
        'Dir003': {'mode': 'drwxr-xr-x'},
        'Dir004': {'mode': 'drwxr-xr-x'},
        'Dir005': {'mode': 'drwxr-xr-x'},
        'Dir006': {'mode': 'drwxr-xr-x'},
        'Dir007': {'mode': 'drwxr-xr-x'},
        'Dir010': {'mode': 'drwxr-xr-x'},
        'Dir020': {'mode': 'drwxr-xr-x'},
        'Dir030': {'mode': 'drwxr-xr-x'},
        'Dir040': {'mode': 'drwxr-xr-x'},
        'Dir050': {'mode': 'drwxr-xr-x'},
        'Dir060': {'mode': 'drwxr-xr-x'},
        'Dir070': {'mode': 'drwxr-xr-x'},
        'Dir100': {'mode': 'drwxr-xr-x'},
        'Dir200': {'mode': 'drwxr-xr-x'},
        'Dir300': {'mode': 'drwxr-xr-x'},
        'Dir400': {'mode': 'drwxr-xr-x'},
        'Dir500': {'mode': 'drwxr-xr-x'},
        'Dir600': {'mode': 'drwxr-xr-x'},
        'Dir700': {'mode': 'drwxr-xr-x'},
        'File000': {'mode': '-rw-------'},
        'File001': {'mode': '-rwx------'},
        'File002': {'mode': '-rw-------'},
        'File003': {'mode': '-rwx------'},
        'File004': {'mode': '-rw-------'},
        'File005': {'mode': '-rwx------'},
        'File006': {'mode': '-rw-------'},
        'File007': {'mode': '-rwx------'},
        'File010': {'mode': '-rwx------'},
        'File020': {'mode': '-rw-------'},
        'File030': {'mode': '-rwx------'},
        'File040': {'mode': '-rw-------'},
        'File050': {'mode': '-rwx------'},
        'File060': {'mode': '-rw-------'},
        'File070': {'mode': '-rwx------'},
        'File100': {'mode': '-rwx------'},
        'File200': {'mode': '-rw-------'},
        'File300': {'mode': '-rwx------'},
        'File400': {'mode': '-rw-------'},
        'File500': {'mode': '-rwx------'},
        'File600': {'mode': '-rw-------'},
        'File700': {'mode': '-rwx------'},
    }

    MountArchiveAndCheckTree('permissions.tar',
                             want_tree,
                             use_md5=False,
                             options=['-o', 'fmask=077'])

    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxrwxrwx', 'nlink': 24},
        'Dir000': {'mode': 'drwxrwxrwx'},
        'Dir001': {'mode': 'drwxrwxrwx'},
        'Dir002': {'mode': 'drwxrwxrwx'},
        'Dir003': {'mode': 'drwxrwxrwx'},
        'Dir004': {'mode': 'drwxrwxrwx'},
        'Dir005': {'mode': 'drwxrwxrwx'},
        'Dir006': {'mode': 'drwxrwxrwx'},
        'Dir007': {'mode': 'drwxrwxrwx'},
        'Dir010': {'mode': 'drwxrwxrwx'},
        'Dir020': {'mode': 'drwxrwxrwx'},
        'Dir030': {'mode': 'drwxrwxrwx'},
        'Dir040': {'mode': 'drwxrwxrwx'},
        'Dir050': {'mode': 'drwxrwxrwx'},
        'Dir060': {'mode': 'drwxrwxrwx'},
        'Dir070': {'mode': 'drwxrwxrwx'},
        'Dir100': {'mode': 'drwxrwxrwx'},
        'Dir200': {'mode': 'drwxrwxrwx'},
        'Dir300': {'mode': 'drwxrwxrwx'},
        'Dir400': {'mode': 'drwxrwxrwx'},
        'Dir500': {'mode': 'drwxrwxrwx'},
        'Dir600': {'mode': 'drwxrwxrwx'},
        'Dir700': {'mode': 'drwxrwxrwx'},
        'File000': {'mode': '-rw-rw-rw-'},
        'File001': {'mode': '-rwxrwxrwx'},
        'File002': {'mode': '-rw-rw-rw-'},
        'File003': {'mode': '-rwxrwxrwx'},
        'File004': {'mode': '-rw-rw-rw-'},
        'File005': {'mode': '-rwxrwxrwx'},
        'File006': {'mode': '-rw-rw-rw-'},
        'File007': {'mode': '-rwxrwxrwx'},
        'File010': {'mode': '-rwxrwxrwx'},
        'File020': {'mode': '-rw-rw-rw-'},
        'File030': {'mode': '-rwxrwxrwx'},
        'File040': {'mode': '-rw-rw-rw-'},
        'File050': {'mode': '-rwxrwxrwx'},
        'File060': {'mode': '-rw-rw-rw-'},
        'File070': {'mode': '-rwxrwxrwx'},
        'File100': {'mode': '-rwxrwxrwx'},
        'File200': {'mode': '-rw-rw-rw-'},
        'File300': {'mode': '-rwxrwxrwx'},
        'File400': {'mode': '-rw-rw-rw-'},
        'File500': {'mode': '-rwxrwxrwx'},
        'File600': {'mode': '-rw-rw-rw-'},
        'File700': {'mode': '-rwxrwxrwx'},
    }

    MountArchiveAndCheckTree('permissions.tar',
                             want_tree,
                             use_md5=False,
                             options=['-o', 'dmask=0,fmask=0'])


# Tests the archive with lots of files.
def TestArchiveWithManyFiles():
    if is_fast: return

    # Only check a few files: the first one, the last one, and one in the middle.
    want_tree = {
        '1': {
            'mode': '-rw-r--r--',
            'nlink': 1,
            'mtime': 1371243195000000000,
            'size': 0,
        },
        '30000': {
            'mode': '-rw-r--r--',
            'nlink': 1,
            'mtime': 1371243200000000000,
            'size': 0,
        },
        '65536': {
            'mode': '-rw-r--r--',
            'nlink': 1,
            'mtime': 1371243206000000000,
            'size': 0,
        },
    }

    MountArchiveAndCheckTree(
        '65536-files.zip',
        want_tree,
        want_blocks=65537,
        want_inodes=65537,
        strict=False,
        use_md5=False,
    )

    want_tree = {
        'a/b/c/d/e/f/g/h/i/j/There are many versions of this file': {'size': 0},
        'a/b/c/d/e/f/g/h/i/j/There are many versions of this file (1)': {'size': 0},
        'a/b/c/d/e/f/g/h/i/j/There are many versions of this file (2)': {'size': 18},
        'a/b/c/d/e/f/g/h/i/j/There are many versions of this file (3)': {'size': 0},
        'a/b/c/d/e/f/g/h/i/j/There are many versions of this file (4)': {'size': 19},
        'a/b/c/d/e/f/g/h/i/j/There are many versions of this file (5)': {'size': 0},
        'a/b/c/d/e/f/g/h/i/j/There are many versions of this file (50000)': {'size': 0},
        'a/b/c/d/e/f/g/h/i/j/There are many versions of this file (99999)': {'size': 0},
        'a/b/c/d/e/f/g/h/i/j/There are many versions of this file (100000)': {'size': 0},
        'a/b/c/d/e/f/g/h/i/j/There are many versions of this file (100001)': {'size': 0},
        'a/b/c/d/e/f/g/h/i/j/There are many versions of this file (100002)': {'size': 8},
    }

    MountArchiveAndCheckTree(
        'collisions.zip',
        want_tree,
        want_blocks=100017,
        want_inodes=100014,
        strict=False,
        use_md5=False,
        options=['-o', 'notrim'],
    )

    want_tree = {
        'There are many versions of this file': {'size': 0},
        'There are many versions of this file (1)': {'size': 0},
        'There are many versions of this file (2)': {'size': 18},
        'There are many versions of this file (3)': {'size': 0},
        'There are many versions of this file (4)': {'size': 19},
        'There are many versions of this file (5)': {'size': 0},
        'There are many versions of this file (50000)': {'size': 0},
        'There are many versions of this file (99999)': {'size': 0},
        'There are many versions of this file (100000)': {'size': 0},
        'There are many versions of this file (100001)': {'size': 0},
        'There are many versions of this file (100002)': {'size': 8},
    }

    MountArchiveAndCheckTree(
        'collisions.zip',
        want_tree,
        want_blocks=100007,
        want_inodes=100004,
        strict=False,
        use_md5=False,
    )


# Tests archives with lots of directories or with the `nodirs` option.
def TestDirectories():
    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
        '0.bytes': {'mode': '-rw-r--r--', 'mtime': 1580883024000000000, 'size': 0, 'md5': 'd41d8cd98f00b204e9800998ecf8427e'},
        'github-tags.json': {'mode': '-rw-r--r--', 'mtime': 1597241062000000000, 'size': 853, 'md5': 'b2d7993ed99c65296bf95824c57b4fdc'},
        'hello.sh': {'mode': '-rwxr-xr-x', 'mtime': 1620022795000000000, 'size': 693, 'md5': '72d710dd3766a67401a79f8d3df3114c'},
        'αβ.txt': {'mode': '-rw-r--r--', 'mtime': 1620022605000000000, 'size': 104, 'md5': '3369a4163a436de59e23daedd371b5f0'},
        '😻.txt': {'mode': '-rw-r--r--', 'mtime': 1620022983000000000, 'size': 151, 'md5': '5d18e0e461374191825c6e7898af5634'},
        'pjw-thumbnail.png': {'mode': '-rw-r--r--', 'mtime': 1580883024000000000, 'size': 208, 'md5': 'f7017e60a0af6d7ad3128c149624aac5'},
        'romeo.txt': {'mode': '-rw-r--r--', 'mtime': 1580883024000000000, 'size': 942, 'md5': '80f1521c4533d017df063c623b75cde3'},
        'romeo.txt.gz': {'mode': '-rw-r--r--', 'mtime': 1580883024000000000, 'size': 558, 'md5': 'f261bc929b34f58d8138413ed6252f2d'},
    }

    MountArchiveAndCheckTree(
        'archive.tar',
        want_tree,
        want_blocks=20,
        want_inodes=9,
        options=['-o', 'nodirs'],
    )

    if is_fast: return

    want_tree = {
        '.': {'mode': 'drwxr-xr-x', 'nlink': 2},
        'x': {'size': 11, 'md5': 'c35b0cc5c42889e0d73641ca9bba4a94'},
        'x (1)': {'size': 11, 'md5': '195da93cb14caf19156fbed6ffbf67bd'},
        'x (5000)': {'size': 11, 'md5': '6a4ccf8421aa25691785783138f79c19'},
        'x (9999)': {'size': 11, 'md5': '016d3cf3f8a3a4951b2e20ca8e783d09'},
    }

    MountArchiveAndCheckTree(
        'many_nodes.zip',
        want_tree,
        want_blocks=20001,
        want_inodes=10001,
        options=['-o', 'nodirs'],
        strict=False,
    )

    # Too many directories to get the tree.
    want_tree = None

    MountArchiveAndCheckTree(
        'many_nodes.zip',
        want_tree,
        want_blocks=20020101,
        want_inodes=20010101,
        strict=False,
    )

    want_tree = {
        '.': {'mode': 'drwxr-xr-x', 'nlink': 2},
        'pwn.txt': {'mode': '-rw-r--r--', 'size': 18, 'md5': 'd733c7099812e836812eaee2516fe4d4'},
        'pwn (1).txt': {'mode': '-rw-r--r--', 'size': 18, 'md5': '60657c4791d6bad79b828cf95885b7ab'},
        'pwn (2).txt': {'mode': '-rw-r--r--', 'size': 18, 'md5': 'bf88334df9e0e0ff2aa2e41a536905e6'},
        'pwn (3).txt': {'mode': '-rw-r--r--', 'size': 18, 'md5': '5973c12c78149afc457b2669063a01ee'},
        'pwn (4).txt': {'mode': '-rw-r--r--', 'size': 17, 'md5': '86b64ca4b01c55db35aba63d05d42de4'},
        'pwn (5).txt': {'mode': '-rw-r--r--', 'size': 17, 'md5': 'bfa9660c42e751b1631478b2b8a0799e'},
        'pwn (6).txt': {'mode': '-rw-r--r--', 'size': 17, 'md5': 'ac4e96ff6f932eeff0fa488cd2c7cb81'},
        'pwn (7).txt': {'mode': '-rw-r--r--', 'size': 17, 'md5': '61c2583acf5bbd88789439e189eb6613'},
        'pwn (8).txt': {'mode': '-rw-r--r--', 'size': 17, 'md5': 'a26cdbfb1cb1798e11b9f72be3f97307'},
        'pwn (9).txt': {'mode': '-rw-r--r--', 'size': 17, 'md5': 'fb26e5e27808aba7ffc4bc46046c5539'},
        'pwn (10).txt': {'mode': '-rw-r--r--', 'size': 17, 'md5': 'f8aa4e298ad0a5f1143f57d81816d4e7'},
        'pwn (11).txt': {'mode': '-rw-r--r--', 'size': 17, 'md5': '9bfd9b859f9ba50b1d9b06388262e303'},
        'pwn (12).txt': {'mode': '-rw-r--r--', 'size': 16, 'md5': 'a72f1c78cb94fa47b1cf1e5ff61bf2fc'},
        'pwn (13).txt': {'mode': '-rw-r--r--', 'size': 16, 'md5': '023a9aac498629076c3b70a9c5e9d21e'},
        'pwn (14).txt': {'mode': '-rw-r--r--', 'size': 16, 'md5': 'e828c8adb2485fee6804d9eeb4a70949'},
        'pwn (15).txt': {'mode': '-rw-r--r--', 'size': 16, 'md5': '30ee60225c4ae88dc4c7a708b0434ae7'},
        'pwn (16).txt': {'mode': '-rw-r--r--', 'size': 16, 'md5': '6f813c0a68b4a1d68527035cb78d31b1'},
        'pwn (17).txt': {'mode': '-rw-r--r--', 'size': 16, 'md5': '8ce0038a307defc7751be7682352e5b6'},
        'pwn (18).txt': {'mode': '-rw-r--r--', 'size': 16, 'md5': 'e450c61d70965770a797eb18861c38c9'},
        'pwn (19).txt': {'mode': '-rw-r--r--', 'size': 16, 'md5': 'a0586d7db1107edb247c831d41e6bc6d'},
        'pwn (20).txt': {'mode': '-rw-r--r--', 'size': 15, 'md5': '62b97928b23d3c69967ea5fc74b781fa'},
        'pwn (21).txt': {'mode': '-rw-r--r--', 'size': 15, 'md5': 'fdcfdd3af603be0e79963bcbfc925302'},
        'pwn (22).txt': {'mode': '-rw-r--r--', 'size': 15, 'md5': '23a39585f78225b36e149316daa2c754'},
        'pwn (23).txt': {'mode': '-rw-r--r--', 'size': 15, 'md5': 'b54df483600503861156d39d24c7506b'},
        'pwn (24).txt': {'mode': '-rw-r--r--', 'size': 15, 'md5': '03ad535d68e82910913cda9458a6fc92'},
        'pwn (25).txt': {'mode': '-rw-r--r--', 'size': 15, 'md5': '3df2a1f63aff9c6b94e4bcfb524d54f6'},
        'pwn (26).txt': {'mode': '-rw-r--r--', 'size': 15, 'md5': 'a5f0018e08394ace925324af0dbabc90'},
        'pwn (27).txt': {'mode': '-rw-r--r--', 'size': 15, 'md5': '16cd80bdcb73f86e00cb626c41e44a91'},
        'pwn (28).txt': {'mode': '-rw-r--r--', 'size': 14, 'md5': '250592824467b1aaedd83e82ca5fb194'},
        'pwn (29).txt': {'mode': '-rw-r--r--', 'size': 14, 'md5': '35c6b99c7de34ada2e2cb80a84e40c33'},
        'pwn (30).txt': {'mode': '-rw-r--r--', 'size': 14, 'md5': '1badee133a149d1a46a673538e238e69'},
        'pwn (31).txt': {'mode': '-rw-r--r--', 'size': 14, 'md5': '2bfcd9de11861f7af0c01e3d91bd56dd'},
        'pwn (32).txt': {'mode': '-rw-r--r--', 'size': 14, 'md5': '3e9fed35b8275175ae5841a24b3a5a2d'},
        'pwn (33).txt': {'mode': '-rw-r--r--', 'size': 14, 'md5': 'a46776ca9d8e292b7cf4358c449d258b'},
    }

    MountArchiveAndCheckTree(
        'deep.tar',
        want_tree,
        want_blocks=69,
        want_inodes=35,
        options=['-o', 'nodirs'],
    )

    want_tree = {
        '.': {'mode': 'drwxr-xr-x', 'nlink': 4},
        'Too Deep': {'mode': 'drwxr-xr-x', 'nlink': 2},
        'Too Deep/pwn.txt': {'mode': '-rw-r--r--', 'size': 18, 'md5': 'd733c7099812e836812eaee2516fe4d4'},
        'Too Deep/pwn (1).txt': {'mode': '-rw-r--r--', 'size': 18, 'md5': '60657c4791d6bad79b828cf95885b7ab'},
        'Too Deep/pwn (2).txt': {'mode': '-rw-r--r--', 'size': 18, 'md5': 'bf88334df9e0e0ff2aa2e41a536905e6'},
        'Too Deep/pwn (3).txt': {'mode': '-rw-r--r--', 'size': 18, 'md5': '5973c12c78149afc457b2669063a01ee'},
        'Too Deep/pwn (4).txt': {'mode': '-rw-r--r--', 'size': 17, 'md5': '86b64ca4b01c55db35aba63d05d42de4'},
        'Too Deep/pwn (5).txt': {'mode': '-rw-r--r--', 'size': 17, 'md5': 'bfa9660c42e751b1631478b2b8a0799e'},
        'Too Deep/pwn (6).txt': {'mode': '-rw-r--r--', 'size': 17, 'md5': 'ac4e96ff6f932eeff0fa488cd2c7cb81'},
        'Too Deep/pwn (7).txt': {'mode': '-rw-r--r--', 'size': 17, 'md5': '61c2583acf5bbd88789439e189eb6613'},
        'Too Deep/pwn (8).txt': {'mode': '-rw-r--r--', 'size': 17, 'md5': 'a26cdbfb1cb1798e11b9f72be3f97307'},
        'Too Deep/pwn (9).txt': {'mode': '-rw-r--r--', 'size': 17, 'md5': 'fb26e5e27808aba7ffc4bc46046c5539'},
        'a/' * 1689 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 17, 'md5': 'f8aa4e298ad0a5f1143f57d81816d4e7'},
        'a/' * 1266 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 17, 'md5': '9bfd9b859f9ba50b1d9b06388262e303'},
        'a/' * 949 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 16, 'md5': 'a72f1c78cb94fa47b1cf1e5ff61bf2fc'},
        'a/' * 711 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 16, 'md5': '023a9aac498629076c3b70a9c5e9d21e'},
        'a/' * 533 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 16, 'md5': 'e828c8adb2485fee6804d9eeb4a70949'},
        'a/' * 399 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 16, 'md5': '30ee60225c4ae88dc4c7a708b0434ae7'},
        'a/' * 299 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 16, 'md5': '6f813c0a68b4a1d68527035cb78d31b1'},
        'a/' * 224 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 16, 'md5': '8ce0038a307defc7751be7682352e5b6'},
        'a/' * 168 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 16, 'md5': 'e450c61d70965770a797eb18861c38c9'},
        'a/' * 126 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 16, 'md5': 'a0586d7db1107edb247c831d41e6bc6d'},
        'a/' * 94 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 15, 'md5': '62b97928b23d3c69967ea5fc74b781fa'},
        'a/' * 70 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 15, 'md5': 'fdcfdd3af603be0e79963bcbfc925302'},
        'a/' * 52 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 15, 'md5': '23a39585f78225b36e149316daa2c754'},
        'a/' * 39 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 15, 'md5': 'b54df483600503861156d39d24c7506b'},
        'a/' * 29 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 15, 'md5': '03ad535d68e82910913cda9458a6fc92'},
        'a/' * 21 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 15, 'md5': '3df2a1f63aff9c6b94e4bcfb524d54f6'},
        'a/' * 15 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 15, 'md5': 'a5f0018e08394ace925324af0dbabc90'},
        'a/' * 11 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 15, 'md5': '16cd80bdcb73f86e00cb626c41e44a91'},
        'a/' * 8 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 14, 'md5': '250592824467b1aaedd83e82ca5fb194'},
        'a/' * 6 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 14, 'md5': '35c6b99c7de34ada2e2cb80a84e40c33'},
        'a/' * 4 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 14, 'md5': '1badee133a149d1a46a673538e238e69'},
        'a/' * 3 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 14, 'md5': '2bfcd9de11861f7af0c01e3d91bd56dd'},
        'a/' * 2 + 'pwn.txt': {'mode': '-rw-r--r--', 'size': 14, 'md5': '3e9fed35b8275175ae5841a24b3a5a2d'},
        'a/pwn.txt': {'mode': '-rw-r--r--', 'size': 14, 'md5': 'a46776ca9d8e292b7cf4358c449d258b'},
    }

    MountArchiveAndCheckTree(
        'deep.tar',
        want_tree,
        want_blocks=1759,
        want_inodes=1725,
        strict=False,
    )


# Tests that a big file can be accessed in random order.
def TestBigArchiveRandomOrder(options=[]):
    if is_fast or not has_zlib: return
    zip_name = 'big.zip'
    s = f'Test {zip_name!r}'
    if options:
        s += f', options = {" ".join(options)!r}'
    logging.info(s)
    with tempfile.TemporaryDirectory(dir=tmp_dir_base) as mount_point:
        zip_path = os.path.join(script_dir, 'data', zip_name)
        logging.debug(f'Mounting {zip_path!r} on {mount_point!r}...')
        try:
            subprocess.run(
                [mount_program, *options, zip_path, mount_point],
                check=True,
                capture_output=True,
                input='',
                encoding='UTF-8',
                env=env,
            )
            try:
                logging.debug(
                    f'Mounted archive {zip_path!r} on {mount_point!r}')

                GetTree(mount_point, use_md5=False)
                st = os.statvfs(mount_point)

                want_blocks = 10546877
                if st.f_blocks != want_blocks:
                    LogError(
                        f'Mismatch for st.f_blocks: got: {st.f_blocks}, want: {want_blocks}'
                    )

                want_inodes = 2
                if st.f_files != want_inodes:
                    LogError(
                        f'Mismatch for st.f_files: got: {st.f_files}, want: {want_inodes}'
                    )

                fd = os.open(os.path.join(mount_point, 'big.txt'), os.O_RDONLY)
                try:
                    random.seed()
                    n = 100000000
                    for j in [random.randrange(n)
                              for i in range(100)] + [n - 1, 0, n - 1]:
                        logging.debug(f'Getting line {j}...')
                        want_line = b'%08d The quick brown fox jumps over the lazy dog.\n' % j
                        got_line = os.pread(fd, len(want_line),
                                            j * len(want_line))
                        if got_line != want_line:
                            LogError(
                                f'Want line: {want_line!r}, Got line: {got_line!r}'
                            )
                    got_line = os.pread(fd, 100, j * len(want_line))
                    if got_line != want_line:
                        LogError(
                            f'Want line: {want_line!r}, Got line: {got_line!r}'
                        )
                    got_line = os.pread(fd, 100, n * len(want_line))
                    if got_line:
                        LogError(f'Want empty line, Got line: {got_line!r}')
                finally:
                    os.close(fd)
            finally:
                logging.debug(
                    f'Unmounting {zip_path!r} from {mount_point!r}...')
                Unmount(mount_point)
                logging.debug(f'Unmounted {zip_path!r} from {mount_point!r}')
        except subprocess.CalledProcessError as e:
            LogError(f'Cannot test {zip_name!r}: {e.stderr}')


# Tests that a big file can be accessed in somewhat globally increasing order
# even with no cache file.
def TestBigArchiveStreamed(options=[]):
    if is_fast or not has_zlib: return
    zip_name = 'big.zip'
    s = f'Test {zip_name!r}'
    if options:
        s += f', options = {" ".join(options)!r}'
    logging.info(s)
    with tempfile.TemporaryDirectory(dir=tmp_dir_base) as mount_point:
        zip_path = os.path.join(script_dir, 'data', zip_name)
        logging.debug(f'Mounting {zip_path!r} on {mount_point!r}...')
        try:
            subprocess.run(
                [mount_program, *options, zip_path, mount_point],
                check=True,
                capture_output=True,
                input='',
                encoding='UTF-8',
                env=env,
            )
            try:
                logging.debug(f'Mounted {zip_path!r} on {mount_point!r}')
                GetTree(mount_point, use_md5=False)
                fd = os.open(os.path.join(mount_point, 'big.txt'), os.O_RDONLY)
                try:
                    random.seed()
                    n = 100000000
                    for i in [(r * 2 + 1) * n // 20
                              for r in range(10)] + [n - 1]:
                        for k in range(3):
                            j = i - k * 1000000
                            if j < 0: continue
                            logging.debug(f'Getting line {j}...')
                            want_line = b'%08d The quick brown fox jumps over the lazy dog.\n' % j
                            got_line = os.pread(fd, len(want_line),
                                                j * len(want_line))
                            if got_line != want_line:
                                LogError(
                                    f'Want line: {want_line!r}, Got line: {got_line!r}'
                                )
                finally:
                    os.close(fd)
            finally:
                logging.debug(
                    f'Unmounting {zip_path!r} from {mount_point!r}...')
                Unmount(mount_point)
                logging.debug(f'Unmounted {zip_path!r} from {mount_point!r}')
        except subprocess.CalledProcessError as e:
            LogError(f'Cannot test {zip_name!r}: {e.stderr}')


# Tests encrypted archive.
def TestEncryptedArchive(options=[]):
    if not has_openssl and not has_nettle: return

    zip_name = 'different-encryptions.zip'

    # With correct password.
    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
        'Encrypted ZipCrypto.txt': {
            'mode': '-rw-r--r--',
            'size': 34,
            'md5': '275e8c5aed7e7ce2f32dd1e5e9ee4a5b',
        },
        'Encrypted AES-256.txt': {
            'mode': '-rw-r--r--',
            'size': 32,
            'md5': 'ca5e064a0835d186f2f6326f88a7078f',
        },
        'Encrypted AES-192.txt': {
            'mode': '-rw-r--r--',
            'size': 32,
            'md5': 'e48d57930ef96ff2ad45867202d3250d',
        },
        'Encrypted AES-128.txt': {
            'mode': '-rw-r--r--',
            'size': 32,
            'md5': '07c4edd2a55c9d5614457a21fb40aa56',
        },
        'ClearText.txt': {
            'mode': '-rw-r--r--',
            'size': 23,
            'md5': '7a542815e2c51837b3d8a8b2ebf36490',
        },
    }

    for password in [
            'password', 'password\n', 'password\nThis line is ignored...\n'
    ]:
        MountArchiveAndCheckTree(
            zip_name,
            want_tree,
            want_blocks=11,
            want_inodes=6,
            options=options,
            password=password,
        )

    # With wrong or no password.
    CheckArchiveMountingError(zip_name,
                              21,
                              options=options,
                              password='wrong password')
    CheckArchiveMountingError(zip_name, 20, options=options, password='\n')
    CheckArchiveMountingError(zip_name, 20, options=options)

    # With wrong or no password and `-o force` option.
    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
        'Encrypted ZipCrypto.txt': {
            'mode': '-rw-r--r--',
            'size': 34,
            'errno': errno.EIO,
        },
        'Encrypted AES-256.txt': {
            'mode': '-rw-r--r--',
            'size': 32,
            'errno': errno.EIO,
        },
        'Encrypted AES-192.txt': {
            'mode': '-rw-r--r--',
            'size': 32,
            'errno': errno.EIO,
        },
        'Encrypted AES-128.txt': {
            'mode': '-rw-r--r--',
            'size': 32,
            'errno': errno.EIO,
        },
        'ClearText.txt': {
            'mode': '-rw-r--r--',
            'size': 23,
            'md5': '7a542815e2c51837b3d8a8b2ebf36490',
        },
    }
    for password in ['wrong password', '\n', '']:
        MountArchiveAndCheckTree(
            zip_name,
            want_tree,
            want_inodes=6,
            options=options + ['-o', 'force'],
            password=password,
        )


# Tests the enforce_permissions, nosymlinks and nospecials mount options.
def TestArchiveWithSpecialFiles():
    zip_name = 'specials.tar'

    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
        'block': {'mode': 'brw-r--r--', 'mtime': 1564833480000000000, 'rdev': 2049},
        'char': {'mode': 'crw-r--r--', 'mtime': 1564833480000000000, 'rdev': 1024},
        'fifo': {'mode': 'prw-r--r--', 'mtime': 1565809123000000000},
        'regular': {'mode': '-rw-r--r--', 'mtime': 1565290018000000000, 'size': 32, 'md5': '456e611a5420b7dd09bae143a7b2deb0'},
        'symlink': {'mode': 'lrwxr-xr-x', 'mtime': 1564834729000000000, 'target': 'regular'},
    }

    MountArchiveAndCheckTree(zip_name, want_tree, want_blocks=8, want_inodes=6)

    # Test -o enforce_permissions
    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
        'block': {'mode': 'brw-r-----', 'uid': 0, 'gid': 6, 'rdev': 2049},
        'char': {'mode': 'crw-------', 'uid': 0, 'gid': 5, 'rdev': 1024},
        'fifo': {'mode': 'prw-r--r--', 'uid': 1000, 'gid': 1000},
        'regular': {'mode': '-rw-r--r--', 'uid': 1000, 'gid': 1000, 'size': 32, 'md5': '456e611a5420b7dd09bae143a7b2deb0'},
        'symlink': {'mode': 'lrwxr-xr-x', 'uid': 1000, 'gid': 1000, 'target': 'regular'},
    }

    MountArchiveAndCheckTree(
        zip_name,
        want_tree,
        want_blocks=8,
        want_inodes=6,
        options=['-o', 'enforce_permissions'],
    )

    # Test -o default_permissions (alias)
    MountArchiveAndCheckTree(
        zip_name,
        want_tree,
        want_blocks=8,
        want_inodes=6,
        options=['-o', 'default_permissions'],
    )

    # Test -o enforce_permissions,fmask=0
    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
        'block': {'mode': 'brw-rw----', 'uid': 0, 'gid': 6, 'rdev': 2049},
        'char': {'mode': 'crw--w----', 'uid': 0, 'gid': 5, 'rdev': 1024},
        'fifo': {'mode': 'prw-r--r--', 'uid': 1000, 'gid': 1000},
        'regular': {'mode': '-rw-r--r--', 'uid': 1000, 'gid': 1000, 'size': 32, 'md5': '456e611a5420b7dd09bae143a7b2deb0'},
        'symlink': {'mode': 'lrwxrwxrwx', 'uid': 1000, 'gid': 1000, 'target': 'regular'},
    }

    MountArchiveAndCheckTree(
        zip_name,
        want_tree,
        want_blocks=8,
        want_inodes=6,
        options=['-o', 'enforce_permissions,fmask=0'],
    )

    # Test -o nosymlinks
    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
        'block': {'mode': 'brw-r--r--', 'mtime': 1564833480000000000, 'rdev': 2049},
        'char': {'mode': 'crw-r--r--', 'mtime': 1564833480000000000, 'rdev': 1024},
        'fifo': {'mode': 'prw-r--r--', 'mtime': 1565809123000000000},
        'regular': {'mode': '-rw-r--r--', 'mtime': 1565290018000000000, 'size': 32, 'md5': '456e611a5420b7dd09bae143a7b2deb0'},
    }

    MountArchiveAndCheckTree(
        zip_name,
        want_tree,
        want_blocks=6,
        want_inodes=5,
        options=['-o', 'nosymlinks'],
    )

    # Test -o nospecials
    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
        'regular': {'mode': '-rw-r--r--', 'mtime': 1565290018000000000, 'size': 32, 'md5': '456e611a5420b7dd09bae143a7b2deb0'},
        'symlink': {'mode': 'lrwxr-xr-x', 'mtime': 1564834729000000000, 'target': 'regular'},
    }

    MountArchiveAndCheckTree(
        zip_name,
        want_tree,
        want_blocks=5,
        want_inodes=3,
        options=['-o', 'nospecials'],
    )

    # Tests -o nosymlinks and -o nospecials together
    want_tree = {
        '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
        'regular': {'mode': '-rw-r--r--', 'mtime': 1565290018000000000, 'size': 32, 'md5': '456e611a5420b7dd09bae143a7b2deb0'},
    }

    MountArchiveAndCheckTree(
        zip_name,
        want_tree,
        want_blocks=3,
        want_inodes=2,
        options=['-o', 'nosymlinks,nospecials'],
    )


# Tests that external helpers are searched for in a sanitized PATH.
def TestPathSanitization():
    with tempfile.TemporaryDirectory(dir=tmp_dir_base) as tmp_dir:
        bin_dir = os.path.join(tmp_dir, 'bin')
        os.mkdir(bin_dir)
        fake_helper = os.path.join(bin_dir, 'base64')
        sentinel_file = os.path.join(tmp_dir, 'was_called')

        with open(fake_helper, 'w') as f:
            f.write(f'#!/bin/sh\ntouch {sentinel_file}\nexit 1\n')
        os.chmod(fake_helper, 0o755)

        # Prepend the fake bin dir to the environment PATH
        custom_env = os.environ.copy()
        custom_env['PATH'] = bin_dir + os.pathsep + custom_env.get('PATH', '')

        # Attempt to mount a file that triggers base64 filter.
        # Verify that -o noexternal doesn't trigger it.
        CheckArchiveMountingError('romeo.txt.b64', 30, options=['-o', 'noexternal'], env=custom_env)

        if os.path.exists(sentinel_file):
            LogError("Security Vulnerability: External helper executed with -o noexternal!")
            os.remove(sentinel_file)

        # Attempt to mount a file that triggers base64 filter.
        want_tree = {
            '.': {'ino': 1, 'mode': 'drwxr-xr-x', 'nlink': 2},
            'romeo.txt': {'mode': '-rw-r--r--', 'size': 942, 'md5': '80f1521c4533d017df063c623b75cde3'},
        }

        MountArchiveAndCheckTree('romeo.txt.b64', want_tree, env=custom_env)

        # It should FAIL to use our fake helper because of PATH sanitization.
        if os.path.exists(sentinel_file):
            LogError("Security Vulnerability: External helper executed from unsanitized PATH!")


# Tests invalid and absent archives.
def TestInvalidArchive():
    CheckArchiveMountingError('', 11)
    CheckArchiveMountingError('absent.zip', 11)
    CheckArchiveMountingError('romeo.txt', 30)

    # https://github.com/google/fuse-archive/issues/38
    CheckArchiveMountingError('empty', 30)
    CheckArchiveMountingError('empty.gz', 30)

    # https://github.com/google/fuse-archive/issues/36
    CheckArchiveMountingError('truncated.7z', 32)
    CheckArchiveMountingError('overflow.tar.gz', 32)

    # 7Z encryption is not supported
    if has_liblzma:
        CheckArchiveMountingError("encrypted.7z", 22, password='password')
    CheckArchiveMountingError("encrypted-solidly.7z", 22, password='password')

    # Test nobidding option with an unrecognized extension
    CheckArchiveMountingError('archive.xxx', 30, options=['-o', 'nobidding'])
    CheckArchiveMountingError('--help', 30, options=['-o', 'nobidding'])

    if os.getuid() != 0:
        with tempfile.NamedTemporaryFile() as f:
            os.chmod(f.name, 0)
            CheckArchiveMountingError(f.name, 11)


# Tests extended attributes functionality
def TestExtendedAttributes(options=[]):
    # Test normal xattr archive
    zip_name = 'many-xattrs.tar'
    want_tree = {
        ".": {"ino": 1, "mode": "drwxr-xr-x", "nlink": 2},
        "file.txt": {
            "xattr": {
                "user.attr_{:04d}".format(i): f"value_{i}" for i in range(1, 11)
            },
        },
    }
    MountArchiveAndCheckTree(zip_name,
                             want_tree,
                             options=options,
                             use_md5=False)

    want_tree = {
        ".": {"ino": 1, "mode": "drwxr-xr-x", "nlink": 2},
        "file.txt": {"xattr": {}},
    }
    MountArchiveAndCheckTree(zip_name,
                             want_tree,
                             options=options + ['-o', 'noxattrs'],
                             use_md5=False)

    # Test very long name xattr cases
    zip_name = 'long-xattr-name.tar'
    want_tree = {
        ".": {"ino": 1, "mode": "drwxr-xr-x", "nlink": 2},
        "file.txt": {
            # "xattr": {
            #     'user.' + 'a' * 200 + '_!@#$%25^&*()': 'Special characters in long name',
            #     'user.' + 'x' * 245: 'This is a very long attribute name',
            # },
        },
    }
    MountArchiveAndCheckTree(zip_name,
                             want_tree,
                             options=options,
                             use_md5=False)

    want_tree = {
        ".": {"ino": 1, "mode": "drwxr-xr-x", "nlink": 2},
        "file.txt": {"xattr": {}},
    }
    MountArchiveAndCheckTree(zip_name,
                             want_tree,
                             options=options + ['-o', 'noxattrs'],
                             use_md5=False)

    # Test xattr error cases
    zip_name = 'xattr-errors.tar'
    want_tree = {
        ".": {"ino": 1, "mode": "drwxr-xr-x", "nlink": 2},
        "file.txt": {
            "xattr": {
                'user.normal_attr': 'valid value',
                'user.corrupt_attr': '-',
            },
        },
    }
    MountArchiveAndCheckTree(zip_name,
                             want_tree,
                             options=options,
                             use_md5=False)

    want_tree = {
        ".": {"ino": 1, "mode": "drwxr-xr-x", "nlink": 2},
        "file.txt": {"xattr": {}},
    }
    MountArchiveAndCheckTree(zip_name,
                             want_tree,
                             options=options + ['-o', 'noxattrs'],
                             use_md5=False)


# Tests FUSE error paths.
# Tests automatic mount point creation, removal, and deduplication.
# Tests multi-archive usage constraints.
def TestMultiArchiveUsage():
    logging.info("Testing multi-archive usage constraints")
    zip_path1 = os.path.join(script_dir, 'data', 'multi1.tar.gz')
    zip_path2 = os.path.join(script_dir, 'data', 'multi2.tar.gz')

    with tempfile.TemporaryDirectory(dir=tmp_dir_base) as tmp_dir:
        mount_point = os.path.join(tmp_dir, 'mnt')
        os.mkdir(mount_point)

        # 1. Correct usage: 2 archives + 1 mount point
        command = [
            mount_program, '-f', '-o', 'notrim', zip_path1, zip_path2,
            mount_point
        ]
        if args.verbose: command.append('-v')

        proc = subprocess.Popen(command)
        try:
            for _ in range(20):
                if os.path.ismount(mount_point): break
                time.sleep(0.1)
            if not os.path.ismount(mount_point):
                LogError(
                    "Multi-archive mount failed with explicit mount point")
            else:
                if not os.path.exists(os.path.join(mount_point,
                                                   'a/b/c/file1')):
                    LogError("Missing file1 in multi-archive mount")
                if not os.path.exists(os.path.join(mount_point,
                                                   'a/b/d/file2')):
                    LogError("Missing file2 in multi-archive mount")
        finally:
            proc.terminate()
            proc.wait()

        # 2. Ambiguous usage: 2 archives, no mount point provided.
        # The second archive will be treated as the mount point.
        # We use symlinks to avoid copying and messing up the test data.
        zip_link1 = os.path.join(tmp_dir, 'link1.tar.gz')
        zip_link2 = os.path.join(tmp_dir, 'link2.tar.gz')
        os.symlink(zip_path1, zip_link1)
        os.symlink(zip_path2, zip_link2)

        # This will attempt to mount zip_link1 ON zip_link2.
        command = [mount_program, '-f', zip_link1, zip_link2]
        if args.verbose: command.append('-v')

        proc = subprocess.Popen(command)
        try:
            for _ in range(20):
                if os.path.ismount(zip_link2): break
                time.sleep(0.1)
            # Mounting on a file might not work on all systems or might require special flags,
            # but we check if fuse-archive at least attempted it.
            # On Linux, mounting on a file IS allowed.
            if not os.path.ismount(zip_link2):
                logging.info(
                    "Note: Mounting on a file (auto-mount point) not supported or failed as expected"
                )
        finally:
            proc.terminate()
            proc.wait()


# Tests usage information, help, and version options.
def TestUsage():
    logging.info("Testing usage information")
    # No arguments
    res = subprocess.run([mount_program], capture_output=True)
    if res.returncode != 1:
        LogError(
            f"Expected exit code 1 for no arguments, got {res.returncode}")

    # Help and version options
    logging.info("Testing help and version options")
    for opt in ['-h', '--help', '-V', '--version']:
        res = subprocess.run([mount_program, opt],
                             capture_output=True,
                             text=True)
        if res.returncode != 0:
            LogError(f"Option {opt} failed with {res.returncode}")

    # maxfilters=0 flag
    logging.info("Testing maxfilters=0 flag")
    zip_name = 'archive.tar'
    with MountArchive(zip_name, options=['-o', 'maxfilters=0']) as mount_point:
        if not os.path.exists(os.path.join(mount_point, 'romeo.txt')):
            LogError("Missing romeo.txt with maxfilters=0")

    # Quiet flag
    logging.info("Testing quiet flag")
    zip_name = 'archive.tar'
    with MountArchive(zip_name, options=['-q']) as mount_point:
        if not os.path.exists(mount_point):
            LogError("Mount point does not exist with -q flag")


# Tests automatic mount point creation, removal, and deduplication.
def TestAutoMountPoint():
    logging.info("Testing automatic mount point creation and removal")
    zip_path = os.path.join(script_dir, 'data', 'archive.tar')
    dash_archive_path = os.path.join(script_dir, 'data', '--help')

    with tempfile.TemporaryDirectory(dir=tmp_dir_base) as tmp_dir:
        # 1. Standard auto-mount (relative path to trigger './' prepending)
        mount_point = os.path.join(tmp_dir, 'archive')
        command = [mount_program, '-f', '-v', zip_path]

        proc = subprocess.Popen(command, cwd=tmp_dir)
        try:
            for _ in range(20):
                if os.path.ismount(mount_point): break
                time.sleep(0.1)
            if not os.path.ismount(mount_point):
                LogError("Automatic mount point was not created or mounted")
        finally:
            proc.terminate()
            proc.wait()
            if os.path.exists(mount_point):
                LogError("Automatic mount point was not removed")

        # 2. Deduplication
        # Create 'archive' directory first
        os.mkdir(os.path.join(tmp_dir, 'archive'))
        dedup_mount_point = os.path.join(tmp_dir, 'archive (1)')

        proc = subprocess.Popen(command, cwd=tmp_dir)
        try:
            for _ in range(20):
                if os.path.ismount(dedup_mount_point): break
                time.sleep(0.1)
            if not os.path.ismount(dedup_mount_point):
                LogError(
                    "Deduplicated mount point 'archive (1)' was not created")
        finally:
            proc.terminate()
            proc.wait()
            if os.path.exists(dedup_mount_point):
                LogError("Deduplicated mount point was not removed")
            if not os.path.exists(os.path.join(tmp_dir, 'archive')):
                LogError(
                    "Original 'archive' directory was accidentally removed")
            os.rmdir(os.path.join(tmp_dir, 'archive'))

        # 3. Mount point starting with '-' (using permanent symlink test/data/--help)
        command_dash = [mount_program, '-f', dash_archive_path]
        if args.verbose: command_dash.append('-v')

        proc = subprocess.Popen(command_dash, cwd=tmp_dir)
        try:
            mount_point_dash = os.path.join(tmp_dir, '--help')
            for _ in range(20):
                if os.path.ismount(mount_point_dash):
                    break
                time.sleep(0.1)
            if not os.path.ismount(mount_point_dash):
                LogError(
                    f"Automatic mount point '--help' was not created or mounted"
                )
        finally:
            proc.terminate()
            proc.wait()
            if os.path.exists(mount_point_dash):
                LogError(f"Automatic mount point '--help' was not removed")

        # 4. Mount point creation failure (e.g. read-only directory)
        readonly_dir = os.path.join(tmp_dir, 'readonly')
        os.mkdir(readonly_dir)
        os.chmod(readonly_dir, 0o555)  # Read and execute, but no write
        try:
            command_fail = [mount_program, zip_path]
            res = subprocess.run(command_fail,
                                 capture_output=True,
                                 cwd=readonly_dir)
            if res.returncode != 10:
                LogError(
                    f"Expected exit code 10 for mount point creation failure, got {res.returncode}"
                )
        finally:
            os.chmod(readonly_dir, 0o777)
            os.rmdir(readonly_dir)

        # 5. Mounting in non-existent parent directory
        nonexistent_parent = os.path.join(tmp_dir, 'no/such/dir/mnt')
        try:
            command_nonexistent = [mount_program, zip_path, nonexistent_parent]
            res = subprocess.run(command_nonexistent, capture_output=True)
            if res.returncode != 10:
                LogError(
                    f"Expected exit code 10 for non-existent parent, got {res.returncode}"
                )
        except:
            pass

        # 6. Empty string for mount point
        try:
            command_empty = [mount_program, zip_path, ""]
            res = subprocess.run(command_empty, capture_output=True)
            if res.returncode != 10:
                LogError(
                    f"Expected exit code 10 for empty mount point, got {res.returncode}"
                )
        except:
            pass


def TestFuseErrors():
    logging.info("Testing FUSE error paths")
    zip_name = 'archive.tar'
    # Use direct_io to ensure Seek callback is triggered for SEEK_DATA/HOLE
    with MountArchive(zip_name, options=['-o', 'direct_io']) as mount_point:
        # ENOENT
        path = os.path.join(mount_point, 'nonexistent')
        try:
            os.stat(path)
            LogError(f"Expected ENOENT for {path}")
        except OSError as e:
            if e.errno != errno.ENOENT:
                LogError(f"Expected ENOENT ({errno.ENOENT}), got {e.errno}")

        if has_xattrs:
            try:
                os.getxattr(path, 'user.attr')
                LogError(f"Expected ENOENT for xattr on nonexistent path")
            except OSError as e:
                if e.errno != errno.ENOENT:
                    LogError(
                        f"Expected ENOENT ({errno.ENOENT}), got {e.errno}")

        # ENOTDIR
        path = os.path.join(mount_point, 'romeo.txt', 'inside')
        try:
            os.stat(path)
            LogError(f"Expected ENOTDIR for {path}")
        except OSError as e:
            if e.errno != errno.ENOTDIR:
                LogError(f"Expected ENOTDIR ({errno.ENOTDIR}), got {e.errno}")

        # EISDIR on open
        path = mount_point
        try:
            fd = os.open(path, os.O_RDONLY)
            os.close(fd)
        except OSError as e:
            if e.errno != errno.EISDIR:
                LogError(f"Expected EISDIR ({errno.EISDIR}), got {e.errno}")

        # EINVAL on readlink of a file
        path = os.path.join(mount_point, 'romeo.txt')
        try:
            os.readlink(path)
            LogError(f"Expected EINVAL for readlink on file")
        except OSError as e:
            if e.errno != errno.EINVAL:
                LogError(f"Expected EINVAL ({errno.EINVAL}), got {e.errno}")

        if has_xattrs:
            try:
                os.getxattr(path, 'user.nonexistent')
                LogError(f"Expected ENODATA for missing xattr")
            except OSError as e:
                if e.errno != errno.ENODATA:
                    LogError(
                        f"Expected ENODATA ({errno.ENODATA}), got {e.errno}")

    # ERANGE on getxattr with small buffer
    zip_name = 'many-xattrs.tar'
    with MountArchive(zip_name) as mount_point:
        path = os.path.join(mount_point, 'file.txt')
        # Use subprocess to call getxattr with a small buffer via 'getfattr'
        # Actually, getfattr doesn't easily allow specifying buffer size.
        # Let's skip ERANGE for now or use a different method if needed.
        pass

    # ENOTSUP for xattrs if disabled
    zip_name = 'archive.tar'
    with MountArchive(zip_name, options=['-o', 'noxattrs']) as mount_point:
        # 'romeo.txt' exists in archive.tar
        path = os.path.join(mount_point, 'romeo.txt')
        # When xattrs are enabled (default) but the file has none, listxattr returns empty.
        # But here we used -o noxattrs.
        # As seen in fuse_ops.cc, GetXattr/ListXattr are always set if HAVE_XATTR is defined.
        # They don't check g_options.xattrs.
        # So they will just see empty node->attributes and return success (empty list) or ENODATA.
        if has_xattrs:
            try:
                os.listxattr(path)
            except OSError:
                pass

        # Path traversal / security components
        for p in ['.', '..', 'foo/../bar']:
            path = os.path.join(mount_point, p)
            # FUSE/OS usually resolves these before they reach the FS,
            # but we want to ensure no crashes or unexpected behavior.
            try:
                os.stat(path)
            except OSError:
                pass

        # ENAMETOOLONG
        path = os.path.join(mount_point, 'a' * 5000)
        try:
            os.stat(path)
        except OSError as e:
            if e.errno != errno.ENAMETOOLONG:
                LogError(
                    f"Expected ENAMETOOLONG ({errno.ENAMETOOLONG}), got {e.errno}"
                )

        # ENOTDIR on opendir of a file
        path = os.path.join(mount_point, 'romeo.txt')
        try:
            os.listdir(path)
            LogError(f"Expected ENOTDIR for listdir on {path}")
        except OSError as e:
            if e.errno != errno.ENOTDIR:
                LogError(f"Expected ENOTDIR ({errno.ENOTDIR}), got {e.errno}")

        # SEEK_DATA / SEEK_HOLE errors
        fd = os.open(path, os.O_RDONLY)
        try:
            # fstat should trigger GetAttr with fi
            st = os.fstat(fd)
            if st.st_size == 0:
                LogError(f"fstat returned 0 size for {path}")

            # macFUSE does not support SEEK_DATA/SEEK_HOLE and returns EINVAL
            want_errno = errno.EINVAL if on_mac else errno.ENXIO
            for whence in [os.SEEK_DATA, os.SEEK_HOLE]:
                try:
                    os.lseek(fd, st.st_size, whence)
                    LogError(f"Want errno {want_errno} when seeking past the end")
                except OSError as e:
                    if e.errno != want_errno:
                        LogError(f"Want errno {want_errno}, got errno {e.errno}")
        finally:
            os.close(fd)

        # StatFs
        os.statvfs(mount_point)

    # Jump tests for Read
    with MountArchive(zip_name, options=['-o',
                                         'nocache,direct_io']) as mount_point:
        path = os.path.join(mount_point, 'romeo.txt')
        fd = os.open(path, os.O_RDONLY)
        try:
            # Read at offset 512KB (past default 256KB rolling buffer)
            os.lseek(fd, 512 * 1024, os.SEEK_SET)
            os.read(fd, 100)
            # Jump backwards to 0 (should reset reader)
            os.lseek(fd, 0, os.SEEK_SET)
            os.read(fd, 100)
            # Jump forwards past rolling buffer (another 512KB)
            os.lseek(fd, 1024 * 1024, os.SEEK_SET)
            os.read(fd, 100)
        finally:
            os.close(fd)

    # Testing redact option
    with MountArchive(zip_name, options=['-o', 'redact']) as mount_point:
        os.stat(mount_point)

    # Multiple archives
    with MountArchive(['multi1.tar.gz', 'multi2.tar.gz'],
                      options=['-o', 'notrim', '-v']) as mount_point:
        if not os.path.exists(os.path.join(mount_point, 'a/b/c/file1')):
            LogError("Missing a/b/c/file1 in multi-archive mount")
        if not os.path.exists(os.path.join(mount_point, 'a/b/d/file2')):
            LogError("Missing a/b/d/file2 in multi-archive mount")


if has_gpg:
    b = b' BLOCK'
    k = b' KEY'
    p = b' PGP'
    s = b' PRIVATE'
    d = b'-----'
    subprocess.run(['gpg', '--quiet', '--import'],
                   check=True,
                   input=d + b'BEGIN' + p + s + k + b + d + b'''

lFgEaZo1fhYJKwYBBAHaRw8BAQdAluzXFhgZsK/hf+nmr5NeRwH/ErXLA7gG6r47
GXdneYoAAP4vOlornRnpAn3s7te7GE2lf04jCHhK3KA9Rboz2Yazkg/JtBlUZXN0
IGtleSBmb3IgZnVzZS1hcmNoaXZliJMEExYKADsWIQR9dJYXArytNdWRu4luxarS
zF/sDgUCaZo1fgIbAwULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRBuxarS
zF/sDp0kAQCIW8TDBjBU1S0DbPfIDzQS7p/OdswpnNqF00PA+4KsWwEA6rhHSiQS
gRlCOFj95E3KbS5tRfaGfgrUnKExlvQEWAGcXQRpmjV+EgorBgEEAZdVAQUBAQdA
3as1UqUnET84sOAvsY5PQwt9ShODOFyx3Dt1LHDN/wUDAQgHAAD/TqX3FvTPyvXu
LBL++L6Mz+JHDJ+oOGTkpPfU3alg9CgVKYh4BBgWCgAgFiEEfXSWFwK8rTXVkbuJ
bsWq0sxf7A4FAmmaNX4CGwwACgkQbsWq0sxf7A66xwEAtm8rQLzLDi39vkdLNdkR
FUW8JyI7dh1QU9A8wSOvXR0A+wbAyMqXk9eHhzBLyxs+Pk+LEK1ekZoep8zaVrZo
3NMK
=059l
''' + d + b'END' + p + s + k + b + d + b'\n')

try:
    TestArchiveWithOptions()
    TestArchiveWithOptions(['-o', 'nocache'])
    TestArchiveWithOptions(['-o', 'lazycache'])
    if has_memcache: TestArchiveWithOptions(['-o', 'memcache'])
    TestFilteredZip()
    TestFilteredOpaque()
finally:
    if has_gpg:
        subprocess.run([
            'gpg', '--batch', '--yes', '--delete-secret-and-public-key',
            '7D74961702BCAD35D591BB896EC5AAD2CC5FEC0E'
        ])

TestHardlinks()
TestHardlinks(['-o', 'nocache'])
TestHardlinks(['-o', 'lazycache'])
if has_memcache: TestHardlinks(['-o', 'memcache'])

TestSparse()
TestSeek()
TestSeek(['-o', 'nocache'])
TestSeek(['-o', 'lazycache'])
if has_memcache: TestSeek(['-o', 'memcache'])
TestSeek(['-o', 'noholes'])

TestMultiArchive()
TestMultiArchive(['-o', 'nocache'])
TestMultiArchive(['-o', 'lazycache'])
if has_memcache: TestMultiArchive(['-o', 'memcache'])

TestSpecialPermissions()
TestSpecialPermissions(['-o', 'nocache'])
TestSpecialPermissions(['-o', 'lazycache'])
if has_memcache: TestSpecialPermissions(['-o', 'memcache'])

TestArchiveWithSpecialFiles()

TestEncryptedArchive()
TestEncryptedArchive(['-o', 'nocache'])
TestEncryptedArchive(['-o', 'lazycache'])
if has_memcache: TestEncryptedArchive(['-o', 'memcache'])

TestExtendedAttributes()
TestExtendedAttributes(['-o', 'nocache'])
TestExtendedAttributes(['-o', 'lazycache'])
if has_memcache: TestExtendedAttributes(['-o', 'memcache'])

TestMultiArchiveUsage()
TestUsage()
TestAutoMountPoint()
TestFuseErrors()
TestPathSanitization()
TestInvalidArchive()
TestMasks()
TestArchiveWithManyFiles()
TestDirectories()
TestBigArchiveRandomOrder(['-o', 'direct_io'])
TestBigArchiveRandomOrder(['-o', 'lazycache,direct_io'])
TestBigArchiveStreamed(['-o', 'nocache,direct_io'])

if error_count:
    LogError(f'FAIL: There were {error_count} errors')
    sys.exit(1)
else:
    logging.info('PASS: All tests passed')

