# badge: https://github.com/borgbackup/borg/workflows/CI/badge.svg?branch=master

name: CI

on:
  push:
    branches: [ master ]
    tags:
    - '2.*'
  pull_request:
    branches: [ master ]
    paths:
    - '**.py'
    - '**.pyx'
    - '**.c'
    - '**.h'
    - '**.yml'
    - '**.toml'
    - '**.cfg'
    - '**.ini'
    - 'requirements.d/*'
    - '!docs/**'

jobs:
  lint:

    runs-on: ubuntu-22.04
    timeout-minutes: 5

    steps:
    - uses: actions/checkout@v4
    - uses: chartboost/ruff-action@v1

  security:

    runs-on: ubuntu-24.04
    timeout-minutes: 5

    steps:
    - uses: actions/checkout@v4
    - name: Set up Python
      uses: actions/setup-python@v5
      with:
        python-version: '3.10'
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install bandit[toml]
    - name: Run Bandit
      run: |
        bandit -r src/borg -c pyproject.toml

  asan_ubsan:

    runs-on: ubuntu-24.04
    timeout-minutes: 25
    needs: [lint, security]

    steps:
    - uses: actions/checkout@v4
      with:
        # Just fetching one commit is not enough for setuptools-scm, so we fetch all.
        fetch-depth: 0
        fetch-tags: true

    - name: Set up Python
      uses: actions/setup-python@v5
      with:
        python-version: '3.12'

    - name: Install system packages
      run: |
        sudo apt-get update
        sudo apt-get install -y pkg-config build-essential
        sudo apt-get install -y libssl-dev libacl1-dev libxxhash-dev liblz4-dev libzstd-dev

    - name: Install Python dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.d/development.txt

    - name: Build Borg with ASan/UBSan
      # Build the C/Cython extensions with AddressSanitizer and UndefinedBehaviorSanitizer enabled.
      # How this works:
      # - The -fsanitize=address,undefined flags inject runtime checks into our native code. If a bug is hit
      #   (e.g., buffer overflow, use-after-free, out-of-bounds, or undefined behavior), the sanitizer prints
      #   a detailed error report to stderr, including a stack trace, and forces the process to exit with
      #   non-zero status. In CI, this will fail the step/job so you will notice.
      # - ASAN_OPTIONS/UBSAN_OPTIONS configure the sanitizers' runtime behavior (see below for meanings).
      env:
        CFLAGS: "-O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined"
        CXXFLAGS: "-O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined"
        LDFLAGS: "-fsanitize=address,undefined"
        # ASAN_OPTIONS controls AddressSanitizer runtime tweaks:
        # - detect_leaks=0: Disable LeakSanitizer to avoid false positives with CPython/pymalloc in short-lived tests.
        # - strict_string_checks=1: Make invalid string operations (e.g., over-reads) more likely to be detected.
        # - check_initialization_order=1: Catch uses that depend on static initialization order (C++).
        # - detect_stack_use_after_return=1: Detect stack-use-after-return via stack poisoning (may increase overhead).
        ASAN_OPTIONS: "detect_leaks=0:strict_string_checks=1:check_initialization_order=1:detect_stack_use_after_return=1"
        # UBSAN_OPTIONS controls UndefinedBehaviorSanitizer runtime:
        # - print_stacktrace=1: Include a stack trace for UB reports to ease debugging.
        #   Note: UBSan is recoverable by default (process may continue after reporting). If you want CI to
        #   abort immediately and fail on the first UB, add `halt_on_error=1` (e.g., UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1").
        UBSAN_OPTIONS: "print_stacktrace=1"
        # PYTHONDEVMODE enables additional Python runtime checks and warnings.
        PYTHONDEVMODE: "1"
      run: pip install -e .

    - name: Run tests under sanitizers
      env:
        ASAN_OPTIONS: "detect_leaks=0:strict_string_checks=1:check_initialization_order=1:detect_stack_use_after_return=1"
        UBSAN_OPTIONS: "print_stacktrace=1"
        PYTHONDEVMODE: "1"
      # Ensure the ASan runtime is loaded first to avoid "ASan runtime does not come first" warnings.
      # We discover libasan/libubsan paths via gcc and preload them for the Python test process.
      # the remote tests are slow and likely won't find anything useful
      run: |
        set -euo pipefail
        export LD_PRELOAD="$(gcc -print-file-name=libasan.so):$(gcc -print-file-name=libubsan.so)"
        echo "Using LD_PRELOAD=$LD_PRELOAD"
        pytest -v --benchmark-skip -k "not remote"

  native_tests:

    needs: [lint, security]
    permissions:
      contents: read
      id-token: write
      attestations: write
    strategy:
      fail-fast: true
      # noinspection YAMLSchemaValidation
      matrix: >-
        ${{ fromJSON(
          github.event_name == 'pull_request' && '{
            "include": [
              {"os": "ubuntu-22.04", "python-version": "3.10", "toxenv": "mypy"},
              {"os": "ubuntu-22.04", "python-version": "3.11", "toxenv": "docs"},
              {"os": "ubuntu-22.04", "python-version": "3.10", "toxenv": "py310-llfuse"},
              {"os": "ubuntu-24.04", "python-version": "3.14", "toxenv": "py314-mfusepy"}
            ]
          }' || '{
            "include": [
              {"os": "ubuntu-22.04", "python-version": "3.10", "toxenv": "mypy"},
              {"os": "ubuntu-22.04", "python-version": "3.11", "toxenv": "docs"},
              {"os": "ubuntu-22.04", "python-version": "3.10", "toxenv": "py310-llfuse"},
              {"os": "ubuntu-22.04", "python-version": "3.11", "toxenv": "py311-llfuse", "binary": "borg-linux-glibc235-x86_64-gh"},
              {"os": "ubuntu-22.04-arm", "python-version": "3.11", "toxenv": "py311-llfuse", "binary": "borg-linux-glibc235-arm64-gh"},
              {"os": "ubuntu-24.04", "python-version": "3.12", "toxenv": "py312-pyfuse3"},
              {"os": "ubuntu-24.04", "python-version": "3.13", "toxenv": "py313-pyfuse3"},
              {"os": "ubuntu-24.04", "python-version": "3.14", "toxenv": "py314-mfusepy"},
              {"os": "macos-15-intel", "python-version": "3.11", "toxenv": "py311-none", "binary": "borg-macos-15-x86_64-gh"},
              {"os": "macos-15", "python-version": "3.11", "toxenv": "py311-none", "binary": "borg-macos-15-arm64-gh"}
            ]
          }'
        ) }}
    env:
      TOXENV: ${{ matrix.toxenv }}

    runs-on: ${{ matrix.os }}
    # macOS machines can be slow, if overloaded.
    timeout-minutes: 360

    steps:
    - uses: actions/checkout@v4
      with:
        # Just fetching one commit is not enough for setuptools-scm, so we fetch all.
        fetch-depth: 0
        fetch-tags: true

    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v5
      with:
        python-version: ${{ matrix.python-version }}

    - name: Cache pip
      uses: actions/cache@v4
      with:
        path: ~/.cache/pip
        key: ${{ runner.os }}-pip-${{ hashFiles('requirements.d/development.txt') }}
        restore-keys: |
            ${{ runner.os }}-pip-
            ${{ runner.os }}-

    - name: Install Linux packages
      if: ${{ runner.os == 'Linux' }}
      run: |
        sudo apt-get update
        sudo apt-get install -y pkg-config build-essential
        sudo apt-get install -y libssl-dev libacl1-dev libxxhash-dev liblz4-dev libzstd-dev
        sudo apt-get install -y bash zsh fish  # for shell completion tests
        sudo apt-get install -y rclone openssh-server curl
        if [[ "$TOXENV" == *"llfuse"* ]]; then
          sudo apt-get install -y libfuse-dev fuse  # Required for Python llfuse module
        elif [[ "$TOXENV" == *"pyfuse3"* || "$TOXENV" == *"mfusepy"* ]]; then
          sudo apt-get install -y libfuse3-dev fuse3  # Required for Python pyfuse3 module
        fi

    - name: Install macOS packages
      if: ${{ runner.os == 'macOS' }}
      run: |
        brew unlink pkg-config@0.29.2 || true
        brew bundle install

    - name: Configure OpenSSH SFTP server (test only)
      if: ${{ runner.os == 'Linux' }}
      run: |
        sudo mkdir -p /run/sshd
        sudo useradd -m -s /bin/bash sftpuser || true
        # Create SSH key for the CI user and authorize it for sftpuser
        mkdir -p ~/.ssh
        chmod 700 ~/.ssh
        test -f ~/.ssh/id_ed25519 || ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519
        sudo mkdir -p /home/sftpuser/.ssh
        sudo chmod 700 /home/sftpuser/.ssh
        sudo cp ~/.ssh/id_ed25519.pub /home/sftpuser/.ssh/authorized_keys
        sudo chown -R sftpuser:sftpuser /home/sftpuser/.ssh
        sudo chmod 600 /home/sftpuser/.ssh/authorized_keys
        # Allow publickey auth and enable Subsystem sftp
        sudo sed -i 's/^#\?PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config
        sudo sed -i 's/^#\?PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config
        if ! grep -q '^Subsystem sftp' /etc/ssh/sshd_config; then echo 'Subsystem sftp /usr/lib/openssh/sftp-server' | sudo tee -a /etc/ssh/sshd_config; fi
        # Ensure host keys exist to avoid slow generation on first sshd start
        sudo ssh-keygen -A
        # Start sshd (listen on default 22 inside runner)
        sudo /usr/sbin/sshd -D &
        # Add host key to known_hosts so paramiko trusts it
        ssh-keyscan -H localhost 127.0.0.1 | tee -a ~/.ssh/known_hosts
        # Start ssh-agent and add our key so paramiko can use the agent
        eval "$(ssh-agent -s)"
        ssh-add ~/.ssh/id_ed25519
        # Export SFTP test URL for tox via GITHUB_ENV
        echo "BORG_TEST_SFTP_REPO=sftp://sftpuser@localhost:22/borg/sftp-repo" >> $GITHUB_ENV

    - name: Install and configure MinIO S3 server (test only)
      if: ${{ runner.os == 'Linux' }}
      run: |
        set -e
        arch=$(uname -m)
        case "$arch" in
          x86_64|amd64) srv_url=https://dl.min.io/server/minio/release/linux-amd64/minio; cli_url=https://dl.min.io/client/mc/release/linux-amd64/mc ;;
          aarch64|arm64) srv_url=https://dl.min.io/server/minio/release/linux-arm64/minio; cli_url=https://dl.min.io/client/mc/release/linux-arm64/mc ;;
          *) echo "Unsupported arch: $arch"; exit 1 ;;
        esac
        curl -fsSL -o /usr/local/bin/minio "$srv_url"
        curl -fsSL -o /usr/local/bin/mc "$cli_url"
        sudo chmod +x /usr/local/bin/minio /usr/local/bin/mc
        export PATH=/usr/local/bin:$PATH
        # Start MinIO on :9000 with default credentials (minioadmin/minioadmin)
        MINIO_DIR="$GITHUB_WORKSPACE/.minio-data"
        MINIO_LOG="$GITHUB_WORKSPACE/.minio.log"
        mkdir -p "$MINIO_DIR"
        nohup minio server "$MINIO_DIR" --address ":9000" >"$MINIO_LOG" 2>&1 &
        # Wait for MinIO port to be ready
        for i in $(seq 1 60); do (echo > /dev/tcp/127.0.0.1/9000) >/dev/null 2>&1 && break; sleep 1; done
        # Configure client and create bucket
        mc alias set local http://127.0.0.1:9000 minioadmin minioadmin
        mc mb --ignore-existing local/borg
        # Export S3 test URL for tox via GITHUB_ENV
        echo "BORG_TEST_S3_REPO=s3:minioadmin:minioadmin@http://127.0.0.1:9000/borg/s3-repo" >> $GITHUB_ENV

    - name: Install Python requirements
      run: |
        python -m pip install --upgrade pip setuptools wheel
        pip install -r requirements.d/development.txt

    - name: Install borgbackup
      run: |
        if [[ "$TOXENV" == *"llfuse"* ]]; then
          pip install -ve ".[llfuse]"
        elif [[ "$TOXENV" == *"pyfuse3"* ]]; then
          pip install -ve ".[pyfuse3]"
        elif [[ "$TOXENV" == *"mfusepy"* ]]; then
          pip install -ve ".[mfusepy]"
        else
          pip install -ve .
        fi

    - name: Build Borg fat binaries (${{ matrix.binary }})
      if: ${{ matrix.binary && startsWith(github.ref, 'refs/tags/') }}
      run: |
        pip install 'pyinstaller==6.14.2'
        mkdir -p dist/binary
        # Ensure locally built binaries in ./dist/binary are found during tox tests
        echo "$GITHUB_WORKSPACE/dist/binary" >> "$GITHUB_PATH"
        pyinstaller --clean --distpath=dist/binary scripts/borg.exe.spec

    - name: Smoke-test the built binary (${{ matrix.binary }})
      if: ${{ matrix.binary && startsWith(github.ref, 'refs/tags/') }}
      run: |
        pushd dist/binary
        echo "single-file binary"
        chmod +x borg.exe
        ./borg.exe -V
        echo "single-directory binary"
        chmod +x borg-dir/borg.exe
        ./borg-dir/borg.exe -V
        tar czf borg.tgz borg-dir
        popd
        echo "borg.exe binary in PATH"
        borg.exe -V

    - name: Prepare binaries (${{ matrix.binary }})
      if: ${{ matrix.binary && startsWith(github.ref, 'refs/tags/') }}
      run: |
        mkdir -p artifacts
        if [ -f dist/binary/borg.exe ]; then
          cp dist/binary/borg.exe artifacts/${{ matrix.binary }}
        fi
        if [ -f dist/binary/borg.tgz ]; then
          cp dist/binary/borg.tgz artifacts/${{ matrix.binary }}.tgz
        fi
        echo "binary files"
        ls -l artifacts/

    - name: Attest binaries provenance (${{ matrix.binary }})
      if: ${{ matrix.binary && startsWith(github.ref, 'refs/tags/') }}
      uses: actions/attest-build-provenance@v3
      with:
        subject-path: 'artifacts/*'

    - name: Upload binaries (${{ matrix.binary }})
      if: ${{ matrix.binary && startsWith(github.ref, 'refs/tags/') }}
      uses: actions/upload-artifact@v4
      with:
        name: ${{ matrix.binary }}
        path: artifacts/*
        if-no-files-found: error

    - name: run tox env
      run: |
        # do not use fakeroot, but run as root. avoids the dreaded EISDIR sporadic failures. see #2482.
        #sudo -E bash -c "tox -e py"
        tox --skip-missing-interpreters

    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v4
      env:
        OS: ${{ runner.os }}
        python: ${{ matrix.python-version }}
      with:
        token: ${{ secrets.CODECOV_TOKEN }}
        env_vars: OS, python

  vm_tests:
    permissions:
      contents: read
      id-token: write
      attestations: write
    runs-on: ubuntu-24.04
    timeout-minutes: 90
    needs: [lint, security]
    continue-on-error: true

    strategy:
      fail-fast: false
      matrix:
        include:
          - os: freebsd
            version: '14.3'
            display_name: FreeBSD
            # Controls binary build and provenance attestation on tags
            do_binaries: true
            artifact_prefix: borg-freebsd-14-x86_64-gh

          - os: netbsd
            version: '10.1'
            display_name: NetBSD
            do_binaries: false

          - os: openbsd
            version: '7.7'
            display_name: OpenBSD
            do_binaries: false

          - os: haiku
            version: 'r1beta5'
            display_name: Haiku
            do_binaries: false

    steps:
      - name: Check out repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
          fetch-tags: true

      - name: Test on ${{ matrix.display_name }}
        id: cross_os
        uses: cross-platform-actions/action@v0.29.0
        env:
          DO_BINARIES: ${{ matrix.do_binaries }}
        with:
          operating_system: ${{ matrix.os }}
          version: ${{ matrix.version }}
          shell: bash
          run: |
            set -euxo pipefail

            case "${{ matrix.os }}" in
              freebsd)
                export IGNORE_OSVERSION=yes
                sudo -E pkg update -f
                sudo -E pkg install -y xxhash liblz4 zstd pkgconf
                sudo -E pkg install -y fusefs-libs
                sudo -E kldload fusefs
                sudo -E sysctl vfs.usermount=1
                sudo -E chmod 666 /dev/fuse
                sudo -E pkg install -y rust
                sudo -E pkg install -y gmake
                sudo -E pkg install -y git
                sudo -E pkg install -y python310 py310-sqlite3
                sudo -E pkg install -y python311 py311-sqlite3 py311-pip py311-virtualenv
                sudo ln -sf /usr/local/bin/python3.11 /usr/local/bin/python3
                sudo ln -sf /usr/local/bin/python3.11 /usr/local/bin/python
                sudo ln -sf /usr/local/bin/pip3.11 /usr/local/bin/pip3
                sudo ln -sf /usr/local/bin/pip3.11 /usr/local/bin/pip

                # required for libsodium/pynacl build
                export MAKE=gmake

                python -m venv .venv
                . .venv/bin/activate
                python -V
                pip -V
                python -m pip install --upgrade pip wheel
                pip install -r requirements.d/development.txt
                pip install -e ".[mfusepy]"
                tox -e py311-mfusepy

                if [[ "${{ matrix.do_binaries }}" == "true" && "${{ startsWith(github.ref, 'refs/tags/') }}" == "true" ]]; then
                  python -m pip install 'pyinstaller==6.14.2'
                  mkdir -p dist/binary
                  pyinstaller --clean --distpath=dist/binary scripts/borg.exe.spec
                  pushd dist/binary
                  echo "single-file binary"
                  chmod +x borg.exe
                  ./borg.exe -V
                  echo "single-directory binary"
                  chmod +x borg-dir/borg.exe
                  ./borg-dir/borg.exe -V
                  tar czf borg.tgz borg-dir
                  popd
                  mkdir -p artifacts
                  if [ -f dist/binary/borg.exe ]; then
                    cp -v dist/binary/borg.exe artifacts/${{ matrix.artifact_prefix }}
                  fi
                  if [ -f dist/binary/borg.tgz ]; then
                    cp -v dist/binary/borg.tgz artifacts/${{ matrix.artifact_prefix }}.tgz
                  fi
                fi
                ;;

              netbsd)
                arch="$(uname -m)"
                sudo -E mkdir -p /usr/pkg/etc/pkgin
                echo "http://ftp.NetBSD.org/pub/pkgsrc/packages/NetBSD/${arch}/10.1/All" | sudo tee /usr/pkg/etc/pkgin/repositories.conf > /dev/null
                sudo -E pkgin update
                sudo -E pkgin -y upgrade
                sudo -E pkgin -y install zstd lz4 xxhash git
                sudo -E pkgin -y install rust
                sudo -E pkgin -y install pkg-config
                sudo -E pkgin -y install py311-pip py311-virtualenv py311-tox
                sudo -E ln -sf /usr/pkg/bin/python3.11 /usr/pkg/bin/python3
                sudo -E ln -sf /usr/pkg/bin/pip3.11 /usr/pkg/bin/pip3
                sudo -E ln -sf /usr/pkg/bin/virtualenv-3.11 /usr/pkg/bin/virtualenv3
                sudo -E ln -sf /usr/pkg/bin/tox-3.11 /usr/pkg/bin/tox3

                # Ensure base system admin tools are on PATH for the non-root shell
                export PATH="/sbin:/usr/sbin:$PATH"

                echo "--- Preparing an extattr-enabled filesystem ---"
                # On many NetBSD setups /tmp is tmpfs without extended attributes.
                # Create a FFS image with extended attributes enabled and use it for TMPDIR.
                VNDDEV="vnd0"
                IMGFILE="/tmp/fs.img"
                sudo -E dd if=/dev/zero of=${IMGFILE} bs=1m count=1024
                sudo -E vndconfig -c "${VNDDEV}" "${IMGFILE}"
                sudo -E newfs -O 2ea /dev/r${VNDDEV}a
                MNT="/mnt/eafs"
                sudo -E mkdir -p ${MNT}
                sudo -E mount -t ffs -o extattr /dev/${VNDDEV}a $MNT
                export TMPDIR="${MNT}/tmp"
                sudo -E mkdir -p ${TMPDIR}
                sudo -E chmod 1777 ${TMPDIR}

                touch ${TMPDIR}/testfile
                lsextattr user ${TMPDIR}/testfile && echo "[xattr] *** xattrs SUPPORTED on ${TMPDIR}! ***"

                tox3 -e py311-none
                ;;

              openbsd)
                sudo -E pkg_add xxhash lz4 zstd git
                sudo -E pkg_add rust
                sudo -E pkg_add openssl%3.4
                sudo -E pkg_add py3-pip py3-virtualenv py3-tox

                export BORG_OPENSSL_NAME=eopenssl34
                tox -e py312-none
                ;;

              haiku)
                pkgman refresh
                pkgman install -y git pkgconfig zstd lz4 xxhash
                pkgman install -y openssl3
                pkgman install -y rust_bin
                pkgman install -y python3.10
                pkgman install -y cffi
                pkgman install -y lz4_devel zstd_devel xxhash_devel openssl3_devel libffi_devel

                # there is no pkgman package for tox, so we install it into a venv
                python3 -m ensurepip --upgrade
                python3 -m pip install --upgrade pip wheel
                python3 -m venv .venv
                . .venv/bin/activate

                export PKG_CONFIG_PATH="/system/develop/lib/pkgconfig:/system/lib/pkgconfig:${PKG_CONFIG_PATH:-}"
                export BORG_LIBLZ4_PREFIX=/system/develop
                export BORG_LIBZSTD_PREFIX=/system/develop
                export BORG_LIBXXHASH_PREFIX=/system/develop
                export BORG_OPENSSL_PREFIX=/system/develop
                pip install -r requirements.d/development.txt
                pip install -e .

                # troubles with either tox or pytest xdist, so we run pytest manually:
                pytest -v -rs --benchmark-skip -k "not remote and not socket"
                ;;
            esac

      - name: Upload artifacts
        if: startsWith(github.ref, 'refs/tags/') && matrix.do_binaries
        uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.artifact_prefix }}
          path: artifacts/*
          if-no-files-found: ignore

      - name: Attest provenance
        if: startsWith(github.ref, 'refs/tags/') && matrix.do_binaries
        uses: actions/attest-build-provenance@v3
        with:
          subject-path: 'artifacts/*'

  windows_tests:

    if: false  # can be used to temporarily disable the build
    runs-on: windows-latest
    timeout-minutes: 120
    needs: native_tests

    env:
      PY_COLORS: 1

    defaults:
      run:
        shell: msys2 {0}

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: msys2/setup-msys2@v2
        with:
          msystem: UCRT64
          update: true

      - name: Install system packages
        run: ./scripts/msys2-install-deps development

      - name: Build python venv
        run: |
          # building cffi / argon2-cffi in the venv fails, so we try to use the system packages
          python -m venv --system-site-packages env
          . env/bin/activate
          # python -m pip install --upgrade pip
          # pip install --upgrade setuptools build wheel
          pip install pyinstaller==6.14.2

      - name: Build
        run: |
          # build borg.exe
          . env/bin/activate
          pip install -e .
          pyinstaller -y scripts/borg.exe.spec
          # build sdist and wheel in dist/...
          python -m build

      - uses: actions/upload-artifact@v4
        with:
          name: borg-windows
          path: dist/borg.exe

      - name: Run tests
        run: |
          ./dist/borg.exe -V
          . env/bin/activate
          borg -V
          python -m pytest -n4 --benchmark-skip -vv -rs -k "not remote"

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        env:
          OS: ${{ runner.os }}
          python: '3.11'
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          env_vars: OS, python
