import errno
import os
from pathlib import Path
import shutil
import stat
from unittest.mock import patch

import pytest

from ... import xattr
from ... import platform
from ...archive import Archive
from ...cache import Cache
from ...chunkers import has_seek_hole
from ...constants import *  # NOQA
from ...item import Item
from ...manifest import Manifest
from ...repository import Repository
from ...helpers import EXIT_WARNING, BackupPermissionError, BackupSymlinkParentError, bin_to_hex
from ...helpers import flags_noatime, flags_normal
from .. import changedir, same_ts_ns, granularity_sleep
from .. import are_symlinks_supported, are_hardlinks_supported, is_utime_fully_supported, is_birthtime_fully_supported
from ...platform import get_birthtime_ns
from ...platformflags import is_darwin, is_freebsd, is_win32
from . import (
    RK_ENCRYPTION,
    requires_hardlinks,
    cmd,
    create_test_files,
    create_regular_file,
    assert_dirs_equal,
    _extract_hardlinks_setup,
    assert_creates_file,
    generate_archiver_tests,
    create_src_archive,
    open_archive,
    src_file,
)

pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local,remote,binary")  # NOQA


@pytest.mark.skipif(not are_symlinks_supported(), reason="symlinks not supported")
def test_symlink_extract(archivers, request):
    archiver = request.getfixturevalue(archivers)
    create_test_files(archiver.input_path)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    cmd(archiver, "create", "test", "input")
    with changedir("output"):
        cmd(archiver, "extract", "test")
        assert os.readlink("input/link1") == "somewhere"


def _create_malicious_archive(archiver, name, items):
    # Inject raw Items directly into an archive, bypassing "borg create" (which would never
    # produce a child below a symlink or a path with embedded "..").
    repository = Repository(archiver.repository_path, exclusive=True)
    with repository:
        manifest = Manifest.load(repository, Manifest.NO_OPERATION_CHECK)
        with Cache(repository, manifest, archive_name=name) as cache:
            archive = Archive(manifest, name, cache=cache, create=True)
            for item in items:
                archive.items_buffer.add(item)
            archive.save(name=name)


@pytest.mark.skipif(not are_symlinks_supported(), reason="symlinks not supported")
def test_extract_through_symlinked_parent(archivers, request):
    # Security (CVE-2026-62268): an archive with a symlink "evil" -> "<victim_dir>", followed by a
    # regular file "evil/passwd", must NOT overwrite/delete files in the symlink target directory.
    archiver = request.getfixturevalue(archivers)
    if archiver.get_kind() != "local":
        pytest.skip("malicious archive is crafted via direct (local) repository access")
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    victim_dir = os.path.join(archiver.tmpdir, "victim")
    os.mkdir(victim_dir)
    victim_file = os.path.join(victim_dir, "passwd")
    with open(victim_file, "w") as fd:
        fd.write("original")
    attrs = dict(mtime=0, uid=0, gid=0, user="root", group="root")
    _create_malicious_archive(
        archiver,
        "evil",
        [
            Item(path="evil", mode=stat.S_IFLNK | 0o777, target=victim_dir, **attrs),
            Item(path="evil/passwd", mode=stat.S_IFREG | 0o644, chunks=[], **attrs),
        ],
    )
    with changedir("output"):
        # borg 2 uses modern exit codes, so the warning surfaces as the specific mcode.
        output = cmd(archiver, "extract", "evil", exit_code=BackupSymlinkParentError.exit_mcode)
    assert "parent directory is a symlink" in output
    # the out-of-tree victim must be untouched (neither deleted nor overwritten)
    assert os.path.exists(victim_file)
    with open(victim_file) as fd:
        assert fd.read() == "original"
    # the symlink item itself was restored faithfully
    assert os.path.islink(os.path.join(archiver.output_path, "evil"))


@pytest.mark.skipif(
    not (are_symlinks_supported() and are_hardlinks_supported()), reason="symlinks/hardlinks not supported"
)
def test_extract_hardlinked_symlink_does_not_leak_target(archivers, request):
    # Security (CVE-2026-62268 Fix 2/2, borg 2 form): a crafted archive with a symlink
    # "s" -> "<victim_file>" plus a contentless hardlink "h" sharing its hlid must restore "h"
    # as a hardlink to the *symlink* (a faithful restore), NOT as a hardlink to the external
    # victim file's inode. The latter (os.link following the symlink) would splice an arbitrary
    # external file into the extracted tree - dangerous especially when restoring as root.
    archiver = request.getfixturevalue(archivers)
    if archiver.get_kind() != "local":
        pytest.skip("malicious archive is crafted via direct (local) repository access")
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    victim_file = os.path.join(archiver.tmpdir, "victim")
    with open(victim_file, "w") as fd:
        fd.write("secret")
    attrs = dict(mtime=0, uid=0, gid=0, user="root", group="root")
    hlid = b"H" * 32  # opaque hardlink-group id shared by both items
    _create_malicious_archive(
        archiver,
        "evil",
        [
            # master: a symlink pointing at the external victim file
            Item(path="s", mode=stat.S_IFLNK | 0o777, target=victim_file, hlid=hlid, **attrs),
            # contentless hardlink to the same hlid
            Item(path="h", mode=stat.S_IFLNK | 0o777, hlid=hlid, **attrs),
        ],
    )
    with changedir("output"):
        cmd(archiver, "extract", "evil")
    s_path = os.path.join(archiver.output_path, "s")
    h_path = os.path.join(archiver.output_path, "h")
    # "h" is a hardlink to the symlink itself (same inode as "s"), a faithful restore ...
    assert os.path.islink(h_path)
    assert os.stat(h_path, follow_symlinks=False).st_ino == os.stat(s_path, follow_symlinks=False).st_ino
    # ... and NOT a hardlink to the external victim file's inode (that would be the leak).
    assert os.stat(h_path, follow_symlinks=False).st_ino != os.stat(victim_file).st_ino
    # the out-of-tree victim is untouched
    with open(victim_file) as fd:
        assert fd.read() == "secret"


def test_extract_path_with_embedded_dotdot_rejected_at_item_layer():
    # Security (CVE-2026-62268, embedded-".." vector): unlike borg 1.4, borg 2 already blocks
    # this vector one layer earlier - Item.path is sanitized on both encode (assert_sanitized_path)
    # and decode (to_sanitized_path), so an item whose path contains ".." can neither be stored in
    # an archive nor read back from one. Hence the "../" branch of Archive._check_safe_parent is
    # defense-in-depth only and cannot be reached via a crafted archive here; we assert the item
    # layer refuses such a path instead.
    with pytest.raises(ValueError):
        Item(path="a/../../victim/passwd", mode=stat.S_IFREG | 0o644, chunks=[])


def test_extract_deep_tree_ok(archivers, request):
    # The parent-path guard (and its safe_dirs cache) must not break normal extraction of a
    # deep tree where many files share the same parent directories.
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "d1/d2/d3/file1", size=10)
    create_regular_file(archiver.input_path, "d1/d2/d3/file2", size=10)
    create_regular_file(archiver.input_path, "d1/d2/other", size=10)
    cmd(archiver, "create", "test", "input")
    with changedir("output"):
        cmd(archiver, "extract", "test")
        assert os.path.exists("input/d1/d2/d3/file1")
        assert os.path.exists("input/d1/d2/d3/file2")
        assert os.path.exists("input/d1/d2/other")


def test_extract_stats(archivers, request):
    archiver = request.getfixturevalue(archivers)
    create_regular_file(archiver.input_path, "file1", size=1024)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    cmd(archiver, "create", "test", "input")
    with changedir("output"):
        output = cmd(archiver, "extract", "test", "--stats")
    assert "Store load volume:" in output
    assert "Store backend load volume:" in output
    assert "Store cache hit ratio:" in output


def test_extract_stats_dry_run(archivers, request):
    archiver = request.getfixturevalue(archivers)
    create_regular_file(archiver.input_path, "file1", size=1024)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    cmd(archiver, "create", "test", "input")
    with changedir("output"):
        output = cmd(archiver, "extract", "test", "--dry-run", "--stats")
    assert "Ignoring --stats" not in output
    assert "Store load volume:" in output


@pytest.mark.skipif(
    not are_symlinks_supported() or not are_hardlinks_supported() or is_darwin,
    reason="symbolic links or hard links or hard-linked sym-links not supported",
)
def test_hardlinked_symlinks_extract(archivers, request):
    archiver = request.getfixturevalue(archivers)
    create_regular_file(archiver.input_path, "target", size=1024)
    with changedir("input"):
        os.symlink("target", "symlink1")
        os.link("symlink1", "symlink2", follow_symlinks=False)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    cmd(archiver, "create", "test", "input")
    with changedir("output"):
        output = cmd(archiver, "extract", "test")
        print(output)
        with changedir("input"):
            assert os.path.exists("target")
            assert os.readlink("symlink1") == "target"
            assert os.readlink("symlink2") == "target"
            st1 = os.stat("symlink1", follow_symlinks=False)
            st2 = os.stat("symlink2", follow_symlinks=False)
            assert st1.st_nlink == 2
            assert st2.st_nlink == 2
            assert st1.st_ino == st2.st_ino
            assert st1.st_size == st2.st_size


@pytest.mark.skipif(not is_utime_fully_supported(), reason="cannot properly setup and execute test without utime")
def test_directory_timestamps1(archivers, request):
    archiver = request.getfixturevalue(archivers)
    create_test_files(archiver.input_path)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    # Default file archiving order (internal recursion)
    cmd(archiver, "create", "test", "input")
    with changedir("output"):
        cmd(archiver, "extract", "test")
    # Extracting a file inside a directory touches the directory mtime
    assert os.path.exists("output/input/dir2/file2")
    # Make sure Borg fixes the directory mtime after touching it
    sti = os.stat("input/dir2")
    sto = os.stat("output/input/dir2")
    assert same_ts_ns(sti.st_mtime_ns, sto.st_mtime_ns)


@pytest.mark.skipif(not is_utime_fully_supported(), reason="cannot properly setup and execute test without utime")
def test_directory_timestamps2(archivers, request):
    archiver = request.getfixturevalue(archivers)
    create_test_files(archiver.input_path)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    # Given order, directory first, file second
    flist_dir_first = b"input/dir2\ninput/dir2/file2\n"
    cmd(archiver, "create", "--paths-from-stdin", "test", input=flist_dir_first)
    with changedir("output"):
        cmd(archiver, "extract", "test")
    # Extracting a file inside a directory touches the directory mtime
    assert os.path.exists("output/input/dir2/file2")
    # Make sure Borg fixes the directory mtime after touching it
    sti = os.stat("input/dir2")
    sto = os.stat("output/input/dir2")
    assert same_ts_ns(sti.st_mtime_ns, sto.st_mtime_ns)


@pytest.mark.skipif(not is_utime_fully_supported(), reason="cannot properly setup and execute test without utime")
def test_directory_timestamps3(archivers, request):
    archiver = request.getfixturevalue(archivers)
    create_test_files(archiver.input_path)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    # Given order, file first, directory second
    flist_file_first = b"input/dir2/file2\ninput/dir2\n"
    cmd(archiver, "create", "--paths-from-stdin", "test", input=flist_file_first)
    with changedir("output"):
        cmd(archiver, "extract", "test")
    # Extracting a file inside a directory touches the directory mtime
    assert os.path.exists("output/input/dir2/file2")
    # Make sure Borg fixes the directory mtime after touching it
    sti = os.stat("input/dir2")
    sto = os.stat("output/input/dir2")
    assert same_ts_ns(sti.st_mtime_ns, sto.st_mtime_ns)


@pytest.mark.skipif(not is_utime_fully_supported(), reason="cannot properly setup and execute test without utime")
def test_atime(archivers, request):
    archiver = request.getfixturevalue(archivers)

    def has_noatime(some_file):
        atime_before = os.stat(some_file).st_atime_ns
        try:
            with open(os.open(some_file, flags_noatime)) as file:
                file.read()
        except PermissionError:
            return False
        else:
            atime_after = os.stat(some_file).st_atime_ns
            noatime_used = flags_noatime != flags_normal
            return noatime_used and atime_before == atime_after

    create_test_files(archiver.input_path)
    atime, mtime = 123456780, 234567890
    have_noatime = has_noatime("input/file1")
    os.utime("input/file1", (atime, mtime))
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    cmd(archiver, "create", "--atime", "test", "input")
    with changedir("output"):
        cmd(archiver, "extract", "test")
    sti = os.stat("input/file1")
    sto = os.stat("output/input/file1")
    assert same_ts_ns(sti.st_mtime_ns, sto.st_mtime_ns)
    assert same_ts_ns(sto.st_mtime_ns, mtime * 10**9)
    if have_noatime:
        assert same_ts_ns(sti.st_atime_ns, sto.st_atime_ns)
        assert same_ts_ns(sto.st_atime_ns, atime * 10**9)
    else:
        # it touched the input file's atime while backing it up
        assert same_ts_ns(sto.st_atime_ns, atime * 10**9)


@pytest.mark.skipif(not is_utime_fully_supported(), reason="cannot setup and execute test without utime")
@pytest.mark.skipif(not is_birthtime_fully_supported(), reason="cannot setup and execute test without birthtime")
def test_birthtime(archivers, request):
    archiver = request.getfixturevalue(archivers)
    create_test_files(archiver.input_path)
    birthtime, mtime, atime = 946598400, 946684800, 946771200
    os.utime("input/file1", (atime, birthtime))
    os.utime("input/file1", (atime, mtime))
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    cmd(archiver, "create", "test", "input")
    with changedir("output"):
        cmd(archiver, "extract", "test")
    sti = os.stat("input/file1")
    sto = os.stat("output/input/file1")
    assert same_ts_ns(sti.st_birthtime * 1e9, sto.st_birthtime * 1e9)
    assert same_ts_ns(sto.st_birthtime * 1e9, birthtime * 1e9)
    assert same_ts_ns(sti.st_mtime_ns, sto.st_mtime_ns)
    assert same_ts_ns(sto.st_mtime_ns, mtime * 10**9)


def test_sparse_file(archivers, request):
    archiver = request.getfixturevalue(archivers)

    def is_sparse(fn, total_size, hole_size):
        st = os.stat(fn)
        assert st.st_size == total_size
        sparse = True
        if sparse and hasattr(st, "st_blocks") and st.st_blocks * 512 >= st.st_size:
            sparse = False
        if sparse and has_seek_hole:
            with open(fn, "rb") as fd:
                # only check if the first hole is as expected, because the 2nd hole check
                # is problematic on xfs due to its "dynamic speculative EOF pre-allocation
                try:
                    if fd.seek(0, os.SEEK_HOLE) != 0:
                        sparse = False
                    # FS allocation granularity may report DATA before the actual first
                    # non-zero byte, so only check it is not after the expected content.
                    if fd.seek(0, os.SEEK_DATA) > hole_size:
                        sparse = False
                except OSError:
                    # OS/FS does not really support SEEK_HOLE/SEEK_DATA
                    sparse = False
        return sparse

    filename_in = os.path.join(archiver.input_path, "sparse")
    content = b"foobar"
    hole_size = 5 * (1 << CHUNK_MAX_EXP)  # 5 full chunker buffers
    total_size = hole_size + len(content) + hole_size
    with open(filename_in, "wb") as fd:
        # create a file that has a hole at the beginning and end (if the
        # OS and filesystem supports sparse files)
        fd.seek(hole_size, 1)
        fd.write(content)
        fd.seek(hole_size, 1)
        pos = fd.tell()
        fd.truncate(pos)
    # we first check if we could create a sparse input file:
    sparse_support = is_sparse(filename_in, total_size, hole_size)
    if sparse_support:
        # we could create a sparse input file, so creating a backup of it and
        # extracting it again (as sparse) should also work:
        cmd(archiver, "repo-create", RK_ENCRYPTION)
        cmd(archiver, "create", "test", "input")
        with changedir(archiver.output_path):
            cmd(archiver, "extract", "test", "--sparse")
        assert_dirs_equal("input", "output/input")
        filename_out = os.path.join(archiver.output_path, "input", "sparse")
        with open(filename_out, "rb") as fd:
            # check if file contents are as expected
            assert fd.read(hole_size) == b"\0" * hole_size
            assert fd.read(len(content)) == content
            assert fd.read(hole_size) == b"\0" * hole_size
        assert is_sparse(filename_out, total_size, hole_size)
        os.unlink(filename_out)  # save space on TMPDIR
    os.unlink(filename_in)  # save space on TMPDIR


def test_unusual_filenames(archivers, request):
    archiver = request.getfixturevalue(archivers)
    filenames = ["normal", "with some blanks", "(with_parens)"]
    for filename in filenames:
        filename = os.path.join(archiver.input_path, filename)
        with open(filename, "wb"):
            pass
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    cmd(archiver, "create", "test", "input")
    for filename in filenames:
        with changedir("output"):
            cmd(archiver, "extract", "test", os.path.join("input", filename))
        assert os.path.exists(os.path.join("output", "input", filename))


def test_strip_components(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "dir/file")
    cmd(archiver, "create", "test", "input")
    with changedir("output"):
        cmd(archiver, "extract", "test", "--strip-components", "3")
        assert not os.path.exists("file")
        with assert_creates_file("file"):
            cmd(archiver, "extract", "test", "--strip-components", "2")
        with assert_creates_file("dir/file"):
            cmd(archiver, "extract", "test", "--strip-components", "1")
        with assert_creates_file("input/dir/file"):
            cmd(archiver, "extract", "test", "--strip-components", "0")


@requires_hardlinks
def test_extract_hardlinks1(archivers, request):
    archiver = request.getfixturevalue(archivers)
    _extract_hardlinks_setup(archiver)
    with changedir("output"):
        cmd(archiver, "extract", "test")
        assert os.stat("input/source").st_nlink == 4
        assert os.stat("input/abba").st_nlink == 4
        assert os.stat("input/dir1/hardlink").st_nlink == 4
        assert os.stat("input/dir1/subdir/hardlink").st_nlink == 4
        assert open("input/dir1/subdir/hardlink", "rb").read() == b"123456"


@requires_hardlinks
def test_extract_hardlinks2(archivers, request):
    archiver = request.getfixturevalue(archivers)
    _extract_hardlinks_setup(archiver)

    with changedir("output"):
        cmd(archiver, "extract", "test", "--strip-components", "2")
        assert os.stat("hardlink").st_nlink == 2
        assert os.stat("subdir/hardlink").st_nlink == 2
        assert open("subdir/hardlink", "rb").read() == b"123456"
        assert os.stat("aaaa").st_nlink == 2
        assert os.stat("source2").st_nlink == 2

    with changedir("output"):
        cmd(archiver, "extract", "test", "input/dir1")
        assert os.stat("input/dir1/hardlink").st_nlink == 2
        assert os.stat("input/dir1/subdir/hardlink").st_nlink == 2
        assert open("input/dir1/subdir/hardlink", "rb").read() == b"123456"
        assert os.stat("input/dir1/aaaa").st_nlink == 2
        assert os.stat("input/dir1/source2").st_nlink == 2


@requires_hardlinks
def test_extract_hardlinks_twice(archivers, request):
    archiver = request.getfixturevalue(archivers)
    # setup for #5603
    path_a = os.path.join(archiver.input_path, "a")
    path_b = os.path.join(archiver.input_path, "b")
    os.mkdir(path_a)
    os.mkdir(path_b)
    hl_a = os.path.join(path_a, "hardlink")
    hl_b = os.path.join(path_b, "hardlink")
    create_regular_file(archiver.input_path, hl_a, contents=b"123456")
    os.link(hl_a, hl_b)
    cmd(archiver, "repo-create", "--encryption=none")
    cmd(archiver, "create", "test", "input", "input")  # give input twice!
    # now test extraction
    with changedir("output"):
        cmd(archiver, "extract", "test")
        # if issue #5603 happens, extraction gives rc == 1 (triggering AssertionError) and warnings like:
        # input/a/hardlink: link: [Errno 2] No such file or directory: 'input/a/hardlink' -> 'input/a/hardlink'
        # input/b/hardlink: link: [Errno 2] No such file or directory: 'input/a/hardlink' -> 'input/b/hardlink'
        # otherwise, when fixed, the hard links should be there and have a link count of 2
        assert os.stat("input/a/hardlink").st_nlink == 2
        assert os.stat("input/b/hardlink").st_nlink == 2


def test_extract_include_exclude(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "file1", size=1024 * 80)
    create_regular_file(archiver.input_path, "file2", size=1024 * 80)
    create_regular_file(archiver.input_path, "file3", size=1024 * 80)
    create_regular_file(archiver.input_path, "file4", size=1024 * 80)
    cmd(archiver, "create", "--exclude=input/file4", "test", "input")

    with changedir("output"):
        cmd(archiver, "extract", "test", "input/file1")
    assert sorted(os.listdir("output/input")) == ["file1"]

    with changedir("output"):
        cmd(archiver, "extract", "test", "--exclude=input/file2")
    assert sorted(os.listdir("output/input")) == ["file1", "file3"]

    with changedir("output"):
        cmd(archiver, "extract", "test", "--exclude-from=" + archiver.exclude_file_path)
    assert sorted(os.listdir("output/input")) == ["file1", "file3"]


def test_extract_include_exclude_regex(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "file1", size=1024 * 80)
    create_regular_file(archiver.input_path, "file2", size=1024 * 80)
    create_regular_file(archiver.input_path, "file3", size=1024 * 80)
    create_regular_file(archiver.input_path, "file4", size=1024 * 80)
    create_regular_file(archiver.input_path, "file333", size=1024 * 80)
    # Create with regular expression exclusion for file4
    cmd(archiver, "create", "--exclude=re:input/file4$", "test", "input")

    with changedir("output"):
        cmd(archiver, "extract", "test")
    assert sorted(os.listdir("output/input")) == ["file1", "file2", "file3", "file333"]
    shutil.rmtree("output/input")

    # Extract with regular expression exclusion
    with changedir("output"):
        cmd(archiver, "extract", "test", "--exclude=re:file3+")
    assert sorted(os.listdir("output/input")) == ["file1", "file2"]
    shutil.rmtree("output/input")

    # Combine --exclude with fnmatch and regular expression
    with changedir("output"):
        cmd(archiver, "extract", "test", "--exclude=input/file2", "--exclude=re:file[01]")
    assert sorted(os.listdir("output/input")) == ["file3", "file333"]
    shutil.rmtree("output/input")

    # Combine --exclude-from and regular expression exclusion
    with changedir("output"):
        cmd(
            archiver,
            "extract",
            "test",
            "--exclude-from=" + archiver.exclude_file_path,
            "--exclude=re:file1",
            "--exclude=re:file(\\d)\\1\\1$",
        )
    assert sorted(os.listdir("output/input")) == ["file3"]


def test_extract_include_exclude_regex_from_file(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "file1", size=1024 * 80)
    create_regular_file(archiver.input_path, "file2", size=1024 * 80)
    create_regular_file(archiver.input_path, "file3", size=1024 * 80)
    create_regular_file(archiver.input_path, "file4", size=1024 * 80)
    create_regular_file(archiver.input_path, "file333", size=1024 * 80)
    # Create while excluding using mixed pattern styles
    with open(archiver.exclude_file_path, "wb") as fd:
        fd.write(b"re:input/file4$\n")
        fd.write(b"fm:*file3*\n")
    cmd(archiver, "create", "--exclude-from=" + archiver.exclude_file_path, "test", "input")

    with changedir("output"):
        cmd(archiver, "extract", "test")
    assert sorted(os.listdir("output/input")) == ["file1", "file2"]
    shutil.rmtree("output/input")

    # Exclude using regular expression
    with open(archiver.exclude_file_path, "wb") as fd:
        fd.write(b"re:file3+\n")

    with changedir("output"):
        cmd(archiver, "extract", "test", "--exclude-from=" + archiver.exclude_file_path)
    assert sorted(os.listdir("output/input")) == ["file1", "file2"]
    shutil.rmtree("output/input")

    # Mixed exclude pattern styles
    with open(archiver.exclude_file_path, "wb") as fd:
        fd.write(b"re:file(\\d)\\1\\1$\n")
        fd.write(b"fm:nothingwillmatchthis\n")
        fd.write(b"*/file1\n")
        fd.write(b"re:file2$\n")

    with changedir("output"):
        cmd(archiver, "extract", "test", "--exclude-from=" + archiver.exclude_file_path)
    assert sorted(os.listdir("output/input")) == []


def test_extract_with_pattern(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "file1", size=1024 * 80)
    create_regular_file(archiver.input_path, "file2", size=1024 * 80)
    create_regular_file(archiver.input_path, "file3", size=1024 * 80)
    create_regular_file(archiver.input_path, "file4", size=1024 * 80)
    create_regular_file(archiver.input_path, "file333", size=1024 * 80)
    cmd(archiver, "create", "test", "input")

    # Extract everything with regular expression
    with changedir("output"):
        cmd(archiver, "extract", "test", "re:.*")
    assert sorted(os.listdir("output/input")) == ["file1", "file2", "file3", "file333", "file4"]
    shutil.rmtree("output/input")

    # Extract with pattern while also excluding files
    with changedir("output"):
        cmd(archiver, "extract", "--exclude=re:file[34]$", "test", r"re:file\d$")
    assert sorted(os.listdir("output/input")) == ["file1", "file2"]
    shutil.rmtree("output/input")

    # Combine --exclude with pattern for extraction
    with changedir("output"):
        cmd(archiver, "extract", "--exclude=input/file1", "test", "re:file[12]$")
    assert sorted(os.listdir("output/input")) == ["file2"]
    shutil.rmtree("output/input")

    # Multiple pattern
    with changedir("output"):
        cmd(archiver, "extract", "test", "fm:input/file1", "fm:*file33*", "input/file2")
    assert sorted(os.listdir("output/input")) == ["file1", "file2", "file333"]


def test_extract_list_output(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "file", size=1024 * 80)
    cmd(archiver, "create", "test", "input")

    with changedir("output"):
        output = cmd(archiver, "extract", "test")
    assert "input/file" not in output
    shutil.rmtree("output/input")

    with changedir("output"):
        output = cmd(archiver, "extract", "test", "--info")
    assert "input/file" not in output
    shutil.rmtree("output/input")

    with changedir("output"):
        output = cmd(archiver, "extract", "test", "--list")
    assert "input/file" in output
    shutil.rmtree("output/input")

    with changedir("output"):
        output = cmd(archiver, "extract", "test", "--list", "--info")
    assert "input/file" in output


def test_extract_progress(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "file", size=1024 * 80)
    cmd(archiver, "create", "test", "input")

    with changedir("output"):
        output = cmd(archiver, "extract", "test", "--progress")
        assert "Extracting:" in output


def test_extract_pattern_opt(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "file1", size=1024 * 80)
    create_regular_file(archiver.input_path, "file2", size=1024 * 80)
    create_regular_file(archiver.input_path, "file_important", size=1024 * 80)
    cmd(archiver, "create", "test", "input")
    with changedir("output"):
        cmd(archiver, "extract", "test", "--pattern=+input/file_important", "--pattern=-input/file*")
    assert sorted(os.listdir("output/input")) == ["file_important"]


@pytest.mark.skipif(not xattr.XATTR_FAKEROOT, reason="Linux capabilities test, requires fakeroot >= 1.20.2")
def test_extract_capabilities(archivers, request):
    archiver = request.getfixturevalue(archivers)
    if archiver.EXE:
        pytest.skip("Skipping binary test due to patch objects")
    fchown = os.fchown

    # We need to patch chown manually to get the behaviour Linux has, since fakeroot does not
    # accurately model the interaction of chown(2) and Linux capabilities, i.e. it does not remove them.
    def patched_fchown(fd, uid, gid):
        xattr.setxattr(fd, b"security.capability", b"", follow_symlinks=False)
        fchown(fd, uid, gid)

    # The capability descriptor used here is valid and taken from a /usr/bin/ping
    capabilities = b"\x01\x00\x00\x02\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
    create_regular_file(archiver.input_path, "file")
    xattr.setxattr(b"input/file", b"security.capability", capabilities)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    cmd(archiver, "create", "test", "input")
    with changedir("output"):
        with patch.object(os, "fchown", patched_fchown):
            cmd(archiver, "extract", "test")
        assert xattr.getxattr(b"input/file", b"security.capability") == capabilities


@pytest.mark.skipif(not xattr.XATTR_FAKEROOT, reason="xattr not supported on this system, or this version of fakeroot")
def test_extract_xattrs_errors(archivers, request):
    archiver = request.getfixturevalue(archivers)
    if archiver.EXE:
        pytest.skip("Skipping binary test due to patch objects")

    def patched_setxattr_E2BIG(*args, **kwargs):
        raise OSError(errno.E2BIG, "E2BIG")

    def patched_setxattr_ENOTSUP(*args, **kwargs):
        raise OSError(errno.ENOTSUP, "ENOTSUP")

    def patched_setxattr_EACCES(*args, **kwargs):
        raise OSError(errno.EACCES, "EACCES")

    create_regular_file(archiver.input_path, "file")
    xattr.setxattr(b"input/file", b"user.attribute", b"value")
    cmd(archiver, "repo-create", "-e", "none")
    cmd(archiver, "create", "test", "input")
    with changedir("output"):
        input_abspath = os.path.abspath("input/file")

        with patch.object(xattr, "setxattr", patched_setxattr_E2BIG):
            out = cmd(archiver, "extract", "test", exit_code=EXIT_WARNING)
            assert "too big for this filesystem" in out
            assert "When setting extended attribute user.attribute" in out
        os.remove(input_abspath)

        with patch.object(xattr, "setxattr", patched_setxattr_ENOTSUP):
            out = cmd(archiver, "extract", "test", exit_code=EXIT_WARNING)
            assert "ENOTSUP" in out
            assert "When setting extended attribute user.attribute" in out
        os.remove(input_abspath)

        with patch.object(xattr, "setxattr", patched_setxattr_EACCES):
            out = cmd(archiver, "extract", "test", exit_code=EXIT_WARNING)
            assert "EACCES" in out
            assert "When setting extended attribute user.attribute" in out
        assert os.path.isfile(input_abspath)


@pytest.mark.skipif(not is_darwin, reason="only for macOS")
def test_extract_xattrs_resourcefork(archivers, request):
    archiver = request.getfixturevalue(archivers)
    create_regular_file(archiver.input_path, "file")
    cmd(archiver, "repo-create", "-e", "none")
    input_path = os.path.abspath("input/file")
    xa_key, xa_value = b"com.apple.ResourceFork", b"whatshouldbehere"  # issue #7234
    xattr.setxattr(input_path.encode(), xa_key, xa_value)
    birthtime_expected = get_birthtime_ns(os.stat(input_path), input_path)
    mtime_expected = os.stat(input_path).st_mtime_ns
    # atime_expected = os.stat(input_path).st_atime_ns
    cmd(archiver, "create", "test", "input")
    with changedir("output"):
        cmd(archiver, "extract", "test")
        extracted_path = os.path.abspath("input/file")
        birthtime_extracted = get_birthtime_ns(os.stat(extracted_path), extracted_path)
        mtime_extracted = os.stat(extracted_path).st_mtime_ns
        # atime_extracted = os.stat(extracted_path).st_atime_ns
        xa_value_extracted = xattr.getxattr(extracted_path.encode(), xa_key)
    assert xa_value_extracted == xa_value
    # cope with small birthtime deviations of less than 1000ns:
    assert birthtime_extracted == birthtime_expected
    assert mtime_extracted == mtime_expected
    # assert atime_extracted == atime_expected  # still broken, but not really important.


@pytest.mark.skipif(not (is_darwin or is_freebsd), reason="only for macOS or FreeBSD")
def test_extract_restores_append_flag(archivers, request):
    archiver = request.getfixturevalue(archivers)
    # create a file and set the append flag on it
    create_regular_file(archiver.input_path, "appendflag", size=1)
    src_path = os.path.abspath("input/appendflag")
    if not hasattr(stat, "UF_APPEND"):
        pytest.skip("UF_APPEND not available on this platform")
    try:
        platform.set_flags(src_path, stat.UF_APPEND)
    except Exception:
        pytest.skip("setting UF_APPEND not supported on this filesystem")
    # Verify the flag actually got set; otherwise skip (filesystem may not support it)
    st = os.lstat(src_path)
    if (platform.get_flags(src_path, st) & stat.UF_APPEND) == 0:
        pytest.skip("UF_APPEND not settable on this filesystem")
    # archive and extract
    cmd(archiver, "repo-create", "-e", "none")
    cmd(archiver, "create", "test", "input")
    with changedir("output"):
        cmd(archiver, "extract", "test")
        out_path = os.path.abspath("input/appendflag")
        st2 = os.lstat(out_path)
        flags = platform.get_flags(out_path, st2)
        assert (flags & stat.UF_APPEND) == stat.UF_APPEND


def test_overwrite(archivers, request):
    archiver = request.getfixturevalue(archivers)
    if archiver.EXE:
        pytest.skip("Test_overwrite seems incompatible with fakeroot and/or the binary.")
    create_regular_file(archiver.input_path, "file1", size=1024 * 80)
    create_regular_file(archiver.input_path, "dir2/file2", size=1024 * 80)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    cmd(archiver, "create", "test", "input")

    # Overwriting regular files and directories should be supported
    os.mkdir("output/input")
    os.mkdir("output/input/file1")
    os.mkdir("output/input/dir2")
    with changedir("output"):
        cmd(archiver, "extract", "test")
    assert_dirs_equal("input", "output/input")

    # But non-empty dirs should fail
    os.unlink("output/input/file1")
    os.mkdir("output/input/file1")
    os.mkdir("output/input/file1/dir")
    expected_ec = BackupPermissionError("open", OSError(21, "is a directory")).exit_code  # WARNING code
    if expected_ec == EXIT_ERROR:  # workaround, TODO: fix it
        expected_ec = EXIT_WARNING
    with changedir("output"):
        cmd(archiver, "extract", "test", exit_code=expected_ec)


# derived from test_extract_xattrs_errors()
@pytest.mark.skipif(not xattr.XATTR_FAKEROOT, reason="xattr not supported on this system, or this version of fakeroot")
def test_do_not_fail_when_percent_is_in_xattr_name(archivers, request):
    """https://github.com/borgbackup/borg/issues/6063"""
    archiver = request.getfixturevalue(archivers)
    if archiver.EXE:
        pytest.skip("Skipping binary test due to patch objects")

    def patched_setxattr_EACCES(*args, **kwargs):
        raise OSError(errno.EACCES, "EACCES")

    create_regular_file(archiver.input_path, "file")
    xattr.setxattr(b"input/file", b"user.attribute%p", b"value")
    cmd(archiver, "repo-create", "-e", "none")
    cmd(archiver, "create", "test", "input")
    with changedir("output"):
        with patch.object(xattr, "setxattr", patched_setxattr_EACCES):
            cmd(archiver, "extract", "test", exit_code=EXIT_WARNING)


# derived from test_extract_xattrs_errors()
@pytest.mark.skipif(not xattr.XATTR_FAKEROOT, reason="xattr not supported on this system, or this version of fakeroot")
def test_do_not_fail_when_percent_is_in_file_name(archivers, request):
    """https://github.com/borgbackup/borg/issues/6063"""
    archiver = request.getfixturevalue(archivers)
    if archiver.EXE:
        pytest.skip("Skipping binary test due to patch objects")

    def patched_setxattr_EACCES(*args, **kwargs):
        raise OSError(errno.EACCES, "EACCES")

    os.makedirs(os.path.join(archiver.input_path, "dir%p"))
    xattr.setxattr(b"input/dir%p", b"user.attribute", b"value")
    cmd(archiver, "repo-create", "-e", "none")
    cmd(archiver, "create", "test", "input")
    with changedir("output"):
        with patch.object(xattr, "setxattr", patched_setxattr_EACCES):
            cmd(archiver, "extract", "test", exit_code=EXIT_WARNING)


@pytest.mark.skipif(not are_hardlinks_supported(), reason="hardlinks not supported")
def test_extract_continue(archivers, request):
    archiver = request.getfixturevalue(archivers)
    CONTENTS1, CONTENTS2, CONTENTS3 = b"contents1" * 100, b"contents2" * 200, b"contents3" * 300
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "dir1/file1", contents=CONTENTS1)
    create_regular_file(archiver.input_path, "dir2/file2", contents=CONTENTS2)
    create_regular_file(archiver.input_path, "dir3/file3", contents=CONTENTS3)
    cmd(archiver, "create", "arch", "input")

    granularity_sleep()

    with changedir("output"):
        # we simulate an interrupted/partial extraction:
        cmd(archiver, "extract", "arch")
        # do not modify dir1 and file1, they stand for a successfully extracted files
        dir1_st = os.stat("input/dir1")
        file1_st = os.stat("input/dir1/file1")
        # simulate a partially extracted dir2 (wrong mtime)
        # simulate a partially extracted file2 (smaller size, archived mtime not yet set)
        dir2_st = os.stat("input/dir2")
        file2_st = os.stat("input/dir2/file2")
        # make a hard link, so it does not free the inode when unlinking input/file2
        os.link("input/dir2/file2", "hardlink-to-keep-inode-f2")
        os.truncate("input/dir2/file2", 123)  # -> incorrect size, incorrect mtime
        Path("input/dir2").touch()  # -> mtime "incorrect" (not as archived)
        # simulate dir3 and file3 have not yet been extracted
        dir3_st = os.stat("input/dir3")
        file3_st = os.stat("input/dir3/file3")
        # make a hard link, so it does not free the inode when unlinking input/file3
        os.link("input/dir3/file3", "hardlink-to-keep-inode-f3")
        os.remove("input/dir3/file3")
        os.rmdir("input/dir3")

    granularity_sleep()

    with changedir("output"):
        # now try to continue extracting, using the same archive, same output dir:
        cmd(archiver, "extract", "arch", "--continue")
        now_dir1_st = os.stat("input/dir1")
        now_file1_st = os.stat("input/dir1/file1")
        assert dir1_st.st_ino == now_dir1_st.st_ino  # dir1 was NOT extracted again
        assert dir1_st.st_mtime_ns == now_dir1_st.st_mtime_ns  # dir1 has correct mtime
        assert file1_st.st_ino == now_file1_st.st_ino  # file1 was NOT extracted again
        assert file1_st.st_mtime_ns == now_file1_st.st_mtime_ns  # has correct mtime
        now_dir2_st = os.stat("input/dir2")
        new_file2_st = os.stat("input/dir2/file2")
        assert dir2_st.st_ino == now_dir2_st.st_ino  # dir2 was not removed/recreated
        assert dir2_st.st_mtime_ns == now_dir2_st.st_mtime_ns  # dir2 mtime was fixed
        assert file2_st.st_ino != new_file2_st.st_ino  # file2 was extracted again
        assert file2_st.st_mtime_ns == new_file2_st.st_mtime_ns  # has correct mtime
        new_dir3_st = os.stat("input/dir3")
        new_file3_st = os.stat("input/dir3/file3")
        assert dir3_st.st_mtime_ns == new_dir3_st.st_mtime_ns  # dir3 was extracted again
        assert file3_st.st_mtime_ns == new_file3_st.st_mtime_ns  # file3 was extracted again
        # windows has a strange ctime behaviour when deleting and recreating a file
        if not is_win32:
            assert file1_st.st_ctime_ns == now_file1_st.st_ctime_ns  # file not extracted again
            assert file2_st.st_ctime_ns != new_file2_st.st_ctime_ns  # file extracted again
            assert file3_st.st_ctime_ns != new_file3_st.st_ctime_ns  # file extracted again
        # check if all contents (and thus also file sizes) are correct:
        with open("input/dir1/file1", "rb") as f:
            assert f.read() == CONTENTS1
        with open("input/dir2/file2", "rb") as f:
            assert f.read() == CONTENTS2
        with open("input/dir3/file3", "rb") as f:
            assert f.read() == CONTENTS3


def test_dry_run_extraction_flags(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "file1", 0)
    create_regular_file(archiver.input_path, "file2", 0)
    create_regular_file(archiver.input_path, "file3", 0)
    cmd(archiver, "create", "test", "input")

    output = cmd(archiver, "extract", "--dry-run", "--list", "test", "-e", "input/file3")

    expected_output = ["+ input/file1", "+ input/file2", "- input/file3"]
    output_lines = output.splitlines()
    for expected in expected_output:
        assert expected in output_lines, f"Expected line not found: {expected}"
        print(output)

    assert not os.listdir("output"), "Output directory should be empty after dry-run"


def test_extract_file_with_missing_chunk(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    create_src_archive(archiver, "archive")
    # Get rid of a chunk
    archive, repository = open_archive(archiver.repository_path, "archive")
    with repository:
        for item in archive.iter_items():
            if item.path.endswith(src_file):
                chunk = item.chunks[-1]
                repository.delete(chunk.id)
                break
        else:
            assert False  # missed the file
    output = cmd(archiver, "extract", "archive")
    # TODO: this is a bit dirty still: no warning/error rc, no filename output for the damaged file.
    assert f"repository object {bin_to_hex(chunk.id)} missing, returning {chunk.size} zero bytes." in output


def test_extract_existing_directory(archivers, request):
    # if we extract a directory and there is already a directory at that location,
    # we should just use the existing directory and not remove/recreate it.
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    os.mkdir("input/dir")
    cmd(archiver, "create", "test", "input")
    with changedir("output"):
        # create pre-existing directory:
        os.makedirs("input/dir", exist_ok=True)
        st1 = os.stat("input/dir")
        # extract
        cmd(archiver, "extract", "test")
        st2 = os.stat("input/dir")
    assert st1.st_ino == st2.st_ino


@pytest.mark.skipif(not is_utime_fully_supported(), reason="cannot properly setup and execute test without utime")
def test_extract_y2261(archivers, request):
    # test if roundtripping of timestamps well beyond y2038 works
    archiver = request.getfixturevalue(archivers)
    create_regular_file(archiver.input_path, "file_y2261", contents=b"post y2038 test")
    # 2261-01-01 00:00:00 UTC as a Unix timestamp (seconds).
    time_y2261 = 9183110400
    try:
        os.utime("input/file_y2261", (time_y2261, time_y2261))
    except OSError as e:
        if e.errno == errno.EOVERFLOW:
            # some filesystems/platforms cannot store timestamps beyond y2038
            # (e.g. ZFS on omniOS rejects this with EOVERFLOW).
            pytest.skip("filesystem cannot store post-y2038 timestamps")
        raise
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    cmd(archiver, "create", "test", "input")
    with changedir("output"):
        cmd(archiver, "extract", "test")
    sto = os.stat("output/input/file_y2261")
    assert same_ts_ns(sto.st_mtime_ns, time_y2261 * 10**9)
