diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b9cb67b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,27 @@ +* text=auto eol=lf + +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf +*.s text eol=lf +*.S text eol=lf +*.jazz text eol=lf + +*.png binary +*.ico binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.zip binary +*.gz binary +*.xz binary +*.bz2 binary +*.zst binary + +# These downstream recipes pin the checksum of the release tarball itself. +# Excluding them from `git archive` avoids a self-referential checksum while +# keeping every recipe versioned in Git and available to its package manager. +/packaging/aur/** export-ignore +/packaging/homebrew/** export-ignore +/packaging/guix/** export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f11515..a04c599 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,251 +1,640 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés -# -# Zupt CI matrix. -# -# Mirrors the project's local-verification protocol from PROMPT.md §6: -# 1. Plain GCC build -# 2. Plain Clang build -# 3. Strict GCC (full warning set) -# 4. Strict Clang (full warning set) -# 5. ASAN + UBSAN -# 6. Full regression suite (12 suites: audit, dedup, path-traversal, -# argument-order, block-swap, F-08, F-09 byte sweep, F-10, F-11, -# F-12, packaging syntax, dist reproducibility) -# 7. License header audit -# 8. `make dist` reproducibility (two runs, sha256 must match) -# 9. aarch64 cross-test via QEMU emulation -# 10. Automatic release on git tag push name: CI on: push: - branches: [main, develop] - tags: ['v*'] + branches: + - master + - 'codex/**' + tags: + - 'v*' pull_request: - branches: [main, develop] + branches: + - master + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' && !startsWith(github.ref, 'refs/tags/') }} + +permissions: + contents: read jobs: - # ─── Plain build + test, exactly as a user would do it ─── + source-policy: + name: Source-only, license, shell and secret policy + runs-on: ubuntu-24.04 + steps: + - name: Check out all refs without LFS or submodules + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + lfs: false + submodules: false + + - name: Install audit tools + run: | + sudo apt-get update + sudo apt-get install -y \ + dpkg-dev file git-lfs libarchive-tools libxml2-utils make python3 ruby \ + shellcheck unzip + + - name: Audit tracked files, worktree and HEAD archive + run: bash scripts/check-source-only.sh + + - name: Exercise positive and negative scanner fixtures + run: bash tests/test_source_only.sh + + - name: Audit license headers + run: make WITH_SDK=0 WITH_PQBOX=0 audit-licenses + + - name: Validate release packaging metadata + run: bash tests/test_packaging_syntax.sh + + - name: ShellCheck release and source-policy scripts + run: | + shellcheck \ + packaging/build-deb.sh \ + packaging/build-rpm.sh \ + packaging/build-appimage.sh \ + packaging/build-dmg.sh \ + packaging/build-gui-appimage.sh \ + packaging/build-gui-deb.sh \ + packaging/build-gui-rpm.sh \ + packaging/opensuse/source-audit.sh \ + scripts/check-source-only.sh \ + scripts/export-opensuse-package.sh \ + scripts/test-installed-zupt.sh \ + tests/test_atomic_archive_output.sh \ + tests/test_authenticated_dedup_reorder.sh \ + tests/test_benchmark_temp_safety.sh \ + tests/test_block_type_confusion.sh \ + tests/test_disk_device_capacity.sh \ + tests/test_f09_preface.sh \ + tests/test_key_files.sh \ + tests/test_legacy_disk_5_2_1.sh \ + tests/test_path_traversal.sh \ + tests/test_pqbox.sh \ + tests/test_sdk.sh \ + tests/test_source_only.sh + + - name: Credential material audit (paths only) + shell: bash + run: | + set -Eeuo pipefail + findings=$(git grep -Il -E -- \ + "-----BEGIN (RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----|https?://[^/@[:space:]]+:[A-Za-z0-9_+=.-]{20,}@|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|AKIA[A-Z0-9]{16}|xox[baprs]-[A-Za-z0-9-]{20,}|(FORGEJO_TOKEN|SECURITYOPS_TOKEN|GITHUB_TOKEN|CODEBERG_TOKEN)[[:space:]]*[:=][[:space:]]*['\\\"]?[A-Za-z0-9_+=./-]{20,}" \ + -- . || true) + if [[ -n $findings ]]; then + printf '%s\n' "$findings" >&2 + echo 'credential-like material found in tracked files' >&2 + exit 1 + fi + echo 'No private-key block, named token assignment, or credential-bearing URL found.' + build-and-test: + name: Build and full tests (${{ matrix.cc }}) + needs: source-policy runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: cc: [gcc, clang] steps: - - uses: actions/checkout@v4 - - name: Install build deps + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + lfs: false + submodules: false + - name: Install build tools run: | sudo apt-get update - sudo apt-get install -y build-essential clang dpkg-dev python3 - - name: Build (${{ matrix.cc }}) - run: make CC=${{ matrix.cc }} -j$(nproc) - - name: zupt version - run: ./zupt version - - name: Full regression suite - run: make test - - name: License header audit - run: make audit-licenses + sudo apt-get install -y build-essential clang file libarchive-tools python3 unzip + - name: Clean source-only build + run: | + make clean + make -j"$(nproc)" CC=${{ matrix.cc }} V=1 WITH_SDK=0 WITH_PQBOX=0 + - name: Distribution checks + run: make CC=${{ matrix.cc }} V=1 WITH_SDK=0 WITH_PQBOX=0 check + - name: Extended upstream tests + run: make CC=${{ matrix.cc }} V=1 WITH_SDK=0 WITH_PQBOX=0 test-all + - name: In-tree SDK atomic key-save regression + run: make CC=${{ matrix.cc }} V=1 sdk-test + - name: Functional test of the built CLI + run: bash scripts/test-installed-zupt.sh "$PWD/zupt" - # ─── Strict warning matrix — what the project's §6 protocol uses ─── strict-warnings: + name: Strict warnings (${{ matrix.cc }}) + needs: source-policy runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: include: - cc: gcc - cflags: "-Wall -Wextra -Wpedantic -Wshadow -Wcast-align -Wstrict-prototypes -Wmissing-prototypes -Wnull-dereference -Wformat-security -Wlogical-op -Wjump-misses-init -Wdouble-promotion -O2 -std=c11 -Werror" + flags: >- + -O2 -g -std=c11 -Wall -Wextra -Wpedantic -Wshadow + -Wcast-align -Wstrict-prototypes -Wmissing-prototypes + -Wnull-dereference -Wformat=2 -Werror - cc: clang - cflags: "-Wall -Wextra -Wpedantic -Wshadow -Wcast-align -Wstrict-prototypes -Wmissing-prototypes -Wnull-dereference -O2 -std=c11 -Werror" + flags: >- + -O2 -g -std=c11 -Wall -Wextra -Wpedantic -Wshadow + -Wcast-align -Wstrict-prototypes -Wmissing-prototypes + -Wnull-dereference -Wformat=2 -Werror steps: - - uses: actions/checkout@v4 - - name: Install build deps - run: sudo apt-get update && sudo apt-get install -y build-essential clang - - name: Strict ${{ matrix.cc }} build (warnings → errors) - run: make CC=${{ matrix.cc }} CFLAGS="${{ matrix.cflags }}" -j$(nproc) - - # ─── ASAN + UBSAN — catches memory bugs the warning matrix can't ─── - sanitizers: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - name: Install build deps - run: sudo apt-get update && sudo apt-get install -y build-essential python3 - - name: Build with ASAN + UBSAN - run: make test-asan - - name: PQ-SDK byte-exact roundtrip under ASAN - env: - ASAN_OPTIONS: detect_leaks=0:abort_on_error=1 - UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1 - run: | - ./zupt_asan keygen --sdk -o /tmp/k.priv - ./zupt_asan compress --pq-sdk /tmp/k.priv.pub /tmp/a.zupt include/ - mkdir -p /tmp/extracted - ./zupt_asan extract --pq-sdk /tmp/k.priv -o /tmp/extracted /tmp/a.zupt - diff -qr include /tmp/extracted/include - - # ─── PIE hardening build — verifies no runtime breakage from -fPIE ─── - pie-hardening: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - name: Install build deps - run: sudo apt-get update && sudo apt-get install -y build-essential - - name: Build with PIE + hardening - run: | - make CFLAGS="-O2 -std=c11 -fPIE -fstack-protector-strong -D_FORTIFY_SOURCE=2 -Wformat -Wformat-security" \ - LDFLAGS="-pie -Wl,-z,relro -Wl,-z,now -Wl,-z,noexecstack" \ - -j$(nproc) - - name: Verify binary is PIE - run: | - file ./zupt | grep -E "ELF .*executable.*pie|ELF .*shared object" || \ - { file ./zupt; echo "binary is not PIE"; exit 1; } - - name: Smoke test - run: | - echo "test" > /tmp/in.txt - ./zupt c -p secret /tmp/a.zupt /tmp/in.txt - mkdir /tmp/out - (cd /tmp/out && ./../../home/runner/work/zupt/zupt/zupt x -p secret /tmp/a.zupt) || \ - { cd /tmp/out && "$GITHUB_WORKSPACE/zupt" x -p secret /tmp/a.zupt; } - diff -q /tmp/in.txt /tmp/out/in.txt - - # ─── aarch64 cross-build via QEMU emulation ─── - cross-aarch64: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - platforms: arm64 - - name: Build + test inside aarch64 container - run: | - docker run --rm --platform linux/arm64 \ - -v "$PWD":/src -w /src \ - ubuntu:24.04 \ - bash -c ' - apt-get update -qq - apt-get install -y -qq build-essential python3 - make -j$(nproc) - ./zupt version - make test - ' - - # ─── make dist reproducibility ─── - dist-reproducibility: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - name: Install build deps - run: sudo apt-get update && sudo apt-get install -y build-essential python3 - - name: First dist build - run: make dist - - name: Capture sha256 (run 1) - id: sha1 - run: | - VER=$(grep '^#define ZUPT_VERSION_STRING' include/zupt.h | awk -F'"' '{print $2}') - SHA=$(sha256sum /tmp/zupt-$VER.tar.gz | awk '{print $1}') - echo "sha=$SHA" >> "$GITHUB_OUTPUT" - echo "ver=$VER" >> "$GITHUB_OUTPUT" - echo "Run 1: $SHA" - - name: Second dist build (must produce identical sha256) - run: make dist - - name: Verify reproducibility - run: | - VER="${{ steps.sha1.outputs.ver }}" - SHA2=$(sha256sum /tmp/zupt-$VER.tar.gz | awk '{print $1}') - if [ "$SHA2" != "${{ steps.sha1.outputs.sha }}" ]; then - echo "::error::make dist is NOT reproducible" - echo " run 1: ${{ steps.sha1.outputs.sha }}" - echo " run 2: $SHA2" - exit 1 - fi - echo "Reproducible ✓ ($SHA2)" - - name: Upload reproducible source tarball - uses: actions/upload-artifact@v4 - with: - name: zupt-source-tarball - path: /tmp/zupt-*.tar.gz - - # ─── Packaging-recipe syntax (cross-distro) ─── - packaging-syntax: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - name: Install validators + persist-credentials: false + - name: Install compilers run: | sudo apt-get update - sudo apt-get install -y build-essential dpkg-dev ruby rpm - - name: Build (for include/zupt.h to exist; not strictly needed for syntax test) - run: make -j$(nproc) - - name: Run packaging syntax test - run: bash tests/test_packaging_syntax.sh + sudo apt-get install -y build-essential clang + - name: Compile with warnings as errors + run: | + make clean + make -j"$(nproc)" CC=${{ matrix.cc }} V=1 WITH_SDK=0 WITH_PQBOX=0 \ + CFLAGS="${{ matrix.flags }}" - # ─── Automatic GitHub release on git tag push ─── - release: - if: startsWith(github.ref, 'refs/tags/v') - needs: [build-and-test, strict-warnings, sanitizers, dist-reproducibility, packaging-syntax] + sanitizers: + name: ASan, LSan and UBSan + needs: source-policy runs-on: ubuntu-24.04 - permissions: - contents: write steps: - - uses: actions/checkout@v4 - - name: Install build deps - run: sudo apt-get update && sudo apt-get install -y build-essential python3 - - name: Build reproducible source tarball - run: make dist - - name: Get version - id: ver + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install compiler and test tools run: | - VER=$(grep '^#define ZUPT_VERSION_STRING' include/zupt.h | awk -F'"' '{print $2}') - echo "version=$VER" >> "$GITHUB_OUTPUT" - - name: Verify tag matches version + sudo apt-get update + sudo apt-get install -y build-essential file python3 + - name: Instrumented functional tests + env: + ASAN_OPTIONS: detect_leaks=1:abort_on_error=1 + UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1 + run: make V=1 WITH_SDK=0 WITH_PQBOX=0 test-asan-run + - name: Mutation smoke under sanitizers + env: + ASAN_OPTIONS: detect_leaks=1:abort_on_error=1 + UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1 + run: make V=1 WITH_SDK=0 WITH_PQBOX=0 fuzz-format-run + + static-analysis: + name: GCC static analyzer + needs: source-policy + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install GCC run: | - TAG="${GITHUB_REF#refs/tags/}" - EXPECTED="v${{ steps.ver.outputs.version }}" - if [ "$TAG" != "$EXPECTED" ]; then - echo "::error::tag $TAG doesn't match include/zupt.h $EXPECTED" + sudo apt-get update + sudo apt-get install -y build-essential + - name: Analyze every source translation unit + run: | + make clean + make -j"$(nproc)" CC=gcc V=1 WITH_SDK=0 WITH_PQBOX=0 \ + CFLAGS="-O1 -g -std=c11 -Wall -Wextra -Werror -fanalyzer" + + source-archive: + name: Reproducible audited source archive + needs: source-policy + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + lfs: false + submodules: false + - name: Install archive audit tools + run: | + sudo apt-get update + sudo apt-get install -y file libarchive-tools python3 unzip + - name: Build the source archive twice + run: | + version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) + mkdir -p "$RUNNER_TEMP/dist-one" "$RUNNER_TEMP/dist-two" \ + "$RUNNER_TEMP/release-source" + make DIST_TARBALL="$RUNNER_TEMP/dist-one/zupt-$version.tar.gz" dist + make DIST_TARBALL="$RUNNER_TEMP/dist-two/zupt-$version.tar.gz" dist + cmp "$RUNNER_TEMP/dist-one/zupt-$version.tar.gz" \ + "$RUNNER_TEMP/dist-two/zupt-$version.tar.gz" + cp "$RUNNER_TEMP/dist-one/zupt-$version.tar.gz" \ + "$RUNNER_TEMP/release-source/" + (cd "$RUNNER_TEMP/release-source" && sha256sum "zupt-$version.tar.gz" > \ + "zupt-$version.tar.gz.sha256") + bash scripts/check-source-only.sh --archive \ + "$RUNNER_TEMP/release-source/zupt-$version.tar.gz" + - name: Match downstream recipe checksums to the tagged source archive + if: startsWith(github.ref, 'refs/tags/v') + run: | + set -Eeuo pipefail + version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) + source_tar="$RUNNER_TEMP/release-source/zupt-$version.tar.gz" + actual_sha=$(sha256sum "$source_tar" | awk '{print $1}') + aur_sha=$(awk -F"'" '/^sha256sums=/ { print $2; exit }' packaging/aur/PKGBUILD) + homebrew_sha=$(awk -F'"' '/^[[:space:]]*sha256 / { print $2; exit }' packaging/homebrew/zupt.rb) + guix_base32=$(sed -n 's/^[[:space:]]*(base32 "\([^"]*\)").*/\1/p' \ + packaging/guix/zupt.scm | head -n 1) + actual_base32=$(python3 - "$source_tar" <<'PY' + import hashlib + import pathlib + import sys + + alphabet = "0123456789abcdfghijklmnpqrsvwxyz" + digest = hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).digest() + value = int.from_bytes(digest, "little") + length = (len(digest) * 8 + 4) // 5 + print("".join(alphabet[(value >> (5 * index)) & 31] + for index in range(length - 1, -1, -1))) + PY + ) + [[ $aur_sha == "$actual_sha" && $homebrew_sha == "$actual_sha" ]] || { + echo 'AUR or Homebrew checksum does not match the source archive' >&2 + exit 1 + } + [[ $guix_base32 == "$actual_base32" ]] || { + echo 'Guix checksum does not match the source archive' >&2 + exit 1 + } + - name: Upload source and checksum + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-source + path: ${{ runner.temp }}/release-source/* + if-no-files-found: error + retention-days: 7 + + debian-package: + name: Debian/Ubuntu source-built package + needs: [source-policy, build-and-test] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + - name: Install Debian package tools + run: | + sudo apt-get update + sudo apt-get install -y build-essential binutils dpkg-dev file git libarchive-tools python3 python3-pyqt6 unzip + - name: Build and extract-test the DEB + run: | + mkdir -p "$RUNNER_TEMP/release-deb" + DIST_DIR="$RUNNER_TEMP/release-deb" RUN_CHECKS=1 bash packaging/build-deb.sh + - name: Build and content-test the GUI DEB + run: | + version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) + test -n "$version" + mkdir -p "$RUNNER_TEMP/release-gui-deb" + DIST_DIR="$RUNNER_TEMP/release-gui-deb" bash packaging/build-gui-deb.sh + gui_deb="$RUNNER_TEMP/release-gui-deb/zupt-gui_${version}_all.deb" + test -s "$gui_deb" + test "$(dpkg-deb -f "$gui_deb" Package)" = zupt-gui + test "$(dpkg-deb -f "$gui_deb" Version)" = "$version" + test "$(dpkg-deb -f "$gui_deb" Architecture)" = all + - name: Install, functionally test and uninstall the DEBs + run: | + version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) + test -n "$version" + deb=$(find "$RUNNER_TEMP/release-deb" -maxdepth 1 -type f -name '*.deb' -print -quit) + gui_deb="$RUNNER_TEMP/release-gui-deb/zupt-gui_${version}_all.deb" + test -n "$deb" && test -s "$gui_deb" + sudo apt-get install -y "$deb" "$gui_deb" + bash scripts/test-installed-zupt.sh /usr/bin/zupt + QT_QPA_PLATFORM=offscreen zupt-gui --version | grep -Fx "zupt-gui $version" + test ! -e /usr/bin/vaptvupt + sudo apt-get purge -y zupt-gui zupt + test ! -e /usr/bin/zupt-gui + test ! -e /usr/bin/zupt + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-deb + path: ${{ runner.temp }}/release-deb/*.deb + if-no-files-found: error + retention-days: 7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-gui-deb + path: ${{ runner.temp }}/release-gui-deb/*.deb + if-no-files-found: error + retention-days: 7 + + tumbleweed-rpm: + name: openSUSE Tumbleweed x86_64 RPM gate + needs: [source-policy, build-and-test] + runs-on: ubuntu-24.04 + container: opensuse/tumbleweed:latest + defaults: + run: + shell: bash + steps: + - name: Bootstrap Git before checkout + run: | + zypper --non-interactive --gpg-auto-import-keys refresh + zypper --non-interactive install --no-recommends \ + bash ca-certificates git-core + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + lfs: false + submodules: false + - name: Trust the exact checked-out workspace + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Install native openSUSE tooling + run: | + if rpm -q busybox-gawk >/dev/null 2>&1; then + zypper --non-interactive remove busybox-gawk + fi + zypper --non-interactive install --no-recommends \ + bash binutils cpio coreutils diffutils file findutils gawk gcc git-core grep gzip \ + libxml2-tools make osc obs-service-obs_scm obs-service-recompress \ + obs-service-tar python3-base rpm-build rpmlint sed \ + shadow spec-cleaner tar unzip util-linux + - name: Confirm the Factory architecture gate + run: test "$(uname -m)" = x86_64 + - name: Validate OBS service and spec syntax + run: | + xmllint --noout packaging/opensuse/_service + test -x /usr/lib/obs/service/obs_scm + test -x /usr/lib/obs/service/tar + test -x /usr/lib/obs/service/recompress + rpmspec -P packaging/opensuse/zupt.spec >/dev/null + spec-cleaner --no-copyright packaging/opensuse/zupt.spec \ + > "$RUNNER_TEMP/zupt.spec.cleaned" + diff -u packaging/opensuse/zupt.spec \ + "$RUNNER_TEMP/zupt.spec.cleaned" + - name: Exercise pinned OBS source service chain on release tags + if: startsWith(github.ref, 'refs/tags/v') + run: | + service_dir=$RUNNER_TEMP/obs-service + mkdir -p "$service_dir" + cp packaging/opensuse/_service "$service_dir/" + # `osc service runall` additionally requires OBS working-copy metadata. + # Use osc's installed service executor to validate this standalone, + # repository-owned _service file with the exact same local services. + python3 - "$service_dir" <<'PY' + import os + import sys + from xml.etree import ElementTree + from osc.obs_scm.serviceinfo import Serviceinfo + + service_dir = sys.argv[1] + os.chdir(service_dir) + service_info = Serviceinfo() + service_info.read(ElementTree.parse(f"{service_dir}/_service").getroot()) + raise SystemExit(service_info.execute(service_dir, "all", verbose=True)) + PY + mapfile -t service_archives < <(find "$service_dir" -maxdepth 1 \ + -type f -name 'zupt-*.tar.gz' -print) + test "${#service_archives[@]}" -eq 1 + bash scripts/check-source-only.sh --archive "${service_archives[0]}" + - name: Build source and binary RPMs with real checks + run: | + mkdir -p "$RUNNER_TEMP/release-rpm" + DIST_DIR="$RUNNER_TEMP/release-rpm" bash packaging/build-rpm.sh + - name: Run rpmlint without suppressions + shell: bash + run: | + set -Eeuo pipefail + rpmlint "$RUNNER_TEMP"/release-rpm/*.rpm 2>&1 \ + | tee "$RUNNER_TEMP/rpmlint.log" + if grep -Eq ': E:' "$RUNNER_TEMP/rpmlint.log"; then + echo 'rpmlint reported one or more errors' >&2 exit 1 fi - - name: Compute sha256 - id: sha + - name: Install, functionally test and uninstall the RPM run: | - VER="${{ steps.ver.outputs.version }}" - SHA=$(sha256sum /tmp/zupt-$VER.tar.gz | awk '{print $1}') - echo "sha=$SHA" >> "$GITHUB_OUTPUT" - echo "$SHA zupt-$VER.tar.gz" > /tmp/zupt-$VER.tar.gz.sha256 - - name: Create GitHub release - uses: softprops/action-gh-release@v2 + rpm_file=$(find "$RUNNER_TEMP/release-rpm" -maxdepth 1 -type f \ + -name '*.rpm' ! -name '*.src.rpm' -print -quit) + test -n "$rpm_file" + zypper --non-interactive install --allow-unsigned-rpm "$rpm_file" + test_home=/tmp/zupt-ci-user + useradd --create-home --home-dir "$test_home" --shell /bin/bash zupt-ci + runuser -u zupt-ci -- env HOME="$test_home" TMPDIR="$test_home" \ + bash "$GITHUB_WORKSPACE/scripts/test-installed-zupt.sh" \ + /usr/bin/zupt + test ! -e /usr/bin/vaptvupt + zypper --non-interactive remove zupt + test ! -e /usr/bin/zupt + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - files: | - /tmp/zupt-${{ steps.ver.outputs.version }}.tar.gz - /tmp/zupt-${{ steps.ver.outputs.version }}.tar.gz.sha256 - body: | - ## Zupt v${{ steps.ver.outputs.version }} + name: release-rpm + path: ${{ runner.temp }}/release-rpm/*.rpm + if-no-files-found: error + retention-days: 7 - Reproducible source tarball. + gui-rpm-package: + name: Fedora noarch GUI RPM and SRPM gate + needs: [source-policy, build-and-test] + runs-on: ubuntu-24.04 + container: fedora:latest + defaults: + run: + shell: bash + steps: + - name: Bootstrap checkout dependencies + run: dnf install -y ca-certificates git + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + lfs: false + submodules: false + - name: Trust the exact checked-out workspace + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Install native build, package, audit and GUI runtime tools + run: | + dnf install -y \ + binutils cpio file findutils gcc git-core gzip libarchive make \ + python3 python3-pyside6 rpm-build rpmdevtools tar unzip + - name: Build and content-test the GUI RPM and source RPM + run: | + version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) + test -n "$version" + mkdir -p "$RUNNER_TEMP/release-gui-rpm" + DIST_DIR="$RUNNER_TEMP/release-gui-rpm" bash packaging/build-gui-rpm.sh + gui_rpm="$RUNNER_TEMP/release-gui-rpm/zupt-gui-$version-1.noarch.rpm" + gui_srpm="$RUNNER_TEMP/release-gui-rpm/zupt-gui-$version-1.src.rpm" + test -s "$gui_rpm" + test -s "$gui_srpm" + test "$(rpm -qp --qf '%{NAME}' "$gui_rpm")" = zupt-gui + test "$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$gui_rpm")" = "$version-1" + test "$(rpm -qp --qf '%{ARCH}' "$gui_rpm")" = noarch + rpm -qp --requires "$gui_rpm" | grep -Fx "zupt >= $version" + test "$(rpm -qp --qf '%{NAME}' "$gui_srpm")" = zupt-gui + test "$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$gui_srpm")" = "$version-1" + test "$(rpm -qp --qf '%{SOURCEPACKAGE}' "$gui_srpm")" = 1 + test "$(rpm -qp --qf '%{SOURCERPM}' "$gui_srpm")" = '(none)' + test "$(rpm -qpl "$gui_srpm" | wc -l)" -eq 2 + rpm -qpl "$gui_srpm" | grep -Fx "zupt-gui-$version.tar.gz" + rpm -qpl "$gui_srpm" | grep -Fx zupt-gui.spec + - name: Build the matching Fedora CLI RPM + run: | + version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) + core_top="$RUNNER_TEMP/core-rpmbuild" + mkdir -p "$core_top"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} + make DIST_TARBALL="$core_top/SOURCES/zupt-$version.tar.gz" dist + rpmbuild --define "_topdir $core_top" -ba packaging/rpm/zupt.spec + - name: Install and functionally test the GUI with the packaged CLI + run: | + version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) + test -n "$version" + core_rpm=$(find "$RUNNER_TEMP/core-rpmbuild/RPMS" -type f \ + -name "zupt-$version-1.*.rpm" ! -name '*-debuginfo-*' \ + ! -name '*-debugsource-*' -print -quit) + gui_rpm="$RUNNER_TEMP/release-gui-rpm/zupt-gui-$version-1.noarch.rpm" + test -n "$core_rpm" && test -s "$gui_rpm" + dnf install -y "$core_rpm" "$gui_rpm" + bash scripts/test-installed-zupt.sh /usr/bin/zupt + QT_QPA_PLATFORM=offscreen zupt-gui --version | grep -Fx "zupt-gui $version" + test ! -e /usr/bin/vaptvupt + dnf remove -y zupt-gui zupt + test ! -e /usr/bin/zupt-gui + test ! -e /usr/bin/zupt + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-gui-rpm + path: ${{ runner.temp }}/release-gui-rpm/*.rpm + if-no-files-found: error + retention-days: 7 - ``` - sha256: ${{ steps.sha.outputs.sha }} - ``` + linux-portable: + name: Linux x86_64 notice-bearing CLI tar.xz gate + needs: [source-policy, build-and-test] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + lfs: false + submodules: false + - name: Install build and archive tools + run: | + sudo apt-get update + sudo apt-get install -y build-essential binutils file python3 xz-utils + - name: Build and audit the native executable + run: | + test "$(uname -m)" = x86_64 + make clean + make -j"$(nproc)" V=1 WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 + make V=1 WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 check + bash scripts/test-installed-zupt.sh "$PWD/zupt" + if readelf -d zupt | grep -Eq '(RPATH|RUNPATH)'; then + echo 'Linux portable binary contains RPATH/RUNPATH' >&2 + exit 1 + fi + mapfile -t needed < <(readelf -d zupt | sed -n 's/.*Shared library: \[\([^]]*\)\].*/\1/p') + ((${#needed[@]} > 0)) + for library in "${needed[@]}"; do + case $library in + libc.so.6|libm.so.6|libpthread.so.0) ;; + *) echo "unexpected Linux runtime dependency: $library" >&2; exit 1 ;; + esac + done + if ldd zupt | grep -Fq 'not found'; then + echo 'Linux portable binary has an unresolved runtime dependency' >&2 + exit 1 + fi + - name: Assemble and extracted-package-test the tar.xz + run: | + version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) + epoch=$(<.source-date-epoch) + root="$RUNNER_TEMP/linux-work/zupt-$version-linux-x86_64" + output="$RUNNER_TEMP/release-linux-x86_64/zupt-$version-linux-x86_64.tar.xz" + mkdir -p "$root" "$(dirname "$output")" + install -m 0755 zupt "$root/zupt" + install -m 0644 README.md CHANGELOG.md SECURITY.md THREAT_MODEL.md \ + LICENSE LICENSE-AGPL-3.0 LICENSE-GPL-3.0 LICENSE-BSD-2-Clause \ + LICENSE-BSD-3-Clause LICENSE-CC0-1.0 NOTICE \ + THIRD-PARTY-NOTICES.md "$root/" + tar --sort=name --mtime="@$epoch" --owner=0 --group=0 --numeric-owner \ + -C "$(dirname "$root")" -cJf "$output" "$(basename "$root")" + extract=$(mktemp -d) + tar -xJf "$output" -C "$extract" + bash scripts/test-installed-zupt.sh \ + "$extract/$(basename "$root")/zupt" + test "$(find "$extract/$(basename "$root")" -maxdepth 1 -type f | wc -l)" -eq 13 + sha256sum "$output" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-linux-x86_64 + path: ${{ runner.temp }}/release-linux-x86_64/*.tar.xz + if-no-files-found: error + retention-days: 7 - See CHANGELOG.md for release notes. + gui-portable: + name: Source-only GUI portable ZIP gate + needs: [source-policy, build-and-test] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + lfs: false + submodules: false + - name: Install GUI smoke-test and archive tools + run: | + sudo apt-get update + sudo apt-get install -y build-essential file python3 python3-pyqt6 unzip zip + - name: Assemble, audit and execute the portable GUI source bundle + run: | + version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) + make -j"$(nproc)" V=1 WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 + root="$RUNNER_TEMP/gui-work/zupt-gui-$version-portable" + output="$RUNNER_TEMP/release-gui-portable/zupt-gui-$version-portable.zip" + mkdir -p "$root/assets" "$(dirname "$output")" + install -m 0644 gui/src/zupt_gui.py "$root/zupt_gui.py" + install -m 0755 packaging/portable/zupt-gui.sh \ + packaging/portable/zupt-gui.command "$root/" + install -m 0644 packaging/portable/zupt-gui.bat "$root/" + install -m 0644 packaging/portable/README.txt "$root/README.txt" + install -m 0644 gui/assets/zupt-icon.png gui/assets/zupt.ico "$root/assets/" + install -m 0644 LICENSE-AGPL-3.0 gui/LICENSE-GUI CHANGELOG.md "$root/" + install -m 0644 gui/assets/README.md "$root/ASSET-PROVENANCE.md" + bash scripts/check-source-only.sh --tree "$root" + QT_QPA_PLATFORM=offscreen PATH="$PWD:$PATH" \ + "$root/zupt-gui.sh" --version | grep -Fx "zupt-gui $version" + epoch=$(<.source-date-epoch) + find "$root" -exec touch -d "@$epoch" {} + + (cd "$(dirname "$root")" && zip -X -9 -r "$output" "$(basename "$root")") + extract=$(mktemp -d) + unzip -q "$output" -d "$extract" + bash scripts/check-source-only.sh --tree "$extract/$(basename "$root")" + QT_QPA_PLATFORM=offscreen PATH="$PWD:$PATH" \ + "$extract/$(basename "$root")/zupt-gui.sh" --version | \ + grep -Fx "zupt-gui $version" + sha256sum "$output" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-gui-portable + path: ${{ runner.temp }}/release-gui-portable/*.zip + if-no-files-found: error + retention-days: 7 - ### Verifying the tarball - - ```sh - sha256sum -c zupt-${{ steps.ver.outputs.version }}.tar.gz.sha256 - ``` - - ### Building - - ```sh - tar xzf zupt-${{ steps.ver.outputs.version }}.tar.gz - cd zupt-${{ steps.ver.outputs.version }} - make - make test - sudo make install - ``` + target-packages: + name: Windows and macOS release gates + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + needs: + - source-policy + - build-and-test + - strict-warnings + - sanitizers + - static-analysis + - source-archive + - debian-package + - tumbleweed-rpm + - gui-rpm-package + - linux-portable + - gui-portable + uses: ./.github/workflows/cross-platform.yml + permissions: + contents: read diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml new file mode 100644 index 0000000..fc21650 --- /dev/null +++ b/.github/workflows/cross-platform.yml @@ -0,0 +1,265 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (c) 2025-2026 Cristian Cezar Moisés + +name: target release packages + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + windows-x86_64: + name: Windows x86_64 package and smoke test + runs-on: windows-latest + defaults: + run: + shell: msys2 {0} + steps: + - name: Check out the audited source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + lfs: false + submodules: false + + - name: Install the Windows C toolchain + uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2.32.0 + with: + msystem: UCRT64 + update: true + install: >- + mingw-w64-ucrt-x86_64-binutils + mingw-w64-ucrt-x86_64-gcc + bsdtar + coreutils + diffutils + file + findutils + git + gzip + make + python + tar + unzip + zip + + - name: Audit source before building + run: bash scripts/check-source-only.sh + + - name: Build from source + run: | + test "$(uname -m)" = x86_64 + make clean + make -j2 CC=gcc V=1 WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 + + - name: Run the source-only distribution checks on Windows + run: make CC=gcc V=1 WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 check + + - name: Native CLI smoke and round-trip + run: | + version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) + if [[ -x ./zupt.exe ]]; then + exe=$PWD/zupt.exe + elif [[ -x ./zupt ]]; then + exe=$PWD/zupt + else + echo 'ZUPT executable was not produced' >&2 + exit 1 + fi + version_output=$("$exe" --version) + version_line=${version_output%%$'\n'*} + read -r product reported_version _ <<< "$version_line" + if [[ $product != zupt || $reported_version != "$version" ]]; then + echo 'native Windows executable version does not match include/zupt.h' >&2 + exit 1 + fi + "$exe" --help >/dev/null + if "$exe" --definitely-invalid-option >/dev/null 2>&1; then + echo 'invalid option returned success' >&2 + exit 1 + fi + test_root=$(mktemp -d) + trap 'chmod -R u+rwX "$test_root" 2>/dev/null || true; rm -rf -- "$test_root"' EXIT + output_dir="$test_root/saída-安全" + archive="$test_root/cópia-安全.zupt" + emoji_name=$'emoji-\xF0\x9F\x98\x80.bin' + mkdir -p "$test_root/input/subdir" "$output_dir" + printf 'Windows release smoke test\n' > "$test_root/input/café.txt" + printf 'UTF-8: café-安全\n' > "$test_root/input/subdir/ação-安全.txt" + printf 'emoji filename\n' > "$test_root/input/subdir/$emoji_name" + : > "$test_root/input/empty" + dd if=/dev/urandom of="$test_root/input/subdir/random.bin" bs=4096 count=4 2>/dev/null + (cd "$test_root" && "$exe" compress "$archive" input) + "$exe" test "$archive" + "$exe" list "$archive" > "$test_root/list.txt" 2>&1 + "$exe" extract -o "$output_dir" "$archive" + diff -r "$test_root/input" "$output_dir/input" + python3 - "$test_root/list.txt" <<'PY' + import pathlib + import sys + + listing = pathlib.Path(sys.argv[1]).read_bytes() + expected = { + "Latin-1": bytes.fromhex("636166c3a92e747874"), + "BMP": bytes.fromhex("61c3a7c3a36f2de5ae89e585a82e747874"), + "non-BMP": bytes.fromhex("656d6f6a692df09f98802e62696e"), + } + missing = [label for label, name in expected.items() if name not in listing] + if missing: + raise SystemExit("list output is missing exact UTF-8 names: " + + ", ".join(missing)) + PY + objdump -p "$exe" > "$test_root/imports.txt" + if grep -Eqi '(vendor[/\\]|libvuptsdk|libpqvaptvupt|libgcc_s|libstdc\+\+|libwinpthread|msys-2[.]0|cygwin1)[^[:space:]]*[.]dll' \ + "$test_root/imports.txt"; then + echo 'Windows binary imports a non-system or vendored runtime' >&2 + exit 1 + fi + version_output=$(env PATH='/c/Windows/System32:/c/Windows' "$exe" --version) + version_line=${version_output%%$'\n'*} + read -r product reported_version _ <<< "$version_line" + if [[ $product != zupt || $reported_version != "$version" ]]; then + echo 'restricted-PATH Windows executable version does not match include/zupt.h' >&2 + exit 1 + fi + + - name: Assemble Windows release files + run: | + version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) + bundle="out/work/zupt-$version-windows-x86_64" + mkdir -p out "$bundle" + if [[ -x ./zupt.exe ]]; then source_exe=./zupt.exe; else source_exe=./zupt; fi + install -m 0755 "$source_exe" "$bundle/zupt.exe" + install -m 0644 README.md CHANGELOG.md LICENSE LICENSE-AGPL-3.0 LICENSE-GPL-3.0 \ + LICENSE-BSD-2-Clause LICENSE-BSD-3-Clause LICENSE-CC0-1.0 \ + NOTICE THIRD-PARTY-NOTICES.md "$bundle/" + toolchain_prefix=${MINGW_PREFIX:-/ucrt64} + install -m 0644 \ + "$toolchain_prefix/share/licenses/crt/COPYING" \ + "$bundle/MINGW-CRT-COPYING.txt" + install -m 0644 \ + "$toolchain_prefix/share/licenses/crt/COPYING.MinGW-w64-runtime.txt" \ + "$bundle/COPYING.MinGW-w64-runtime.txt" + install -m 0644 \ + "$toolchain_prefix/share/licenses/crt/COPYING.MinGW-w64.txt" \ + "$bundle/COPYING.MinGW-w64.txt" + install -m 0644 \ + "$toolchain_prefix/share/licenses/gcc-libs/COPYING3" \ + "$bundle/GCC-COPYING3.txt" + install -m 0644 \ + "$toolchain_prefix/share/licenses/gcc-libs/COPYING.RUNTIME" \ + "$bundle/GCC-RUNTIME-LIBRARY-EXCEPTION.txt" + zip_path=$PWD/out/zupt-$version-windows-x86_64.zip + (cd out/work && zip -9 -r "$zip_path" \ + "zupt-$version-windows-x86_64") + + - name: Extract and functionally test the Windows ZIP + run: | + set -Eeuo pipefail + version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) + zip_path=$PWD/out/zupt-$version-windows-x86_64.zip + unzip -t "$zip_path" + extract_root=$(mktemp -d) + cleanup() { + chmod -R u+rwX "$extract_root" 2>/dev/null || true + rm -rf -- "$extract_root" + } + trap cleanup EXIT HUP INT TERM + unzip -q "$zip_path" -d "$extract_root" + for notice in MINGW-CRT-COPYING.txt COPYING.MinGW-w64-runtime.txt \ + COPYING.MinGW-w64.txt GCC-COPYING3.txt \ + GCC-RUNTIME-LIBRARY-EXCEPTION.txt; do + test -s "$extract_root/zupt-$version-windows-x86_64/$notice" + done + packaged_exe=$extract_root/zupt-$version-windows-x86_64/zupt.exe + test -x "$packaged_exe" + version_output=$(env PATH='/c/Windows/System32:/c/Windows' \ + "$packaged_exe" --version) + version_line=${version_output%%$'\n'*} + read -r product reported_version _ <<< "$version_line" + if [[ $product != zupt || $reported_version != "$version" ]]; then + echo 'Windows ZIP executable version does not match include/zupt.h' >&2 + exit 1 + fi + env PATH='/c/Windows/System32:/c/Windows' "$packaged_exe" --help >/dev/null + mkdir -p "$extract_root/smoke/input" "$extract_root/smoke/saída-安全" + printf 'Windows ZIP package test\n' > "$extract_root/smoke/input/payload-ação-😀.txt" + ( + cd "$extract_root/smoke" + env PATH='/c/Windows/System32:/c/Windows' \ + "$packaged_exe" compress cópia-安全.zupt input + env PATH='/c/Windows/System32:/c/Windows' \ + "$packaged_exe" test cópia-安全.zupt + env PATH='/c/Windows/System32:/c/Windows' \ + "$packaged_exe" extract -o saída-安全 cópia-安全.zupt + ) + cmp "$extract_root/smoke/input/payload-ação-😀.txt" \ + "$extract_root/smoke/saída-安全/input/payload-ação-😀.txt" + + - name: Upload tested Windows files + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-windows-x86_64 + path: out/*.zip + if-no-files-found: error + retention-days: 7 + + macos-native: + name: macOS native DMG and installed-image test + runs-on: macos-latest + steps: + - name: Check out the audited source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + lfs: false + submodules: false + + - name: Audit source before building + run: bash scripts/check-source-only.sh + + - name: Build and validate the native DMG + run: | + mkdir -p out + DIST_DIR="$PWD/out" RUN_CHECKS=1 bash packaging/build-dmg.sh + + - name: Mount and functionally test the packaged binary + run: | + dmg=$(find out -maxdepth 1 -type f -name '*.dmg' -print -quit) + test -n "$dmg" + mount_point=$(mktemp -d) + cleanup() { + hdiutil detach "$mount_point" >/dev/null 2>&1 || true + chmod -R u+rwX "$mount_point" 2>/dev/null || true + rm -rf -- "$mount_point" + } + trap cleanup EXIT HUP INT TERM + hdiutil attach -nobrowse -readonly -mountpoint "$mount_point" "$dmg" >/dev/null + version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' \ + include/zupt.h) + packaged_binary=$mount_point/ZUPT.app/Contents/MacOS/zupt + version_output=$("$packaged_binary" --version) + version_line=${version_output%%$'\n'*} + read -r product reported_version _ <<< "$version_line" + if [[ $product != zupt || $reported_version != "$version" ]]; then + echo 'mounted macOS executable version does not match include/zupt.h' >&2 + exit 1 + fi + bash packaging/build-dmg.sh --test-binary \ + "$packaged_binary" + hdiutil detach "$mount_point" + trap - EXIT HUP INT TERM + rmdir "$mount_point" + + - name: Upload tested macOS DMG + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-macos-native + path: out/*.dmg + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml new file mode 100644 index 0000000..0dc0ec4 --- /dev/null +++ b/.github/workflows/promote-release.yml @@ -0,0 +1,656 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (c) 2025-2026 Cristian Cezar Moisés + +name: Promote a tested release + +on: + workflow_dispatch: + inputs: + source_run_id: + description: Successful manually dispatched CI run that produced the assets + required: true + type: number + tag: + description: Existing annotated release tag, for example v5.2.8 + required: true + type: string + +permissions: {} + +concurrency: + group: promote-release-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + promote: + name: Promote tested assets to the canonical GitHub release + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + actions: read + contents: write + steps: + - name: Validate the tag and source CI run through the GitHub API + id: provenance + env: + GH_TOKEN: ${{ github.token }} + SOURCE_RUN_ID: ${{ inputs.source_run_id }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -Eeuo pipefail + set +x + umask 077 + [[ $SOURCE_RUN_ID =~ ^[1-9][0-9]*$ ]] || { + echo 'source_run_id must be a positive integer' >&2 + exit 1 + } + [[ $RELEASE_TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo 'tag must have the form vX.Y.Z' >&2 + exit 1 + } + + tag_ref_api="repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" + tag_object_type=$(gh api "$tag_ref_api" --jq '.object.type') + tag_object_sha=$(gh api "$tag_ref_api" --jq '.object.sha') + [[ $tag_object_type == tag && $tag_object_sha =~ ^[0-9a-f]{40}$ ]] || { + echo 'GitHub release ref is not an annotated tag' >&2 + exit 1 + } + tag_object_api="repos/$GITHUB_REPOSITORY/git/tags/$tag_object_sha" + target_type=$(gh api "$tag_object_api" --jq '.object.type') + peeled_sha=$(gh api "$tag_object_api" --jq '.object.sha') + [[ $target_type == commit && $peeled_sha =~ ^[0-9a-f]{40}$ ]] || { + echo 'annotated tag does not point directly to a commit' >&2 + exit 1 + } + + run_api="repos/$GITHUB_REPOSITORY/actions/runs/$SOURCE_RUN_ID" + run_status=$(gh api "$run_api" --jq '.status') + run_conclusion=$(gh api "$run_api" --jq '.conclusion') + run_event=$(gh api "$run_api" --jq '.event') + run_head_branch=$(gh api "$run_api" --jq '.head_branch // ""') + run_workflow_id=$(gh api "$run_api" --jq '.workflow_id') + run_sha=$(gh api "$run_api" --jq '.head_sha') + run_repository=$(gh api "$run_api" --jq '.head_repository.full_name // ""') + workflow_path=$(gh api \ + "repos/$GITHUB_REPOSITORY/actions/workflows/$run_workflow_id" \ + --jq '.path') + [[ $run_status == completed && $run_conclusion == success ]] || { + echo 'source CI run is not completed successfully' >&2 + exit 1 + } + [[ $run_event == workflow_dispatch ]] || { + echo 'source CI run must have been started with workflow_dispatch' >&2 + exit 1 + } + [[ $run_head_branch == "$RELEASE_TAG" ]] || { + echo 'source CI run must have been dispatched from the release tag' >&2 + exit 1 + } + [[ $workflow_path == .github/workflows/ci.yml ]] || { + echo 'source run did not execute .github/workflows/ci.yml' >&2 + exit 1 + } + [[ $run_repository == "$GITHUB_REPOSITORY" ]] || { + echo 'source CI run belongs to a different head repository' >&2 + exit 1 + } + [[ $run_sha =~ ^[0-9a-f]{40}$ && $run_sha == "$peeled_sha" ]] || { + echo 'source CI head SHA does not match the peeled release tag' >&2 + exit 1 + } + + artifact_json=$RUNNER_TEMP/source-run-artifacts.json + gh api "$run_api/artifacts?per_page=100" > "$artifact_json" + python3 - "$artifact_json" <<'PY' + import json + import pathlib + import sys + + payload = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) + expected = { + "release-source", + "release-deb", + "release-rpm", + "release-gui-deb", + "release-gui-rpm", + "release-linux-x86_64", + "release-gui-portable", + "release-windows-x86_64", + "release-macos-native", + } + artifacts = payload.get("artifacts", []) + names = [artifact.get("name", "") for artifact in artifacts] + if payload.get("total_count") != len(expected): + raise SystemExit("source CI run artifact count mismatch") + if set(names) != expected or len(names) != len(set(names)): + raise SystemExit("source CI run artifact-name allowlist mismatch") + if any(artifact.get("expired") for artifact in artifacts): + raise SystemExit("one or more source CI artifacts have expired") + PY + { + printf 'head_sha=%s\n' "$peeled_sha" + printf 'tag_object_sha=%s\n' "$tag_object_sha" + printf 'tag=%s\n' "$RELEASE_TAG" + } >> "$GITHUB_OUTPUT" + + - name: Check out the exact tested commit without persisted credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ steps.provenance.outputs.head_sha }} + persist-credentials: false + fetch-depth: 0 + lfs: false + submodules: false + + - name: Confirm the local annotated tag and source version + id: release + env: + RELEASE_TAG: ${{ steps.provenance.outputs.tag }} + EXPECTED_SHA: ${{ steps.provenance.outputs.head_sha }} + EXPECTED_TAG_OBJECT: ${{ steps.provenance.outputs.tag_object_sha }} + run: | + set -Eeuo pipefail + [[ $(git rev-parse HEAD) == "$EXPECTED_SHA" ]] || { + echo 'checked-out commit differs from the validated source run' >&2 + exit 1 + } + [[ $(git cat-file -t "refs/tags/$RELEASE_TAG") == tag ]] || { + echo 'checked-out release ref is not an annotated tag' >&2 + exit 1 + } + [[ $(git rev-parse "refs/tags/$RELEASE_TAG") == "$EXPECTED_TAG_OBJECT" ]] || { + echo 'local annotated tag object differs from the validated GitHub tag' >&2 + exit 1 + } + [[ $(git rev-parse "$RELEASE_TAG^{commit}") == "$EXPECTED_SHA" ]] || { + echo 'local peeled tag does not match the tested commit' >&2 + exit 1 + } + version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' \ + include/zupt.h) + [[ -n $version && $RELEASE_TAG == "v$version" ]] || { + echo 'tag does not match include/zupt.h' >&2 + exit 1 + } + printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" + + - name: Install validation tools + run: | + sudo apt-get update + sudo apt-get install -y file libarchive-tools python3 python3-pyqt6 rpm unzip xz-utils + + - name: Download the exact source artifact from the validated run + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-source + path: ${{ runner.temp }}/incoming/release-source + repository: ${{ github.repository }} + run-id: ${{ inputs.source_run_id }} + github-token: ${{ github.token }} + + - name: Download the exact DEB artifact from the validated run + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-deb + path: ${{ runner.temp }}/incoming/release-deb + repository: ${{ github.repository }} + run-id: ${{ inputs.source_run_id }} + github-token: ${{ github.token }} + + - name: Download the exact RPM artifact from the validated run + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-rpm + path: ${{ runner.temp }}/incoming/release-rpm + repository: ${{ github.repository }} + run-id: ${{ inputs.source_run_id }} + github-token: ${{ github.token }} + + - name: Download the exact GUI DEB artifact from the validated run + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-gui-deb + path: ${{ runner.temp }}/incoming/release-gui-deb + repository: ${{ github.repository }} + run-id: ${{ inputs.source_run_id }} + github-token: ${{ github.token }} + + - name: Download the exact GUI RPM artifacts from the validated run + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-gui-rpm + path: ${{ runner.temp }}/incoming/release-gui-rpm + repository: ${{ github.repository }} + run-id: ${{ inputs.source_run_id }} + github-token: ${{ github.token }} + + - name: Download the exact Linux tar.xz artifact from the validated run + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-linux-x86_64 + path: ${{ runner.temp }}/incoming/release-linux-x86_64 + repository: ${{ github.repository }} + run-id: ${{ inputs.source_run_id }} + github-token: ${{ github.token }} + + - name: Download the exact portable GUI source bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-gui-portable + path: ${{ runner.temp }}/incoming/release-gui-portable + repository: ${{ github.repository }} + run-id: ${{ inputs.source_run_id }} + github-token: ${{ github.token }} + + - name: Download the exact Windows artifact from the validated run + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-windows-x86_64 + path: ${{ runner.temp }}/incoming/release-windows-x86_64 + repository: ${{ github.repository }} + run-id: ${{ inputs.source_run_id }} + github-token: ${{ github.token }} + + - name: Download the exact macOS artifact from the validated run + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-macos-native + path: ${{ runner.temp }}/incoming/release-macos-native + repository: ${{ github.repository }} + run-id: ${{ inputs.source_run_id }} + github-token: ${{ github.token }} + + - name: Enforce the allowlist and validate every release format + env: + RELEASE_TAG: ${{ steps.provenance.outputs.tag }} + VERSION: ${{ steps.release.outputs.version }} + run: | + set -Eeuo pipefail + umask 077 + export LC_ALL=C + incoming=$RUNNER_TEMP/incoming + asset_dir=$RUNNER_TEMP/release-assets + mkdir -p "$asset_dir" + + artifact_names=( + release-source + release-deb + release-rpm + release-gui-deb + release-gui-rpm + release-linux-x86_64 + release-gui-portable + release-windows-x86_64 + release-macos-native + ) + expected_dirs=$RUNNER_TEMP/artifact-dirs.expected + actual_dirs=$RUNNER_TEMP/artifact-dirs.actual + printf '%s\0' "${artifact_names[@]}" | LC_ALL=C sort -z > "$expected_dirs" + find "$incoming" -mindepth 1 -maxdepth 1 -type d -printf '%f\0' | \ + LC_ALL=C sort -z > "$actual_dirs" + cmp "$expected_dirs" "$actual_dirs" || { + echo 'downloaded artifact directory allowlist mismatch' >&2 + exit 1 + } + if find "$incoming" -mindepth 1 -maxdepth 1 ! -type d -print -quit | \ + grep -q .; then + echo 'unexpected non-directory entry in artifact download root' >&2 + exit 1 + fi + if find "$incoming" -mindepth 2 ! -type f -print -quit | grep -q .; then + echo 'artifact contains a directory, symlink, or special file' >&2 + exit 1 + fi + + source_name="zupt-$VERSION.tar.gz" + source_sidecar="$source_name.sha256" + deb_name="zupt_${VERSION}_amd64.deb" + rpm_name="zupt-$VERSION-0.x86_64.rpm" + srpm_name="zupt-$VERSION-0.src.rpm" + gui_deb_name="zupt-gui_${VERSION}_all.deb" + gui_rpm_name="zupt-gui-$VERSION-1.noarch.rpm" + gui_srpm_name="zupt-gui-$VERSION-1.src.rpm" + linux_tar_name="zupt-$VERSION-linux-x86_64.tar.xz" + gui_portable_name="zupt-gui-$VERSION-portable.zip" + windows_zip_name="zupt-$VERSION-windows-x86_64.zip" + dmg_relative=() + for arch in x86_64 arm64; do + candidate="release-macos-native/ZUPT-$VERSION-macOS-$arch.dmg" + [[ ! -f $incoming/$candidate || -L $incoming/$candidate ]] || \ + dmg_relative+=("$candidate") + done + ((${#dmg_relative[@]} == 1)) || { + echo 'expected exactly one native macOS DMG' >&2 + exit 1 + } + + expected_relative=( + "release-source/$source_name" + "release-source/$source_sidecar" + "release-deb/$deb_name" + "release-rpm/$rpm_name" + "release-rpm/$srpm_name" + "release-gui-deb/$gui_deb_name" + "release-gui-rpm/$gui_rpm_name" + "release-gui-rpm/$gui_srpm_name" + "release-linux-x86_64/$linux_tar_name" + "release-gui-portable/$gui_portable_name" + "release-windows-x86_64/$windows_zip_name" + "${dmg_relative[0]}" + ) + expected_relative_list=$RUNNER_TEMP/artifact-files.expected + actual_relative_list=$RUNNER_TEMP/artifact-files.actual + printf '%s\0' "${expected_relative[@]}" | LC_ALL=C sort -z \ + > "$expected_relative_list" + find "$incoming" -mindepth 2 -type f -printf '%P\0' | LC_ALL=C sort -z \ + > "$actual_relative_list" + cmp "$expected_relative_list" "$actual_relative_list" || { + echo 'downloaded file allowlist mismatch' >&2 + exit 1 + } + + expected_assets=() + for relative in "${expected_relative[@]}"; do + name=${relative#*/} + cp -- "$incoming/$relative" "$asset_dir/$name" + expected_assets+=("$name") + done + expected_list=$RUNNER_TEMP/release-assets.expected + printf '%s\0' "${expected_assets[@]}" | LC_ALL=C sort -z > "$expected_list" + + source_tar=$asset_dir/$source_name + sidecar=$asset_dir/$source_sidecar + actual_source_sha=$(sha256sum "$source_tar" | awk '{print $1}') + [[ $(<"$sidecar") == "$actual_source_sha $source_name" ]] || { + echo 'source archive sidecar is not the exact expected SHA-256 record' >&2 + exit 1 + } + (cd "$asset_dir" && sha256sum -c -- "$source_sidecar") + file "$source_tar" | grep -Eqi 'gzip compressed data' + tar -tzf "$source_tar" >/dev/null + bash scripts/check-source-only.sh --archive "$source_tar" + archive_version=$(tar -xOf "$source_tar" \ + "zupt-$VERSION/include/zupt.h" | sed -n \ + 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p') + [[ $archive_version == "$VERSION" && $RELEASE_TAG == "v$archive_version" ]] || { + echo 'source archive version does not match the release tag' >&2 + exit 1 + } + + deb=$asset_dir/$deb_name + dpkg-deb --info "$deb" >/dev/null + [[ $(dpkg-deb -f "$deb" Package) == zupt ]] + [[ $(dpkg-deb -f "$deb" Version) == "$VERSION" ]] + [[ $(dpkg-deb -f "$deb" Architecture) == amd64 ]] + + rpm_file=$asset_dir/$rpm_name + [[ $(rpm -qp --qf '%{NAME}' "$rpm_file") == zupt ]] + [[ $(rpm -qp --qf '%{VERSION}' "$rpm_file") == "$VERSION" ]] + [[ $(rpm -qp --qf '%{RELEASE}' "$rpm_file") == 0 ]] + [[ $(rpm -qp --qf '%{ARCH}' "$rpm_file") == x86_64 ]] + [[ $(rpm -qp --qf '%{SOURCEPACKAGE}' "$rpm_file") == '(none)' ]] + [[ $(rpm -qp --qf '%{SOURCERPM}' "$rpm_file") == "$srpm_name" ]] + srpm=$asset_dir/$srpm_name + [[ $(rpm -qp --qf '%{NAME}' "$srpm") == zupt ]] + [[ $(rpm -qp --qf '%{VERSION}' "$srpm") == "$VERSION" ]] + [[ $(rpm -qp --qf '%{RELEASE}' "$srpm") == 0 ]] + [[ $(rpm -qp --qf '%{SOURCEPACKAGE}' "$srpm") == 1 ]] + [[ $(rpm -qp --qf '%{SOURCERPM}' "$srpm") == '(none)' ]] + [[ $(rpm -qpl "$srpm" | wc -l) -eq 2 ]] + rpm -qpl "$srpm" | grep -Fx "zupt-$VERSION.tar.gz" + rpm -qpl "$srpm" | grep -Fx zupt.spec + + gui_deb=$asset_dir/$gui_deb_name + dpkg-deb --info "$gui_deb" >/dev/null + [[ $(dpkg-deb -f "$gui_deb" Package) == zupt-gui ]] + [[ $(dpkg-deb -f "$gui_deb" Version) == "$VERSION" ]] + [[ $(dpkg-deb -f "$gui_deb" Architecture) == all ]] + + gui_rpm=$asset_dir/$gui_rpm_name + [[ $(rpm -qp --qf '%{NAME}' "$gui_rpm") == zupt-gui ]] + [[ $(rpm -qp --qf '%{VERSION}' "$gui_rpm") == "$VERSION" ]] + [[ $(rpm -qp --qf '%{RELEASE}' "$gui_rpm") == 1 ]] + [[ $(rpm -qp --qf '%{ARCH}' "$gui_rpm") == noarch ]] + [[ $(rpm -qp --qf '%{SOURCEPACKAGE}' "$gui_rpm") == '(none)' ]] + [[ $(rpm -qp --qf '%{SOURCERPM}' "$gui_rpm") == "$gui_srpm_name" ]] + rpm -qp --requires "$gui_rpm" | grep -Fx "zupt >= $VERSION" + gui_srpm=$asset_dir/$gui_srpm_name + [[ $(rpm -qp --qf '%{NAME}' "$gui_srpm") == zupt-gui ]] + [[ $(rpm -qp --qf '%{VERSION}' "$gui_srpm") == "$VERSION" ]] + [[ $(rpm -qp --qf '%{RELEASE}' "$gui_srpm") == 1 ]] + [[ $(rpm -qp --qf '%{SOURCEPACKAGE}' "$gui_srpm") == 1 ]] + [[ $(rpm -qp --qf '%{SOURCERPM}' "$gui_srpm") == '(none)' ]] + [[ $(rpm -qpl "$gui_srpm" | wc -l) -eq 2 ]] + rpm -qpl "$gui_srpm" | grep -Fx "zupt-gui-$VERSION.tar.gz" + rpm -qpl "$gui_srpm" | grep -Fx zupt-gui.spec + + linux_tar=$asset_dir/$linux_tar_name + python3 - "$linux_tar" "zupt-$VERSION-linux-x86_64" <<'PY' + import pathlib + import sys + import tarfile + + archive = pathlib.Path(sys.argv[1]) + root = sys.argv[2] + expected_files = { + "zupt", "README.md", "CHANGELOG.md", "SECURITY.md", + "THREAT_MODEL.md", "LICENSE", "LICENSE-AGPL-3.0", + "LICENSE-GPL-3.0", "LICENSE-BSD-2-Clause", + "LICENSE-BSD-3-Clause", "LICENSE-CC0-1.0", "NOTICE", + "THIRD-PARTY-NOTICES.md", + } + with tarfile.open(archive, "r:xz") as package: + members = package.getmembers() + names = [member.name for member in members] + if len(names) != len(set(names)): + raise SystemExit("duplicate Linux tar member") + actual_files = set() + for member in members: + path = pathlib.PurePosixPath(member.name) + if (path.is_absolute() or ".." in path.parts or not path.parts or + path.parts[0] != root or member.issym() or member.islnk() or + not (member.isdir() or member.isfile())): + raise SystemExit("unsafe Linux tar member") + if member.isfile(): + actual_files.add("/".join(path.parts[1:])) + if actual_files != expected_files: + raise SystemExit("Linux tar member allowlist mismatch") + PY + linux_extract=$RUNNER_TEMP/linux-package + mkdir -p "$linux_extract" + tar -xJf "$linux_tar" -C "$linux_extract" + linux_binary="$linux_extract/zupt-$VERSION-linux-x86_64/zupt" + file "$linux_binary" | grep -Eqi 'ELF.*executable' + bash scripts/test-installed-zupt.sh "$linux_binary" + + gui_portable=$asset_dir/$gui_portable_name + python3 - "$gui_portable" "zupt-gui-$VERSION-portable" <<'PY' + import pathlib + import sys + import zipfile + + archive = pathlib.Path(sys.argv[1]) + root = sys.argv[2] + expected = { + f"{root}/", f"{root}/assets/", f"{root}/zupt_gui.py", + f"{root}/zupt-gui.sh", f"{root}/zupt-gui.command", + f"{root}/zupt-gui.bat", f"{root}/README.txt", + f"{root}/assets/zupt-icon.png", f"{root}/assets/zupt.ico", + f"{root}/LICENSE-AGPL-3.0", f"{root}/LICENSE-GUI", + f"{root}/ASSET-PROVENANCE.md", f"{root}/CHANGELOG.md", + } + with zipfile.ZipFile(archive) as package: + names = package.namelist() + if len(names) != len(set(names)) or set(names) != expected: + raise SystemExit("portable GUI ZIP member allowlist mismatch") + for name in names: + path = pathlib.PurePosixPath(name) + if path.is_absolute() or ".." in path.parts or path.parts[0] != root: + raise SystemExit("unsafe portable GUI ZIP member") + PY + bash scripts/check-source-only.sh --archive "$gui_portable" + gui_extract=$RUNNER_TEMP/gui-portable + mkdir -p "$gui_extract" + unzip -q "$gui_portable" -d "$gui_extract" + QT_QPA_PLATFORM=offscreen ZUPT_BIN="$linux_binary" \ + "$gui_extract/zupt-gui-$VERSION-portable/zupt-gui.sh" --version | \ + grep -Fx "zupt-gui $VERSION" + + windows_zip=$asset_dir/$windows_zip_name + unzip -t "$windows_zip" >/dev/null + python3 - "$windows_zip" "zupt-$VERSION-windows-x86_64" <<'PY' + import pathlib + import sys + import zipfile + + archive = pathlib.Path(sys.argv[1]) + root = sys.argv[2] + with zipfile.ZipFile(archive) as package: + names = package.namelist() + if len(names) != len(set(names)): + raise SystemExit("duplicate Windows ZIP member") + expected = { + f"{root}/", + f"{root}/zupt.exe", + f"{root}/README.md", + f"{root}/CHANGELOG.md", + f"{root}/LICENSE", + f"{root}/LICENSE-AGPL-3.0", + f"{root}/LICENSE-GPL-3.0", + f"{root}/LICENSE-BSD-2-Clause", + f"{root}/LICENSE-BSD-3-Clause", + f"{root}/LICENSE-CC0-1.0", + f"{root}/NOTICE", + f"{root}/THIRD-PARTY-NOTICES.md", + f"{root}/MINGW-CRT-COPYING.txt", + f"{root}/COPYING.MinGW-w64-runtime.txt", + f"{root}/COPYING.MinGW-w64.txt", + f"{root}/GCC-COPYING3.txt", + f"{root}/GCC-RUNTIME-LIBRARY-EXCEPTION.txt", + } + if set(names) != expected: + raise SystemExit("Windows ZIP member allowlist mismatch") + for name in names: + path = pathlib.PurePosixPath(name) + if (path.is_absolute() or "\\" in name or ".." in path.parts or + not path.parts or path.parts[0] != root): + raise SystemExit("unsafe or unexpected Windows ZIP member") + executable = f"{root}/zupt.exe" + if names.count(executable) != 1: + raise SystemExit("Windows ZIP executable is missing or duplicated") + for notice in ( + f"{root}/MINGW-CRT-COPYING.txt", + f"{root}/COPYING.MinGW-w64-runtime.txt", + f"{root}/COPYING.MinGW-w64.txt", + f"{root}/GCC-COPYING3.txt", + f"{root}/GCC-RUNTIME-LIBRARY-EXCEPTION.txt", + ): + if not package.read(notice): + raise SystemExit("Windows toolchain notice is empty") + PY + unzip -p "$windows_zip" \ + "zupt-$VERSION-windows-x86_64/zupt.exe" \ + > "$RUNNER_TEMP/windows-zip-zupt.exe" + python3 - "$RUNNER_TEMP/windows-zip-zupt.exe" <<'PY' + import pathlib + import struct + import sys + + executable = pathlib.Path(sys.argv[1]) + with executable.open("rb") as stream: + header = stream.read(64) + if len(header) != 64 or header[:2] != b"MZ": + raise SystemExit("Windows ZIP executable lacks MZ magic") + pe_offset = struct.unpack_from(" "$checksum_tmp" + mv "$checksum_tmp" "$asset_dir/SHA256SUMS" + (cd "$asset_dir" && sha256sum -c SHA256SUMS) + cp "$expected_list" "$RUNNER_TEMP/release-assets.list" + printf 'SHA256SUMS\0' >> "$RUNNER_TEMP/release-assets.list" + echo 'All downloaded release assets match the exact allowlist and formats.' + + - name: Refuse to mutate an existing GitHub release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.provenance.outputs.tag }} + run: | + set -Eeuo pipefail + set +x + umask 077 + existing_tags=$RUNNER_TEMP/github-release-tags + gh api --paginate "repos/$GITHUB_REPOSITORY/releases" \ + --jq '.[].tag_name' > "$existing_tags" + if grep -Fxq -- "$RELEASE_TAG" "$existing_tags"; then + echo 'GitHub release already exists; refusing to replace or add assets' >&2 + exit 1 + fi + + - name: Publish the already-tested byte-identical asset set + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.provenance.outputs.tag }} + RELEASE_COMMIT: ${{ steps.provenance.outputs.head_sha }} + VERSION: ${{ steps.release.outputs.version }} + SOURCE_RUN_ID: ${{ inputs.source_run_id }} + run: | + set -Eeuo pipefail + set +x + umask 077 + asset_dir=$RUNNER_TEMP/release-assets + (cd "$asset_dir" && sha256sum -c SHA256SUMS) + mapfile -d '' -t asset_names < "$RUNNER_TEMP/release-assets.list" + release_assets=() + for name in "${asset_names[@]}"; do + path=$asset_dir/$name + [[ -f $path && ! -L $path ]] || { + printf 'validated release asset disappeared or changed type: %q\n' \ + "$name" >&2 + exit 1 + } + release_assets+=("$path") + done + cat > "$RUNNER_TEMP/release-notes.md" < +# ZUPT 5.2.8 audit guide and finding history + +This document describes review surfaces and reproducible checks. It is an +upstream self-review, not an independent audit, certification, or guarantee. +`SECURITY.md` defines reporting policy and `THREAT_MODEL.md` defines the +security boundary. + +## 5.2.8 scope + +The baseline scope is the source-only CLI and its bundled source codec: + +- first-party C and headers under `src/` and `include/`; +- textual architecture-specific source under `jasmin/`, distinguishing + compiler-generated output from separately identified hand-written assembly; +- VaptVupt codec source at release 2.65.3, with provenance and licensing in + `THIRD-PARTY-NOTICES.md`; +- CLI tests, source scanner, build system, CI, and packaging recipes; +- the Python GUI source as a caller of the CLI. + +The baseline is built with `WITH_SDK=0 WITH_PQBOX=0`. The optional system +`libvuptsdk` and `libpqvaptvupt` implementations are outside this scope unless +their exact source packages and versions are added to an assessment. Assembly +under `jasmin/` is disabled by default and is a separate `WITH_JASMIN=1` build +choice on supported x86_64 compiler targets. Generated files must record their +compiler provenance; hand-written files must not be represented as compiler +output. + +## Source-only review + +The 5.2.8 baseline retains the source-only boundary introduced in 5.2.2, which +removed incomplete SDK/PQBOX header snapshots and local precompiled-library +expectations. Git and new upstream source +archives are intended to contain no compiled executable, object, shared/static +library, distribution package, unsafe symlink, or unresolved Git LFS pointer. + +Run the same scanner over each representation: + +```sh +# tracked files and working tree +scripts/check-source-only.sh + +# committed Git tree or immutable tag +scripts/check-source-only.sh --tag HEAD +scripts/check-source-only.sh --tag v5.2.8 + +# generated source archive +scripts/check-source-only.sh --archive /path/to/zupt-5.2.8.tar.gz +``` + +The scanner checks extensions and magic bytes, nested archives, symlink targets, +LFS pointers, generated compiler output, and stale vendor-library references. +It reports paths without printing file contents. Its negative tests include +renamed ELF, ar, PE/MZ, versioned `.so`, RPM/DEB/AppImage, escaping symlinks, +and LFS pointers; textual assembly is a permitted source type. + +Archive inspection must also fail closed at bounded recursion depth, member +count, individual expanded size, and total expanded size so a nested archive or +decompression bomb cannot turn the release scanner into an unbounded resource +consumer. On committed Linux candidate `ff99770`, this hardening and its +adversarial fixtures passed all 39 source-only scanner cases, including GNU +thin-archive and safe-diagnostic-path cases. + +An unknown `.bin` fails by default. A necessary binary data fixture can be +declared only through `--data-manifest`, with four tab-separated fields for +path, purpose, provenance, and SPDX license. That manifest does not override a +compiled/executable magic finding. + +An artifact is not clean merely because it has a harmless extension. Conversely, +binary image data is not executable code: the documented GUI icon assets are +necessary data and are reviewed separately for purpose, provenance, and license. + +## Reproducible project checks + +The baseline gates are: + +```sh +make clean +make -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf 1)" \ + WITH_SDK=0 WITH_PQBOX=0 V=1 +make WITH_SDK=0 WITH_PQBOX=0 check +make WITH_SDK=0 WITH_PQBOX=0 test-all +make sdk-test +``` + +Relevant review layers include: + +| Layer | Evidence source | Interpretation | +|---|---|---| +| Source boundary | `scripts/check-source-only.sh`, `tests/test_source_only.sh` | Fails on prohibited artifacts or unsafe source layout | +| Primitive vectors | `tests/test_vectors.c` | Known-answer regression for implemented primitives | +| ML-KEM interoperability | `tests/test_mlkem_fips203.sh` | Runs only with an ML-KEM-capable OpenSSL 3.5+; otherwise `SKIP` | +| Archive behavior | quick/regression, traversal, argument-order, block-swap, nonce, and exact-size tests | Exercises current parser, integrity, and round-trip properties | +| Password sources | `tests/test_password_sources.sh` | Exercises password-file, inherited-descriptor and explicit-prompt rejection paths without logging password contents | +| Key files | native key regressions | Exercises no-replace private-file creation, POSIX mode `0600`/Windows current-user-only DACL, failed-partial behavior, checksum, and exact ZKEY/ZPQK version/flags/reserved/size/role validation | +| SDK key publication | `make sdk-test` | Exercises atomic descriptor/handle-backed key copies, POSIX private/public modes, and symlink/hardlink target preservation; this now runs in `release-check` and hosted GCC/Clang Linux CI | +| Terminal output | archive-comment regression | Requires displayed untrusted comments to contain no raw terminal-control sequence | +| Prompt cleanup | PTY signal regression | Requires handled POSIX interruption to restore the saved terminal state | +| Sanitizers | `make test-asan-run` | Builds and executes separate ASan/UBSan/LSan evidence where supported; not a substitute for normal tests | +| Static analysis | compiler analyzer, cppcheck, scan-build, clang-tidy where installed | Tool-specific findings must be reviewed, not suppressed globally | +| Shell and metadata | shellcheck, SPDX/license checks, packaging syntax checks | Applies only when the named tool actually executed | +| Source reproducibility | two `make dist` runs with identical committed input and epoch | Requires equal SHA-256 digests and clean archive scans | +| Installed package | target-native package inspection and `scripts/test-installed-zupt.sh` | Applies only to the exact OS/release/architecture tested | + +This table identifies evidence layers rather than results. Missing tools, OBS +access, other architectures, Leap, and SLE must not be reported as passing +without evidence. + +## Prior 5.2.2 committed-candidate local Linux evidence + +The following upstream self-audit results apply only to the 5.2.2 candidate at +commit `ff99770` on the recorded local Linux environments. The immutable 5.2.2 +tag was not promoted after post-tag CI integration failures. These results are +not independent certification, a 5.2.8 result, or evidence that release assets +were published. + +| Gate | Result | Recorded evidence | +|---|---|---| +| Full project gate | PASS | `make release-check` completed successfully on `ff99770`, including the late key-file, terminal-comment, password-prompt, explicit-Bash, and scanner-limit regressions. | +| Packaging policy/syntax | PASS | `PASS=49 FAIL=0 SKIP=0`. | +| Source-only scanner adversarial suite | PASS | 39/39, including GNU thin archives, bounded archive expansion, and safe diagnostic cases. | +| Strict compilers and compiler analyzer | PASS | GCC and Clang strict builds passed; GCC `-fanalyzer` passed. | +| Static-analysis suite | PASS | 9/9 in the full tool-enabled run. A separate reduced-environment `release-check` run completed six available checks and reported `cppcheck` unavailable; unavailable tooling was not relabelled as a pass. | +| Dynamic analysis | PASS | ASan, UBSan, and LSan runs passed. | +| Mutation fuzzing | PASS | 1,000 mutation iterations completed without a sanitizer-detected crash. | + +An earlier off-screen GUI smoke run remains supporting evidence, but is not +represented as an exact-`ff99770` GUI-package result. The immutable 5.2.3 +candidate was not promoted because its source-policy test assumed LF for a +Windows `.bat` checkout that correctly used CRLF. + +## Prior 5.2.4 exact-tag integration evidence + +GitHub Actions exact-tag run `33431386002` completed 12 jobs successfully. Its +sole failed job was the openSUSE gate: the standalone `Serviceinfo` harness did +not change into the directory containing `_service` before executing the +service chain. Dependent native Windows and macOS jobs were therefore skipped, +and v5.2.4 was not promoted. The tag and its record remain immutable. + +A separate local openSUSE Tumbleweed reproduction resolved the explicit +`refs/tags/v5.2.4` revision to the tagged commit and, after +`os.chdir(service_dir)`, completed `obs_scm`, `tar`, and `recompress`. It +produced exactly one `zupt-5.2.4.tar.gz`, which passed the source-only scanner. +This isolates a release/test harness defect; it is not evidence of a product, +archive-format, cryptographic, codec, or SDK ABI change. It also does not turn +the skipped native jobs into passes or transfer any result to 5.2.8. + +## Prior 5.2.5 exact-tag native-gate evidence + +The immutable `v5.2.5` candidate was not promoted. Exact-tag GitHub Actions run +`33434986357` completed 13 jobs successfully, while its native Windows and +macOS jobs failed. The Windows regression did not preserve every requested +hostile path byte across its command-line boundary. The macOS gate exposed both +an unavailable `explicit_bzero` assumption and Bash 3.2 empty-array behavior in +the source scanner exercised by `make check`. + +The 5.2.6 corrections select the existing compiler-resistant volatile wipe on +Darwin and NetBSD, guard every relevant scanner array, and make the Windows +fixture accept explicit hexadecimal bytes, verify the full requested path in +the archive, and reject each dangerous raw byte fragment anywhere in diagnostic +output. These changes do not alter the archive format, cryptography, bundled +codec, or SDK ABI. + +A separate local compatibility run executed the corrected scanner with genuine +GNU Bash 3.2.57 in a clean clone. All four exercised modes completed: the +repository audit reported 609 files and one archive; `--tree` reported 204/0; +`--archive` reported 201/1; and `--root` plus `--tag v5.2.5` reported 810/2. +This is targeted scanner compatibility evidence only, not exact-v5.2.6 or +v5.2.8 hosted CI, package, native-platform, or promotion evidence. + +## Prior 5.2.6 exact-tag native-gate evidence + +The immutable `v5.2.6` candidate was not promoted. Exact-tag GitHub Actions run +`33442264243` completed 13 jobs successfully and failed two native jobs. On +macOS arm64, the strict SHA-NI regression build diagnosed x86-only helper +declarations as unused under `-Werror`. On Windows, argv transcoding of the safe +printable UTF-8 fixture caused the path regression to abort before its intended +archive and diagnostic assertions. + +The 5.2.7 changes scope those helper declarations to supported x86 builds and +carry the safe UTF-8 fixture across the Windows argument boundary without +locale-dependent byte conversion. These are test/release integration changes, +not archive-format, cryptographic, codec, or SDK ABI changes. + +## Prior 5.2.7 exact-tag native-gate evidence + +The immutable `v5.2.7` candidate was not promoted. Exact-tag GitHub Actions run +`33445470664` concluded `cancelled` at `2026-08-31T23:11:19Z`, with 13 +successful jobs, one failed macOS job, and one cancelled Windows job. The macOS +runner filesystem rejected creation of the +raw-C1 filename fixture with `EILSEQ`. The hosted Windows job stalled in `make +check`; a MinGW/Wine reproduction isolated the cause to +`test --password-prompt ... /dev/null || printf 1)" \ + WITH_SDK=0 WITH_PQBOX=0 +make WITH_SDK=0 WITH_PQBOX=0 check ``` -## Recipes shipped +`WITH_SDK=1` and `WITH_PQBOX=1` use separately installed system development +libraries. They never load a library committed under `vendor/`, never download +a dependency during build or test, and fail explicitly when their development +metadata is unavailable. Distribution builds should keep both options at `0` +unless the corresponding source-built system packages are declared as build +requirements. -| Distro / Platform | Path | Format | -|-------------------|---------------------------------|----------------| -| Arch Linux | `packaging/aur/PKGBUILD` | AUR PKGBUILD | -| Debian / Ubuntu | `packaging/debian/` | Source package (`3.0 (quilt)`) | -| Fedora / RHEL | `packaging/rpm/vaptvupt.spec` | RPM .spec | -| openSUSE | `packaging/opensuse/` | RPM .spec (OBS) | -| macOS | `packaging/homebrew/vaptvupt.rb`| Homebrew formula | -| NixOS / Nix flake | `packaging/nix/flake.nix` | Nix flake | - -All recipes: - -- Install the binary to `$PREFIX/bin/vaptvupt` (default `/usr/bin/vaptvupt`) -- Install manpage to `$PREFIX/share/man/man1/vaptvupt.1.gz` -- Install docs (README, SECURITY, CHANGELOG) to `$PREFIX/share/doc/vaptvupt/` -- Run the full upstream regression suite (`make test`) during build when the distro's package guidelines allow check-phase execution - -## Arch Linux (AUR) - -Maintainer flow: +Audit the current tree or a generated archive with: ```sh -# 1. Produce the upstream tarball -make dist -# → /tmp/vaptvupt-4.1.0.tar.gz - -# 2. Upload to a stable URL (e.g. git.securityops.co releases) - -# 3. Update packaging/aur/PKGBUILD: -# - Set pkgver=4.1.0 -# - Set sha256sums=("$(sha256sum /tmp/vaptvupt-4.1.0.tar.gz | awk '{print $1}')") - -# 4. Generate .SRCINFO -cd packaging/aur && makepkg --printsrcinfo > .SRCINFO - -# 5. Test locally -makepkg -s - -# 6. Push to AUR -git clone ssh://aur@aur.archlinux.org/vaptvupt.git aur-vaptvupt -cp packaging/aur/PKGBUILD packaging/aur/.SRCINFO aur-vaptvupt/ -cd aur-vaptvupt && git add -A && git commit -m "v4.1.0" && git push +scripts/check-source-only.sh +scripts/check-source-only.sh --archive /path/to/zupt-5.2.8.tar.gz ``` -User install: +The scanner reports paths, not file contents, and exits nonzero on a violation. + +## Reproducible source archive + +`make dist` verifies committed `HEAD` and exports its tree object, normalizes +member order, timestamps, owner/group metadata, and gzip metadata, and audits +the result before moving it to its destination. Exporting the tree rather than +the commit omits Git's commit-ID PAX header: ```sh -yay -S vaptvupt # or paru, pikaur, etc. +make DIST_TARBALL=/tmp/zupt-5.2.8.tar.gz dist +sha256sum /tmp/zupt-5.2.8.tar.gz ``` -## Shell completions +The canonical release uses the tracked `.source-date-epoch`; an explicit +`SOURCE_DATE_EPOCH` override intentionally creates a different archive. With +identical committed input and epoch, repeated exports must have the same +SHA-256 digest. Do not generate a release tarball from uncommitted working-tree +files. -`make install` automatically installs Bash, zsh, and fish completion files alongside the binary and manpage: +The AUR, Homebrew, and Guix recipes pin the checksum of this tarball. They are +marked `export-ignore` in `.gitattributes` so their own checksum fields do not +make the archive self-referential. A commit changing only those ignored recipes +therefore leaves the fixed-epoch archive byte-identical. The recipes remain +versioned in Git and must be updated after the final source archive checksum is +known. -| Shell | Path | -|---|---| -| Bash | `$PREFIX/share/bash-completion/completions/vaptvupt` | -| zsh | `$PREFIX/share/zsh/site-functions/_vaptvupt` | -| fish | `$PREFIX/share/fish/vendor_completions.d/vaptvupt.fish` | +Do not commit the generated tarball or checksum file. Host them as immutable +release assets after the release tag is published. -The source files live under `completions/` in the project tree. Distros that prefer a different install location should override the relevant paths in their `make install` invocation. +## Staged installation -For per-user installation without root: +Packagers should preserve distribution flags and install into a package root: ```sh -# Bash -cp completions/vaptvupt.bash ~/.local/share/bash-completion/completions/vaptvupt - -# zsh (somewhere in $fpath; add the directory to ~/.zshrc if needed) -cp completions/_vaptvupt ~/.zsh/completion/_vaptvupt - -# fish -cp completions/vaptvupt.fish ~/.config/fish/completions/vaptvupt.fish +make -j"${JOBS:-1}" WITH_SDK=0 WITH_PQBOX=0 \ + CPPFLAGS="$CPPFLAGS" CFLAGS="$CFLAGS" \ + LDFLAGS="$LDFLAGS" LDLIBS="$LDLIBS" +make WITH_SDK=0 WITH_PQBOX=0 check +make DESTDIR="$pkgroot" PREFIX=/usr \ + WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 install ``` -Completions cover every CLI flag the binary actually parses (`--kdf`, `--comment`, `--comment-file`, `--pq`, `--dedup`, etc.) and are validated on every CI run via `tests/test_completions_manpage.sh`. +`INSTALL_LEGACY_ALIAS=0` installs only `zupt`. The `vaptvupt` +command can be requested explicitly with `INSTALL_LEGACY_ALIAS=1`, but it is +not installed by default and is not part of the openSUSE main package. This +keeps the canonical package surface limited to ZUPT and `zupt`. -## Debian / Ubuntu +The Makefile accepts the usual `BINDIR`, `LIBDIR`, `INCLUDEDIR`, `MANDIR`, and +completion-directory overrides. It does not strip package builds or add a +private-library RPATH. -The `packaging/debian/` tree is a Debian source-package layout. Maintainer flow: +## Packaging material + +| Target | Maintained path | Intended output | +|---|---|---| +| openSUSE / OBS | `packaging/opensuse/` | source and binary RPM through OBS | +| Debian / Ubuntu | `packaging/debian/`, `packaging/build-deb.sh` | Debian metadata and binary DEB after the target gate | +| RPM release artifact | `packaging/opensuse/zupt.spec`, `packaging/build-rpm.sh` | source and binary RPM after the target gate | +| GUI DEB | `packaging/build-gui-deb.sh` | `zupt-gui_5.2.8_all.deb` after payload/dependency and installed integration gates | +| GUI RPM | `packaging/build-gui-rpm.sh` | `zupt-gui-5.2.8-1.noarch.rpm` and matching `.src.rpm` after package and installed integration gates | +| Linux CLI archive | `.github/workflows/ci.yml` | `zupt-5.2.8-linux-x86_64.tar.xz` with notices after dependency, member, and extracted functional gates | +| Portable GUI source | `packaging/portable/`, `.github/workflows/ci.yml` | `zupt-gui-5.2.8-portable.zip` after source scan, member allowlist, and extracted off-screen integration gate | +| Fedora / RPM-based systems | `packaging/rpm/zupt.spec` | downstream RPM starting point | +| AppImage helper | `packaging/build-appimage.sh` | downstream-only helper; no 5.2.8 AppImage is promoted | +| Windows | `.github/workflows/cross-platform.yml` | native ZIP (executable plus notices) after the required native gate | +| macOS | `packaging/build-dmg.sh` | native-architecture DMG after the native gate | +| Arch Linux | `packaging/aur/PKGBUILD` | AUR package recipe | +| Homebrew | `packaging/homebrew/zupt.rb` | formula-built package | +| Guix | `packaging/guix/zupt.scm` | Guix package definition | +| Nix | `packaging/nix/flake.nix` | flake-built package | + +These files are upstream starting points. Use each distribution's isolated +builder and current policy checks; do not claim support based only on parsing a +recipe. + +### openSUSE / OBS + +The authoritative instructions, tested matrix, and outstanding gates are in +`packaging/opensuse/README.md`. The normal local flow is: ```sh -# 1. Produce the upstream tarball with the standard Debian -# orig.tar.gz naming convention: -make dist -cp /tmp/vaptvupt-4.1.0.tar.gz /tmp/vaptvupt_4.1.0.orig.tar.gz - -# 2. Unpack and overlay the debian/ tree: -cd /tmp && tar xzf vaptvupt_4.1.0.orig.tar.gz && cd vaptvupt-4.1.0 -cp -a /path/to/vaptvupt/packaging/debian ./debian - -# 3. Build the source package: -dpkg-buildpackage -S -us -uc # source-only -dpkg-buildpackage -b -us -uc # binary - -# 4. Lint: -lintian vaptvupt_4.1.0-1_*.deb - -# 5. Submit via the standard Debian mentors process: -# https://mentors.debian.net/intro-maintainers/ +cd packaging/opensuse +xmllint --noout _service +osc service manualrun +rpmspec -P zupt.spec >/dev/null +osc build openSUSE_Tumbleweed x86_64 zupt.spec ``` -User install (after the package lands in Debian unstable / Ubuntu): +Run `rpmlint` on all produced RPMs and install the binary RPM in a disposable +environment for `--version`, `--help`, and archive round-trip tests. Presence of +the OBS files upstream does not mean the package has been submitted or accepted +by openSUSE Factory. + +### Debian and RPM release artifacts + +The release helper scripts build from this source tree, stage into temporary +directories, run their format and installed-binary checks, and place only their +final outputs in an explicitly selected directory. Run them from an exact +checkout of the immutable tag inside a clean target container, chroot, or VM: ```sh -sudo apt install vaptvupt +release_dir=$(mktemp -d) + +# Native Debian/Ubuntu binary package +DIST_DIR="$release_dir" RUN_CHECKS=1 packaging/build-deb.sh + +# Source and binary RPM using the openSUSE spec +DIST_DIR="$release_dir" packaging/build-rpm.sh + +# Architecture-independent GUI DEB and noarch/source GUI RPM +DIST_DIR="$release_dir" packaging/build-gui-deb.sh +DIST_DIR="$release_dir" packaging/build-gui-rpm.sh ``` -## Fedora / RHEL / CentOS +`packaging/build-deb.sh` creates a native binary DEB; it does not claim to +create a Debian source package. The files in `packaging/debian/` are Debian +source-package metadata and must be staged as the source package's top-level +`debian/` directory before using `dpkg-buildpackage`. Running +`dpkg-buildpackage` directly at the ZUPT repository root is not the +documented release-artifact path. + +`packaging/build-rpm.sh` creates its audited Source0 archive, builds both the +binary RPM and source RPM, inspects the installed payload, and copies both +outputs to `DIST_DIR`. The separate `packaging/rpm/zupt.spec` is a +Fedora-family downstream starting point; build and lint it only after staging +Source0 in a normal RPM build tree. + +Run the target's metadata and lint tools in addition to the script gates. A +package built for one distribution release or architecture is not evidence for +another. + +The GUI helpers package Python/Qt source rather than compiled application code. +They validate exact version, payload, dependency, ownership and legacy-alias +expectations, then test the installed launcher off-screen against the matching +`zupt` CLI. A successful GUI DEB gate does not imply an RPM gate, or vice versa. + +### Portable and native release artifacts + +The Linux x86_64 gate packages the tested `zupt` executable as +`zupt-5.2.8-linux-x86_64.tar.xz` beside README, changelog, security guidance, +and every applicable public license and notice. Its dynamic-library allowlist, +archive member allowlist, and extracted CLI functional suite must pass. + +The `zupt-gui-5.2.8-portable.zip` artifact is source-only: it contains the GUI +Python source, shell/macOS/Windows launchers, icons, provenance, changelog, and +licenses, but no Python, Qt, CLI, or compiled runtime. The gate scans both the +assembled and extracted trees, verifies an exact safe member allowlist, and +runs the extracted launcher off-screen against the tested CLI. + +AppImage creation is deliberately offline and is not a 5.2.8 release gate. +Supply a locally verified `appimagetool`, type-2 runtime, and the complete +license/source-relink compliance notice for those exact runtime bytes; the +helper never downloads any input: ```sh -# 1. Produce the tarball -make dist -cp /tmp/vaptvupt-4.1.0.tar.gz ~/rpmbuild/SOURCES/ - -# 2. Drop the .spec into the SPECS directory: -cp packaging/rpm/vaptvupt.spec ~/rpmbuild/SPECS/ - -# 3. Build source + binary RPMs: -cd ~/rpmbuild && rpmbuild -ba SPECS/vaptvupt.spec - -# 4. Lint: -rpmlint RPMS/x86_64/vaptvupt-4.1.0-1.fc*.rpm - -# 5. Submit via the Fedora new-package review process: -# https://docs.fedoraproject.org/en-US/package-maintainers/Package_Review_Process/ -# EPEL automatically inherits Fedora packages. +DIST_DIR="$release_dir" RUN_CHECKS=1 \ +APPIMAGETOOL=/verified/path/appimagetool \ +APPIMAGE_RUNTIME_FILE=/verified/path/runtime-x86_64 \ +APPIMAGE_RUNTIME_COMPLIANCE_FILE=/verified/path/runtime-compliance.txt \ + packaging/build-appimage.sh ``` -User install (after the package lands in Fedora / EPEL): +The runtime inspected while preparing 5.2.2 omitted a linked component from +its notice and did not provide the complete LGPL source/relink handoff required +by this release policy. No AppImage produced by this helper is promoted by the +upstream 5.2.8 workflow. AppDir and Flatpak bundles and GUI platform installers +are also excluded. Bare Linux and Windows executables are not promoted; their +CLI programs appear only inside notice-bearing archives. The Windows ZIP and +macOS DMG remain CLI-only. + +Run `packaging/build-dmg.sh` only on a native macOS host. It records the host +architecture in the filename and tests the binary before and after packaging: ```sh -sudo dnf install vaptvupt # Fedora -sudo dnf install epel-release vaptvupt # RHEL/CentOS via EPEL +DIST_DIR="$release_dir" RUN_CHECKS=1 packaging/build-dmg.sh ``` -## openSUSE +The Windows ZIP (including its executable and notices) must be built and tested +by the Windows job in `.github/workflows/cross-platform.yml`; it is not a +cross-compiled release claim from a Linux build. No Wine result is retained as +5.2.8 release evidence. Extended-length/device namespace paths, raw UNC output +roots, and mapped/network-drive output are not supported in 5.2.8. Publish the +exact architecture recorded by the native job. +These helpers create binary distribution artifacts for the release page, not +content to be committed to Git or included in the source archive. -The `packaging/opensuse/` tree carries an RPM `.spec` suited to the Open Build Service (OBS). +### AUR, Homebrew, Guix, and Nix -```sh -# 1. Produce the tarball -make dist +After calculating the final reproducible source archive, but before creating or +publishing the immutable tag, update each recipe to version 5.2.8 and to the +exact digest or content hash expected by its package manager. These recipe +directories are excluded from the source archive, so this does not create a +checksum cycle. Commit the pinned recipes in the tagged tree, then build and +test with each package manager before publishing its recipe. Keep build inputs +offline-capable: the check phase must not fetch source or dependencies +dynamically. -# 2. In an OBS package checkout (osc), stage the sources and spec: -cp /tmp/vaptvupt-4.1.0.tar.gz . -cp /path/to/vaptvupt/packaging/opensuse/vaptvupt.spec . +## Release-page artifacts -# 3. Build locally against a target repository: -osc build openSUSE_Tumbleweed x86_64 +The source-only policy applies to Git and upstream source archives. A release +page may also carry CLI/GUI DEB and RPM artifacts, the notice-bearing Linux CLI +tar.xz, source-only portable GUI ZIP, CLI Windows ZIP, or CLI macOS DMG when +each is built from the tagged source in its target environment and passes its +format-specific tests. These are separate outputs, never inputs to a source +build. -# 4. Commit to OBS once the build and check phase pass: -osc addremove && osc commit -``` +For every published artifact: -User install (after the package lands in a distribution or OBS repository): +1. start from the immutable `v5.2.8` tag; +2. keep `WITH_SDK=0 WITH_PQBOX=0` unless system dependencies are declared; +3. record the exact OS, distribution release, architecture, and toolchain; +4. run format validation plus installed `--version`, `--help`, and archive + round-trip tests; +5. publish a SHA-256 checksum; +6. scan the source inputs and ensure no credential or build path is embedded; +7. label an unbuilt or untested target `SKIP`, never `PASS`. -```sh -sudo zypper install vaptvupt -``` +Do not infer multi-architecture compatibility from portable source. Do not add +precompiled optional libraries to make a package build. -## macOS (Homebrew) +The 13 gated assets are published at the +[canonical GitHub release](https://github.com/cristiancmoises/zupt/releases/tag/v5.2.8). +If an expected asset is absent or has a different checksum, report that target +as unpublished rather than redirecting consumers to an unverified file. -```sh -# 1. Produce the tarball and upload to a stable release URL. +## Downstream checklist -# 2. Update packaging/homebrew/vaptvupt.rb: -# - Set url to the release URL -# - Set sha256 to the upstream tarball sha256 - -# 3. Test locally: -brew install --build-from-source ./packaging/homebrew/vaptvupt.rb -brew test vaptvupt -brew audit --strict --online vaptvupt - -# 4. Submit to homebrew-core (preferred, requires popularity threshold): -# https://docs.brew.sh/Adding-Software-to-Homebrew -# -# OR host in your own tap: -# https://docs.brew.sh/How-to-Create-and-Maintain-a-Tap -``` - -User install (after submission lands): - -```sh -brew install vaptvupt -# OR from a custom tap: -brew install cristiancmoises/tap/vaptvupt -``` - -## NixOS / Nix flake - -```sh -# 1. Build directly from the flake (no central submission needed): -nix build github:cristiancmoises/vaptvupt#vaptvupt -nix run github:cristiancmoises/vaptvupt#vaptvupt -- version - -# 2. To consume from another flake: -# inputs.vaptvupt.url = "github:cristiancmoises/vaptvupt?ref=v4.1.0"; -# packages.x86_64-linux.default = inputs.vaptvupt.packages.x86_64-linux.vaptvupt; - -# 3. To submit to nixpkgs (https://github.com/NixOS/nixpkgs): -# - Adapt packaging/nix/flake.nix's `vaptvupt` derivation into a -# pkgs/by-name/va/vaptvupt/package.nix using fetchurl and a hash. -# - Follow the nixpkgs contribution guide: -# https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md -``` - -## Submitting upstream — checklist - -Before pushing any recipe to a distro repository: - -- [ ] `make dist` produces a reproducible tarball (verified by `tests/test_dist_reproducible.sh` on every `make test`) -- [ ] The tarball is uploaded to a stable, immutable URL -- [ ] The recipe's checksum field is updated to match `sha256sum /tmp/vaptvupt-VERSION.tar.gz` -- [ ] The recipe builds and tests pass in a clean chroot/container -- [ ] The CHANGELOG mentions distro-relevant changes since the last release -- [ ] The license metadata is correct (AGPL-3.0-or-later for VaptVupt core; GPL-3.0-or-later for the vendored VaptVupt codec) - -## Security posture for downstream - -Every packaging recipe runs `make test` during build (`check()` for AUR, `override_dh_auto_test` for Debian, `%check` for RPM and openSUSE, `checkPhase` for Nix, `test` block for Homebrew). The test suite runs in each recipe's check phase, including the tamper/integrity regressions and the `make dist` byte-identical reproducibility check. - -A build that doesn't pass `make test` will fail at distro check time — the recipes don't paper over regressions. +- [ ] The source URL resolves to the immutable `v5.2.8` tag. +- [ ] The source archive passes `scripts/check-source-only.sh --archive`. +- [ ] The recipe checksum matches the downloaded source exactly. +- [ ] `WITH_SDK=0 WITH_PQBOX=0` is explicit, or system dependencies are complete. +- [ ] Distribution compiler and linker flags are preserved. +- [ ] The real upstream `check` target runs without network access. +- [ ] Installation uses `DESTDIR` and does not write under `/usr/local`. +- [ ] The main package installs `zupt`; any `vaptvupt` alias is explicitly documented as compatibility-only. +- [ ] Licenses include AGPL-3.0-or-later for the application, + GPL-3.0-or-later for the bundled source codec, and BSD-2-Clause for the + xxHash-derived XXH64 routines, plus CC0-1.0 for the + pq-crystals/kyber-derived ML-KEM portions and BSD-3-Clause for the + curve25519-donna-derived X25519 portions. +- [ ] Package contents, dependencies, hardening, RPATH/RUNPATH, and debug info + have been inspected with target-native tools. +- [ ] Installed-package smoke and round-trip tests pass. +- [ ] Only tested target artifacts are attached to the release. diff --git a/INSTALL.md b/INSTALL.md index c817a66..76a320e 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,232 +1,255 @@ -# VaptVupt + VaptVupt GUI — Install Guide for Linux +# Installing ZUPT 5.2.8 -If you're seeing the error: +This guide covers the ZUPT command-line program and the optional Python GUI. +The canonical source repository is +`https://github.com/cristiancmoises/zupt`. -``` -vaptvupt-gui depende de python3-pyqt6 | python3-pyside6; porém: - Pacote python3-pyqt6 não está instalado. -vaptvupt-gui depende de vaptvupt (>= 4.1.0); porém: - Versão de vaptvupt no sistema é 2.1.7-1. -``` +## Choosing an installation method -This is correct behavior. The `vaptvupt-gui` deb requires: -- Python 3 with **PyQt6** or **PySide6** (the GUI toolkit) -- The **vaptvupt CLI 4.1.0** or newer +- Build from the immutable source tag when you want the upstream source-only + path described below. +- Use a distribution package only when it matches your distribution release + and architecture. +- Release-page DEB, RPM, Linux tar.xz, portable GUI ZIP, Windows ZIP, and macOS + files are separate artifacts. Their presence does not make them part of the + Git tree or upstream source archive. Use only artifacts whose release notes + record a successful format-specific test for your target. -## The fastest fix — one command (Linux Mint, Ubuntu, Debian) +The immutable `v5.2.2` candidate was not promoted after CI integration +failures. The immutable `v5.2.3` candidate was not promoted because its +source-policy test assumed LF for a Windows `.bat` file checked out as the +required CRLF. The immutable `v5.2.4` candidate was not promoted after exact-tag +GitHub Actions run `33431386002`: 12 jobs succeeded, the sole openSUSE +service-harness job failed because its executor did not enter the service +directory, and dependent Windows/macOS jobs were skipped. A local Tumbleweed +reproduction confirmed both the explicit tag ref and the corrected +working-directory contract. This is release/test integration only; the product, +archive format, cryptography, codec, and SDK ABI are unchanged. The immutable +`v5.2.5` candidate was likewise not promoted: exact-tag GitHub Actions run +`33434986357` recorded 13 successful jobs and failed native Windows/macOS jobs. +The immutable `v5.2.6` candidate was not promoted after run `33442264243` +recorded 13 successful jobs and two native failures: unused x86 SHA-NI helper +declarations on macOS arm64 under `-Werror`, and early Windows abortion while +argv-transcoding a safe UTF-8 fixture. Version 5.2.7 corrected those boundaries +but was not promoted after exact-tag run `33445470664`: 13 jobs succeeded, +macOS failed because its filesystem rejected the raw-C1 filename fixture with +`EILSEQ`, and Windows was cancelled after the hosted job stalled in `make +check`; a MinGW/Wine reproduction isolated the cause to the non-console +password-prompt test entering `_getch`. Version 5.2.8 makes both +fixtures portable, hardens the three CodeQL High path-race boundaries described +in the security documents, and adds `sdk-test` to release and hosted Linux +gates. Exact-tag run `33456209269` passed all 15 jobs, and promotion run +`33457868306` published the exact tested set. Do not treat any prior candidate's +artifacts or evidence as 5.2.8 packages or validation. -Put all the downloaded files in the same folder, then: +The published 5.2.8 package set is exactly these 13 gated assets: -```bash -sudo bash install-zupt-gui.sh -``` - -This script auto-detects your distribution and installs everything in -the right order. - -## Manual fix — three commands (if you prefer) - -### Linux Mint / Ubuntu / Debian / Pop!_OS - -```bash -# 1. Install the Qt6 Python binding -sudo apt update -sudo apt install -y python3-pyqt6 - -# 2. Upgrade vaptvupt CLI to 4.1.0 -sudo dpkg -i vaptvupt_4.1.0_amd64.deb - -# 3. Install the GUI -sudo dpkg -i vaptvupt-gui_1.3.0_all.deb -``` - -If step 3 still complains about deps, run: - -```bash -sudo apt --fix-broken install -``` - -### Fedora / RHEL / Rocky / AlmaLinux - -```bash -sudo dnf install -y python3-pyqt6 -sudo dnf install -y vaptvupt-4.1.0-1.x86_64.rpm vaptvupt-gui-1.3.0-1.noarch.rpm -``` - -(Or build the RPM from the SRPM tarball with `rpmbuild -bb SPECS/vaptvupt.spec`) - -### openSUSE Leap / Tumbleweed - -```bash -sudo zypper install python3-pyqt6 -# Build the RPM from the source tarball — see the SRPM .tar.gz -``` - -### Arch Linux / Manjaro / EndeavourOS - -```bash -sudo pacman -S python-pyqt6 -# Build vaptvupt from the source tarball -``` - -### Anything else (or no apt/dnf/pacman handy) - -Use the AppImage — no install needed: - -```bash -tar xzf VaptVupt-GUI-1.3.0-x86_64.AppDir.tar.gz -cd vaptvupt-gui.AppDir -./AppRun -``` - -The AppImage still needs Python 3 + Qt6 binding on the host. For a -fully standalone executable with no Python dependency, use a future -PyInstaller-built version (not in this release). - -## Why does the GUI need Qt6? - -The VaptVupt GUI is written in Python, using either PyQt6 or PySide6 (it -auto-detects whichever is installed). These are bindings to the Qt 6 -graphical toolkit — they're how the GUI draws windows, buttons, and -dialogs. - -PyQt6 is in the default repositories of major Linux distributions, so -installing it is one apt/dnf/zypper/pacman command away. We don't bundle -Qt6 inside the deb because: - -- It's already on most modern systems -- Bundling would make the deb 80 MB+ instead of 35 KB -- Distribution-managed Qt gets security updates automatically - -## Why does the GUI need vaptvupt 4.1.0? - -The GUI calls `vaptvupt --pq` and `vaptvupt keygen` for native -post-quantum encryption (ML-KEM-768 + X25519, in-tree implementation). -Older CLI versions lack these flags, so the GUI's compress/extract will -fail against them. - -## After installing — verify - -```bash -vaptvupt version # should show: 4.1.0 -vaptvupt-gui # should launch the GUI window -``` - -## If the GUI window still doesn't appear - -```bash -# Run from terminal to see error messages -vaptvupt-gui - -# If you see "ImportError: No module named 'PyQt6'": -# The GUI fell back through both PyQt6 and PySide6 imports. -# Re-check: python3 -c 'import PyQt6.QtWidgets' - -# If you see "DISPLAY not set": -# You're on SSH without X forwarding. Use ssh -X or run locally. - -# If you see "qt.qpa.plugin: Could not load the Qt platform plugin": -# Missing Qt platform plugin. On Mint/Ubuntu: -# sudo apt install qt6-qpa-plugins -``` - -## Reporting issues - -If you've tried the above and vaptvupt-gui still won't work, open an issue -at https://git.securityops.co/cristiancmoises/vaptvupt/issues with: - -1. Output of `lsb_release -a` (or `cat /etc/os-release`) -2. Output of `python3 --version` -3. Output of `python3 -c 'import PyQt6; print(PyQt6.__version__)' 2>&1` -4. Output of `vaptvupt version` -5. Output of `vaptvupt-gui` (the error message it printed to terminal) - ---- - -## Building from source - -If you want to build VaptVupt from the source tarball instead of installing -the pre-built `.deb` / `.rpm` packages, you'll need only a C compiler and -make. The default build has NO external crypto dependency and installs no -shared library. - -### Build dependencies (default build) - -| Component | Why needed | +| Component | Gated artifacts | |---|---| -| `gcc` ≥ 7 or `clang` ≥ 10 | C11 compiler | -| `make` | build driver | -| libm, pthread | math and threading (part of the standard C library/toolchain) | +| Source and checksums | `zupt-5.2.8.tar.gz`, `zupt-5.2.8.tar.gz.sha256`, and `SHA256SUMS` | +| CLI | `zupt_5.2.8_amd64.deb`, `zupt-5.2.8-0.x86_64.rpm`, `zupt-5.2.8-0.src.rpm`, `zupt-5.2.8-linux-x86_64.tar.xz`, `zupt-5.2.8-windows-x86_64.zip`, and exactly one `ZUPT-5.2.8-macOS-{x86_64\|arm64}.dmg` | +| GUI | `zupt-gui_5.2.8_all.deb`, `zupt-gui-5.2.8-1.noarch.rpm`, `zupt-gui-5.2.8-1.src.rpm`, and `zupt-gui-5.2.8-portable.zip` | -The default build uses PBKDF2-SHA256 (600k iterations) for password KDF -and the in-tree native `--pq` mode (ML-KEM-768 + X25519) for post-quantum -encryption. No `libzuptsdk`, no OpenSSL, no libargon2 is required. +The GUI packages require the matching `zupt` CLI package and must pass exact +payload/dependency checks plus an installed off-screen GUI/CLI integration +test. The source-only portable GUI ZIP bundles launchers, notices, and GUI +source, but not Python, Qt, or the CLI. The Linux tar.xz carries the tested CLI +beside the complete public license/notice payload. AppImage, AppDir, Flatpak +bundles, GUI platform installers, and bare Linux/Windows executables are not +promoted for 5.2.8. The Windows ZIP and macOS DMG contain the CLI only. Exact +target boundaries are listed in `README.md`. +The release's `SHA256SUMS` and validation notes, not the mere presence of a +download link, identify an artifact that completed its gate. -### Install build dependencies (Debian/Ubuntu/Mint) +Do not install a package for a different distribution or CPU architecture. -```bash -sudo apt install build-essential +## Build requirements + +The default CLI build requires: + +- a C11 compiler; +- GNU make; +- the platform C library, math library, and threading support; +- standard build utilities including `gzip` for installation and source export. + +It does not need a vendored binary, OpenSSL, libargon2, `libvuptsdk`, or +`libpqvaptvupt`. Dependencies must be installed before the build; `make` does +not download anything. + +Typical package-manager commands are: + +```sh +# Debian / Ubuntu +sudo apt install build-essential gzip + +# Fedora / RHEL family +sudo dnf install gcc make gzip + +# openSUSE +sudo zypper install gcc make gzip + +# Arch Linux +sudo pacman -S base-devel gzip ``` -### Install build dependencies (Fedora/RHEL/openSUSE) +Package names can differ by distribution release. These commands are examples, +not a statement that 5.2.8 has been accepted into each distribution repository. -```bash -sudo dnf install gcc make # Fedora/RHEL -sudo zypper install gcc make # openSUSE +## Build and test from source + +Verify the checkout or extracted archive, then use the source-only feature set: + +```sh +scripts/check-source-only.sh +make clean +make -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf 1)" \ + WITH_SDK=0 WITH_PQBOX=0 V=1 +make WITH_SDK=0 WITH_PQBOX=0 check +./zupt --version +./zupt --help ``` -### Build VaptVupt itself +From a release archive, run the scanner as follows before extraction or from a +trusted checkout after download: -```bash -tar -xzf vaptvupt-4.1.0-source.tar.gz -cd vaptvupt-4.1.0 - -make # build the `./vaptvupt` binary -sudo make install # install to /usr/local/bin (override with PREFIX=/usr) - -./vaptvupt version # verify +```sh +scripts/check-source-only.sh --archive /path/to/zupt-5.2.8.tar.gz ``` -The `make` step takes 10-30 seconds. The build emits the binary as -`./vaptvupt`. The default install prefix is `/usr/local`; override with -`PREFIX=/usr` for system-wide install. +The default build provides the native password, ML-KEM-768 + X25519 hybrid +`--pq`, and ML-KEM-768 `--pq-only` paths. See `SECURITY.md` and +`THREAT_MODEL.md` before selecting an encryption mode. -### Run the test suite +For password encryption, prefer one of the explicit non-argv inputs: -```bash -make test +```sh +# Interactive, without terminal echo; compress confirms the password. +zupt compress --password-prompt backup.zupt files/ + +# Read the first line of a mode-0600 file. +zupt test --pass-file /secure/path/password.txt backup.zupt + +# Read the first line from an inherited descriptor. +zupt extract --pass-fd 3 -o restored backup.zupt 3 +Copyright (C) 2025-2026 Cristian Cezar Moisés - VaptVupt (formerly Zupt; renamed in v3.0.0 due to a prior INPI Brasil - trademark registration of "Zupt" for unrelated software) is free - software: you can redistribute it and/or modify it under the terms of - the GNU Affero General Public License as published by the Free - Software Foundation, either version 3 of the License, or (at your - option) any later version. +The ZUPT application, command-line interface, graphical interface, +cryptographic tool code, build files, and documentation identified by the +following SPDX expression are free software under: - VaptVupt is distributed in the hope that it will be useful, but - WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Affero General Public License for more details. + AGPL-3.0-or-later - You should have received a copy of the GNU Affero General Public - License along with this program. If not, see: +The integrated VaptVupt compression codec is a separately identified component. +The codec files carry this SPDX expression: - https://www.gnu.org/licenses/agpl-3.0.txt - https://www.gnu.org/licenses/agpl-3.0.html + GPL-3.0-or-later - SPDX-License-Identifier: AGPL-3.0-or-later +The two source files derived from Yann Collet's xxHash implementation carry an +additional BSD-2-Clause obligation: - ───────────────────────────────────────────────────────────────────── + src/zupt_xxh.c + src/vv_xxh64.c - ABOUT THIS LICENSE +Portions of the native ML-KEM implementation were adapted from the +pq-crystals/kyber reference implementation. Upstream offers that code under +CC0-1.0 or Apache-2.0; this distribution uses the CC0-1.0 option for those +portions: - The GNU Affero General Public License v3 (AGPLv3) is a copyleft - license designed for software that may be run as a network service. - It is identical to the GNU General Public License v3, with one - additional requirement (Section 13): if you modify libzuptsdk and - make the modified version available to users over a computer network, - you must offer those users access to the corresponding modified - source code. + src/zupt_mlkem.c - This protects Zupt against being adopted by SaaS providers as - a private fork without contributing back, while keeping it freely - usable by individuals, small businesses, and the broader open-source - community. +Portions of the native X25519 implementation were adapted from +curve25519-donna and conservatively retain its repository BSD-3-Clause terms: - If you write a separate program that is distributed alongside - Zupt (for example, statically linking it into your own - application), the AGPL requires you to license that combined work - under the AGPL as well — which means you must publish the source. - If this is not acceptable for your use case, please contact the - author for commercial licensing options: + src/zupt_x25519.c - sac@securityops.co - https://git.securityops.co/cristiancmoises/zupt +The codec scope consists of src/vv_*.c, src/vaptvupt_api.c, +include/vaptvupt*.h, and include/vv_*.h. Per-file SPDX notices are +authoritative if a file falls outside this summary. - ───────────────────────────────────────────────────────────────────── +GPL-3.0-or-later and AGPL-3.0-or-later code may be combined under section 13 of +the licenses. Distribution of this repository therefore needs to preserve both +license scopes and their notices. The unmodified license texts are provided in: - The full text of the GNU Affero General Public License version 3 - should accompany this distribution as a separate file (or you may - download it from the URLs above). It is approximately 35 KB / 619 - lines of plain text. + LICENSE-AGPL-3.0 + LICENSE-GPL-3.0 + LICENSE-BSD-2-Clause + LICENSE-BSD-3-Clause + LICENSE-CC0-1.0 - ───────────────────────────────────────────────────────────────────── +ZUPT is distributed without warranty; see the applicable license text for +the complete terms. - NOTE ON VAPTVUPT (GPL, NOT AGPL) +Historical licensing note: published repository history includes earlier +first-party application and GUI material distributed with MIT license notices. +Those permissions remain applicable to the exact material distributed under +them; the current license summary does not revoke or reinterpret an earlier +grant. The 5.2.2 erratum in CHANGELOG.md identifies the known repository +evidence. Current files follow their current per-file SPDX notices. - The VaptVupt LZ + tANS codec, located in: +The applicable copyright holder may separately offer commercial terms for +first-party rights that the holder controls. LICENSE-COMMERCIAL is only a +licensing inquiry and scope notice; it is not a commercial license grant and +does not relicense third-party or separately noticed material. - src/vv_*.c - src/vaptvupt_api.c - include/vaptvupt*.h - include/vv_*.h - - is licensed under the GNU General Public License version 3 or later - (GPL-3.0-or-later), NOT the AGPL. This deliberate licensing choice is - made so that, with sufficient maturity, VaptVupt can be considered - for upstreaming into the Linux or BSD kernels (which require GPL- - compatible licensing). - - The standalone repository for VaptVupt is at: - - https://git.securityops.co/cristiancmoises/vaptvupt - - The combination of GPL-licensed VaptVupt with AGPL-licensed Zupt is - explicitly intended by the author and consistent with the rights - retained by sole-authorship. - - ───────────────────────────────────────────────────────────────────── - - COMMERCIAL LICENSING - - Both AGPL and GPL components may be commercially relicensed by the - author. If you require relief from copyleft terms, contact: - - sac@securityops.co +Commercial licensing contact: sac@securityops.co +Canonical repository: https://github.com/cristiancmoises/zupt diff --git a/LICENSE-AGPL-3.0 b/LICENSE-AGPL-3.0 new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE-AGPL-3.0 @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/LICENSE-BSD-2-Clause b/LICENSE-BSD-2-Clause new file mode 100644 index 0000000..e4c5da7 --- /dev/null +++ b/LICENSE-BSD-2-Clause @@ -0,0 +1,26 @@ +xxHash Library +Copyright (c) 2012-2021 Yann Collet +All rights reserved. + +BSD 2-Clause License (https://www.opensource.org/licenses/bsd-license.php) + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/LICENSE-BSD-3-Clause b/LICENSE-BSD-3-Clause new file mode 100644 index 0000000..33a3240 --- /dev/null +++ b/LICENSE-BSD-3-Clause @@ -0,0 +1,46 @@ +Copyright 2008, Google Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. +* Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +curve25519-donna: Curve25519 elliptic curve, public key function + +http://code.google.com/p/curve25519-donna/ + +Adam Langley + +Derived from public domain C code by Daniel J. Bernstein + +More information about curve25519 can be found here + http://cr.yp.to/ecdh.html + +djb's sample implementation of curve25519 is written in a special assembly +language called qhasm and uses the floating point registers. + +This is, almost, a clean room reimplementation from the curve25519 paper. It +uses many of the tricks described therein. Only the crecip function is taken +from the sample implementation. diff --git a/LICENSE-CC0-1.0 b/LICENSE-CC0-1.0 new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/LICENSE-CC0-1.0 @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/LICENSE-COMMERCIAL b/LICENSE-COMMERCIAL new file mode 100644 index 0000000..ea81b74 --- /dev/null +++ b/LICENSE-COMMERCIAL @@ -0,0 +1,22 @@ +ZUPT COMMERCIAL LICENSING NOTICE + +First-party ZUPT code is publicly licensed under the per-file terms: +AGPL-3.0-or-later for the application/GUI/cryptographic tool code and +GPL-3.0-or-later for the separately identified VaptVupt compression codec. + +The applicable copyright holder may also offer those first-party rights under +a separate written commercial agreement executed with the licensee. + +THIS FILE IS NOT A COMMERCIAL LICENSE GRANT. It provides no permission outside +the applicable AGPL or GPL public option. Commercial-option rights, including +any proprietary redistribution right, exist only in an executed agreement that +identifies its exact files, version, use, and licensee. This notice promises no +support, warranty, patent, indemnification, trademark, pricing, or sublicensing +term. + +Vendored, generated, contributed, and separately noticed material is not +relicensed by this option unless the executed agreement expressly covers +rights owned or controlled by the licensor. See THIRD-PARTY-NOTICES.md and all +per-file SPDX notices. + +Commercial licensing inquiries: sac@securityops.co diff --git a/LICENSE-GPL-3.0 b/LICENSE-GPL-3.0 new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/LICENSE-GPL-3.0 @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Makefile b/Makefile index 7395e4e..5b6ec4e 100644 --- a/Makefile +++ b/Makefile @@ -1,51 +1,120 @@ -# Zupt — backup compression with hybrid post-quantum encryption +# SPDX-License-Identifier: AGPL-3.0-or-later +# ZUPT — backup compression with hybrid post-quantum encryption # Build system. Pure GNU make, no autotools, no cmake required. # # Targets: -# make Build the zupt binary (uses CC, CFLAGS, LDFLAGS env) +# make Build the zupt binary # make V=1 Verbose: show every command line # make install Install to /usr/local (override with PREFIX=/usr) -# make test Run the full test suite (55 tests across 6 suites) -# make test-asan Build and run with AddressSanitizer + UBSan +# make check Run the source-only distribution test suite +# make test-all Run the extended upstream test suite +# make test-asan Build with AddressSanitizer + UBSan +# make test-asan-run Execute the sanitizer smoke test # make clean Remove build artifacts # # Build profiles (all controllable via standard env vars): # CC=clang make Use Clang instead of GCC -# CFLAGS="-O3 -march=native" make Optimize for current host +# CFLAGS="-O3 -g" make Override the default optimization # make PREFIX=/usr DESTDIR=/tmp/stage Staged install for packagers # -# Architectures supported (auto-detected from $(uname -m)): -# x86_64 — full speed: Jasmin constant-time crypto, AVX2 SIMD decode -# aarch64 — full speed: C crypto, NEON SIMD decode -# armhf, ppc64le, s390x, riscv64 — C crypto, scalar decode -# -# Operating systems supported: -# Linux (glibc 2.28+), macOS 10.15+, Windows (MSYS2/MinGW), Termux Android, -# FreeBSD, OpenBSD (with system make compatibility shims). +# The compiler target, rather than the build host, controls architecture +# selection. This keeps cross builds from accidentally enabling host assembly. +CC ?= cc +CPPFLAGS ?= +CFLAGS ?= -O2 -g +LDFLAGS ?= +LDLIBS ?= +AR ?= ar +ARFLAGS ?= rcs +RANLIB ?= ranlib +STRIP ?= strip +PKG_CONFIG ?= pkg-config +ASFLAGS ?= -CC ?= cc -# v3.0.2: -Woverlength-strings catches usage()-style string literals -# that violate the C99 4095-char single-string limit. F-13 was hit -# in v3.0.1 when usage() drifted past the limit; the warning now -# fails the build under -Werror downstream. -CFLAGS ?= -Wall -Wextra -Woverlength-strings -O2 -std=c11 -CFLAGS += -Iinclude -Isrc -LDFLAGS ?= -LDLIBS ?= -lm - -# pthreads: link -lpthread on Linux/BSD, skip on Android/Termux (bionic built-in) -ifeq ($(shell uname -o 2>/dev/null),Android) - # Termux/Android: pthreads built into bionic libc -else - LDLIBS += -lpthread +# GNU make has a built-in ARFLAGS=rv. Use archive creation flags by default, +# while preserving values supplied through the environment or command line. +ifeq ($(origin ARFLAGS),default) + ARFLAGS := rcs endif -PREFIX ?= /usr/local -BINDIR ?= $(PREFIX)/bin -MANDIR ?= $(PREFIX)/share/man -MAN1DIR ?= $(MANDIR)/man1 -GZIP ?= gzip -GZIPFLAGS ?= -9 -n +DESTDIR ?= +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/bin +LIBDIR ?= $(PREFIX)/lib +INCLUDEDIR ?= $(PREFIX)/include +DATADIR ?= $(PREFIX)/share +MANDIR ?= $(DATADIR)/man +MAN1DIR ?= $(MANDIR)/man1 +BASHCOMPDIR ?= $(DATADIR)/bash-completion/completions +ZSHCOMPDIR ?= $(DATADIR)/zsh/site-functions +FISHCOMPDIR ?= $(DATADIR)/fish/vendor_completions.d +LICENSEDIR ?= $(DATADIR)/licenses/zupt +GZIP ?= gzip +GZIPFLAGS ?= -9 -n +INSTALL_LEGACY_ALIAS ?= 0 +INSTALL_LICENSES ?= 1 +LICENSE_FILES = LICENSE LICENSE-AGPL-3.0 LICENSE-GPL-3.0 \ + LICENSE-BSD-2-Clause LICENSE-BSD-3-Clause LICENSE-CC0-1.0 \ + NOTICE THIRD-PARTY-NOTICES.md + +# Standard packager variables above are never rewritten. Project-owned flags +# are passed alongside them on every command line. +PROJECT_CPPFLAGS := -D_DEFAULT_SOURCE -Iinclude -Isrc +PROJECT_CFLAGS := -Wall -Wextra -Woverlength-strings -std=c11 +PROJECT_LDFLAGS := +PROJECT_CLI_LDFLAGS := +PROJECT_LDLIBS := -lm +EXEEXT := +CREATE_TEST_ALIAS := 0 +FEATURE_CPPFLAGS := +FEATURE_LDLIBS := + +# Clang's -Wcast-align diagnoses the pointer casts required by the explicitly +# unaligned x86 load/store intrinsics, and cannot infer alignment through the +# byte-backed VaptVupt arenas. Those arenas start at malloc alignment and all +# typed offsets are rounded to at least 8 bytes. Keep the compatibility +# suppression local to the three audited translation units; every other file +# retains a caller-supplied -Wcast-align/-Werror policy. +CLANG_CAST_ALIGN_FLAGS := +ifneq ($(findstring clang,$(shell $(CC) --version 2>/dev/null | head -n 1)),) + CLANG_CAST_ALIGN_FLAGS := -Wno-cast-align +endif +CLANG_CAST_ALIGN_OBJS := src/vv_ans.o src/vv_simd.o src/zupt_sha256_shani.o + +TARGET_MACHINE ?= $(shell $(CC) -dumpmachine 2>/dev/null) +TARGET_CPU := $(firstword $(subst -, ,$(TARGET_MACHINE))) +ifeq ($(strip $(TARGET_CPU)),) + TARGET_CPU := unknown +endif + +# The Windows extraction path uses the documented NtCreateFile RootDirectory +# facility so directory components are resolved relative to pinned handles. +# A self-contained PE is required because POSIX-thread MinGW toolchains may +# otherwise add an undeclared libwinpthread-1.dll runtime dependency. +ifneq ($(strip $(findstring mingw,$(TARGET_MACHINE))$(findstring windows,$(TARGET_MACHINE))),) + EXEEXT := .exe + CREATE_TEST_ALIAS := 0 + PROJECT_LDFLAGS += -static + PROJECT_CLI_LDFLAGS += -municode + PROJECT_LDLIBS += -lntdll +endif + +# pthread is part of bionic and the Windows implementation uses native APIs. +ifeq ($(findstring android,$(TARGET_MACHINE)),) + ifeq ($(findstring mingw,$(TARGET_MACHINE)),) + ifeq ($(findstring windows,$(TARGET_MACHINE)),) + PROJECT_CFLAGS += -pthread + PROJECT_LDLIBS += -pthread + endif + endif +endif + +ifneq ($(filter $(INSTALL_LEGACY_ALIAS),0 1),$(INSTALL_LEGACY_ALIAS)) + $(error INSTALL_LEGACY_ALIAS must be 0 or 1) +endif +ifneq ($(filter $(INSTALL_LICENSES),0 1),$(INSTALL_LICENSES)) + $(error INSTALL_LICENSES must be 0 or 1) +endif # --- Verbose build --- V ?= 0 @@ -55,7 +124,7 @@ else Q = @ endif -# --- Zupt core sources --- +# --- ZUPT core sources --- ZUPT_SOURCES = src/zupt_main.c src/zupt_format.c src/zupt_lz.c src/zupt_lzh.c \ src/zupt_xxh.c src/zupt_sha256.c src/zupt_sha256_shani.c src/zupt_aes256.c src/zupt_crypto.c \ src/zupt_crypto_sdk.c src/zupt_crypto_pqbox.c \ @@ -63,31 +132,37 @@ ZUPT_SOURCES = src/zupt_main.c src/zupt_format.c src/zupt_lz.c src/zupt_lzh.c \ src/zupt_x25519.c src/zupt_mlkem.c src/zupt_cpuid.c src/zupt_mlock.c \ src/zupt_filetype.c src/zupt_disk.c src/zupt_dedup.c -# --- Optional vendored libraries (libzuptsdk + libpqvaptvupt) --- -# -# These are PREBUILT shared libraries shipped only as binaries (no source), so -# they are NOT part of the source tree and a distro/source build must not need -# them. WITH_SDK is therefore OFF by default: the tool builds entirely from the -# in-tree C sources, using native crypto (PBKDF2-SHA256 password KDF and native -# ML-KEM-768 + X25519 via --pq). The SDK-backed modes (--pq-sdk, --pq-box, and -# the Argon2id password KDF) compile to "unsupported" stubs in that case. -# -# Set WITH_SDK=1 (with the vendored libs present under vendor/) to enable them. +# --- Optional system libraries (never vendored, never downloaded) --- WITH_SDK ?= 0 +WITH_PQBOX ?= 0 + +ifneq ($(filter $(WITH_SDK),0 1),$(WITH_SDK)) + $(error WITH_SDK must be 0 or 1) +endif +ifneq ($(filter $(WITH_PQBOX),0 1),$(WITH_PQBOX)) + $(error WITH_PQBOX must be 0 or 1) +endif + ifeq ($(WITH_SDK),1) -ZUPTSDK_DIR ?= vendor/zuptsdk -ZUPTSDK_ABS := $(abspath $(ZUPTSDK_DIR)) -CFLAGS += -DZUPT_WITH_SDK -I$(ZUPTSDK_DIR)/include -PQVV_DIR ?= vendor/pqvaptvupt -CFLAGS += -I$(PQVV_DIR)/include -LDFLAGS += -L$(ZUPTSDK_DIR) -Wl,-rpath,$(ZUPTSDK_ABS) -Wl,-rpath,'$$ORIGIN/$(ZUPTSDK_DIR)' -LDLIBS += -lzuptsdk -PQVV_ABS := $(abspath $(PQVV_DIR)) -LDFLAGS += -L$(PQVV_DIR) -Wl,-rpath,$(PQVV_ABS) -Wl,-rpath,'$$ORIGIN/$(PQVV_DIR)' -# Installed layout: vendored libs live in $(PREFIX)/lib/$(TARGET)/ — give the -# binary a matching relative rpath so `make install` is self-contained. -LDFLAGS += -Wl,-rpath,'$$ORIGIN/../lib/vaptvupt' -LDLIBS += -lpqvaptvupt + SDK_PKG_CONFIG ?= libvuptsdk + SDK_CPPFLAGS ?= $(shell $(PKG_CONFIG) --cflags $(SDK_PKG_CONFIG) 2>/dev/null) + SDK_LDLIBS ?= $(shell $(PKG_CONFIG) --libs $(SDK_PKG_CONFIG) 2>/dev/null) + ifeq ($(strip $(SDK_LDLIBS)),) + $(error WITH_SDK=1 requires the system libvuptsdk development package; set SDK_CPPFLAGS and SDK_LDLIBS to explicit system paths if no pkg-config file is provided) + endif + FEATURE_CPPFLAGS += -DZUPT_WITH_SDK $(SDK_CPPFLAGS) + FEATURE_LDLIBS += $(SDK_LDLIBS) +endif + +ifeq ($(WITH_PQBOX),1) + PQBOX_PKG_CONFIG ?= libpqvaptvupt + PQBOX_CPPFLAGS ?= $(shell $(PKG_CONFIG) --cflags $(PQBOX_PKG_CONFIG) 2>/dev/null) + PQBOX_LDLIBS ?= $(shell $(PKG_CONFIG) --libs $(PQBOX_PKG_CONFIG) 2>/dev/null) + ifeq ($(strip $(PQBOX_LDLIBS)),) + $(error WITH_PQBOX=1 requires the system libpqvaptvupt development package; set PQBOX_CPPFLAGS and PQBOX_LDLIBS to explicit system paths if no pkg-config file is provided) + endif + FEATURE_CPPFLAGS += -DZUPT_WITH_PQBOX $(PQBOX_CPPFLAGS) + FEATURE_LDLIBS += $(PQBOX_LDLIBS) endif # --- VAPTVUPT: VaptVupt codec sources (GPL-3.0-or-later; tool is AGPL-3.0-or-later) --- @@ -101,202 +176,211 @@ HEADERS = include/zupt.h include/zupt_keccak.h include/zupt_mlkem.h \ include/zupt_acsl.h \ include/vaptvupt.h include/vaptvupt_api.h include/vv_huffman.h include/vv_ans.h \ include/vv_platform.h \ - src/zupt_thread.h src/zupt_parallel.h + src/zupt_thread.h src/zupt_parallel.h src/zupt_internal.h -TARGET = vaptvupt -LEGACY_LINK = zupt -MANPAGE = doc/vaptvupt.1 -MANPAGE_GZ = $(TARGET).1.gz +PROGRAM = zupt +TARGET = $(PROGRAM)$(EXEEXT) +LEGACY_PROGRAM = vaptvupt +LEGACY_LINK = $(LEGACY_PROGRAM)$(EXEEXT) +MANPAGE = doc/zupt.1 +MANPAGE_GZ = $(PROGRAM).1.gz -# ═══════════════════════════════════════════════════════════════════ -# ARCHITECTURE DETECTION -# -# Jasmin CT assembly: x86_64 only (pre-compiled .s files) -# AVX2 SIMD decode: x86_64 only (-mavx2 on VV decode/encode/simd) -# NEON SIMD decode: aarch64 (auto-detected by compiler, no extra flags) -# Scalar fallback: all architectures -# ═══════════════════════════════════════════════════════════════════ - -ARCH := $(shell uname -m) - -# --- AVX2: enable SIMD for VaptVupt on x86_64 --- -ifeq ($(ARCH),x86_64) - VV_SIMD_FLAGS = -mavx2 - SHANI_FLAGS = -msha -mssse3 -msse4.1 -else - VV_SIMD_FLAGS = - SHANI_FLAGS = +# Architecture-specific code is opt-in and isolated. The normal x86_64 build +# stays at the ABI baseline; in particular, no complete codec TU gets -mavx2. +SHANI_FLAGS := +ifneq ($(filter x86_64 amd64 i386 i486 i586 i686,$(TARGET_CPU)),) + SHANI_FLAGS := -msha -mssse3 -msse4.1 endif -# --- Jasmin: enable only on x86_64 with pre-compiled .s files --- +# Optional textual assembly is disabled by default so every +# compiler/architecture has the audited C fallback. Four files are jasminc +# outputs and zupt_aes_ctr4.s is separately identified as hand-written. When +# requested, the compiler driver assembles them while preserving cross-target +# and sysroot settings. +WITH_JASMIN ?= 0 +ifneq ($(filter $(WITH_JASMIN),0 1),$(WITH_JASMIN)) + $(error WITH_JASMIN must be 0 or 1) +endif JAZZ_S = jasmin/zupt_mac_verify.s jasmin/zupt_mlkem_select.s \ jasmin/zupt_aes_ctr.s jasmin/zupt_x25519_fe.s jasmin/zupt_aes_ctr4.s -JAZZ_O = - -ifeq ($(ARCH),x86_64) - JAZZ_AVAILABLE := $(wildcard $(JAZZ_S)) - ifeq ($(JAZZ_AVAILABLE),$(JAZZ_S)) - CFLAGS += -DZUPT_USE_JASMIN - JAZZ_O = jasmin/zupt_mac_verify.o jasmin/zupt_mlkem_select.o \ - jasmin/zupt_aes_ctr.o jasmin/zupt_x25519_fe.o jasmin/zupt_aes_ctr4.o - $(info [jasmin] Enabled (x86_64) — linking CT crypto) - else - $(info [jasmin] Assembly not found — using C fallback) +JAZZ_O := +ifeq ($(WITH_JASMIN),1) + ifeq ($(filter x86_64 amd64,$(TARGET_CPU)),) + $(error WITH_JASMIN=1 is supported only for an x86_64 compiler target; detected $(TARGET_MACHINE)) endif -else - $(info [jasmin] Disabled on $(ARCH) — using C fallback) + ifneq ($(words $(wildcard $(JAZZ_S))),$(words $(JAZZ_S))) + $(error WITH_JASMIN=1 requested, but one or more optional .s sources are missing) + endif + FEATURE_CPPFLAGS += -DZUPT_USE_JASMIN + JAZZ_O := $(JAZZ_S:.s=.o) endif # --- Object files --- -# VV SIMD files need -mavx2 on x86_64 (no-op on other arches) +# These objects contain the baseline codec implementation. Optimized SHA-NI +# remains in its own translation unit below and is guarded at runtime. VV_SIMD_OBJS = src/vv_encoder.o src/vv_decoder.o src/vv_simd.o -# Vendored codec sources follow the UPSTREAM warning policy (kept byte-exact -# to canonical releases for clean future drop-ins). Two benign clang-only -# categories are silenced here instead of patching upstream files: -# vv_decoder.c: unused helper retained upstream; vv_ans.c: stats variable. -VV_WPOLICY = -Wno-unused-function -Wno-unused-but-set-variable -$(VV_SOURCES:.c=.o): CFLAGS += $(VV_WPOLICY) +# The bundled codec uses the same warning policy as the application. Do not +# add broad -Wno-* flags here: localized upstream changes are recorded in +# THIRD-PARTY-NOTICES.md and must compile without masked diagnostics. VV_PLAIN_OBJS = src/vv_ans.o src/vv_huffman.o src/vv_xxh64.o src/vv_bcj.o src/vaptvupt_api.o ZUPT_OBJS = $(patsubst %.c,%.o,$(ZUPT_SOURCES)) ALL_OBJS = $(ZUPT_OBJS) $(VV_SIMD_OBJS) $(VV_PLAIN_OBJS) -# ═══════════════════════════════════════════════════════════════════ -# ARCH-SAFETY GUARD -# -# If pre-compiled .o files from a different architecture are present -# (e.g. x86_64 .o files in an aarch64 build), the linker will fail -# with "incompatible with ". Detect and remove stale objects. -# This happens when tarballs accidentally include build artifacts, -# or when the same source tree is shared between different machines. -# -# Detection: uses $(CC) -dumpmachine which works on ALL platforms -# including Termux (where /bin/sh does not exist). -# ═══════════════════════════════════════════════════════════════════ - -STALE_OBJS := $(wildcard src/*.o jasmin/*.o) -ifneq ($(STALE_OBJS),) - FIRST_OBJ := $(firstword $(STALE_OBJS)) - # Normalise to a canonical token (no '-' / '_' so x86-64 == x86_64). - OBJ_ARCH := $(shell file $(FIRST_OBJ) 2>/dev/null | grep -oiE 'x86.64|aarch64|arm|powerpc|s390|riscv' | head -1 | tr -d '_-' | tr '[:upper:]' '[:lower:]') - HOST_TRIPLE := $(shell $(CC) -dumpmachine 2>/dev/null) - HOST_ARCH_CC := $(shell echo "$(HOST_TRIPLE)" | grep -oiE 'x86.64|aarch64|arm|powerpc|s390|riscv' | head -1 | tr -d '_-' | tr '[:upper:]' '[:lower:]') - # Fallback: try uname -m if CC -dumpmachine fails - ifeq ($(HOST_ARCH_CC),) - HOST_ARCH_CC := $(shell uname -m 2>/dev/null | grep -oiE 'x86.64|aarch64|arm|powerpc|s390|riscv' | head -1 | tr -d '_-' | tr '[:upper:]' '[:lower:]') - endif - ifneq ($(OBJ_ARCH),) - ifneq ($(HOST_ARCH_CC),) - ifneq ($(OBJ_ARCH),$(HOST_ARCH_CC)) - $(info [arch] Removing stale $(OBJ_ARCH) objects for $(HOST_ARCH_CC) build) - $(shell rm -f src/*.o jasmin/*.o) - endif - endif - endif -endif - # ═══════════════════════════════════════════════════════════════════ # BUILD RULES # ═══════════════════════════════════════════════════════════════════ -.PHONY: all clean install uninstall test test-all test-asan test-asan-run test-vectors test-vv fuzz-build fuzz-format fuzz-format-run help audit-licenses dist check +.DELETE_ON_ERROR: +.PHONY: all clean install uninstall test test-all release-check test-asan test-asan-run \ + test-vectors test-f06 test-vv fuzz-build fuzz-format fuzz-format-run \ + help audit-licenses source-audit dist check all: $(TARGET) # ═══════════════════════════════════════════════════════════════════ -# audit-licenses — verify every source file carries the correct SPDX -# header. AGPL-3.0-or-later for all Zupt code, GPL-3.0-or-later for -# VaptVupt files (vv_* and vaptvupt*) — see THIRD-PARTY-NOTICES.md -# for the rationale. +# audit-licenses — verify covered code, build, CI, and packaging files carry +# the correct SPDX marker. Legal-document completeness is audited separately +# through LICENSE*, NOTICE, and THIRD-PARTY-NOTICES.md; this target is not a +# claim of full REUSE conformance. +# AGPL-3.0-or-later for the application/core, GPL-3.0-or-later for the +# bundled codec files, BSD-2-Clause for the two xxHash-derived units, and +# CC0-1.0 for the pq-crystals/kyber-derived portions of native ML-KEM, and +# BSD-3-Clause for curve25519-donna-derived X25519 portions. +# See THIRD-PARTY-NOTICES.md. # ═══════════════════════════════════════════════════════════════════ audit-licenses: @MISSING=0; WRONG=0; \ for f in $$(find . -type f \( -name '*.c' -o -name '*.h' -o -name '*.hpp' \ - -o -name '*.py' -o -name '*.sh' -o -name '*.yml' \ - -o -name '*.jazz' -o -name '*.s' -o -name 'Makefile' \ - -o -name '*.map' \) \ + -o -name '*.py' -o -name '*.sh' -o -name '*.yml' -o -name '*.yaml' \ + -o -name '*.jazz' -o -name '*.s' -o -name '*.S' -o -name 'Makefile' \ + -o -name '*.map' -o -name '*.bat' -o -name '*.command' \ + -o -name '*.desktop' -o -name '*.nemo_action' -o -name '*.spec' \ + -o -name '*.rb' -o -name '*.scm' -o -name '*.nix' \ + -o -name '*.iss' -o -name '*.nsi' -o -name '*.fish' \ + -o -name 'PKGBUILD' -o -name '_service' -o -name 'rules' \) \ -not -path './build/*' \ -not -path './build_obj/*' \ - -not -path './sdk/build/*' \ - -not -path './vendor/zuptsdk/include/*'); do \ - BASE=$$(basename "$$f"); \ - case "$$BASE" in \ - vv_*|vaptvupt*) \ - EXPECTED="SPDX-License-Identifier: GPL-3.0-or-later" ;; \ + -not -path './sdk/build/*'); do \ + case "$$f" in \ + ./src/zupt_mlkem.c) \ + EXPECTED_ID="AGPL-3.0-or-later AND CC0-1.0" ;; \ + ./src/zupt_x25519.c) \ + EXPECTED_ID="AGPL-3.0-or-later AND BSD-3-Clause" ;; \ + ./src/zupt_xxh.c) \ + EXPECTED_ID="AGPL-3.0-or-later AND BSD-2-Clause" ;; \ + ./src/vv_xxh64.c) \ + EXPECTED_ID="GPL-3.0-or-later AND BSD-2-Clause" ;; \ + ./src/vv_*.c|./src/vaptvupt_api.c|./include/vv_*.h|./include/vaptvupt*.h) \ + EXPECTED_ID="GPL-3.0-or-later" ;; \ *) \ - EXPECTED="SPDX-License-Identifier: AGPL-3.0-or-later" ;; \ + EXPECTED_ID="AGPL-3.0-or-later" ;; \ esac; \ - if ! grep -q "SPDX-License-Identifier" "$$f"; then \ + HEADER=$$(sed -n '1,12p' "$$f"); \ + HEADER_COUNT=$$(printf '%s\n' "$$HEADER" | \ + grep -c 'SPDX-License-Identifier:' || true); \ + ACTUAL_ID=$$(printf '%s\n' "$$HEADER" | \ + sed -n 's/^.*SPDX-License-Identifier:[[:space:]]*//p' | \ + sed 's/[[:space:]]*\*\/[[:space:]]*$$//; s/[[:space:]]*-->[[:space:]]*$$//; s/[[:space:]]*$$//' | \ + head -n 1); \ + if [ "$$HEADER_COUNT" -eq 0 ]; then \ echo " ✗ $$f (missing SPDX)"; \ MISSING=$$((MISSING+1)); \ - elif ! grep -q "$$EXPECTED" "$$f"; then \ - echo " ✗ $$f (wrong SPDX, expected: $$EXPECTED)"; \ + elif [ "$$HEADER_COUNT" -ne 1 ] || [ "$$ACTUAL_ID" != "$$EXPECTED_ID" ]; then \ + echo " ✗ $$f (wrong SPDX header, expected exactly once: $$EXPECTED_ID)"; \ WRONG=$$((WRONG+1)); \ fi; \ done; \ if [ $$MISSING -eq 0 ] && [ $$WRONG -eq 0 ]; then \ - echo " ✓ All source files carry correct SPDX headers"; \ - echo " (AGPL-3.0-or-later for Zupt, GPL-3.0-or-later for VaptVupt)"; \ + echo " ✓ Covered code/build/CI/packaging files carry correct SPDX markers"; \ + echo " (AGPL core; GPL codec; BSD-2 XXH64; BSD-3 X25519; CC0 ML-KEM)"; \ else \ echo ""; \ echo " $$MISSING missing, $$WRONG with wrong SPDX. Aborting."; \ exit 1; \ fi -# Jasmin pre-compiled assembly (x86_64 only) -# Jasmin emits GNU-as syntax (macros with C-style trailing comments); -# clang's integrated assembler rejects it, so assemble with as(1) directly. +# Optional Jasmin textual assembly (x86_64 only). Use the compiler driver so a +# cross compiler's target, sysroot, assembler and reproducibility flags apply. jasmin/%.o: jasmin/%.s - $(Q)as -o $@ $< + $(Q)$(CC) $(CPPFLAGS) $(ASFLAGS) -c -o $@ $< -# VaptVupt SIMD files: compile with AVX2 on x86_64 +# VaptVupt codec files are compiled for the target ABI baseline. $(VV_SIMD_OBJS): src/%.o: src/%.c $(HEADERS) - $(Q)$(CC) $(CFLAGS) $(VV_SIMD_FLAGS) -c -o $@ $< + $(Q)$(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(FEATURE_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) \ + $(if $(filter $@,$(CLANG_CAST_ALIGN_OBJS)),$(CLANG_CAST_ALIGN_FLAGS)) \ + -c -o $@ $< -# VaptVupt non-SIMD files +# VaptVupt non-SIMD files use the same warning policy. $(VV_PLAIN_OBJS): src/%.o: src/%.c $(HEADERS) - $(Q)$(CC) $(CFLAGS) -c -o $@ $< + $(Q)$(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(FEATURE_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) \ + $(if $(filter $@,$(CLANG_CAST_ALIGN_OBJS)),$(CLANG_CAST_ALIGN_FLAGS)) \ + -c -o $@ $< -# Zupt core files (the SHA-NI object has its own rule below with -msha) +# ZUPT core files (the SHA-NI object has its own rule below with -msha) ZUPT_OBJS_GENERIC = $(filter-out src/zupt_sha256_shani.o,$(ZUPT_OBJS)) $(ZUPT_OBJS_GENERIC): src/%.o: src/%.c $(HEADERS) - $(Q)$(CC) $(CFLAGS) -c -o $@ $< + $(Q)$(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(FEATURE_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) -c -o $@ $< # SHA-NI path needs -msha -mssse3 -msse4.1 on x86_64. # On non-x86_64, SHANI_FLAGS is empty and the file is a no-op TU. src/zupt_sha256_shani.o: src/zupt_sha256_shani.c $(HEADERS) - $(Q)$(CC) $(CFLAGS) $(SHANI_FLAGS) -c -o $@ $< + $(Q)$(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(FEATURE_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) $(SHANI_FLAGS) \ + $(if $(filter $@,$(CLANG_CAST_ALIGN_OBJS)),$(CLANG_CAST_ALIGN_FLAGS)) \ + -c -o $@ $< # Final link step. Order matters: CFLAGS before LDFLAGS, then objects, # then LDLIBS — keeps GCC/Clang happy when LDFLAGS contains -pie or # similar position-sensitive flags. $(TARGET): $(ALL_OBJS) $(JAZZ_O) - $(Q)$(CC) $(CFLAGS) $(LDFLAGS) $(ALL_OBJS) $(JAZZ_O) -o $(TARGET) $(LDLIBS) - @# v3.0.0: in-tree legacy symlink. Existing tests, scripts and IDE - @# launchers reference `./zupt`; we keep that working without - @# modifying 27 test files. The install rule emits the same symlink - @# at $(BINDIR)/zupt for runtime users. - $(Q)ln -sf $(TARGET) $(LEGACY_LINK) - @echo "Build complete: ./$(TARGET) [$(ARCH)] (legacy: ./$(LEGACY_LINK) -> $(TARGET))" + $(Q)$(CC) $(CFLAGS) $(PROJECT_CFLAGS) $(LDFLAGS) $(PROJECT_LDFLAGS) $(PROJECT_CLI_LDFLAGS) \ + $(ALL_OBJS) $(JAZZ_O) -o $(TARGET) \ + $(FEATURE_LDLIBS) $(PROJECT_LDLIBS) $(LDLIBS) + @# In-tree test compatibility only. Installation emits the legacy + @# alias solely when INSTALL_LEGACY_ALIAS=1 is requested explicitly. + $(Q)if [ "$(CREATE_TEST_ALIAS)" = 0 ]; then \ + :; \ + elif [ -L "$(LEGACY_LINK)" ]; then \ + test "$$(readlink "$(LEGACY_LINK)")" = "$(TARGET)" || { \ + echo "ERROR: refusing to replace non-ZUPT symlink: $(LEGACY_LINK)" >&2; exit 1; \ + }; \ + elif [ -e "$(LEGACY_LINK)" ]; then \ + echo "ERROR: refusing to replace existing path: $(LEGACY_LINK)" >&2; exit 1; \ + fi; \ + if [ "$(CREATE_TEST_ALIAS)" = 1 ]; then ln -sf "$(TARGET)" "$(LEGACY_LINK)"; fi + @if [ "$(CREATE_TEST_ALIAS)" = 1 ]; then \ + echo "Build complete: ./$(TARGET) [$(TARGET_MACHINE)] (test alias: ./$(LEGACY_LINK) -> $(TARGET))"; \ + else \ + echo "Build complete: ./$(TARGET) [$(TARGET_MACHINE)] (no in-tree compatibility alias)"; \ + fi # ═══════════════════════════════════════════════════════════════════ # INSTALL / UNINSTALL # ═══════════════════════════════════════════════════════════════════ install: $(TARGET) - $(Q)mkdir -p $(DESTDIR)$(BINDIR) - $(Q)install -m 755 $(TARGET) $(DESTDIR)$(BINDIR)/$(TARGET) - # v3.0.0 (INPI Brasil rename): legacy `zupt` symlink so existing - # scripts and shell history keep working. Distros may strip this - # after one major version cycle. - $(Q)ln -sf $(TARGET) $(DESTDIR)$(BINDIR)/$(LEGACY_LINK) + $(Q)mkdir -p "$(DESTDIR)$(BINDIR)" + $(Q)install -m 0755 "$(TARGET)" "$(DESTDIR)$(BINDIR)/$(TARGET)" + $(Q)if [ "$(INSTALL_LICENSES)" = 1 ]; then \ + mkdir -p "$(DESTDIR)$(LICENSEDIR)"; \ + install -m 0644 $(LICENSE_FILES) "$(DESTDIR)$(LICENSEDIR)/"; \ + fi + $(Q)if [ "$(INSTALL_LEGACY_ALIAS)" = 1 ]; then \ + ln -sf "$(TARGET)" "$(DESTDIR)$(BINDIR)/$(LEGACY_LINK)"; \ + fi $(Q)if [ -f "$(MANPAGE)" ]; then \ - mkdir -p $(DESTDIR)$(MAN1DIR); \ + mkdir -p "$(DESTDIR)$(MAN1DIR)"; \ $(GZIP) $(GZIPFLAGS) -c "$(MANPAGE)" > "$(DESTDIR)$(MAN1DIR)/$(MANPAGE_GZ)"; \ chmod 0644 "$(DESTDIR)$(MAN1DIR)/$(MANPAGE_GZ)"; \ - ln -sf "$(MANPAGE_GZ)" "$(DESTDIR)$(MAN1DIR)/$(LEGACY_LINK).1.gz"; \ - echo "Installed: $(DESTDIR)$(MAN1DIR)/$(MANPAGE_GZ) (+ $(LEGACY_LINK).1.gz symlink)"; \ + if [ "$(INSTALL_LEGACY_ALIAS)" = 1 ]; then \ + ln -sf "$(MANPAGE_GZ)" "$(DESTDIR)$(MAN1DIR)/$(LEGACY_PROGRAM).1.gz"; \ + fi; \ + echo "Installed: $(DESTDIR)$(MAN1DIR)/$(MANPAGE_GZ)"; \ else \ echo "Warning: man page not found: $(MANPAGE)"; \ fi @@ -304,51 +388,54 @@ install: $(TARGET) # Shell completions (v2.4.7+). Honour distro path conventions where # possible; downstream packagers can override DESTDIR + the specific # dirs as needed. - $(Q)if [ -f completions/vaptvupt.bash ]; then \ - mkdir -p "$(DESTDIR)$(PREFIX)/share/bash-completion/completions"; \ - install -m 0644 completions/vaptvupt.bash \ - "$(DESTDIR)$(PREFIX)/share/bash-completion/completions/$(TARGET)"; \ - ln -sf "$(TARGET)" "$(DESTDIR)$(PREFIX)/share/bash-completion/completions/$(LEGACY_LINK)"; \ - echo "Installed: $(DESTDIR)$(PREFIX)/share/bash-completion/completions/$(TARGET) (+ $(LEGACY_LINK) symlink)"; \ + $(Q)if [ -f completions/zupt.bash ]; then \ + mkdir -p "$(DESTDIR)$(BASHCOMPDIR)"; \ + install -m 0644 completions/zupt.bash \ + "$(DESTDIR)$(BASHCOMPDIR)/$(PROGRAM)"; \ + if [ "$(INSTALL_LEGACY_ALIAS)" = 1 ]; then \ + ln -sf "$(PROGRAM)" "$(DESTDIR)$(BASHCOMPDIR)/$(LEGACY_PROGRAM)"; \ + fi; \ + echo "Installed: $(DESTDIR)$(BASHCOMPDIR)/$(PROGRAM)"; \ fi - $(Q)if [ -f completions/_vaptvupt ]; then \ - mkdir -p "$(DESTDIR)$(PREFIX)/share/zsh/site-functions"; \ - install -m 0644 completions/_vaptvupt \ - "$(DESTDIR)$(PREFIX)/share/zsh/site-functions/_$(TARGET)"; \ - ln -sf "_$(TARGET)" "$(DESTDIR)$(PREFIX)/share/zsh/site-functions/_$(LEGACY_LINK)"; \ - echo "Installed: $(DESTDIR)$(PREFIX)/share/zsh/site-functions/_$(TARGET) (+ _$(LEGACY_LINK) symlink)"; \ + $(Q)if [ -f completions/_zupt ]; then \ + mkdir -p "$(DESTDIR)$(ZSHCOMPDIR)"; \ + install -m 0644 completions/_zupt \ + "$(DESTDIR)$(ZSHCOMPDIR)/_$(PROGRAM)"; \ + if [ "$(INSTALL_LEGACY_ALIAS)" = 1 ]; then \ + ln -sf "_$(PROGRAM)" "$(DESTDIR)$(ZSHCOMPDIR)/_$(LEGACY_PROGRAM)"; \ + fi; \ + echo "Installed: $(DESTDIR)$(ZSHCOMPDIR)/_$(PROGRAM)"; \ fi - $(Q)if [ -f completions/vaptvupt.fish ]; then \ - mkdir -p "$(DESTDIR)$(PREFIX)/share/fish/vendor_completions.d"; \ - install -m 0644 completions/vaptvupt.fish \ - "$(DESTDIR)$(PREFIX)/share/fish/vendor_completions.d/$(TARGET).fish"; \ - echo "Installed: $(DESTDIR)$(PREFIX)/share/fish/vendor_completions.d/$(TARGET).fish"; \ + $(Q)if [ -f completions/zupt.fish ]; then \ + mkdir -p "$(DESTDIR)$(FISHCOMPDIR)"; \ + install -m 0644 completions/zupt.fish \ + "$(DESTDIR)$(FISHCOMPDIR)/$(PROGRAM).fish"; \ + if [ "$(INSTALL_LEGACY_ALIAS)" = 1 ]; then \ + ln -sf "$(PROGRAM).fish" "$(DESTDIR)$(FISHCOMPDIR)/$(LEGACY_PROGRAM).fish"; \ + fi; \ + echo "Installed: $(DESTDIR)$(FISHCOMPDIR)/$(PROGRAM).fish"; \ fi - # Vendored runtime libraries — installed ONLY for a WITH_SDK=1 build. In the - # default source-only build the binary links no external library and there is - # nothing to install here (the vendored .so are prebuilt binaries kept out of - # the source tree). -ifeq ($(WITH_SDK),1) - $(Q)mkdir -p $(DESTDIR)$(PREFIX)/lib/vaptvupt - $(Q)install -m 755 vendor/zuptsdk/libzuptsdk.so.2.0.0 $(DESTDIR)$(PREFIX)/lib/vaptvupt/libzuptsdk.so.2.0.0 - $(Q)ln -sf libzuptsdk.so.2.0.0 $(DESTDIR)$(PREFIX)/lib/vaptvupt/libzuptsdk.so.2 - $(Q)ln -sf libzuptsdk.so.2.0.0 $(DESTDIR)$(PREFIX)/lib/vaptvupt/libzuptsdk.so - $(Q)install -m 755 vendor/pqvaptvupt/libpqvaptvupt.so.0.6.0 $(DESTDIR)$(PREFIX)/lib/vaptvupt/libpqvaptvupt.so.0.6.0 - $(Q)ln -sf libpqvaptvupt.so.0.6.0 $(DESTDIR)$(PREFIX)/lib/vaptvupt/libpqvaptvupt.so.0 - $(Q)ln -sf libpqvaptvupt.so.0.6.0 $(DESTDIR)$(PREFIX)/lib/vaptvupt/libpqvaptvupt.so -endif - - @echo "Installed: $(DESTDIR)$(BINDIR)/$(TARGET) (legacy: $(DESTDIR)$(BINDIR)/$(LEGACY_LINK) -> $(TARGET))" + @echo "Installed: $(DESTDIR)$(BINDIR)/$(TARGET)" uninstall: - $(Q)rm -rf $(DESTDIR)$(PREFIX)/lib/vaptvupt - $(Q)rm -f $(DESTDIR)$(BINDIR)/$(TARGET) $(DESTDIR)$(BINDIR)/$(LEGACY_LINK) - $(Q)rm -f $(DESTDIR)$(MAN1DIR)/$(MANPAGE_GZ) $(DESTDIR)$(MAN1DIR)/$(LEGACY_LINK).1.gz - $(Q)rm -f $(DESTDIR)$(PREFIX)/share/bash-completion/completions/$(TARGET) \ - $(DESTDIR)$(PREFIX)/share/bash-completion/completions/$(LEGACY_LINK) - $(Q)rm -f $(DESTDIR)$(PREFIX)/share/zsh/site-functions/_zupt - $(Q)rm -f $(DESTDIR)$(PREFIX)/share/fish/vendor_completions.d/zupt.fish + $(Q)rm -f "$(DESTDIR)$(BINDIR)/$(TARGET)" + $(Q)rm -f "$(DESTDIR)$(MAN1DIR)/$(MANPAGE_GZ)" + $(Q)rm -f "$(DESTDIR)$(BASHCOMPDIR)/$(PROGRAM)" + $(Q)rm -f "$(DESTDIR)$(ZSHCOMPDIR)/_$(PROGRAM)" + $(Q)rm -f "$(DESTDIR)$(FISHCOMPDIR)/$(PROGRAM).fish" + $(Q)set -eu; for license_file in $(LICENSE_FILES); do \ + rm -f "$(DESTDIR)$(LICENSEDIR)/$${license_file##*/}"; \ + done + $(Q)for item in \ + "$(DESTDIR)$(BINDIR)/$(LEGACY_LINK):$(TARGET)" \ + "$(DESTDIR)$(MAN1DIR)/$(LEGACY_PROGRAM).1.gz:$(MANPAGE_GZ)" \ + "$(DESTDIR)$(BASHCOMPDIR)/$(LEGACY_PROGRAM):$(PROGRAM)" \ + "$(DESTDIR)$(ZSHCOMPDIR)/_$(LEGACY_PROGRAM):_$(PROGRAM)" \ + "$(DESTDIR)$(FISHCOMPDIR)/$(LEGACY_PROGRAM).fish:$(PROGRAM).fish"; do \ + path=$${item%:*}; expected=$${item##*:}; \ + if [ -L "$$path" ] && [ "$$(readlink "$$path")" = "$$expected" ]; then rm -f "$$path"; fi; \ + done # ═══════════════════════════════════════════════════════════════════ # DIST — reproducible source tarball for distro packaging @@ -358,96 +445,113 @@ uninstall: # the same input source tree. Properties: # # - Files sorted by name (stable order regardless of filesystem layout) -# - mtime fixed to SOURCE_DATE_EPOCH (or to the version-string-derived -# epoch when SOURCE_DATE_EPOCH is unset) +# - mtime fixed to SOURCE_DATE_EPOCH (`.source-date-epoch`, then HEAD fallback) # - uid/gid fixed to root (0/0) via --owner / --group # - gzip wrapped with --no-name (no embedded timestamp/filename) # - No binaries, no .o, no .so. Source only. # -# Used by AUR / Debian / Homebrew / RPM upstream packaging. -# Output: /tmp/zupt-VERSION.tar.gz so it doesn't pollute the source tree. +# The archive always represents the tree of committed HEAD, never ignored build +# output or uncommitted files. Archiving the tree object also avoids Git's +# commit-ID PAX header, so an export-ignored-only commit cannot change its bytes. +# Override DIST_TARBALL for a packaging work directory. +DIST_VERSION = $(shell sed -n 's/^\#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) +DIST_NAME = $(PROGRAM)-$(DIST_VERSION) +DIST_TARBALL ?= /tmp/$(DIST_NAME).tar.gz +SOURCE_DATE_EPOCH ?= $(shell epoch=$$(sed -n 's/^[[:space:]]*\([0-9][0-9]*\)[[:space:]]*$$/\1/p' .source-date-epoch 2>/dev/null | head -n 1); if test -n "$$epoch"; then printf '%s' "$$epoch"; else git log -1 --format=%ct HEAD 2>/dev/null; fi) +SOURCE_AUDIT ?= scripts/check-source-only.sh -DIST_VERSION = $(shell grep '^\#define ZUPT_VERSION_STRING' include/zupt.h | awk -F'"' '{print $$2}') -DIST_NAME = $(TARGET)-$(DIST_VERSION) -DIST_DIR = /tmp/$(DIST_NAME).distbuild -DIST_TARBALL = /tmp/$(DIST_NAME).tar.gz -SOURCE_DATE_EPOCH ?= 1747699200 # 2025-05-20 UTC — stable epoch for this release line +source-audit: + $(Q)test -f "$(SOURCE_AUDIT)" || { \ + echo "ERROR: source-only scanner not found: $(SOURCE_AUDIT)" >&2; \ + exit 1; \ + } + $(Q)bash "$(SOURCE_AUDIT)" -dist: clean - $(Q)rm -rf $(DIST_DIR) $(DIST_TARBALL) - $(Q)mkdir -p $(DIST_DIR)/$(DIST_NAME) - $(Q)git ls-files 2>/dev/null > $(DIST_DIR)/filelist.txt || \ - find . \( -type f -o -type l \) \! -path './.git/*' \! -path './*.o' \! -name '*.o' \! -name '$(TARGET)' \ - \! -name '$(LEGACY_LINK)' \ - \! -name 'zupt_asan' \! -name 'test_vectors' \! -name 'test_vaptvupt' \ - \! -name 'fuzz_decompress' \! -name 'fuzz_vv_decompress' \ - \! -path './.distbuild*' 2>/dev/null | sed 's|^\./||' | sort > $(DIST_DIR)/filelist.txt - $(Q)tar -cf - --files-from=$(DIST_DIR)/filelist.txt | tar -xf - -C $(DIST_DIR)/$(DIST_NAME) - $(Q)find $(DIST_DIR)/$(DIST_NAME) -exec touch -d "@$(SOURCE_DATE_EPOCH)" {} + - $(Q)tar --sort=name \ - --owner=0 --group=0 --numeric-owner \ - --mtime="@$(SOURCE_DATE_EPOCH)" \ - -C $(DIST_DIR) -cf - $(DIST_NAME) \ - | gzip -9n > $(DIST_TARBALL) - $(Q)rm -rf $(DIST_DIR) - @echo "" - @echo " Reproducible source tarball:" - @echo " $(DIST_TARBALL)" - @echo " sha256: `sha256sum $(DIST_TARBALL) | awk '{print $$1}'`" - @echo " bytes: `wc -c < $(DIST_TARBALL)`" - @echo " Reproducibility: re-run 'make dist' on the same tree, sha256 MUST match." +dist: + $(Q)set -eu; \ + export LC_ALL=C; \ + umask 022; \ + unset TAR_OPTIONS; \ + test -n "$(DIST_VERSION)" || { echo "ERROR: cannot determine source version" >&2; exit 1; }; \ + test -n "$(SOURCE_DATE_EPOCH)" || { echo "ERROR: SOURCE_DATE_EPOCH is empty" >&2; exit 1; }; \ + case "$(SOURCE_DATE_EPOCH)" in *[!0-9]*) echo "ERROR: SOURCE_DATE_EPOCH must be an integer" >&2; exit 1;; esac; \ + test -f "$(SOURCE_AUDIT)" || { echo "ERROR: source-only scanner not found: $(SOURCE_AUDIT)" >&2; exit 1; }; \ + git rev-parse --verify 'HEAD^{commit}' >/dev/null; \ + git rev-parse --verify 'HEAD^{tree}' >/dev/null; \ + tmp=$$(mktemp -d "$${TMPDIR:-/tmp}/zupt-dist.XXXXXXXX"); \ + trap 'rm -rf -- "$$tmp"' EXIT HUP INT TERM; \ + git archive --format=tar --mtime="@$(SOURCE_DATE_EPOCH)" \ + --prefix="$(DIST_NAME)/" 'HEAD^{tree}' | \ + $(GZIP) $(GZIPFLAGS) > "$$tmp/$(DIST_NAME).tar.gz"; \ + bash "$(SOURCE_AUDIT)" --archive "$$tmp/$(DIST_NAME).tar.gz"; \ + mkdir -p "$$(dirname "$(DIST_TARBALL)")"; \ + mv -f "$$tmp/$(DIST_NAME).tar.gz" "$(DIST_TARBALL)"; \ + trap - EXIT HUP INT TERM; \ + rm -rf -- "$$tmp"; \ + if command -v sha256sum >/dev/null 2>&1; then \ + digest=$$(sha256sum "$(DIST_TARBALL)" | awk '{print $$1}'); \ + elif command -v shasum >/dev/null 2>&1; then \ + digest=$$(shasum -a 256 "$(DIST_TARBALL)" | awk '{print $$1}'); \ + else \ + echo "ERROR: sha256sum or shasum is required" >&2; exit 1; \ + fi; \ + bytes=$$(wc -c < "$(DIST_TARBALL)"); \ + printf '\n Reproducible source tarball:\n %s\n sha256: %s\n bytes: %s\n' \ + "$(DIST_TARBALL)" "$$digest" "$$bytes" # ═══════════════════════════════════════════════════════════════════ # CLEAN # ═══════════════════════════════════════════════════════════════════ clean: - $(Q)rm -f $(TARGET) $(MANPAGE_GZ) zupt_asan test_vectors test_vaptvupt \ - fuzz_decompress fuzz_vv_decompress jasmin/*.o src/*.o + $(Q)rm -f $(PROGRAM) $(PROGRAM).exe $(LEGACY_PROGRAM) $(LEGACY_PROGRAM).exe $(MANPAGE_GZ) \ + zupt_asan \ + test_vectors test_f06 test_vaptvupt \ + fuzz_decompress fuzz_vv_decompress tests/fuzz_format \ + *.gcda *.gcno *.profraw *.profdata \ + src/*.o src/*.gcda src/*.gcno jasmin/*.o jasmin/*.gcda jasmin/*.gcno \ + tests/*.gcda tests/*.gcno sdk/*.gcda sdk/*.gcno + $(Q)for link in $(LEGACY_PROGRAM) $(LEGACY_PROGRAM).exe; do \ + if [ -L "$$link" ]; then \ + case "$$(readlink "$$link")" in $(PROGRAM)|$(PROGRAM).exe) rm -f "$$link" ;; esac; \ + fi; \ + done + $(Q)rm -rf sdk/build build build_obj coverage # ═══════════════════════════════════════════════════════════════════ # TEST TARGETS # ═══════════════════════════════════════════════════════════════════ -test: $(TARGET) - $(Q)sh tests/run_quick.sh - $(Q)bash tests/test_sdk.sh - $(Q)bash tests/test_audit.sh - $(Q)bash tests/test_dedup_props.sh - $(Q)bash tests/test_path_traversal.sh - $(Q)bash tests/test_arg_order.sh - $(Q)bash tests/test_block_swap.sh - $(Q)bash tests/test_f08_topmac.sh - $(Q)bash tests/test_f09_preface.sh - $(Q)bash tests/test_f10_kdf_default.sh - $(Q)bash tests/test_f11_authfail_message.sh - $(Q)bash tests/test_f12_comment.sh - $(Q)bash tests/test_gui_branding.sh - $(Q)bash tests/test_help_consistency.sh - $(Q)bash tests/test_static_analysis.sh - $(Q)bash tests/test_vv_decode_slack.sh - $(Q)bash tests/test_sha256_shani.sh - $(Q)bash tests/test_hmac_incremental.sh - $(Q)bash tests/test_kdf_transparency.sh +test: check + +test-all: check + $(Q)bash tests/regression.sh ./$(TARGET) + $(Q)sh tests/test_threaded.sh ./$(TARGET) + $(Q)sh tests/test_pq.sh ./$(TARGET) + $(Q)bash tests/test_dedup_props.sh ./$(TARGET) $(Q)bash tests/test_ct_timing.sh $(Q)bash tests/test_codec_exact_size.sh + $(Q)bash tests/test_mlkem_fips203.sh + $(Q)bash tests/test_sdk.sh $(Q)bash tests/test_pqbox.sh - $(Q)bash tests/test_packaging_syntax.sh - $(Q)bash tests/test_completions_manpage.sh - $(Q)bash tests/test_dist_reproducible.sh + $(Q)bash tests/test_audit.sh + $(Q)bash tests/test_kdf_transparency.sh -test-all: $(TARGET) test-vectors test-vv - @echo "===============================================" - @sh tests/regression.sh 2>&1 | tail -3 - @echo "" - @sh tests/test_threaded.sh 2>&1 | tail -3 - @echo "" - @sh tests/test_pq.sh ./zupt 2>&1 | tail -3 - @echo "" - @./test_vectors 2>&1 | tail -2 - @echo "" - @./test_vaptvupt 2>&1 | tail -2 - @echo "===============================================" +# Release-only gates need a committed Git checkout and packaging metadata. +# Keep them out of downstream %check, which intentionally has no dist rebuild. +release-check: test-all audit-licenses + $(Q)$(MAKE) sdk-test + $(Q)bash tests/test_static_analysis.sh + $(Q)bash tests/test_packaging_syntax.sh + $(Q)bash scripts/test-installed-zupt.sh ./$(TARGET) + $(Q)if [ "$(WITH_SDK)" = 1 ]; then \ + bash tests/test_audit_flake.sh "$${AUDIT_FLAKE_RUNS:-3}"; \ + else \ + echo "SKIP: audit flake stress needs WITH_SDK=1 and system libvuptsdk"; \ + fi + $(Q)$(MAKE) clean + $(Q)bash "$(SOURCE_AUDIT)" + $(Q)bash tests/test_dist_reproducible.sh # ═══════════════════════════════════════════════════════════════════ # CHECK — distro-friendly safe subset @@ -462,70 +566,105 @@ test-all: $(TARGET) test-vectors test-vv # (no python3 PyYAML, no ruby, no dpkg-parsechangelog) # - Doesn't depend on multi-threading that's flaky under emulation # (skips test_threaded.sh and test_pq.sh's MT subset) -# - Covers the security-critical regressions: F-06 HMAC, F-08 AIT, -# F-09 byte-level integrity, F-10 KDF default, F-11 auth-fail -# wording, F-12 comments +# - Covers the source-only CLI, archive safety, HMAC/integrity regressions, +# codec checks and cryptographic primitive vectors # - Verifies cryptographic primitives against NIST/RFC vectors # # This is the recommended target for OBS %check sections. -check: $(TARGET) test-vectors - $(Q)sh tests/run_quick.sh - $(Q)bash tests/test_audit.sh - $(Q)bash tests/test_path_traversal.sh - $(Q)bash tests/test_arg_order.sh - $(Q)bash tests/test_block_swap.sh - $(Q)bash tests/test_f08_topmac.sh - $(Q)bash tests/test_f10_kdf_default.sh - $(Q)bash tests/test_f11_authfail_message.sh - $(Q)bash tests/test_f12_comment.sh - $(Q)bash tests/test_gui_branding.sh - $(Q)bash tests/test_help_consistency.sh - $(Q)bash tests/test_static_analysis.sh - $(Q)bash tests/test_vv_decode_slack.sh +check: $(TARGET) test-vectors test-f06 test-vv + $(Q)sh tests/run_quick.sh ./$(TARGET) + $(Q)bash tests/test_path_traversal.sh ./$(TARGET) + $(Q)bash tests/test_arg_order.sh ./$(TARGET) + $(Q)bash tests/test_password_sources.sh ./$(TARGET) + $(Q)bash tests/test_password_prompt_signal.sh ./$(TARGET) + $(Q)bash tests/test_key_files.sh ./$(TARGET) + $(Q)ZUPT_BIN="$(CURDIR)/$(TARGET)" bash tests/test_f08_topmac.sh + $(Q)ZUPT_BIN="$(CURDIR)/$(TARGET)" bash tests/test_f09_preface.sh + $(Q)ZUPT_BIN="$(CURDIR)/$(TARGET)" bash tests/test_f10_kdf_default.sh + $(Q)ZUPT_BIN="$(CURDIR)/$(TARGET)" bash tests/test_f11_authfail_message.sh + $(Q)ZUPT_BIN="$(CURDIR)/$(TARGET)" bash tests/test_f12_comment.sh + $(Q)bash tests/test_atomic_archive_output.sh ./$(TARGET) + $(Q)bash tests/test_legacy_disk_5_2_1.sh ./$(TARGET) + $(Q)bash tests/test_disk_device_capacity.sh ./$(TARGET) + $(Q)bash tests/test_block_swap.sh ./$(TARGET) + $(Q)bash tests/test_block_type_confusion.sh ./$(TARGET) + $(Q)bash tests/test_authenticated_dedup_reorder.sh ./$(TARGET) + $(Q)bash tests/test_format_little_endian.sh ./$(TARGET) + $(Q)bash tests/test_dedup_nonce.sh ./$(TARGET) + $(Q)bash tests/test_gui_branding.sh ./$(TARGET) + $(Q)bash tests/test_help_consistency.sh ./$(TARGET) + $(Q)bash tests/test_benchmark_temp_safety.sh ./$(TARGET) + $(Q)bash tests/test_vv_decode_slack.sh ./$(TARGET) $(Q)bash tests/test_sha256_shani.sh $(Q)bash tests/test_hmac_incremental.sh - $(Q)bash tests/test_kdf_transparency.sh - $(Q)bash tests/test_ct_timing.sh - $(Q)bash tests/test_codec_exact_size.sh - $(Q)bash tests/test_pqbox.sh + $(Q)bash tests/test_completions_manpage.sh + $(Q)bash tests/test_source_only.sh $(Q)./test_vectors @echo "" @echo " ═════════════════════════════════════════" - @echo " All distro-safe checks passed." + @echo " All executed distro-safe checks passed (see SKIP lines above)." @echo " ═════════════════════════════════════════" -test-vectors: tests/test_vectors.c $(HEADERS) - $(Q)$(CC) -O2 -std=c11 -Iinclude -Isrc $(SHANI_FLAGS) $(LDFLAGS) tests/test_vectors.c \ - src/zupt_sha256.c src/zupt_sha256_shani.c src/zupt_crypto.c src/zupt_aes256.c src/zupt_xxh.c \ - src/zupt_keccak.c src/zupt_x25519.c src/zupt_mlkem.c src/zupt_cpuid.c \ - src/zupt_mlock.c \ - -o test_vectors $(LDLIBS) +TEST_CRYPTO_SOURCES = src/zupt_sha256.c src/zupt_sha256_shani.c \ + src/zupt_crypto.c src/zupt_aes256.c src/zupt_xxh.c src/zupt_keccak.c \ + src/zupt_x25519.c src/zupt_mlkem.c src/zupt_cpuid.c src/zupt_mlock.c +TEST_CRYPTO_OBJS = $(TEST_CRYPTO_SOURCES:.c=.o) -# F-06 regression — HMAC accept-on-disjoint-bits (Zupt 2.2.5). +test-vectors: tests/test_vectors.c $(HEADERS) $(TEST_CRYPTO_OBJS) $(JAZZ_O) + $(Q)$(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(FEATURE_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) $(LDFLAGS) $(PROJECT_LDFLAGS) \ + tests/test_vectors.c $(TEST_CRYPTO_OBJS) $(JAZZ_O) \ + -o test_vectors $(FEATURE_LDLIBS) $(PROJECT_LDLIBS) $(LDLIBS) + +# F-06 regression — HMAC accept-on-disjoint-bits (ZUPT 2.2.5). # Inherits $(CFLAGS) so ZUPT_USE_JASMIN is defined on x86_64 (exercising # the original buggy path). Links the same crypto modules as test-vectors # plus the Jasmin .o files when available. -test-f06: tests/test_f06_hmac.c $(HEADERS) $(JAZZ_O) - $(Q)$(CC) $(CFLAGS) $(SHANI_FLAGS) $(LDFLAGS) tests/test_f06_hmac.c \ - src/zupt_sha256.c src/zupt_sha256_shani.c src/zupt_crypto.c src/zupt_aes256.c src/zupt_xxh.c \ - src/zupt_keccak.c src/zupt_cpuid.c src/zupt_mlock.c \ - src/zupt_x25519.c src/zupt_mlkem.c $(JAZZ_O) \ - -o test_f06 $(LDLIBS) +test-f06: tests/test_f06_hmac.c $(HEADERS) $(TEST_CRYPTO_OBJS) $(JAZZ_O) + $(Q)$(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(FEATURE_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) $(LDFLAGS) $(PROJECT_LDFLAGS) \ + tests/test_f06_hmac.c $(TEST_CRYPTO_OBJS) $(JAZZ_O) -o test_f06 \ + $(FEATURE_LDLIBS) $(PROJECT_LDLIBS) $(LDLIBS) $(Q)./test_f06 # VAPTVUPT: VaptVupt codec unit tests -test-vv: tests/test_vaptvupt.c $(HEADERS) - $(Q)$(CC) $(CFLAGS) $(VV_SIMD_FLAGS) $(LDFLAGS) tests/test_vaptvupt.c \ - src/vv_encoder.c src/vv_decoder.c src/vv_ans.c src/vv_huffman.c \ - src/vv_simd.c src/vv_xxh64.c src/vaptvupt_api.c src/zupt_xxh.c src/zupt_cpuid.c \ - -o test_vaptvupt $(LDLIBS) +TEST_VV_OBJS = $(VV_SIMD_OBJS) $(VV_PLAIN_OBJS) src/zupt_xxh.o src/zupt_cpuid.o +test-vv: tests/test_vaptvupt.c $(HEADERS) $(TEST_VV_OBJS) + $(Q)$(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(CFLAGS) $(PROJECT_CFLAGS) \ + $(LDFLAGS) $(PROJECT_LDFLAGS) tests/test_vaptvupt.c $(TEST_VV_OBJS) \ + -o test_vaptvupt $(PROJECT_LDLIBS) $(LDLIBS) $(Q)./test_vaptvupt -test-asan: $(SOURCES) $(HEADERS) $(JAZZ_O) - $(Q)$(CC) $(CFLAGS) -fsanitize=address,undefined -g -O1 \ - $(VV_SIMD_FLAGS) $(SHANI_FLAGS) $(LDFLAGS) \ - $(SOURCES) $(JAZZ_O) -o zupt_asan $(LDLIBS) +ASAN_BUILD_DIR = build/asan +ASAN_CFLAGS ?= -O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined +ASAN_LDFLAGS ?= -fsanitize=address,undefined +ASAN_OBJS = $(patsubst src/%.c,$(ASAN_BUILD_DIR)/%.o,$(SOURCES)) + +$(ASAN_BUILD_DIR): + $(Q)mkdir -p "$@" + +$(ASAN_BUILD_DIR)/zupt_sha256_shani.o: src/zupt_sha256_shani.c $(HEADERS) | $(ASAN_BUILD_DIR) + $(Q)$(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(FEATURE_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) $(ASAN_CFLAGS) $(SHANI_FLAGS) -c -o $@ $< + +$(ASAN_BUILD_DIR)/vv_%.o: src/vv_%.c $(HEADERS) | $(ASAN_BUILD_DIR) + $(Q)$(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(FEATURE_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) $(ASAN_CFLAGS) -c -o $@ $< + +$(ASAN_BUILD_DIR)/vaptvupt_api.o: src/vaptvupt_api.c $(HEADERS) | $(ASAN_BUILD_DIR) + $(Q)$(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(FEATURE_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) $(ASAN_CFLAGS) -c -o $@ $< + +$(ASAN_BUILD_DIR)/%.o: src/%.c $(HEADERS) | $(ASAN_BUILD_DIR) + $(Q)$(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(FEATURE_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) $(ASAN_CFLAGS) -c -o $@ $< + +test-asan: $(ASAN_OBJS) $(JAZZ_O) + $(Q)$(CC) $(CFLAGS) $(PROJECT_CFLAGS) $(ASAN_CFLAGS) \ + $(LDFLAGS) $(PROJECT_LDFLAGS) $(ASAN_LDFLAGS) \ + $(ASAN_OBJS) $(JAZZ_O) -o zupt_asan \ + $(FEATURE_LDLIBS) $(PROJECT_LDLIBS) $(LDLIBS) @echo "ASAN build: ./zupt_asan" # Build the format-parser fuzz harness. Runs against ./zupt_asan to catch @@ -533,77 +672,123 @@ test-asan: $(SOURCES) $(HEADERS) $(JAZZ_O) fuzz-format: tests/fuzz_format tests/fuzz_format: tests/fuzz_format.c - $(Q)$(CC) -std=c11 -O2 -Wall tests/fuzz_format.c -o tests/fuzz_format + $(Q)$(CC) $(CPPFLAGS) $(CFLAGS) $(PROJECT_CFLAGS) \ + $(LDFLAGS) $(PROJECT_LDFLAGS) tests/fuzz_format.c \ + -o tests/fuzz_format $(PROJECT_LDLIBS) $(LDLIBS) @echo "Format fuzz harness: ./tests/fuzz_format" # Run 5000 iterations of mutation fuzz against the ASAN binary. # Any crash or sanitizer error fails CI. fuzz-format-run: tests/fuzz_format test-asan $(TARGET) - @echo "Building seed archive..." - @echo "fuzz seed file" > /tmp/_zupt_fuzz_input.txt - @./zupt c /tmp/_zupt_fuzz_seed.zupt /tmp/_zupt_fuzz_input.txt > /dev/null 2>&1 - @ASAN_OPTIONS=detect_leaks=0:abort_on_error=1 \ - ./tests/fuzz_format 1000 ./zupt_asan /tmp/_zupt_fuzz_seed.zupt - @rm -f /tmp/_zupt_fuzz_input.txt /tmp/_zupt_fuzz_seed.zupt - @echo " Format fuzz: 1000 iters under ASAN/UBSAN — no crashes." + $(Q)set -eu; \ + tmp=$$(mktemp -d "$${TMPDIR:-/tmp}/zupt-fuzz.XXXXXXXX"); \ + trap 'rm -rf -- "$$tmp"' EXIT HUP INT TERM; \ + printf '%s\n' 'fuzz seed file' > "$$tmp/input.txt"; \ + ./$(TARGET) c "$$tmp/seed.zupt" "$$tmp/input.txt" >/dev/null; \ + ASAN_OPTIONS=detect_leaks=0:abort_on_error=1 \ + ./tests/fuzz_format 1000 ./zupt_asan "$$tmp/seed.zupt" + @echo " Format fuzz: 1000 iterations under ASAN/UBSAN — no crashes." -# Runs the test suites against the ASAN-instrumented binary. -# Catches use-after-free, OOB, leaks, signed-overflow that aren't visible -# in the optimized release build. +# Runs a round-trip smoke test against the ASAN/UBSAN/LSAN-instrumented binary. +# The exhaustive codec exact-size sanitizer loop remains part of test-all. test-asan-run: test-asan - @echo "Running test suites under ASAN/UBSAN..." - @ZUPT_BIN_OVERRIDE=$$(realpath ./zupt_asan); \ - cp $$ZUPT_BIN_OVERRIDE zupt.bak 2>/dev/null; \ - ln -sf zupt_asan zupt_asan_run; \ - mv zupt zupt.real; \ - ln -sf zupt_asan zupt; \ - ASAN_OPTIONS=detect_leaks=0:abort_on_error=1 sh tests/run_quick.sh; \ - rc1=$$?; \ - ASAN_OPTIONS=detect_leaks=0:abort_on_error=1 bash tests/test_sdk.sh; \ - rc2=$$?; \ - ASAN_OPTIONS=detect_leaks=0:abort_on_error=1 bash tests/test_audit.sh; \ - rc3=$$?; \ - rm zupt zupt_asan_run; mv zupt.real zupt; \ - if [ $$rc1 -eq 0 ] && [ $$rc2 -eq 0 ] && [ $$rc3 -eq 0 ]; then \ - echo ""; echo " ASAN/UBSAN: all tests pass cleanly."; \ - else \ - echo ""; echo " ASAN/UBSAN: failures detected (run codes $$rc1 $$rc2 $$rc3)."; exit 1; \ - fi + $(Q)set -eu; \ + tmp=$$(mktemp -d "$${TMPDIR:-/tmp}/zupt-asan.XXXXXXXX"); \ + trap 'rm -rf -- "$$tmp"' EXIT HUP INT TERM; \ + export ASAN_OPTIONS=detect_leaks=1:abort_on_error=1; \ + export UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1; \ + printf '%s\n' 'sanitizer round-trip' > "$$tmp/input.txt"; \ + ./zupt_asan c "$$tmp/archive.zupt" "$$tmp/input.txt" >/dev/null; \ + ./zupt_asan t "$$tmp/archive.zupt" >/dev/null; \ + mkdir "$$tmp/out"; \ + (cd "$$tmp/out" && "$(CURDIR)/zupt_asan" x "$$tmp/archive.zupt" >/dev/null); \ + extracted=$$(find "$$tmp/out" -type f -print -quit); \ + test -n "$$extracted"; \ + cmp "$$tmp/input.txt" "$$extracted"; \ + dd if=/dev/urandom of="$$tmp/block" bs=65536 count=1 2>/dev/null; \ + cp "$$tmp/block" "$$tmp/disk.img"; \ + dd if="$$tmp/block" of="$$tmp/disk.img" bs=65536 seek=1 conv=notrunc 2>/dev/null; \ + printf '%s\n' 'sanitizer-disk-password' > "$$tmp/password"; \ + ./zupt_asan disk backup --dedup -b 65536 --pass-file "$$tmp/password" -s \ + "$$tmp/disk.zupt" "$$tmp/disk.img" >/dev/null; \ + ./zupt_asan t --pass-file "$$tmp/password" "$$tmp/disk.zupt" >/dev/null; \ + ./zupt_asan disk restore --pass-file "$$tmp/password" \ + "$$tmp/disk.zupt" "$$tmp/restored.img" >/dev/null; \ + cmp "$$tmp/disk.img" "$$tmp/restored.img"; \ + ./zupt_asan --help >/dev/null; \ + ./zupt_asan --version >/dev/null + @echo " ASAN/UBSAN: source-only smoke test passed." -# AFL++ fuzzing harnesses (requires afl-clang-fast) -fuzz-build: - @echo "Building AFL++ fuzzing harnesses..." - $(Q)afl-clang-fast -fsanitize=address,undefined -g -O1 -std=c11 \ - -Iinclude -Isrc $(VV_SIMD_FLAGS) $(LDFLAGS) \ - $(filter-out src/zupt_main.c,$(SOURCES)) tests/fuzz_decompress.c \ - -o fuzz_decompress $(LDLIBS) - $(Q)afl-clang-fast -fsanitize=address,undefined -g -O1 -std=c11 \ - -Iinclude -Isrc $(VV_SIMD_FLAGS) $(LDFLAGS) \ - tests/fuzz_vv_decompress.c \ - src/vv_encoder.c src/vv_decoder.c src/vv_ans.c src/vv_huffman.c \ - src/vv_simd.c src/zupt_xxh.c src/zupt_cpuid.c \ - -o fuzz_vv_decompress $(LDLIBS) +# AFL++ fuzzing harnesses (requires afl-clang-fast). Compile every source with +# instrumentation while retaining translation-unit-local ISA flags. +AFL_CC ?= afl-clang-fast +FUZZ_BUILD_DIR = build/fuzz +FUZZ_SOURCES = $(filter-out src/zupt_main.c,$(SOURCES)) +FUZZ_OBJS = $(patsubst src/%.c,$(FUZZ_BUILD_DIR)/%.o,$(FUZZ_SOURCES)) +FUZZ_VV_OBJS = $(addprefix $(FUZZ_BUILD_DIR)/,vv_encoder.o vv_decoder.o \ + vv_ans.o vv_huffman.o vv_simd.o zupt_xxh.o zupt_cpuid.o) + +$(FUZZ_BUILD_DIR): + $(Q)mkdir -p "$@" + +$(FUZZ_BUILD_DIR)/zupt_sha256_shani.o: src/zupt_sha256_shani.c $(HEADERS) | $(FUZZ_BUILD_DIR) + $(Q)$(AFL_CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(FEATURE_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) $(ASAN_CFLAGS) $(SHANI_FLAGS) -c -o $@ $< + +$(FUZZ_BUILD_DIR)/vv_%.o: src/vv_%.c $(HEADERS) | $(FUZZ_BUILD_DIR) + $(Q)$(AFL_CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(FEATURE_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) $(ASAN_CFLAGS) $(VV_WARNING_FLAGS) \ + $(if $(filter $(FUZZ_BUILD_DIR)/vv_decoder.o,$@),$(VV_DECODER_WARNING_FLAGS)) -c -o $@ $< + +$(FUZZ_BUILD_DIR)/vaptvupt_api.o: src/vaptvupt_api.c $(HEADERS) | $(FUZZ_BUILD_DIR) + $(Q)$(AFL_CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(FEATURE_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) $(ASAN_CFLAGS) $(VV_WARNING_FLAGS) -c -o $@ $< + +$(FUZZ_BUILD_DIR)/%.o: src/%.c $(HEADERS) | $(FUZZ_BUILD_DIR) + $(Q)$(AFL_CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(FEATURE_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) $(ASAN_CFLAGS) -c -o $@ $< + +fuzz_decompress: tests/fuzz_decompress.c $(FUZZ_OBJS) + $(Q)$(AFL_CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(FEATURE_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) $(ASAN_CFLAGS) \ + $(LDFLAGS) $(PROJECT_LDFLAGS) $(ASAN_LDFLAGS) \ + tests/fuzz_decompress.c $(FUZZ_OBJS) -o $@ \ + $(FEATURE_LDLIBS) $(PROJECT_LDLIBS) $(LDLIBS) + +fuzz_vv_decompress: tests/fuzz_vv_decompress.c $(FUZZ_VV_OBJS) + $(Q)$(AFL_CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) $(ASAN_CFLAGS) \ + $(LDFLAGS) $(PROJECT_LDFLAGS) $(ASAN_LDFLAGS) \ + tests/fuzz_vv_decompress.c $(FUZZ_VV_OBJS) -o $@ \ + $(PROJECT_LDLIBS) $(LDLIBS) + +fuzz-build: fuzz_decompress fuzz_vv_decompress @echo "Fuzz harnesses built. Run:" @echo " afl-fuzz -i corpus -o findings -- ./fuzz_decompress" @echo " afl-fuzz -i corpus_vv -o findings_vv -- ./fuzz_vv_decompress" help: - @echo "Zupt v$(shell grep '^#define ZUPT_VERSION_STRING' include/zupt.h | awk -F'\"' '{print $$2}') build targets:" + @echo "ZUPT v$(DIST_VERSION) build targets:" @echo " make Build zupt binary" @echo " make V=1 Build with verbose output" - @echo " make test Quick test" - @echo " make test-all Full test suite (regression + threaded + PQ + vectors + VV)" + @echo " make check Distro-safe source-only test suite" + @echo " make test-all Complete runtime suite; unavailable integrations SKIP" + @echo " make release-check Runtime, static, packaging, source and dist gates" @echo " make test-vv VaptVupt codec unit tests" @echo " make test-asan Build with AddressSanitizer" @echo " make fuzz-build Build AFL++ fuzzing harnesses" + @echo " make dist Reproducible, audited source archive" + @echo " make source-audit Audit tracked, worktree and HEAD archive content" @echo " make install Install to $(PREFIX)" @echo " make uninstall Remove from $(PREFIX)" @echo " make clean Remove build artifacts" @echo "" - @echo "Architecture: $(ARCH)" - @echo " x86_64: Jasmin CT crypto + AVX2 SIMD decode" - @echo " aarch64: C crypto fallback + NEON SIMD decode" - @echo " other: C crypto fallback + scalar decode" + @echo "Compiler target: $(TARGET_MACHINE)" + @echo "Optional integrations (off by default):" + @echo " WITH_SDK=1 system libvuptsdk via pkg-config/overrides" + @echo " WITH_PQBOX=1 system libpqvaptvupt via pkg-config/overrides" + @echo " WITH_JASMIN=1 optional textual assembly on x86_64" + @echo " INSTALL_LEGACY_ALIAS=1 installs opt-in 'vaptvupt' compatibility links" # ───────────────────────────────────────────────────────────────────── # SDK targets — see sdk/Makefile.sdk diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..af4ffc5 --- /dev/null +++ b/NOTICE @@ -0,0 +1,39 @@ +ZUPT notices +============ + +Copyright remains with the holders identified by per-file notices and repository +history. + +Public source-license scopes: + +- application, GUI, cryptographic tool, build and documentation code: + AGPL-3.0-or-later; +- integrated VaptVupt compression codec files identified in LICENSE: + GPL-3.0-or-later. +- xxHash-derived routines in `src/zupt_xxh.c` and `src/vv_xxh64.c`: + BSD-2-Clause in addition to their application/codec license. +- pq-crystals/kyber-derived portions in `src/zupt_mlkem.c`: + CC0-1.0 in addition to the application license. +- curve25519-donna-derived portions in `src/zupt_x25519.c`: + BSD-3-Clause in addition to the application license; Copyright 2008, + Google Inc. +- the x86 BCJ state machine in `src/vv_bcj.c` is adapted from Igor Pavlov's + public-domain LZMA SDK source. +- the SHA-NI compression path in `src/zupt_sha256_shani.c` is adapted from + Jeffrey Walton's public-domain SHA-Intrinsics reference. + +The corresponding unmodified texts are LICENSE-AGPL-3.0, +LICENSE-GPL-3.0, LICENSE-BSD-2-Clause, LICENSE-BSD-3-Clause, and +LICENSE-CC0-1.0. Preserve THIRD-PARTY-NOTICES.md and all per-file SPDX and +copyright notices when redistributing the source. + +Published historical revisions contain MIT notices for some first-party +application and GUI material. Those historical permissions remain attached to +the exact material distributed under them; see the 5.2.2 licensing erratum in +CHANGELOG.md. The current source scopes above do not revoke an earlier grant. + +LICENSE-COMMERCIAL describes a possible separately executed commercial +agreement for controlled first-party rights. It grants no additional permission +by itself and does not alter the public licenses. + +Commercial licensing contact: sac@securityops.co diff --git a/README.md b/README.md index 865df6e..905bbad 100644 --- a/README.md +++ b/README.md @@ -1,678 +1,587 @@ - - +# ZUPT 5.2.8 -# VaptVupt +ZUPT is a command-line backup archiver written in C11. It combines the +bundled VaptVupt compression codec with authenticated AES-256-CTR + +HMAC-SHA256 encryption, native ML-KEM-768/X25519 hybrid encryption, archive +integrity checks, multithreaded operation, and a Python/Qt graphical frontend. -Backup compression with hardware-adaptive codec selection, AES-256 -authenticated encryption, post-quantum key encapsulation, and full-disk -backup. Pure C11, ~13,000 lines. Builds and runs on x86_64, aarch64, -armhf, ppc64le, s390x, and riscv64. +Version 5.2.8 closes three CodeQL High path-race findings: SDK key copies now +publish atomically through an already-open private object, POSIX disk restore +classifies and retains the descriptor it actually opened, and benchmark cleanup +traverses only pinned descriptors or handles without following links or Windows +reparse points. It also makes the raw-C1 scanner fixture explicitly skip a +filesystem that rejects creation with `EILSEQ`, normalizes Bash 3.2 signed-byte +diagnostics, and brings `sdk-test` into the +release and hosted Linux gates. Windows password prompts now reject redirected +input before entering `_getch` and treat console EOF as an error; its key-file +regression validates the protected current-user-only DACL rather than an MSYS +POSIX-mode projection. The C/C++ default-branch scan run `33452563116` of +commit `7a8e5c5` completed successfully after the follow-up changed the new SDK +regression to no-follow descriptors plus `fstat` and descriptor reads. Alerts +#5 through #10 are fixed, and the authenticated code-scanning API reported zero +open alerts. Final release-commit CodeQL run `33456049125` also completed +successfully, with the API still reporting zero open alerts. These corrections +do not change archive format v1.6, +cryptography, the bundled codec release, or the SDK ABI. -License: AGPL-3.0-or-later (dual-licensed AGPL + commercial). +The predecessor `v5.2.7` tag is immutable and was not promoted. Exact-tag run +`33445470664` concluded `cancelled` at `2026-08-31T23:11:19Z`, with 13 +successful jobs, a macOS raw-C1/EILSEQ fixture failure, and a cancelled Windows +job. The hosted Windows job stalled in `make check`; a MinGW/Wine reproduction +attributed the stall to a non-console password prompt entering `_getch`. No +v5.2.7 evidence transfers automatically to v5.2.8. -> **Renamed from "Zupt" in v3.0.0** because of a prior INPI Brasil -> trademark registration on the name "Zupt" for unrelated software. -> The `.zupt` archive extension and `ZUPT` header magic bytes are -> unchanged — v2.x and v3.0.0 archives remain compatible. The `zupt` -> command is preserved as a symlink to `vaptvupt` for one major version -> cycle. +Manual pre-tag run `33452602634` then completed 14 of 15 jobs successfully at +`7a8e5c5`, including the native macOS DMG gate and the Windows build and full +distribution checks. Its Windows smoke test failed only when the old MSYS +`grep` matched a literal non-BMP filename after the product had already +compressed and verified all five files. MinGW/Wine reproduction confirmed the +exact `F0 9F 98 80` UTF-8 bytes in ZUPT's redirected listing. The corrected +gate creates that name from byte escapes, validates Latin-1, BMP, and non-BMP +listing bytes with Python, and requires extraction plus a full tree diff. The +failed run is diagnostic evidence, not release-candidate approval. -## What's new in 4.1.0 +The immutable `v5.2.8` candidate at commit +`ebb9ab3aa1d42c50030ca02883f6162dc4771fe1` subsequently passed all 15 jobs in +manually dispatched exact-tag run `33456209269`. That run includes the pinned +local OBS source-service chain, reproducible source checks, GCC/Clang, analyzers, +sanitizers, DEB/RPM/SRPM and portable-package gates, the native Windows ZIP +round trip, and the mounted macOS arm64 DMG test. Corrected promotion run +`33457868306` validated and published exactly 13 assets. The canonical source +archive is 798296 bytes with SHA-256 +`378b9506211545b9594cf0d38ac8955d9b1cac34eb6b379ae0ec26b84edb65f7`. -- **Source-only tree.** The prebuilt vendored libraries `libzuptsdk.so` - and `libpqvaptvupt.so` have been removed. The default `make` needs only - a C compiler and make (plus libm/pthread) — no external crypto library — - and installs no `.so`. -- **Native `--pq` is the default post-quantum mode.** ML-KEM-768 + X25519 - hybrid KEM, in-tree C implementation, available in the default build. -- **SDK-backed modes are optional.** `--pq-sdk`, `--pq-box`, and the - Argon2id KDF are only available in an upstream `make WITH_SDK=1` build - linked against the separately distributed `libzuptsdk`/`libpqvaptvupt`. -- **Wire/on-disk format is v1.6, unchanged.** Archives created by 4.0.0 - are read and written identically. +Version 5.2.2 restored the original ZUPT product name and the `zupt` command. +The `.zupt` archive extension, format v1.6, magic bytes, codec identifiers, and +SDK ABI remain unchanged. An optional `vaptvupt` command alias may be provided +for scripts written against versions 3.0.0 through 5.2.1. -> **F-16 (data loss):** archives created by **≤ 3.8.0** at `-l 8`/`-l 9` -> whose inputs included x86/ELF/PE executables may be **undecodable by any -> version** (write-time defect in the old in-tree BCJ encoder). Re-create -> such archives with 4.1.0 and verify extraction before deleting source -> data. Details in [CHANGELOG.md](CHANGELOG.md). +## Corrective changes in 5.2.8 -Binaries for the CLI (4.1.0) and GUI (1.3.0) are on the -[release page](https://git.securityops.co/cristiancmoises/vaptvupt/releases/tag/v4.1.0). +SDK key saves use atomic descriptor/handle-backed publication and preserve the +requested private/public modes without reopening the destination. Disk restore +opens a POSIX target once before its type, identity, and device-capacity +decisions, and benchmark cleanup is descriptor-relative on POSIX and +handle/reparse-point aware on Windows. The live-workspace symlink regression, +SDK link-target/mode regression, static path-race guards, portable raw-C1 +fixture with Bash 3.2 unsigned-byte normalization, native redirected-prompt +and protected-DACL regressions, byte-exact BMP/non-BMP Windows list and extract +checks, and `sdk-test` CI step cover these boundaries. All current release +paths moved to 5.2.8 and received fresh exact-tag hosted CI, package, +native-platform, source-only, checksum, OBS, and promotion evidence in runs +`33456209269` and `33457868306`. ---- +## Corrective changes introduced in 5.2.7 -## Features +The SHA-NI regression keeps x86-only helper declarations out of unsupported +arm64 builds, and the safe UTF-8 Windows fixture crosses the argv boundary in a +byte-stable representation. Those test-harness changes did not alter the +archive format, cryptography, codec, or SDK ABI. Exact-tag run `33445470664` +subsequently exposed the separate macOS raw-C1/EILSEQ fixture failure; Windows +was cancelled after the hosted job stalled in `make check`; MinGW/Wine then +isolated the stall to a non-console password prompt entering `_getch`. The run recorded 13 successful jobs, one +failure, and one cancellation; v5.2.7 remained unpromoted. -- **Hardware-adaptive codec** — auto-detects AVX2/NEON at runtime and - selects the codec: VaptVupt (LZ77 + tANS + SIMD decode) on capable - hardware, VaptVupt-LZHP on everything else. Override with `--vv` or - `--lzhp`. -- **Post-quantum encryption** — `--pq` uses ML-KEM-768 + X25519 hybrid - KEM (the approach used by Signal and iMessage), protecting against - "harvest now, decrypt later" attacks. In-tree, available in the default - build. -- **AES-NI acceleration** — AES-256-CTR via Jasmin-verified assembly with - a 4-block interleaved pipeline. AVX detection validates OSXSAVE/XCR0 (no - SIGILL). Falls back to C table-based AES on unsupported hardware. -- **SHA-NI acceleration** — HMAC-SHA256 (the Encrypt-then-MAC pass) and - PBKDF2 use the Intel SHA-NI compression path when the CPU supports it - (Intel Goldmont+/Ice Lake+, AMD Zen+), selected at runtime via CPUID. - Bit-identical output; scalar C fallback elsewhere. `vaptvupt version` - prints the acceleration set for your CPU. -- **Incremental HMAC** — the per-block MAC streams its segments through an - incremental HMAC-SHA256 instead of copying each block's ciphertext into - a temporary buffer, removing a per-block heap allocation and full-payload - copy on encrypt and decrypt with a byte-for-byte identical MAC (RFC 2104). -- **Multi-threaded** — compression and decompression both parallelized. - `-t 0` auto-detects cores. -- **Full-disk backup** — `vaptvupt disk backup` clones disks or partitions - in one command. Sparse block detection skips zero regions; all encryption - modes supported; restore verifies per-block XXH64 checksums. -- **Per-block integrity** — XXH64 checksum + HMAC-SHA256 per block. Wrong - password rejected immediately. -- **Self-describing KDF** — password archives record their key-derivation - profile in the authenticated header, so an archive carries the parameters - needed to open it later. Unknown profiles are refused fail-closed rather - than mis-derived. Default is PBKDF2-SHA256 (600K iterations); Argon2id is - available in a `WITH_SDK=1` build. -- **Constant-time comparisons** — every security-critical comparison (HMAC - tag, archive-integrity trailer, ML-KEM-768 implicit-rejection check) - routes through a single primitive (`zupt_ct_memeq`, branch-free, volatile - accumulator, length-independent), checked by a dudect-style Welch t-test - in CI. -- **Formally verified crypto** — 5 Jasmin assembly functions with - constant-time proofs; 19 ACSL-annotated functions for Frama-C memory - safety analysis. -- **Multi-architecture** — builds on x86_64, aarch64, armhf, ppc64le, - s390x, riscv64. Jasmin CT crypto on x86_64, C fallback everywhere else. - Any archive decompresses on any architecture. -- **No external dependencies (default build)** — ML-KEM, X25519, Keccak, - SHA-256, AES-256, HMAC, PBKDF2 and the VaptVupt codec are all pure C11. - Builds with `gcc` or `cl` alone. +## Corrective changes introduced in 5.2.6 ---- +Darwin and NetBSD select the portable compiler-resistant secure-wipe fallback; +scanner option/path arrays are guarded for Bash 3.2; and hostile Windows path +fixtures use explicit bytes and reject dangerous raw diagnostic fragments. +Those corrections changed release/test integration only. The resulting v5.2.6 +candidate was not promoted because its next exact-tag run exposed the distinct +arm64 SHA-NI helper and safe UTF-8 Windows argv failures described above. -## Quick Start +## Corrective changes introduced in 5.2.5 -### Build & install -``` -git clone https://git.securityops.co/cristiancmoises/vaptvupt.git && \ -cd vaptvupt && \ -make && \ +The exact-tag openSUSE gate executes its standalone service chain from the +directory containing `_service`. A local Tumbleweed reproduction confirmed +that `refs/tags/v5.2.4` resolves correctly and that entering the service +directory completes `obs_scm`, `tar`, and `recompress`. The immutable v5.2.4 +candidate recorded 12 successful jobs in run `33431386002`; its openSUSE job +failed before the correction and dependent Windows/macOS jobs were skipped. + +## Corrective changes introduced in 5.2.4 + +The release gate now validates the required CRLF checkout form without treating +it as source drift. That candidate required fresh exact-tag CI, package, +native-platform, source-only, and checksum evidence before promotion. The +`v5.2.3` tag remains immutable and unpromoted. + +## Corrective changes introduced in 5.2.3 + +The corrective release carries the 5.2.2 security and format work forward +without a new archive format, codec, or SDK ABI. It realigns every current +version-bearing package and release path to 5.2.3, stabilizes the GUI version +contract used by package gates, and repairs native RPM container setup for +Tumbleweed and Fedora. A fresh exact-tag CI, package, native-platform, +source-only, and checksum record is required before any asset is promoted. See +[CHANGELOG.md](CHANGELOG.md) for the release record. + +## Security and source baseline introduced in 5.2.2 + +This patch release makes the upstream and distribution path auditable from +source and tightens archive integrity handling: + +- removed incomplete vendored SDK/PQBOX header snapshots and every fallback to + local precompiled libraries; +- made WITH_SDK and WITH_PQBOX opt-in system integrations with explicit failure + when their development dependencies are unavailable; +- removed build-tree RPATH/RUNPATH injection and architecture-wide AVX2 flags; +- made compiler target detection, packager flags, staged installation and + cleanup portable; +- hardened extraction against traversal components, symlinks, hardlinks, + Windows reparse points, and pre-existing output files; verified data is + published from a private temporary file only after size and checksum checks; +- made normal, solid, and disk-image compression publish archives atomically + without opening a symlink or hardlink target at the requested leaf; POSIX + canonicalizes a user-selected parent once and then pins its physical + directory, while Windows rejects reparse-point parents; compression also + rejects an output that resolves to the input itself, including alternate + path spellings, hardlinks, and symlinks, even when `--force` is used; +- made disk restore validate and consume one private snapshot of the input + archive before opening its destructive destination; raw-device capacity is + queried before the first write and an unknown or undersized target fails + closed; +- require both XXH64 and an independent SHA-256/128 digest match in the writer + before a block is replaced with a deduplication reference; +- bind every encrypted data or dedup-reference frame to its logical position; + an authenticated reference also carries the position needed to authenticate + the original data frame, so neither a data frame nor an otherwise equivalent + reference can be moved silently; +- require an archive-integrity trailer (AIT) for every validating content-read + path by default, without trusting unauthenticated header flags; the explicit + legacy override is only for a known, trusted archive created before AIT + existed; +- authenticate reference offsets in new encrypted+dedup archives, and bind the + encrypted disk index to its archive metadata; +- store and verify a chained whole-image content hash in new disk archives; + this XXH64 value detects corruption but is not a cryptographic authenticator + in an unencrypted archive; +- serialize fixed-width format values explicitly as little-endian and reject + non-canonical or overflowing uint64 varints; +- reject unexpected frame types wherever decoded DATA is required, including + multithreaded, serial, solid, test, and disk-image readers; +- retain narrow reader paths for the fixed-width disk index and encrypted + deduplication AAD sequence published by 5.2.1; an actual password-encrypted + DATA/DATA/REF/DATA disk fixture is tested byte-exact without claiming that older + readers accept the new 5.2.2 records; +- use a randomly created private directory for benchmark scratch files and + remove it without following links, instead of deriving a writable path only + from the process ID; +- added a reusable source-only scanner, adversarial scanner tests and CI gates; +- added current openSUSE/OBS packaging under packaging/opensuse; +- restored ZUPT/`zupt` as the product, command, package, GUI, documentation, + and release-artifact identity without changing the archive or SDK formats; +- added explicit `--password-prompt`, `--pass-file`, and `--pass-fd` inputs so a + password need not be placed in process arguments; +- create native private-key files without replacement using POSIX mode `0600` + or a Windows current-user-only DACL, and strictly validate ZKEY/ZPQK checksum, + version, flags, reserved bytes, exact size, and public/private role before use; +- restore POSIX terminal state after handled password-prompt interruptions and + render archive comments without emitting raw terminal-control sequences; +- make the regression interpreter explicitly Bash and add bounded nested- + archive resource handling to the source-only scanner; +- documented the bundled codec, GUI data assets and all applicable license scopes; +- added gated, source-built release-package workflows without committing + package artifacts or compiled code to Git. + +See [CHANGELOG.md](CHANGELOG.md) for the release record. + +## Canonical source + +- Canonical: https://github.com/cristiancmoises/zupt +- Codeberg mirror: https://codeberg.org/berkeley/zupt +- SecurityOps Brazil mirror: https://git.securityops.com.br/cristiancmoises/zupt +- SecurityOps global mirror: https://git.securityops.co/cristiancmoises/zupt + +GitHub remains canonical. The `v5.2.8` tag and its 13 release assets are also +published byte-for-byte on the three mirrors above. + +## Source-only policy + +Tracked Git state and source archives contain source/build/packaging files, +documentation, tests, and necessary non-executable data only. They do not +contain object files, shared or static libraries, compiled executables, +RPM/DEB/AppImage packages, unresolved Git LFS pointers, or release binaries. + +Release pages may provide separately generated packages requested for end +users. Those assets must be built from the tagged source, tested on their target +environment, and kept outside Git and the source archive. A format that was not +built and tested is not presented as supported. + +## 5.2.8 release artifacts + +The published 5.2.8 release contains exactly the following 13 files after +every corresponding target gate succeeded. `SHA256SUMS` records the exact promoted +filenames and digests. The release notes identify the tested commit and the +manually dispatched CI run; that run's job definitions and logs are the runtime +evidence for runner image, architecture, toolchain, results, and explicit +skips. This table is not a substitute for that evidence. + +| Format | Intended target and validation boundary | +| --- | --- | +| `zupt-5.2.8.tar.gz` | Reproducible, source-only archive; scanned twice-built input plus SHA-256. | +| `zupt-5.2.8.tar.gz.sha256` | SHA-256 sidecar for the reproducible source archive. | +| `zupt_5.2.8_amd64.deb` | Ubuntu 24.04 amd64 package; install, functional round trip, and uninstall gate. | +| `zupt-5.2.8-0.x86_64.rpm` | openSUSE Tumbleweed x86_64 binary RPM; package inspection, install, round trip, and uninstall gate. | +| `zupt-5.2.8-0.src.rpm` | Source RPM corresponding exactly to the gated openSUSE binary RPM. | +| `zupt-5.2.8-linux-x86_64.tar.xz` | Linux x86_64 CLI plus the complete public license/notice payload; dependency allowlist and extracted-package functional gate. | +| `zupt-gui_5.2.8_all.deb` | Architecture-independent Python/Qt GUI package; exact dependency/payload checks plus installed off-screen GUI/CLI integration gate. | +| `zupt-gui-5.2.8-1.noarch.rpm` | Architecture-independent Python/Qt GUI RPM; package inspection plus installed off-screen GUI/CLI integration gate. | +| `zupt-gui-5.2.8-1.src.rpm` | Source RPM corresponding exactly to the gated noarch GUI RPM. | +| `zupt-gui-5.2.8-portable.zip` | Source-only GUI and launchers with licenses/provenance; source scan, exact member allowlist, and extracted off-screen GUI/CLI gate. | +| `zupt-5.2.8-windows-x86_64.zip` | Native Windows x86_64 executable with notices; extracted-ZIP round-trip gate. | +| Exactly one `ZUPT-5.2.8-macOS-{x86_64\|arm64}.dmg` | Native macOS image; mounted packaged executable round-trip gate, with the actual runner architecture in the filename. | +| `SHA256SUMS` | Deterministic manifest covering the other 12 promoted files. | + +An asset absent from the release was not promoted through its mandatory gate. +Do not infer support for another distribution release, OS version, CPU +architecture, raw UNC/SMB destination, or package manager from a similarly +named file. Binary assets are release outputs, never source-build inputs. + +No AppImage is promised for 5.2.8. The inspected upstream type-2 runtime lacked +a complete notice/source-relink handoff for every statically linked component, +so redistributing it would not meet this release's provenance gate. AppDir and +Flatpak bundles and GUI platform installers are likewise outside the promoted +set because their runtime, license, or target gates are incomplete. A bare +Linux executable or Windows `.exe` is not promoted: each CLI executable is +carried only inside its notice-bearing archive. The Windows ZIP and macOS DMG +remain CLI-only. + +The promoted GUI artifacts are the gated architecture-independent DEB, +noarch/source RPM, and source-only portable ZIP listed above. The portable ZIP +does not bundle Python, Qt, or the ZUPT CLI; its launchers select compatible +software already installed on the target. Other historical GUI packages and +platform installers are not carried forward implicitly. + +The canonical source repository is +. The canonical release is +. Assets referenced +by the AUR, Homebrew, Guix, or generic RPM recipes must exist there at their +recorded URL before those recipes are published. + +Audit the current checkout and its Git archive with: + +~~~sh +bash scripts/check-source-only.sh +bash tests/test_source_only.sh +~~~ + +For a tag or an existing source archive: + +~~~sh +bash scripts/check-source-only.sh --tag v5.2.8 +bash scripts/check-source-only.sh --archive /path/to/zupt-5.2.8.tar.gz +~~~ + +Unknown `.bin` files fail the scan. A necessary binary data fixture may be +allowed only with `--data-manifest FILE`; each tab-separated record must name +its path, purpose, provenance, and SPDX license. This exception never permits +compiled or executable magic, packages, AppImages, bytecode, or Git LFS +pointers. Nested scans cap recursion, member count, individual expansion, and +total expanded bytes and fail closed at a limit. On committed Linux candidate +`ff99770`, all 39 source-only scanner cases passed, including GNU thin archives, +scanner-bomb limits, and safe diagnostic cases. + +## Build from source + +Required for the default build: + +- a C11 compiler; +- GNU make; +- the system C, math and threading libraries. + +Git, tar and gzip are needed for source-archive generation. Bash and Python 3 +are used by the complete test suite. No build target downloads dependencies. + +Build the distribution configuration: + +~~~sh +make clean +make -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1)" \ + WITH_SDK=0 WITH_PQBOX=0 V=1 +make WITH_SDK=0 WITH_PQBOX=0 check +~~~ + +The Makefile honors CC, CPPFLAGS, CFLAGS, LDFLAGS, LDLIBS, AR, RANLIB, +STRIP, DESTDIR, PREFIX, BINDIR, LIBDIR, INCLUDEDIR and MANDIR. Project include +paths are added separately and do not replace distribution optimization or +hardening flags. + +The default x86 build targets the architecture ABI baseline. SHA-NI is compiled +in its own translation unit and runtime-gated. AVX2 is not enabled across whole +codec translation units. Textual assembly under `jasmin/` can be requested with +WITH_JASMIN=1 on a compatible x86_64 compiler target; it includes generated +Jasmin output and separately identified hand-written assembly. The portable C +fallback is the default. + +## Optional SDK and PQBOX integrations + +Both optional integrations are off by default and never load a library from the +repository: + +| Option | Enables | Dependency behavior | +| --- | --- | --- | +| WITH_SDK=1 | --pq-sdk and the SDK-backed Argon2id path | Uses the system libvuptsdk development package through pkg-config. | +| WITH_PQBOX=1 | --pq-box | Uses the system libpqvaptvupt development package through pkg-config. | + +If a system package has no pkg-config file, an administrator may supply +SDK_CPPFLAGS and SDK_LDLIBS, or PQBOX_CPPFLAGS and PQBOX_LDLIBS, explicitly. +Enabling an option without usable system link flags stops at Makefile parsing +with an actionable error. There is no download, vendored binary fallback or +automatic RPATH. + +The default source-only build retains password encryption through +PBKDF2-SHA256, native hybrid encryption through --pq, and ML-KEM-only encryption +through --pq-only. It reports SDK/PQBOX-only operations as unavailable rather +than silently changing modes. + +## Install and uninstall + +For a normal local installation: + +~~~sh sudo make install -``` - -The default build needs only a C compiler and `make` (plus libm/pthread). -`make WITH_SDK=1` additionally links the separately distributed -`libzuptsdk`/`libpqvaptvupt` to enable `--pq-sdk`, `--pq-box`, and the -Argon2id KDF. - -### Pre-built packages - -Assets are published on the -[v4.1.0 release page](https://git.securityops.co/cristiancmoises/vaptvupt/releases/tag/v4.1.0) -and verifiable against the published `SHA256SUMS.txt`. - -**Command-line tool (`vaptvupt` 4.1.0):** - -| Format | File | Distros | -|---|---|---| -| Debian/Ubuntu | `vaptvupt_4.1.0_amd64.deb` | Debian 11+, Ubuntu 22.04+, Mint 21+ | -| RPM | `vaptvupt-4.1.0-1.x86_64.rpm` | Fedora 38+, RHEL 9+, openSUSE, AlmaLinux, Rocky, other RPM-based distributions | -| AppDir tarball | `vaptvupt-4.1.0-x86_64.AppDir.tar.gz` | Any glibc 2.28+ (extract & run, no FUSE) | -| Source tarball | `vaptvupt-4.1.0.tar.gz` | Build from source on any platform | -| openSUSE OBS | `vaptvupt-4.1.0-opensuse-obs.tar.gz` | Open Build Service source bundle | - -**Graphical front-end (`vaptvupt-gui` 1.3.0):** - -| Format | File | Distros | -|---|---|---| -| Debian/Ubuntu | `vaptvupt-gui_1.3.0_all.deb` | Debian 11+, Ubuntu 22.04+, Mint 21+ | -| RPM | `vaptvupt-gui-1.3.0-1.noarch.rpm` | RPM-based distributions | -| AppImage | `VaptVupt-GUI-1.3.0-x86_64.AppImage` | Any glibc 2.28+ (single-file, no install) | -| AppDir tarball | `VaptVupt-GUI-1.3.0-x86_64.AppDir.tar.gz` | Any glibc 2.28+ (extract & run) | - -```bash -# Verify downloads first -sha256sum -c SHA256SUMS.txt - -# Debian / Ubuntu / Mint -sudo dpkg -i vaptvupt_4.1.0_amd64.deb -sudo apt-get install -f # resolve any missing deps - -# Fedora / RHEL / openSUSE / AlmaLinux / Rocky and other RPM-based distros -sudo rpm -i vaptvupt-4.1.0-1.x86_64.rpm -# or -sudo dnf install ./vaptvupt-4.1.0-1.x86_64.rpm - -# AppDir tarball (no install, no FUSE required) -tar xzf vaptvupt-4.1.0-x86_64.AppDir.tar.gz -./vaptvupt-4.1.0-x86_64.AppDir/AppRun --help - -# GUI AppImage (single executable) -chmod +x VaptVupt-GUI-1.3.0-x86_64.AppImage -./VaptVupt-GUI-1.3.0-x86_64.AppImage -``` - -### Building from SRPM (Fedora / RHEL / RPM-based distributions) - -```bash -tar xzf vaptvupt-4.1.0.srpm.tar.gz -cd ~/rpmbuild # or use rpmbuild --define "_topdir $(pwd)" -rpmbuild -bb SPECS/vaptvupt.spec -sudo rpm -i RPMS/x86_64/vaptvupt-4.1.0-1.*.rpm -``` - -### Basic usage - -```bash -# Compress a directory (auto-selects codec for your hardware) -vaptvupt compress backup.zupt ~/Documents/ - -# Compress at a specific level (1=fast, 5=balanced, 9=extreme) -vaptvupt compress -l 9 backup.zupt ~/Documents/ - -# Force the VaptVupt codec (default on AVX2/NEON hardware) -vaptvupt compress --vv -l 5 backup.zupt ~/Documents/ - -# Multi-threading (-t 0 = auto-detect cores) -vaptvupt compress -t 0 -l 5 backup.zupt ~/Documents/ - -# Password encryption (AES-256-CTR + HMAC-SHA256, PBKDF2-SHA256 KDF) -vaptvupt compress -p "my-strong-password" backup.zupt ~/Documents/ - -# List archive contents -vaptvupt list backup.zupt - -# Show archive metadata (no password needed) -vaptvupt info backup.zupt - -# Verify archive integrity (HMAC + per-block checksums) -vaptvupt test backup.zupt -vaptvupt test -p "my-strong-password" backup.zupt - -# Extract -vaptvupt extract -o ~/restored/ backup.zupt -vaptvupt extract -p "my-strong-password" -o ~/restored/ backup.zupt - -# Benchmark all 9 levels on a file -vaptvupt bench big-file.tar -``` - -#### Post-quantum encryption - -```bash -# Native --pq (ML-KEM-768 + X25519 hybrid KEM, in-tree, default build). -# Recommended for new archives. -vaptvupt keygen -o mykey.key -vaptvupt keygen --pub -o pub.key -k mykey.key -vaptvupt compress --pq pub.key backup.zupt ~/Documents/ -vaptvupt extract --pq mykey.key -o ~/restored/ backup.zupt -``` - -The SDK-backed modes below require a `make WITH_SDK=1` build linked against -the separately distributed `libzuptsdk`/`libpqvaptvupt`: - -```bash -# --pq-sdk (HKDF combiner + key commitment + HPKE binding + Argon2id) -vaptvupt keygen --sdk -o mykey.priv # writes mykey.priv and mykey.priv.pub -vaptvupt compress --pq-sdk mykey.priv.pub backup.zupt ~/Documents/ -vaptvupt extract --pq-sdk mykey.priv -o ~/restored/ backup.zupt - -# --pq-box sealed-box (ML-KEM-768 + X25519 via HKDF-SHA256 combiner) -vaptvupt keygen --box -o box.key # writes box.key + box.key.pub -vaptvupt compress --pq-box box.key.pub backup.zupt ~/Documents/ -vaptvupt extract --pq-box box.key -o ~/restored/ backup.zupt -``` - -#### Full-disk backup - -```bash -# Backup a disk or partition (sparse-detection skips zero regions) -sudo vaptvupt disk backup -l 5 disk.zupt /dev/sda - -# With encryption -sudo vaptvupt disk backup -p "passphrase" -l 5 disk.zupt /dev/sda - -# Restore (writes raw bytes back to a block device or file) -sudo vaptvupt disk restore disk.zupt /dev/sdb -sudo vaptvupt disk restore -p "passphrase" disk.zupt /dev/sdb - -# Backup a partition image file (no root needed) -vaptvupt disk backup -l 5 part.zupt /path/to/partition.img -``` - ---- - -## Auto Codec Detection - -VaptVupt selects the compression codec based on your hardware (since -v2.0.0). No flags needed — `vaptvupt compress` picks the fastest option -available. - -| Architecture | SIMD Available | Default Codec | Decode Throughput | -|---|---|---|---| -| x86_64 + AVX2 | AVX2 inline SIMD | VaptVupt | ~2–3 GB/s | -| x86_64 (no AVX2) | Scalar | VaptVupt-LZHP | ~500 MB/s | -| aarch64 + NEON | NEON SIMD | VaptVupt | ~1–2 GB/s | -| armhf, ppc64le, s390x, riscv64 | Scalar | VaptVupt-LZHP | ~300–500 MB/s | - -Decompression is universal. An archive created with VaptVupt on x86_64 -extracts on aarch64 (NEON or scalar decode) and vice versa. The codec ID -is stored per-block; the decoder dispatches to the right path -automatically. Override with `--vv` or `--lzhp`. - ---- - -## VaptVupt Codec - -VaptVupt combines LZ77 dictionary matching with tANS (table-based -Asymmetric Numeral Systems) entropy coding and SIMD-accelerated -decompression. - -This release embeds VaptVupt codec 2.60.4 (security release: fixes an OOB -heap write in the AVX2 decode fast path; adds CBMC-verified BCJ filters -with auto-detection). The codec API is byte-identical to the 2.48.x line; -the 2.48.5 → 2.60.4 upgrades add the optimal parser (measured on our -fixtures: text −1.95%, binary −1.31%, source −4.72% smaller), large-window -extreme mode, faster decode (roughly on par with zstd-19, up from 1.5–2× -slower), and six upstream corrupt-input decoder memory-safety fixes. See -[CHANGELOG.md](CHANGELOG.md). - -### Architecture - -``` -Encoder: Hash-chain LZ77 → 5-byte multiply-shift hash, rep-match (3 recent offsets), - lazy-2 parsing, AVX2 match extension (32 bytes/cycle), cost-aware lazy parser -Entropy: Canonical Huffman | tANS | 4-way interleaved ANS | order-1 context model - 4-stream Huffman literal coding (lit_fmt=4) for structured data -Decoder: AVX2 inline SIMD copies, tiered by offset (32/16/8/overlap), safe-zone fast path - NEON SIMD on aarch64, scalar fallback on all architectures -Format: v1 frame (default) and v2 frame (T-tag, min_match=3) for binary data -``` - -### Modes - -| Mode | CLI | Chain Depth | Entropy | Use Case | -|------|-----|-------------|---------|----------| -| Ultra-Fast | `-l 1` to `-l 2` | 4 | None | Speed priority, streaming | -| Balanced | `-l 3` to `-l 7` (default) | 48 | 4-way ANS | General backup data | -| Extreme | `-l 8` to `-l 9` | 256 | Order-1 context ANS + cost-aware lazy parser | Maximum compression | - -The wrapper enables the codec's `format_v2` flag (4–7% better real-binary -ratio) for Balanced and Extreme modes. Ultra-Fast stays on the v1 frame -because the `format_v2 + ULTRA_FAST` combination is not yet covered by the -codec's upstream test matrix. - -### Measured benchmark (codec 2.60.4) - -Measured against gzip-9, zstd-3, zstd-19 on a 4-fixture suite (text 10 MB, -binary-struct 7.5 MB, source code 10 MB, random 5 MB). Decode timed across -3 runs, minimum reported; wall-clock including the `.zupt` envelope (HMAC -etc.). Host: Intel Xeon @ 2.1 GHz, single vCPU, AVX2 build. Reproduce with -`vaptvupt bench `. - -| Fixture | Tool | Ratio | Dec MB/s | -|---------------|-----------|---------:|---------:| -| text 10 MB | vv-9 | 25.6% | 278 | -| text 10 MB | gzip-9 | 22.6% | 156 | -| text 10 MB | zstd-3 | 24.2% | 556 | -| text 10 MB | zstd-19 | 17.6% | 435 | -| binary 7.5 MB | vv-9 | 46.1% | 300 | -| binary 7.5 MB | gzip-9 | 46.8% | 123 | -| binary 7.5 MB | zstd-3 | 44.8% | 577 | -| binary 7.5 MB | zstd-19 | 41.1% | 417 | -| source 10 MB | vv-9 | 4.5% | 714 | -| source 10 MB | gzip-9 | 4.0% | 238 | -| source 10 MB | zstd-3 | 5.6% | 1000 | -| source 10 MB | zstd-19 | 2.7% | 769 | -| random 5 MB | vv-9 | 100.0% | 625 | -| random 5 MB | zstd-3 | 100.0% | 681 | - -Reading these numbers: - -- On ratio, zstd-19 wins every fixture. VaptVupt L9 lands between zstd-3 - and zstd-19 on text and binary, beats zstd-3 on source (4.5% vs 5.6%), - and loses to zstd-19 everywhere. For smallest-file only, use `xz -9` or - `zstd -19`. -- Decode is competitive: 278–714 MB/s, in the same band as zstd-19 and - within ~1.3× of zstd-3. -- Encode throughput is the weakness. The optimal parser and hash-chain - walk that win ratio cost encode speed; balanced mode is ~6× slower than - fast mode. For encode-latency-bound workloads use `-l 1`/`-l 2`. -- On a degenerate single-pattern input, large-window extreme (L9) can be - slightly worse than L5/L7 — a tradeoff of optimizing for real long-range - matches. It does not affect realistic corpora. -- On random / already-compressed data, all codecs hit the - incompressibility wall. - -### Security regression tests - -Every release re-runs the security regression matrix (`make check`, -≈2 minutes on x86_64 and aarch64). It covers: - -- HMAC single-bit tamper detection and honest roundtrips. -- Archive-integrity trailer (header/footer tamper detection). -- Byte-level integrity sweep on a PQ archive (every byte flipped). -- KDF default (PBKDF2-SHA256) and self-describing header transparency, - with back-compat and fail-closed on unknown profiles. -- Indistinguishable wrong-password vs tampered-archive error messages. -- Encrypted comment block bound to per-block AAD. -- Constant-time comparison (dudect Welch t-test on MAC tag and ML-KEM - decaps) plus a source-routing guard. -- Codec exact-`content_size` decode cases (incl. BCJ payloads) under ASan. -- NIST/RFC test vectors: SHA-256, SHA-3, SHAKE-128, ML-KEM-768, - AES-256-CTR (SP 800-38A), HMAC-SHA256, X25519, XXH64. -- Path-traversal refusal, block-swap detection, deduplication correctness, - and CLI argument-order invariance. - -`make test` runs the full suite including dist reproducibility and -packaging-syntax checks. - -### Codec notes - -- **tANS entropy** — asymptotically optimal coding with single-instruction - decode per symbol (vs Huffman's multi-step tree walk). -- **4-way interleaved ANS** — decodes 4 symbols per bitstream refill cycle. -- **4-stream Huffman literal coding** (`lit_fmt=4`) — improves ratio on - structured data. -- **AVX2/NEON SIMD decode** — inline 32-byte copies with tiered offset - handling. Scalar fallback on unsupported hardware. -- **Rep-match** — checks 3 recent offsets before the hash probe (O(1) vs - O(chain_depth)), hitting ~30% of matches. -- **Order-1 context model** — captures byte-pair correlations in structured - data (JSON, CSV, logs). -- **Cost-aware lazy parser** — puts Extreme mode ahead of zstd-3 in - aggregate ratio. -- **Adaptive window** — trial-compresses at wlog=16 vs wlog=20, picking the - larger window only if ≥3% improvement. -- **`format_v2`** (T-tag, min_match=3) — 4–7% better binary ratio; - transparent to v2.33.0+ decoders. -- **Memory hygiene** — encoder working buffers scrubbed via - `vv_secure_zero` before `free()`. -- **~6,500 lines** of pure C11. - ---- - -## Post-Quantum Encryption - -`--pq` uses hybrid ML-KEM-768 + X25519 key encapsulation per NIST FIPS 203, -in-tree and available in the default build. - -``` -Public key → ML-KEM-768 Encaps + X25519 ECDH → hybrid shared secret - → SHA3-512(ss ‖ transcript) → enc_key[32] + mac_key[32] - → AES-256-CTR + HMAC-SHA256 per block -``` - -Security model: secure if EITHER ML-KEM-768 (post-quantum) OR X25519 -(classical) is secure. - -Password mode (`-p`) is not quantum-safe. Use `--pq` for long-term -protection. - -The SDK-backed `--pq-sdk` and `--pq-box` modes are optional and require a -`make WITH_SDK=1` build against `libzuptsdk`/`libpqvaptvupt`. - ---- - -## Full-Disk Backup - -Clone disks, partitions, or raw images with compression and encryption in -one command. - -### Quick start -```bash -# Clone a partition (requires read access) -sudo vaptvupt disk backup backup.zupt /dev/sda1 - -# Clone with post-quantum encryption -vaptvupt keygen -o mykey.key -vaptvupt keygen --pub -o pub.key -k mykey.key -sudo vaptvupt disk backup --pq pub.key backup.zupt /dev/nvme0n1p2 - -# Clone with password encryption -sudo vaptvupt disk backup -p backup.zupt /dev/sda1 - -# Maximum compression (level 9, extreme mode) -sudo vaptvupt disk backup -l 9 backup.zupt /dev/sda1 - -# Restore to a device or file -sudo vaptvupt disk restore backup.zupt /dev/sda1 -sudo vaptvupt disk restore --pq mykey.key backup.zupt /dev/sda1 -``` - -### How it works - -``` -Source device → Read 4MB blocks → Sparse detection → Compress → Encrypt → Write .zupt - │ │ │ - │ │ └─ AES-256-CTR + HMAC-SHA256 - │ └─ VaptVupt/LZHP (auto-selected) - └─ Zero blocks stored as STORE (near-zero overhead) -``` - -VaptVupt reads the source device sequentially in 4MB chunks. Each block is -checked for all-zero content (8-byte-wide comparison). Zero blocks are -stored with codec `STORE` — effectively just the block header with no -payload. Non-zero blocks are compressed with the selected codec and -optionally encrypted. Per-block XXH64 checksums ensure byte-for-byte -integrity on restore. - -### Best practices - -Encryption modes: - -| Mode | Command | Security Level | Speed Impact | -|------|---------|---------------|-------------| -| PQ Hybrid | `--pq pub.key` | Quantum-resistant + classical | ~5% overhead | -| Password | `-p` | AES-256, PBKDF2-SHA256 600K iter | ~3% overhead | -| None | (default) | Integrity only (XXH64) | Fastest | - -Compression levels for disks: - -| Level | Mode | Best for | Typical ratio | -|-------|------|----------|--------------| -| `-l 1` to `-l 3` | Ultra-Fast | Live systems, NVMe (speed priority) | 1.5–2.5:1 | -| `-l 4` to `-l 7` | Balanced (default) | General partitions, ext4/NTFS | 2–5:1 | -| `-l 8` to `-l 9` | Extreme | Cold storage, archival backups | 3–10:1 | - -Operational guidance: - -- Unmount before backup for filesystem consistency. For live systems use - LVM snapshots or filesystem freeze: - `fsfreeze -f /mnt/data && vaptvupt disk backup ... && fsfreeze -u /mnt/data`. -- Block devices require root on Linux. Regular files (disk images, `.img`, - `.raw`) do not. -- Sparse-heavy disks compress well — the sparse detector skips zero blocks - at memory-copy speed with no compression overhead. -- Verify after backup with `vaptvupt test archive.zupt` — checks every - block's XXH64 checksum without extracting. -- For long-term disk backups use `--pq`. Generate one keypair, store the - private key offline, distribute the public key. -- Restore is non-destructive on files (creates/overwrites the file); - writing to a block device overwrites the raw device. Double-check the - target path before restoring to a device. - ---- - -## Multi-Architecture Support - -The Makefile auto-detects the platform and enables the best available -features. - -| Feature | x86_64 | aarch64 | armhf | ppc64le | s390x | riscv64 | -|---------|--------|---------|-------|---------|-------|---------| -| Jasmin CT crypto | yes | C fallback | C fallback | C fallback | C fallback | C fallback | -| AES-NI hardware | yes (with AVX) | — | — | — | — | — | -| AVX2 SIMD decode | yes | — | — | — | — | — | -| NEON SIMD decode | — | yes | — | — | — | — | -| Default codec | VaptVupt | VaptVupt | LZHP | LZHP | LZHP | LZHP | -| All codecs decode | yes | yes | yes | yes | yes | yes | - -Build for packaging (PIE, hardening flags): -```bash -make CFLAGS="-Wall -Wextra -O2 -std=c11 -fPIE -Iinclude -Isrc" LDFLAGS="-pie -Wl,-z,relro,-z,now" -make install DESTDIR=/buildroot -``` - ---- - -## Security - -``` -Password mode: Password → PBKDF2-SHA256 (600K iter) → enc_key + mac_key -PQ hybrid mode: Public key → ML-KEM-768 Encaps + X25519 ECDH → enc_key + mac_key -Per-block: AES-256-CTR(enc_key, nonce ⊕ seq) + HMAC-SHA256(mac_key) -Key protection: mlock() prevents swap, buffer canaries detect overflow -Timing: Always-decrypt mitigation (no timing oracle on MAC failure) -AES dispatch: AVX+AES-NI check with OSXSAVE/XCR0 (no SIGILL on any CPU) -Path safety: Zip Slip / symlink defenses (zupt_path_is_safe + O_NOFOLLOW) -Verification: 5 Jasmin CT proofs, 19 ACSL contracts, 16 NIST/RFC test vectors -``` - -The `WITH_SDK=1` build adds an HKDF-SHA3 combiner with domain separation, -key commitment, and HPKE binding for the `--pq-sdk`/`--pq-box` modes, plus -the Argon2id KDF. - -Internal audit passes on the 2.2.x line fixed 14 bugs, including a -HIGH-severity Zip Slip path traversal. There has been no external audit. -See [SECURITY.md](SECURITY.md) for the threat model and honest scope, and -[FORMAL_AUDIT_PROMPT.md](FORMAL_AUDIT_PROMPT.md) for the audit methodology. - -Report security vulnerabilities per [SECURITY.md](SECURITY.md). - ---- - -## Usage - -``` -vaptvupt compress [OPTIONS] -vaptvupt extract [OPTIONS] -vaptvupt list [OPTIONS] -vaptvupt test [OPTIONS] -vaptvupt disk backup [OPTIONS] -vaptvupt disk restore [OPTIONS] -vaptvupt bench [--compare] -vaptvupt keygen [-o file] [--pub] [-k privkey] -vaptvupt version -vaptvupt help -``` - -| Option | Description | -|--------|-------------| -| `-l <1-9>` | Compression level (default: 7) | -| `-t ` | Thread count (0=auto, 1=single, 2–64) | -| `-p [PW]` | Password encryption (PBKDF2-SHA256 → AES-256) | -| `--pq ` | Post-quantum hybrid encryption | -| `-o ` | Output directory (extract) | -| `-s` | Store without compression | -| `-f` | Fast LZ codec (VaptVupt-LZ) | -| `--vv` | Force VaptVupt codec | -| `--lzhp` | Force VaptVupt-LZHP codec | -| `-v` | Verbose | -| `--solid` | Solid mode (cross-file LZ context) | -| `--compare` | Codec comparison benchmark | - ---- - -## Building - -```bash -make # Default build: C compiler + make only -make WITH_SDK=1 # Link libzuptsdk/libpqvaptvupt: --pq-sdk, --pq-box, Argon2id -make V=1 # Verbose build output -make test-all # Regression + NIST + VV + MT + PQ + disk -make test-vv # VaptVupt codec unit tests only -make test-asan # AddressSanitizer + UBSan build -make fuzz-build # AFL++ fuzzing harnesses -make install # Install binary + man page -make help # Show all targets + detected capabilities -build.bat # Windows (MSVC) -``` - -### Benchmark -```bash -vaptvupt bench ~/Documents/ # Per-level benchmark (levels 1-9) -vaptvupt bench --compare # Cross-codec comparison (auto-generates corpus) -vaptvupt bench --compare ~/Documents/ # Compare codecs on your own data -``` - ---- - -## Codec Reference - -| ID | Name | Algorithm | Default on | Override | -|----|------|-----------|------------|----------| -| `0x0010` | VaptVupt | LZ77 + tANS + AVX2/NEON SIMD | x86_64 (AVX2), aarch64 (NEON) | `--vv` | -| `0x000A` | VaptVupt-LZHP | LZ77 + Huffman + byte prediction | armhf, ppc64le, s390x, riscv64 | `--lzhp` | -| `0x0009` | VaptVupt-LZH | LZ77 + Huffman | — | — | -| `0x0008` | VaptVupt-LZ | Fast LZ77, 64KB window | — | `-f` | -| `0x0000` | Store | No compression | — | `-s` | - -All codecs are forward-compatible: archives created with any codec can be -read by any VaptVupt version that includes that codec, on any architecture. -VaptVupt archives require VaptVupt v2.0+. - ---- - -## Release History - -| Version | Description | -|---------|-------------| -| v0.1–v0.6 | LZ77 compression, AES-256 encryption, multi-threading | -| v0.7 | Post-quantum hybrid encryption (ML-KEM-768 + X25519) | -| v1.0 | Stable release — format frozen v1.4, security audit | -| v1.1–v1.5.5 | X25519 fix, NIST vectors, CPUID detection, Jasmin CT assembly linked, build-system improvements | -| v2.0 | VaptVupt codec, auto hardware detection, all 5 Jasmin wired, AVX SIGILL fix, ACSL, mlock, fuzzing, canaries, AES-NI pipeline, MT decompress, multi-arch (6 arches), `--lzhp` | -| v2.1.x | Cross-block dictionary carry, Termux/Android build fix, full-disk backup/restore, LZHP fix, CodeQL fixes, block-level deduplication | -| v2.2.x | libzuptsdk integration (`--pq-sdk`), VaptVupt 2.48.2 codec (cost-aware lazy parser, 4-stream Huffman, `format_v2`), audit findings F-01..F-07 closed (incl. F-06 high) | -| v2.3.x | F-08/F-09 closed: archive-integrity trailer + preface-AAD MAC (format v1.5 → v1.6) | -| v2.4.x | PBKDF2/Argon2id KDF work (F-10), error-message hygiene (F-11), encrypted comments (F-12), packaging arc (deb/RPM/AUR/Nix/Homebrew/OBS), THREAT_MODEL.md, manpage + completions, distro-safe `make check` | -| v3.0.x | Renamed Zupt → VaptVupt (INPI Brasil trademark), VV codec 2.48.5, GUI fixes, F-13 fix. Wire format unchanged; `zupt` kept as compat symlink | -| v3.1.0–v3.3.0 | Codec 2.48.5 → 2.53.3, decode over-copy fix, SHA-256 hardware acceleration (Intel SHA-NI), incremental per-block HMAC | -| v3.4.0–v3.8.0 | F-15 KDF parameter transparency, measured constant-time MAC comparison (dudect), NIST SP 800-38A AES-CTR vectors, ML-KEM decaps through the CT primitive, consolidated benchmarks | -| v4.0.0 | Codec 2.60.4 security release (OOB heap write fixed in AVX2 decode fast path), `--pq-box` sealed-box mode, F-16 data-loss disclosure + fix (old in-tree BCJ encoder), CBMC-verified BCJ filters with auto ELF/PE/Mach-O detection, SHA-NI acceleration. Wire format v1.6 | -| v4.1.0 | Source-only tree (prebuilt libzuptsdk/libpqvaptvupt removed); default build needs only a C compiler + make; native `--pq` is the default PQ mode; `--pq-sdk`/`--pq-box`/Argon2id gated behind `make WITH_SDK=1`. Wire format stays v1.6 | - -See [CHANGELOG.md](CHANGELOG.md) for detailed per-version changes. - ---- +~~~ + +The upstream default prefix is `/usr/local`. Use a staged `PREFIX=/usr` +installation for packaging rather than writing directly into `/usr` as an +unprivileged user. + +For packaging or inspection: + +~~~sh +stage=$(mktemp -d) +make install DESTDIR="$stage" PREFIX=/usr INSTALL_LEGACY_ALIAS=0 +find "$stage" -print +~~~ + +`INSTALL_LEGACY_ALIAS=1` explicitly adds the renamed-era compatibility command +and manual page named `vaptvupt`. The default is 0. Distribution packages +should keep it at 0 unless they have verified ownership and conflicts for that +compatibility name. +The openSUSE package installs `zupt` as the primary command. + +Uninstall uses the same path variables: + +~~~sh +sudo make uninstall PREFIX=/usr/local INSTALL_LEGACY_ALIAS=0 +~~~ + +## Tests + +The principal source-only gates are: + +~~~sh +make WITH_SDK=0 WITH_PQBOX=0 check +make WITH_SDK=0 WITH_PQBOX=0 test-all +make sdk-test +make test-asan +make test-asan-run +make audit-licenses +bash tests/test_source_only.sh +bash scripts/test-installed-zupt.sh ./zupt +~~~ + +The installed/functional test covers text, random and empty files, nested +directories, spaces and UTF-8 names, archive verification, extraction and +SHA-256 comparison, wrong-password rejection, corrupt-archive rejection, +destination-symlink escape protection, atomic archive-output replacement, +--help, --version and invalid options. + +`disk restore` first copies the measured archive into a private, auto-deleted +scratch file, validates that snapshot, and restores from the same open stream. +Set `ZUPT_TMPDIR` to an existing private scratch directory when the default +temporary filesystem lacks space; it must hold at least the compacted archive +size. An invalid override fails without falling back elsewhere or opening the +destination. Regular-file destinations retain atomic publication; raw block +devices are accepted only when their capacity can be determined and is large +enough. The privileged undersized-loop-device regression is reported `SKIP`, +not `PASS`, when the environment cannot create a loop device. + +The immutable, non-promoted 5.2.2 candidate at commit `ff99770` passed the local +`make release-check`. Recorded results include packaging +`PASS=49 FAIL=0 SKIP=0`, the 39/39 source-only scanner suite, strict GCC and +Clang, GCC `-fanalyzer`, a 9/9 full tool-enabled static-analysis run, +ASan/UBSan/LSan, and 1,000 mutation-fuzz iterations without a +sanitizer-detected crash. An earlier off-screen GUI smoke run remains supporting +evidence rather than an exact-candidate package result. + +Those results are historical upstream self-audit evidence, not independent +certification and not 5.2.8 results. Post-tag CI integration failures prevented +5.2.2 promotion. The immutable 5.2.3 candidate was also not promoted because its +source-policy test assumed LF for a `.bat` checkout that correctly used CRLF. +The immutable v5.2.4 candidate then recorded 12 successful jobs in exact-tag CI +run `33431386002`; the sole openSUSE service-harness job failed because the +standalone executor did not enter its service directory, so dependent Windows +and macOS jobs were skipped. A local Tumbleweed reproduction proved the explicit +tag ref and corrected working-directory contract, but neither that reproduction +nor the successful v5.2.4 jobs are v5.2.8 evidence. The immutable v5.2.5 +candidate was not promoted after exact-tag GitHub Actions run `33434986357`: +13 jobs succeeded, but the native Windows hostile-path fixture and macOS +build/check gate failed. Their 5.2.6 corrections were followed by exact-tag run +`33442264243`, which also completed 13 jobs successfully but failed native +macOS on arm64-unused SHA-NI helper declarations under `-Werror` and native +Windows during safe UTF-8 fixture argv transcoding. The immutable v5.2.6 tag was +not promoted. The immutable v5.2.7 tag was also not promoted: exact-tag run +`33445470664` reached the macOS raw-C1 filename-creation failure with `EILSEQ`, +recorded 13 successful jobs, and cancelled Windows after the hosted job stalled +in `make check`; a MinGW/Wine reproduction isolated the stall to +`test --password-prompt ... /dev/null || printf 1)" \ + WITH_SDK=0 WITH_PQBOX=0 V=1 +make WITH_SDK=0 WITH_PQBOX=0 check +make WITH_SDK=0 WITH_PQBOX=0 test-all +make sdk-test +``` -These functions are compiled from Jasmin source to x86-64 assembly. The -Jasmin compiler enforces that no secret-typed variable flows into branch -conditions or memory addresses. This guarantee holds at the machine code -level — no C compiler optimization can introduce timing leaks. +Where the compiler supports them, run the sanitizer target separately: -### C Constant-Time (branchless, compiler-dependent) - -| Function | Method | Risk | -|----------|--------|------| -| X25519 `fe_cswap` | Masked XOR (`mask & (a ^ b)`) | Low — branchless but compiler may optimize | -| ML-KEM NTT/basemul | Montgomery reduction (no branches) | Low | -| ML-KEM CBD sampling | Bitwise operations only | Low | -| Key wipe (`zupt_secure_wipe`) | `explicit_bzero` / volatile | Low | - -### NOT Constant-Time (documented risks) - -| Function | Risk | Mitigation | -|----------|------|------------| -| AES-256 block encrypt | HIGH on shared hardware — S-box table lookups leak via cache timing | Jasmin AES-NI path planned; do not use on multi-tenant VMs | -| SHA-256 | Low — table constants are public, not indexed by secret data | Accepted | - ---- - -## Threat Model - -### What VaptVupt Protects - -| Asset | Protection | -|-------|-----------| -| File contents | AES-256-CTR encryption | -| File names, sizes, structure | Encrypted in central index block, HMAC-protected | -| Archive integrity (payloads + index) | Per-block HMAC-SHA256 | -| Archive integrity (header + footer metadata) | v1.5+ archives: 32-byte archive-integrity-trailer HMAC-SHA256 over `hdr ‖ ft[0..23]`. v1.4 archives: not covered, downgrade warning on extract. | -| Against stolen backups | AES-256 requires key/password to read | -| Against tampering of file contents, names, sizes, offsets | HMAC detects any modification | -| Against tampering of per-block frame preface bytes (codec_id, block_flags, varints, plaintext-XXH64) | v1.6: per-block MAC binds the canonical preface AAD; encryption-header block validated structurally | -| Against tampering of archive comment (when present) | Comment block goes through the same per-block AEAD pipeline as data (AES-256-CTR + HMAC-SHA256 + preface AAD); `hdr.comment_offset` pointer is in the AIT-signed region | -| Against block-swap (reorder) attacks | MAC binds an 8-byte position AAD; a block moved to another position fails verification and its partial output is unlinked. Dedup refs use sentinel seq=0 and rely on plaintext XXH64 for per-block integrity. | -| Against malicious archive entries (Zip Slip / path traversal) | `zupt_path_is_safe()` rejects `..`, absolute paths, Windows drive/UNC paths, embedded NULs | -| Against symlink at extract target (TOCTOU) | `zupt_safe_fopen_output()` uses `O_NOFOLLOW` on POSIX. Windows relies on directory ACLs (documented limitation). | -| Against quantum adversary | `--pq` mode: ML-KEM-768 (NIST Level 3) hybridized with X25519 | - -The wire/on-disk format is v1.6. See CHANGELOG.md for the per-release -finding history behind these protections. - -### What VaptVupt Does NOT Protect Against - -| Threat | Reason | Mitigation Path | -|--------|--------|----------------| -| Attacker who knows the password or has the private key | Fundamental to encryption | Use strong passwords (12+ chars); protect key files | -| Endpoint compromise (keylogger, malware on the host) | Outside the archive's trust boundary | Secure the machine where you type the password or hold the key | -| Cache-timing side channels (C AES) | Table-based S-box lookups | Build with Jasmin AES-NI when available; avoid multi-tenant VMs | -| Memory forensics during operation | Keys on stack during compress/extract | `zupt_secure_wipe()` on completion; `mlock()` planned | -| Deniability | Archive header identifies format | `.zupt` magic bytes visible; ENCRYPTED flag in header | -| Weak passwords | PBKDF2-SHA256 (600k) is the default KDF; Argon2id (memory-hard) is available in a WITH_SDK=1 build | Use `--pq` mode for critical data — keys are random, not derived from a password | -| Traffic analysis / metadata | Archive size reveals data volume; file list, sizes, mtimes not padded | Outside VaptVupt's scope | -| File permission/ownership | Not stored in archive | Documented in README.md | -| Spectre-class side channels in callers | Below the constant-time primitive layer | Host OS / compiler mitigations | - -### Quantum Threat Analysis - -Scenario: adversary captures an encrypted archive today, stores it, and -attempts decryption when a cryptographically-relevant quantum computer is -available. - -| Mode | Classical Security | Quantum Security | Verdict | -|------|-------------------|-----------------|---------| -| Password (`-p`) | Password-dependent + 256-bit AES | ~128-bit (Grover on AES), PBKDF2 accelerated | Vulnerable — use `--pq` | -| PQ Hybrid (`--pq`) | ~128-bit (X25519) | NIST Level 3 (ML-KEM-768) | Protected | - -In `--pq` mode: even if Shor's algorithm breaks X25519, ML-KEM-768 -protects the archive; even if a novel classical attack breaks ML-KEM, -X25519 still provides ~128-bit security. The hybrid design is secure if -either component holds. - -### Extracting untrusted archives — operational guidance - -The in-binary defenses are the primary control; the following are defense -in depth: - -1. Extract into a dedicated empty directory (not `~/Downloads` or `/tmp`). -2. Audit symlinks in the target directory before extraction. -3. Run extraction as a low-privilege user, never root. -4. On Windows, pre-create the target directory with restrictive ACLs - (the `O_NOFOLLOW` defense is POSIX-only). - -### Out of scope - -- External independent audit. -- Side-channel testing on production hardware (timing leaks). -- Formal verification beyond the Jasmin constant-time primitives. - ---- - -## CSPRNG Policy - -| Platform | Primary Source | Fallback | Failure Mode | -|----------|---------------|----------|--------------| -| Linux | `getrandom(2)` | `/dev/urandom` | Hard exit — no encryption without CSPRNG | -| macOS | `/dev/urandom` | None | Hard exit | -| Windows | `RtlGenRandom` | None | Hard exit | - -There is no `rand()`, `srand()`, or any weak PRNG fallback anywhere in the -codebase. If the OS CSPRNG is unavailable, VaptVupt exits with an error. -This is a deliberate design choice — weak random keys are worse than no -encryption. - ---- - -## Supported Platforms - -| Platform | Compiler | Threading | CSPRNG | Status | -|----------|----------|-----------|--------|--------| -| Linux x86-64 | GCC 5+ / Clang 3.5+ | pthreads | `getrandom(2)` | Primary | -| Linux ARM64 | GCC 5+ | pthreads | `getrandom(2)` | Tested | -| macOS x86-64/ARM64 | Apple Clang | pthreads | `/dev/urandom` | Tested | -| Windows x86-64 | MinGW / MSVC 2015+ | Win32 threads | `RtlGenRandom` | Tested | -| FreeBSD | GCC / Clang | pthreads | `/dev/urandom` | Untested (expected to work) | - ---- - -## Verification Commands - -Anyone can verify the security claims. The default build needs only a C -compiler + make (plus libm/pthread); no external crypto library. - -```bash -# Build -make - -# Functional tests -make test-all - -# Memory safety +```sh make test-asan - -# NIST/RFC test vectors -make test-vectors && ./test_vectors - -# Verify Jasmin symbols are active -nm vaptvupt | grep "zupt_mac_verify_ct\|zupt_ct_select_32" -# Expected: T zupt_mac_verify_ct -# T zupt_ct_select_32 - -# Verify Jasmin compilation (requires jasminc) -jasminc -arch x86-64 -o /dev/null jasmin/zupt_mac_verify.jazz -jasminc -arch x86-64 -o /dev/null jasmin/zupt_mlkem_select.jazz +make test-asan-run ``` ---- +The first command builds the sanitizer configuration; the second executes its +test suite. Neither substitutes for the normal optimized build and tests. -© 2026 Cristian Cezar Moisés — AGPL-3.0-or-later (dual-licensed AGPL + commercial) +The full local Linux `make release-check` passed on the immutable, non-promoted +5.2.2 candidate at `ff99770`. Its recorded evidence includes packaging +`PASS=49 FAIL=0 SKIP=0`, +the 39/39 source-only scanner suite, strict GCC and Clang builds, GCC +`-fanalyzer`, 9/9 static analysis in a tool-enabled run, ASan/UBSan/LSan, and +1,000 mutation-fuzz iterations without a sanitizer-detected crash. An earlier +off-screen GUI smoke run is supporting evidence, not an exact-candidate package +result. + +Post-tag CI integration failures prevented 5.2.2 promotion. Those upstream +self-audit results are not independent certification and do not transfer to +5.2.8. The immutable 5.2.3 candidate was not promoted because its source-policy +test assumed LF for a Windows `.bat` file checked out as CRLF. The immutable +v5.2.4 candidate was not promoted after exact-tag GitHub Actions run +`33431386002`: 12 jobs succeeded, the sole openSUSE job failed in its +standalone source-service harness because it did not enter the service +directory, and dependent Windows and macOS jobs were skipped. A local +Tumbleweed reproduction confirmed that `refs/tags/v5.2.4` is valid and that +`os.chdir(service_dir)` lets `obs_scm`, `tar`, and `recompress` complete with a +source-scanned archive. This was a release/test integration defect, not a +product, archive, cryptographic, codec, or SDK ABI change, and its evidence does +not transfer automatically to 5.2.8. The immutable v5.2.5 candidate was also +not promoted: exact-tag GitHub Actions run `33434986357` recorded 13 successful +jobs and failed native Windows/macOS jobs. Its Windows fixture-byte and macOS +secure-wipe/Bash 3.2 defects were corrected for 5.2.6. A targeted clean-clone +run of the corrected scanner under genuine GNU Bash 3.2.57 passed repository, standalone +tree, standalone archive, and root-plus-tag modes; that local compatibility +result does not transfer to any other gate. Exact-tag v5.2.6 run `33442264243` +then completed 13 jobs successfully but failed native macOS because x86 SHA-NI +test helpers were unused on arm64 under `-Werror`, and failed native Windows +when argv transcoding aborted the safe UTF-8 fixture. Those are test-harness +integration defects, not product, archive, cryptographic, codec, or SDK ABI +changes; v5.2.6 remained unpromoted, so its results did not transfer to the +required 5.2.8 suite. The immutable v5.2.7 candidate was likewise not +promoted: exact-tag run `33445470664` concluded `cancelled` at +`2026-08-31T23:11:19Z`, with 13 successful jobs, one failed macOS job after +raw-C1 fixture creation returned `EILSEQ`, and one cancelled Windows job after +the hosted job stalled in `make check`; a MinGW/Wine reproduction isolated the +cause to a redirected password prompt entering `_getch`. Version 5.2.8 makes +both test boundaries fail or skip without hanging. Manual pre-tag run +`33452602634` subsequently passed 14 of 15 jobs, including the native macOS +DMG and the Windows source audit, build, and full distribution checks. The +remaining Windows smoke failure was an old MSYS `grep` non-BMP pattern boundary +after ZUPT had compressed and verified all inputs; MinGW/Wine confirmed ZUPT's +byte-exact UTF-8 listing. The corrected gate validates Latin-1, BMP, and +non-BMP listing bytes without locale-sensitive matching, then requires +extraction and a full tree diff. The failed run is not exact-candidate +evidence. Exact-tag run `33456209269` then completed 15/15 jobs successfully, +including native Windows/macOS, the pinned local OBS service chain, package +installation/round trips, source-only checks, analyzers, and sanitizers. +Promotion run `33457868306` published the exact 13-file allowlist after +format, metadata, payload, and checksum validation. An unavailable or +unexecuted environment remains `SKIP`, never `PASS`; successful project CI is +still not independent security certification. + +Run target-native static analyzers and package checks as additional evidence. +Do not infer x86_64, aarch64, ppc64le, s390x, riscv64, macOS, Windows, Leap, or +SLE success from these commands unless that exact environment produced a +successful recorded result. + +ZUPT application code is distributed under AGPL-3.0-or-later. The bundled +VaptVupt codec source is GPL-3.0-or-later. The two xxHash-derived XXH64 units +also carry BSD-2-Clause. The pq-crystals/kyber-derived portions of native +ML-KEM carry CC0-1.0 in addition to the application license, and the x86 BCJ +state machine is adapted from public-domain LZMA SDK source. Native X25519 +portions adapted from curve25519-donna conservatively retain BSD-3-Clause. See +`LICENSE`, `LICENSE-GPL-3.0`, `LICENSE-BSD-2-Clause`, `LICENSE-BSD-3-Clause`, +`LICENSE-CC0-1.0`, `NOTICE`, and `THIRD-PARTY-NOTICES.md`. Historical license +grants for exact earlier material are recorded in the 5.2.2 licensing erratum; +the current notices do not revoke them. diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 60fedea..025f76d 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -1,110 +1,179 @@ -THIRD-PARTY NOTICES -=================== +# Third-party and bundled-component notices -This document records VaptVupt's runtime dependencies and build-time -tools. If you redistribute VaptVupt, you must preserve this attribution -document along with the LICENSE file. +This file records bundled source, generated textual source and optional system +dependencies. Preserve it with LICENSE, NOTICE, and the applicable license +texts. -------------------------------------------------------------------------- -Licensing -------------------------------------------------------------------------- +## Bundled VaptVupt codec -**Note on VaptVupt LZ codec licensing**: the VaptVupt LZ codec -(src/vv_*.c, src/vaptvupt_api.c, include/vaptvupt*.h) is licensed -GPL-3.0-or-later (not AGPL like the rest of the project) so that, with -sufficient maturity, it can be considered for upstreaming into the Linux -or BSD kernels, which require GPL-compatible licenses. The author retains -the right to dual-license the codec under other terms for commercial use; -contact sac@securityops.co for inquiries. +The compression codec in src/vv_*.c, src/vaptvupt_api.c, +include/vaptvupt*.h, and include/vv_*.h is bundled as source and licensed +GPL-3.0-or-later. -The rest of the project (vaptvupt CLI, Jasmin source, GUI) is licensed -AGPL-3.0-or-later. Commercial licenses (relief from the AGPL network-use -clause) are available; contact sac@securityops.co. +- Recorded codec release: 2.65.3 +- Recorded upstream tag: v2.65.3 +- Standalone upstream: https://git.securityops.co/cristiancmoises/vaptvupt-codec +- Integration commit in this repository: + 59f9ebc59ea13c6edf1d199ca795cdbf00e62226 -------------------------------------------------------------------------- -Build-time tool (not redistributed) -------------------------------------------------------------------------- +The integration commit records an in-tree ANS safe-zone reserve applied on top +of that tag. Earlier integration commit a2350dd also records wrapper-default +changes used by the CLI. This repository did not retain the standalone tag +object hash, so the tag name and the immutable integration commits are the +provenance evidence available here; no unverified external hash is asserted. -**jasminc** — the Jasmin language compiler +The openSUSE package truthfully declares +bundled(vaptvupt-codec) = 2.65.3. No compiled codec object or library is +distributed in the source tree or source archive. -The constant-time cryptographic primitives in jasmin/*.jazz are -compiled to native assembly (jasmin/*.s) using the external `jasminc` -compiler. The jasminc tool is not bundled with VaptVupt; the AGPL .jazz -source files and their AGPL-licensed .s assembly output are bundled. +## Jasmin and textual assembly - Upstream: https://github.com/jasmin-lang/jasmin - License: MIT (the compiler itself; not relevant to VaptVupt's licensing) - Used by: VaptVupt's build system, only when re-generating jasmin/*.s - from jasmin/*.jazz (most users won't need to do this — - pre-built .s files ship in this repo). +Files under `jasmin/` include AGPL-licensed `.jazz` source or algorithm +descriptions and textual GNU assembly `.s`. The assembly is source, not an +object file. Provenance is recorded per production unit rather than treating +every `.s` file as generated: -------------------------------------------------------------------------- -Runtime system libraries (linked from the OS, never bundled) -------------------------------------------------------------------------- +- `zupt_mac_verify.s`, `zupt_mlkem_select.s`, and `zupt_x25519_fe.s` identify + themselves as output of Jasmin Compiler 2026.03.0; +- `zupt_aes_ctr.s` is recorded in its file header as `jasminc` output, but the + exact compiler version was not retained in that file, so no version stronger + than the repository record is asserted; +- `zupt_aes_ctr4.s` is hand-written production assembly matching the algorithm + documented by `zupt_aes_ctr4.jazz`; that `.jazz` file is not compiled. -These are standard system libraries provided by the operating system's -package manager (apt, dnf, pacman, etc.). They are dynamically linked -at runtime and are NOT redistributed as part of VaptVupt. +Regeneration of files identified as compiler output uses the external +`jasminc` compiler: -**libargon2** — Argon2id password hashing function (RFC 9106) +- Upstream: https://github.com/jasmin-lang/jasmin +- Compiler license: MIT - Required only for: the optional `make WITH_SDK=1` build. The default - build uses native PBKDF2-SHA256 and does not link - libargon2. - Linked at runtime: libargon2.so.1 - Version expected: 1.0+ (Debian/Ubuntu: libargon2-1) - Upstream: https://github.com/P-H-C/phc-winner-argon2 - License: Apache-2.0 OR CC0-1.0 (dual) - Copyright: (c) 2015 The Argon2 Authors - Used by: Argon2id password-derived encryption mode +The compiler itself is not bundled or redistributed. Hand-written assembly +must not be represented as generated or formally verified merely because a +corresponding `.jazz` description exists. -**OpenSSL libcrypto** — AES, SHA-256, AES-NI hardware backends +## Optional system libraries - Linked at runtime: libcrypto.so.3 - Version expected: 3.0+ - Upstream: https://www.openssl.org - License: Apache-2.0 - Copyright: (c) 1998-2026 The OpenSSL Project - Used by: AES-256-CTR, SHA-256, hardware-accelerated paths +The default WITH_SDK=0 WITH_PQBOX=0 build uses the operating system's C runtime, +math and threading libraries and does not bundle a shared library. -------------------------------------------------------------------------- -Compatibility with public standards -------------------------------------------------------------------------- +WITH_SDK=1 and WITH_PQBOX=1 are opt-in integrations. They use only headers and +libraries supplied by the system/toolchain configuration and fail explicitly +when those dependencies are unavailable: -Where VaptVupt implements public standards, it does so independently from -any reference implementation. Other projects in the post-quantum hybrid -encryption space (libsodium, age, Tink, rustls, etc.) were referenced as -prior art during design, but no code was copied from any external -project. Standards followed: +- libvuptsdk: enables --pq-sdk and the Argon2id-backed SDK path; +- libpqvaptvupt: enables --pq-box. - - FIPS 197 (AES) - - FIPS 202 (Keccak / SHA-3) - - FIPS 203 (ML-KEM) - - RFC 5297 (AES-SIV) - - RFC 5869 (HKDF) - - RFC 7748 (X25519) - - RFC 8032 (Ed25519) - - RFC 8439 (ChaCha20-Poly1305) - - RFC 9106 (Argon2) - - RFC 9180 (HPKE) +The former vendor/vuptsdk and vendor/pqvaptvupt header snapshots and all +fallbacks to local precompiled libraries were removed. No download occurs in +make, packaging build, or package checks. -------------------------------------------------------------------------- -Reporting attribution issues -------------------------------------------------------------------------- +## xxHash-derived source -If you believe VaptVupt redistributes code from a project not listed here, -or if attribution information is incomplete, please email: +`src/zupt_xxh.c` and `src/vv_xxh64.c` contain adapted XXH64 routines based on +xxHash by Yann Collet. xxHash is BSD-2-Clause, not public domain. The upstream +copyright, conditions, and disclaimer are preserved in +`LICENSE-BSD-2-Clause`; those obligations apply in addition to the AGPL or GPL +scope identified by each source file. - sac@securityops.co +- Upstream: https://github.com/Cyan4973/xxHash +- Upstream license: https://github.com/Cyan4973/xxHash/blob/dev/LICENSE -with the subject "[third-party]" and details of the issue. +## pq-crystals/kyber-derived ML-KEM source -------------------------------------------------------------------------- -License summary -------------------------------------------------------------------------- +`src/zupt_mlkem.c` contains portions adapted from the pq-crystals/kyber +reference implementation, including its NTT, base multiplication, Montgomery +conversion, and related representation conventions. The upstream project +offers that reference code under either CC0-1.0 or Apache-2.0; ZUPT elects +the CC0-1.0 option for those portions. Local integration and modifications +remain under AGPL-3.0-or-later, as recorded by the compound per-file SPDX +identifier. - VaptVupt CLI, Jasmin source, GUI: AGPL-3.0-or-later - VaptVupt LZ codec: GPL-3.0-or-later - Commercial license (any component): contact sac@securityops.co +- Upstream: https://github.com/pq-crystals/kyber +- Upstream license record: https://github.com/pq-crystals/kyber/blob/main/LICENSE +- Local introduction commit: c80332778fb10364a606bf0380f440dc7be66ced +- Local FIPS 203 correction commit: 862f4a2df6c756ebd0369e176ea68b5ac506f422 - Project home: https://git.securityops.co/cristiancmoises/vaptvupt +The repository did not retain an immutable upstream Kyber revision for the +original adaptation. No unverified upstream commit is asserted. The complete +CC0-1.0 legal text is in `LICENSE-CC0-1.0`. + +## curve25519-donna-derived X25519 source + +`src/zupt_x25519.c` contains portions adapted from the 5x51-bit +curve25519-donna implementation, including its field representation, packing, +constant-time swap, and inversion-chain approach. The upstream source file +describes the code as public domain, while the repository preserves a +BSD-3-Clause notice. This distribution conservatively retains that complete +BSD-3-Clause notice in `LICENSE-BSD-3-Clause`; local integration and +modifications remain AGPL-3.0-or-later under the compound per-file SPDX +identifier. + +- Upstream: https://github.com/agl/curve25519-donna +- Upstream license record: https://github.com/agl/curve25519-donna/blob/master/LICENSE.md +- Upstream copyright: Copyright 2008, Google Inc. +- Upstream author record: Adam Langley +- Local introduction commit: c80332778fb10364a606bf0380f440dc7be66ced + +The repository did not retain an immutable upstream revision for the original +adaptation. No unverified upstream commit is asserted, and the historical +reference to libsodium is treated as an implementation comparison rather than +an unsupported claim that libsodium was the copied source. + +## LZMA SDK x86 BCJ source + +The x86 state machine in `src/vv_bcj.c` is adapted from Igor Pavlov's +`C/Bra86.c` in the LZMA SDK. The official LZMA SDK is placed in the public +domain. The AArch64 filter in the same file is separately documented local +code and is not represented as LZMA SDK source. + +- Upstream: https://www.7-zip.org/sdk.html +- Upstream author: Igor Pavlov +- Upstream status: public domain + +The exact SDK version or revision used by the original integration was not +retained, so none is asserted. The former `clean-room` description was removed +because repository evidence cannot establish that development process. + +## SHA-Intrinsics SHA-NI source + +The SHA-NI compression path in `src/zupt_sha256_shani.c` is adapted from +Jeffrey Walton's public-domain `SHA-Intrinsics/sha256-x86.c` reference, which +records that it is based on Intel and Sean Gulley's miTLS material. The +immutable upstream reference below explicitly places the code in the public +domain; it therefore adds no separate package-license term. Local integration +and modifications remain AGPL-3.0-or-later. + +- Upstream: https://github.com/noloader/SHA-Intrinsics +- Audited source revision: d03795497f3e4576083fc2cd8fe0b924f24d0bb2 +- Upstream source: https://github.com/noloader/SHA-Intrinsics/blob/d03795497f3e4576083fc2cd8fe0b924f24d0bb2/sha256-x86.c +- Upstream author: Jeffrey Walton +- Upstream status: public domain +- Local introduction commit: 544a2cd64758478690e33a923b2ab75347122f51 + +## GUI image data + +The PNG and ICO files under gui/assets/ are non-executable first-party GUI data. +Their purpose, Git provenance and license scope, including the historical MIT +grant attached to their unchanged Git blobs, are recorded in +`gui/assets/README.md`. + +## AppImage type-2 runtime + +No AppImage is a promised or promoted 5.2.8 release asset. The upstream +type-2 runtime inspected during the 5.2.2 review statically linked musl, libfuse, +squashfuse, zstd, zlib, and mimalloc, but its own license notice did not list +mimalloc and the available release inputs did not provide a complete +LGPL-compatible source/relink handoff. ZUPT therefore does not +redistribute that runtime. + +`packaging/build-appimage.sh` remains an offline downstream helper. It accepts +no network input and requires the operator to supply both a locally verified +runtime and `APPIMAGE_RUNTIME_COMPLIANCE_FILE`, containing the license notices, +source correspondence or offer, and relink information applicable to those +exact runtime bytes. An artifact produced independently with that helper is +not covered by the 5.2.8 upstream release gates. + +## Reporting attribution issues + +Report incomplete or incorrect attribution to sac@securityops.co with the +subject [third-party]. diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 8147b47..edce9f9 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -1,311 +1,362 @@ -# VaptVupt threat model - -Plain-English description of what VaptVupt protects against, what it -doesn't, and what assumptions you're making when you use it. - -This document is for users and downstream packagers. Read it before -trusting VaptVupt with anything you can't afford to lose. - ---- - -## TL;DR - -VaptVupt is designed for at-rest backup encryption by someone who -controls the machine doing the encryption and the machine doing the -extraction. It is not a network protocol, a multi-party scheme, or -a substitute for full-disk encryption. - -| Use case | VaptVupt is appropriate? | -|---|---| -| Backing up files to an untrusted cloud (S3, Backblaze, Google Drive) | Yes | -| Backing up a disk image to external media you might lose | Yes | -| Long-term archival of personal/business data | Yes | -| Sharing an encrypted archive with someone you trust to handle the key | Yes, with care (see "Key distribution" below) | -| Real-time encrypted communication | No (use Signal, age, or TLS) | -| Multi-party access (n-of-m) | No (no threshold scheme) | -| Hiding the existence of an archive (steganography) | No (archive header has fixed magic bytes) | -| Protecting against a hostile machine you're encrypting on | No (a compromised host can read plaintext before encryption) | - ---- - -## Modes referenced in this document - -- `-p` / password mode: symmetric encryption with a key derived from a - password. The default build derives the key with PBKDF2-SHA256 - (600k iterations). Argon2id is available only in an upstream - `make WITH_SDK=1` build against the separately distributed - libraries. -- `--pq`: native post-quantum mode (ML-KEM-768 + X25519), the PQ mode - in the default build. The ML-KEM-768 implementation is in-tree. -- `--pq-sdk` / `--pq-box`: optional post-quantum modes backed by the - separately distributed `libzuptsdk` / `libpqvaptvupt` libraries. - Available only in a `make WITH_SDK=1` build. Key files for these - modes are produced by `vaptvupt keygen --sdk`, also SDK-only. - ---- - -## What VaptVupt protects against - -### 1. Confidentiality of archive contents (encrypted mode) - -An attacker with read access to the archive bytes cannot recover -plaintext file contents, file names, file sizes, file modes, or -embedded comments without the key/password, assuming: - -- The chosen mode is one of the encrypted modes (`-p`, `--pq`, or the - optional `--pq-sdk` / `--pq-box`) -- The password is strong enough to resist offline brute-force - (see "Password strength" below) -- The key file (for `--pq-sdk` / `--pq-box`) was not compromised at - generation time - -### 2. Integrity of every byte of an encrypted archive - -If any single bit of the on-disk archive bytes is flipped, the -extraction fails with an authentication error. Coverage layers: - -- Per-block HMAC-SHA256 with frame-preface AAD (F-09): every data - block carries an HMAC over its ciphertext and over the canonical - 29-byte preface (block_type, codec_id, block_flags, sizes, - plaintext-XXH64) -- Archive Integrity Trailer (F-08): HMAC-SHA256 over the 64-byte - header and 24 bytes of footer, appended after the footer -- Strict structural validation of the encryption-header block (F-09): - codec must be `STORE`, flags must be 0, csz must equal usz, the - plaintext XXH64 must match - -### 3. Tamper detection on plaintext archives (best-effort) - -Plaintext archives (no `-p`, no `--pq*`) are protected by XXH64 -plaintext checksums per block plus structural validation. This is -not cryptographic integrity — a determined attacker with write access -can produce a tampered plaintext archive that passes the checksum -(XXH64 is not collision-resistant). It does catch accidental -corruption and naive tampering. - -Use an encrypted mode if you need cryptographic integrity. - -### 4. Authentication failure indistinguishability (F-11) - -The default error message for "wrong password", "wrong PQ key", -and "actual header tamper" is the same single line: - -> `Error: Authentication failed (wrong key, wrong password, or tampered archive).` - -This prevents an attacker who can issue extraction attempts from -learning which check failed first via the stderr output. Timing is -also constant (HMAC is always run, branchless return). - -The detailed cause is available via `--verbose` for debugging on -machines under the user's own control. - -### 5. Post-quantum forward secrecy (`--pq` and optional `--pq-sdk`) - -The native `--pq` mode uses ML-KEM-768 (FIPS 203) hybridized with -X25519 via an HKDF combiner. Archives encrypted today cannot be -decrypted by a future quantum adversary holding only the ciphertext, -assuming: - -- ML-KEM-768 retains its claimed security level (NIST Category 3, - 192-bit classical / 96-bit quantum strength) -- X25519 hybridization protects against an unforeseen ML-KEM break -- The recipient's private key is not later compromised - -The optional `--pq-sdk` mode provides the same hybrid guarantee via -the separately distributed SDK libraries. - -### 6. Side-channel resistance for cryptographic primitives - -The hot crypto paths (AES-256-CTR, HMAC-SHA256 comparison, X25519 -field operations, ML-KEM polynomial arithmetic) are implemented in -Jasmin and proved constant-time at the assembly level on x86_64. -Non-Jasmin platforms (aarch64, fallback x86_64) use C implementations -that avoid secret-dependent branches and memory accesses where -feasible — but without formal proof. - ---- - -## What VaptVupt does NOT protect against - -### 1. Compromised endpoints - -VaptVupt cannot protect against: - -- Malware on the machine doing the encryption (it sees plaintext - before any crypto is applied) -- Malware on the machine doing the extraction (it sees plaintext - after decryption) -- A hardware keylogger capturing the password -- A compromised user account that can read your files or - `~/.zupt-key` directly -- Cold-boot attacks on running machines - -If you don't trust the machine, VaptVupt cannot help. - -### 2. Key compromise - -If the password or `~/.zupt-key` is leaked: - -- All archives encrypted with that key are decryptable -- VaptVupt has no forward secrecy across archives — each archive - is encrypted under a single static key derived from the password - or stored in the key file -- There is no key-rotation feature; rotate by re-encrypting - archives under a new password/key and securely deleting the old - password/key - -For high-value, long-term archives, treat the key file as you -would a master password: store it offline, encrypt it under -another layer (e.g. on an encrypted USB), and rotate periodically. - -### 3. Password strength - -Password mode derives the key with PBKDF2-SHA256 (600k iterations) -in the default build, or Argon2id in a `make WITH_SDK=1` build. A -key derivation function slows offline guessing but does not make a -short, common password safe: a determined attacker with GPU clusters -or cloud compute can still exhaust a weak password. - -Use a long, high-entropy password — a multi-word diceware passphrase -or a random 16+ character string with a full alphabet. For critical -data, use a key-file mode (native `--pq`, or the optional `--pq-sdk` -with a random key file from `vaptvupt keygen --sdk`) so the key is -CSPRNG output, not derived from human-typed text. - -### 4. Metadata leakage from archive structure - -Even with encryption, an attacker who can see the archive bytes -can infer: - -- Approximate file count (from `total_blocks` in the footer) -- Total archive size (file size on disk) -- Whether the archive is encrypted at all (`ZUPT_FLAG_ENCRYPTED` - in the global flags is visible) -- Whether the archive is solid or per-file mode (visible flag) -- Whether post-quantum mode is in use (visible flag) -- Approximate file size distribution (block sizes are visible - even when block payloads are encrypted) -- Archive creation time (a 64-bit timestamp in the header) -- A random 16-byte UUID per archive (no information leak, but - globally identifies the archive across copies) - -If metadata privacy matters, layer VaptVupt under another tool that -hides bulk metadata (e.g., put the `.zupt` file inside a fixed-size -encrypted container). - -### 5. Network attacks - -VaptVupt is not a network protocol. There is no: - -- Forward-secure session establishment (use TLS or Noise) -- Mutual authentication of remote parties (use signed messages or - TLS client certs) -- Replay protection across sessions (archives can be replayed by - an attacker who can write to the destination) -- Network-layer encryption (use TLS to transport `.zupt` files) - -### 6. Multi-party schemes - -There is no threshold cryptography, no n-of-m sharing, no -multi-party computation, no proxy re-encryption. Each archive -has exactly one decryption credential (one password OR one -recipient key). To give two people access to the same archive, -they must share the password or the key file. - -### 7. Plausible deniability / hidden volumes - -VaptVupt archives have a fixed 6-byte magic `\x90\x5a\x55\x50\x54\x01` -at offset 0. Anyone scanning the bytes can see it's a VaptVupt -archive. VaptVupt has no hidden-volume or duress-password feature. - -### 8. Side channels we don't claim to address - -- Power analysis (relevant for embedded targets, not commodity desktops) -- Electromagnetic emanation -- Acoustic side channels -- Network timing of upload patterns -- Filesystem-level metadata (mtime/atime of the `.zupt` file) - -### 9. Trusted setup of post-quantum primitives - -The in-tree ML-KEM-768 implementation was not independently audited -at the time of writing. We use NIST KAT vectors for correctness -verification but have not formally proven constant-time properties -for every PQ code path. - -For maximum assurance, treat the post-quantum layer as a hedge — it -does not replace the X25519 layer; both must be broken for an -attacker to recover plaintext. - -### 10. Format extension attacks - -The format is versioned (v1.6). Older readers may accept newer -archives in unexpected ways. We try to maintain forward -compatibility, but a careful attacker who can produce -malformed-but-just-valid archives may find parser-state issues that -don't rise to the level of a CVE. The fuzzing harness -(`make fuzz-format`) is the primary mitigation; report bugs. - -### 11. Compression-side-channel attacks (CRIME / BREACH style) - -VaptVupt compresses before encryption. If an attacker can: - -- Influence part of the plaintext (e.g. inject a known prefix) -- Observe the resulting archive size precisely - -then they can use the compression ratio to learn information about -the rest of the plaintext — this is the classic CRIME/BREACH attack -against TLS compression. - -VaptVupt is designed for offline backup, where attacker-controlled -plaintext injection is rare. If your threat model includes -attacker-chosen plaintext mixed with secret plaintext in the same -archive, use `--no-compress` (codec 0 = STORE) to disable the -LZ codec and eliminate this side channel. - ---- - -## Cryptographic assumptions - -VaptVupt's security rests on the following standard assumptions: - -| Assumption | What breaks if it fails | -|---|---| -| AES-256-CTR is a secure stream cipher | All encrypted archives become readable | -| HMAC-SHA256 is a secure PRF / MAC | Tamper detection fails; integrity can be forged | -| PBKDF2-SHA256 (or Argon2id, WITH_SDK) is a secure password KDF | Password-mode archives become brute-forceable faster | -| ML-KEM-768 retains NIST Category 3 security | `--pq` / `--pq-sdk` reduce to the X25519 layer | -| X25519 retains 128-bit security (no quantum) | PQ modes reduce to the ML-KEM layer; classical password mode unaffected | -| HKDF-SHA256 is a secure key-derivation construction | Combined PQ + classical keys may be predictable | -| SHA3 / SHAKE retain pre-image and collision resistance | Auxiliary protocol bindings may be forged | - -If you don't trust one of these primitives, VaptVupt cannot protect -you. We rely on the same primitives the broader cryptographic -community has standardized. - ---- +# ZUPT 5.2.8 threat model + +This document defines the security boundary of the ZUPT archive tool. It is +not a certification, a guarantee against every hostile input, or a substitute +for reviewing the exact source and binary used for important data. + +## Intended use + +ZUPT is intended for at-rest backup archives created and restored on +machines controlled by the user. It can be used when the storage provider or +physical medium is not trusted, provided encryption is enabled and credentials +remain secret. + +It is not a network protocol, a full-disk encryption system, a multi-party or +threshold scheme, a password manager, or a way to make an archive's existence +plausibly deniable. + +## Baseline considered here + +The upstream baseline is built from the 5.2.8 source with: + +```sh +make WITH_SDK=0 WITH_PQBOX=0 +``` + +It contains the native password, ML-KEM-768 + X25519 hybrid `--pq`, and +ML-KEM-768-only `--pq-only` modes. It does not load a precompiled library from +the repository and does not download a dependency while building. + +`WITH_SDK=1` and `WITH_PQBOX=1` add separately installed system libraries and +change the assessed code boundary. The SDK and PQBOX integrations must be +reviewed with their exact packaged source and version; success of the baseline +tests is not evidence for them. + +Textual assembly under `jasmin/` is a separate `WITH_JASMIN=1` option for +supported x86_64 compiler targets. The directory contains both generated and +separately identified hand-written assembly. Portable C is the default. +Architecture portability is a source property, not evidence that an unexecuted +architecture passed. + +## Assets + +The assets ZUPT tries to protect are: + +- archived file contents and encrypted index data; +- the integrity and ordering of encrypted archive blocks and current global + metadata covered by the archive integrity trailer; +- private keys, passwords, and derived encryption/MAC keys while held by the + trusted caller; +- safe placement of extracted entries within the requested destination. + +The archive's existence, total byte length, magic, encryption/framing flags, and +some size/structure information are observable. Plain archives provide +corruption detection, not cryptographic protection against an active attacker. + +## Adversaries considered + +The design considers an adversary who can read, copy, truncate, reorder, or +modify stored archive bytes but cannot read the encryption endpoint's memory or +credentials. It also considers accidental corruption and malicious archive +entry paths during extraction. + +The following adversaries are outside the protection boundary: + +- malware, a keylogger, or an administrator on the source or restore endpoint; +- an attacker who obtains the password or matching private key; +- a malicious compiler, kernel, CPU, firmware, or random-number generator; +- an attacker with unrestricted side-channel observation of a shared machine; +- an attacker allowed unbounded CPU, memory, or storage denial of service. + +## Security properties + +### Encrypted archive confidentiality + +Password and native PQ modes encrypt blocks with AES-256-CTR and authenticate +them with HMAC-SHA256. Confidentiality depends on unique nonces, correct +implementations, OS randomness, and credential secrecy. In password mode it +also depends on password entropy; PBKDF2-SHA256 slows but cannot prevent offline +guessing of a weak password. + +Prefer `--password-prompt`, `--pass-file`, or `--pass-fd`. A password supplied +through `-p/--password` can be visible through process inspection or shell +history. A password file is protected only by the caller's filesystem choices; +ZUPT does not validate its ownership or permission bits. A descriptor is +trusted input inherited from the caller. Both non-interactive forms read one +line and reject empty, NUL-containing, or overlong values. The descriptor form +duplicates but shares the underlying stream/offset and may buffer beyond the +line, so callers should provide a descriptor dedicated to that password read. +On POSIX, handled prompt interruptions restore the saved terminal state before +termination; an exact-candidate PTY regression is required before release. +On Windows, a prompt is entered only for a real console input handle; +redirected input and console EOF fail instead of blocking in `_getch`. + +Native private-key generation uses no-replace creation with POSIX mode `0600` +or a Windows current-user-only DACL. A failed write, flush/fsync, or close leaves +the incomplete or durability-uncertain exclusive file for manual review and +removal instead of risking an unlink-after-close race against a replacement +pathname. ZKEY and ZPQK inputs +are accepted only after checksum, version, flags, reserved bytes, exact size, +and public/private role validation. This prevents role confusion and +partial/trailing-key acceptance; it does not protect a key after endpoint or +account compromise. + +When the optional system SDK is enabled, the in-repository adapter copies a key +through the core atomic publisher, applies POSIX mode through the already-open +temporary descriptor, and publishes only after copy/close checks succeed. Its +`sdk-test` regression preserves existing symlink/hardlink targets and verifies +private/public modes. This narrows the adapter boundary; it does not extend the +baseline assessment to the external SDK implementation. + +### Encrypted archive integrity + +Current encrypted archives authenticate ciphertext, canonical block metadata, +and each frame's logical position. DATA and DEDUP_REF frames both receive this +positional AAD. A reference is authenticated at its own position and carries +the authenticated source position needed to verify the referenced DATA frame, +so exchanging otherwise equivalent frames is not accepted. + +Current archives carry an archive-integrity trailer for global metadata. The +`extract`, `list`, `test`, and `disk restore` paths refuse any no-AIT layout by +default without relying on an unauthenticated header flag. +`--allow-legacy-no-ait` is a narrowly scoped, warning-producing recovery option +for those commands when the caller already trusts a pre-AIT archive. Selecting +it for attacker-controlled storage removes the header/footer authentication +assumption and is outside this threat model. `info` is an unauthenticated +framing inspection that reports apparent AIT presence but validates neither the +trailer nor archive contents. These checks do not prevent deletion of the +entire archive, rollback to an older valid archive, or storage-layer replay. + +Archive comments remain untrusted presentation data even when they are +authenticated. Display paths render control bytes without emitting raw terminal +control sequences, limiting terminal-output injection while leaving the stored +and authenticated comment bytes unchanged. + +New 5.2.2 encrypted+dedup archives authenticate each reference offset. New +encrypted disk archives also authenticate an index that binds image size, +block count, and a chained XXH64 hash of the complete restored stream. The +writer's additional SHA-256/128 comparison is only an in-memory collision guard +before deduplication; it is not an on-disk cryptographic hash. XXH64 is not +cryptographic, so a writer who controls a plain archive can recompute it. + +Plain archives use non-cryptographic checksums. A writer who controls a plain +archive can recompute them. + +### Native hybrid post-quantum mode + +The `--pq` mode combines an ML-KEM-768 shared secret and an X25519 shared secret +as implemented in 5.2.2: + +```text +hybrid_ikm = ml_ss XOR x25519_ss +archive_key = SHA3-512(hybrid_ikm || ml_ct || ephemeral_pk || + "ZUPT-HYBRID-v1") +``` + +Its goal is harvest-now/decrypt-later resistance if ML-KEM-768 remains secure, +with X25519 as a classical hedge under the combiner assumptions. This is not +session forward secrecy: compromise of the recipient's long-term private key +can compromise previously captured archives encrypted to it. + +The native `--pq-only` mode removes X25519 and derives a key from ML-KEM-768 +alone. Use it only when a policy specifically excludes the classical component; +it loses the hybrid hedge. + +The in-tree ML-KEM code has project tests, including known-answer vectors and a +conditional OpenSSL 3.5 interoperability test. It has not been independently +audited or formally verified as a whole implementation. + +### Extraction containment + +The reader rejects absolute paths, traversal components, control characters, +ambiguous trailing dot/space components, NTFS alternate-stream syntax, and +reserved Windows device names. POSIX extraction resolves every parent below a +pinned destination descriptor with no-follow operations after canonicalizing +the user-selected root once. Windows extraction +uses handle-relative traversal, rejects reparse-point parents, and publishes the +final name by handle without replacing an existing leaf. A checked path is not +re-resolved through a mutable parent. + +Decoded bytes are first written to a private, exclusively created temporary +file. The final name is published only after the expected decoded size and +chained checksum match and the stream closes successfully; failures remove the +temporary through its descriptor or handle. These controls reduce traversal, +link, race, and partial-output risks, but do not establish that no parser or +filesystem bug can exist. + +Benchmark scratch data lives in a random private directory. Cleanup resolves +POSIX components without following links and deletes relative to pinned +descriptors. On Windows it retains no-delete-sharing ancestor handles, refuses +reparse-point recursion, then reopens each emptied directory relative to its +pinned parent and verifies its filesystem identity before handle-based +deletion. An attacker who inserts a link can cause cleanup failure, but the +cleanup must not traverse to the link target. + +The Windows handle-relative boundary in 5.2.8 covers normal local Win32 paths. +Win32 extended-length and device-namespace paths, raw UNC output roots, and +mapped/network-drive output are not supported. Cross-build and Wine results are +not a substitute for the required native `windows-latest` Unicode package +gate. Restore locally before moving verified output to network storage. + +Disk restore copies the measured compacted archive into one exclusively +created, auto-deleted scratch file. Preflight and restoration consume that same +open snapshot. An explicit `ZUPT_TMPDIR` selects an existing scratch directory; +failure there does not fall back to consuming the mutable source pathname. On +POSIX, the destination is opened once without truncation or final-symlink +following, classified with `fstat`, and the same raw-device descriptor is +retained for supported Linux, macOS, and FreeBSD capacity checks and writes. +Regular-file output retains atomic publication. A raw target is rejected before +writing if its capacity is unknown or smaller than the image. These controls +reduce source exchange, target exchange, and immediate overrun risk but do not +protect against a compromised kernel/device, a wrongly selected sufficiently +large device, power loss, or hardware failure. + +The SDK publication, POSIX disk-target, and benchmark-cleanup changes address +CodeQL High #5, #6, and #7 respectively. Their source review and regressions +alone are project evidence, not independent certification. Exact-tag run +`33456209269` subsequently passed all 15 hosted jobs at +`ebb9ab3aa1d42c50030ca02883f6162dc4771fe1`; final release-commit CodeQL run +`33456049125` completed successfully with zero open alerts. + +For an untrusted archive: + +1. use a new empty destination outside sensitive trees; +2. run as a dedicated unprivileged user, never root; +3. apply a container, sandbox, resource limits, and a storage quota when + available; +4. inspect extracted paths, types, permissions, and content before moving them; +5. never restore a disk image to a device without independently confirming both + source and destination. + +## Non-goals and residual risks + +ZUPT does not claim to provide: + +- resistance to cache, power, EM, acoustic, speculative-execution, or all + compiler-introduced timing side channels; +- bounded resource consumption for every malformed archive; +- confidentiality of archive size or complete framing metadata; +- protection against compression-length oracles when secret and + attacker-controlled data are compressed together; +- rollback detection across multiple valid versions of a backup; +- forward-secure sessions, remote authentication, replay protection, or secure + transport; +- automatic key rotation, recovery, escrow, threshold access, or secure + deletion; +- preservation of every operating-system ACL, ownership attribute, extended + attribute, or special-file semantic; +- safe operation on a compromised host. + +## Credential handling + +- Generate PQ keys on a trusted system using the OS CSPRNG. +- Keep private keys separate from the archive and from release/package inputs. +- Store an offline recovery copy and test recovery before relying on a backup. +- Use a distinct high-entropy credential where compromise isolation matters. +- Re-encrypt under a new credential after suspected disclosure; there is no + in-place key rotation. +- Never include credentials or sensitive archives in bug reports or CI logs. + +## Supply-chain boundary + +Git and upstream source archives are source-only. They must pass +`scripts/check-source-only.sh` and must not contain executable code artifacts, +objects, shared/static libraries, distribution packages, unsafe symlinks, or Git +LFS pointers. + +Nested inspection is itself an untrusted-input boundary. The release scanner +must cap recursion depth, archive members, per-entry expansion, and total +expanded bytes and fail closed when a cap is reached. Commit `ff99770` passed +all 39 source-only scanner cases, including GNU thin archives, scanner-bomb +limits, and safe diagnostic cases. + +DEB, binary RPM, SRPM, notice-bearing Linux tar.xz, source-only portable GUI +ZIP, Windows ZIP, and macOS DMG files can be published separately from the +tagged source. Each artifact extends the trust boundary to its builder, +toolchain, runner image, and packaging scripts. Treat it as validated only when +the exact target has a recorded build, content/package inspection, extracted or +installed smoke test, and applicable archive round trip. An AppImage is not +promoted for 5.2.8; bare Linux and Windows executables are also excluded. + +For 5.2.8, that gated artifact scope covers the CLI files plus the exact GUI +DEB, noarch/source RPM, and source-only portable ZIP named in the README. The +portable ZIP contains no compiled runtime and crosses the release boundary only +after source scans and an exact safe-member check. AppDir and Flatpak bundles +and GUI platform installers remain excluded; Windows ZIP and macOS DMG outputs +remain CLI-only. + +The immutable, non-promoted 5.2.2 candidate at `ff99770` passed the full local +`make release-check`: packaging reported `PASS=49 FAIL=0 SKIP=0`; strict GCC, +strict Clang, GCC `-fanalyzer`, the 9/9 tool-enabled static-analysis run, +ASan/UBSan/LSan, and 1,000 mutation-fuzz iterations passed. Earlier off-screen +GUI smoke evidence is retained separately. Post-tag CI integration failures +prevented 5.2.2 promotion. This upstream self-review is not an independent +certification and is not 5.2.8 evidence. The immutable 5.2.3 candidate was not +promoted because its source-policy test assumed LF for a Windows `.bat` checkout +that correctly used CRLF. The immutable v5.2.4 candidate was not promoted after +exact-tag GitHub Actions run `33431386002`: 12 jobs succeeded, the sole openSUSE +service-harness job failed because its standalone executor did not enter the +service directory, and dependent Windows/macOS jobs were skipped. A local +Tumbleweed reproduction established that the explicit `refs/tags/v5.2.4` +revision works and that `os.chdir(service_dir)` completes the source-service +chain. This narrows the failure to release/test integration; it changes no +product, archive, cryptographic, codec, or SDK ABI boundary and supplies no +automatic 5.2.8 evidence. The immutable v5.2.5 candidate was not promoted after +exact-tag GitHub Actions run `33434986357`: 13 jobs succeeded, but native +Windows and macOS failed on fixture-byte preservation and Darwin/Bash 3.2 +portability respectively. The corresponding 5.2.6 corrections were followed by +exact-tag run `33442264243`: 13 jobs succeeded, while native macOS failed on +x86-only SHA-NI helper declarations unused on arm64 under `-Werror`, and native +Windows aborted during safe UTF-8 fixture argv transcoding. The v5.2.6 tag was +not promoted. Version 5.2.7 corrected those two boundaries, but its exact-tag +run `33445470664` concluded `cancelled` at `2026-08-31T23:11:19Z`, with 13 +successful jobs, one failed macOS job after raw-C1 filename creation returned +`EILSEQ`, and one cancelled Windows job after the hosted job stalled in `make +check`; a MinGW/Wine reproduction isolated the cause to a redirected password +prompt entering `_getch`. +The corresponding 5.2.8 fixture and prompt corrections alone did not establish +a result. Exact-tag run `33456209269` then passed 15/15 jobs, including +`sdk-test`, native Windows/macOS, the pinned local OBS source-service chain, +and the package/source gates. Promotion run `33457868306` published the exact +13 tested assets. Official authenticated OBS/Factory acceptance, the full +automatic debug-package result, and untested architectures remain unclaimed; +the earlier `debugsource` rpmlint `no-binary` finding remains unresolved and +unsuppressed. + +## Historical compatibility notes + +These are historical facts about earlier releases, retained to support recovery: + +- Releases through 4.1.0 could reuse an AES-CTR nonce in encrypted `--dedup` + archives. Release 4.2.0 changed to fresh random per-block nonces. Re-encrypt + affected older archives. +- Releases through 4.2.1 used round-3 CRYSTALS-Kyber semantics in the native PQ + path. Release 5.0.0 corrected the implementation to FIPS 203 ML-KEM-768, + changing native PQ key/archive compatibility. See `CHANGELOG.md` before + planning cross-version restoration. +- Pre-AIT archive layouts now fail closed by default. The explicit + `--allow-legacy-no-ait` read option is only for recovery from a known, trusted + historical archive and leaves its header/footer metadata outside the current + authenticated boundary. +- The 5.2.2 reader retains compatibility parsers for the fixed-width disk index + and encrypted-dedup linear AAD sequence published through 5.2.1. An actual + v5.2.1 password-encrypted DATA/DATA/REF/DATA disk fixture is stored as hexadecimal + text with source and hash provenance. The candidate lists, tests, extracts, and restores + it byte-exact, with a warning that the legacy index has no whole-image hash; + the full local Linux gate passed on commit `ff99770`. Older readers are not + claimed to accept new flag-gated 5.2.2 records, and untested historical mode + combinations remain unclaimed. + +Historical test counts in the changelog describe those releases. They do not +automatically become 5.2.8 results; current outcomes belong in the release +validation record, with unavailable environments marked `SKIP`. In particular, +runs made before the final positional-AAD and mandatory-AIT changes are not +final release gates for the resulting candidate. ## Reporting security issues -Email `sac@securityops.co` with the subject `VaptVupt security report`. -PGP key available on request. +Email **zupt@riseup.net** with `[security]` in the subject. Include the version, +platform, impact, and a minimal non-sensitive reproducer. Do not disclose the +issue publicly until a coordinated timeline has been agreed. -We will: - -- Acknowledge receipt within 7 days -- Investigate and publish a CVE / advisory if warranted -- Credit you in the CHANGELOG if you wish - -Please don't open public issues for security reports until we've -coordinated disclosure. For non-security bugs (parser edge cases, -documentation typos, performance issues), open a public issue -normally. - ---- - -## Document version - -This threat model covers archive format v1.6 as shipped in VaptVupt -4.1.0. It is part of the source tree (`THREAT_MODEL.md`) and -versioned with the project; this section will be updated as the -format evolves. +Document version: 5.2.8, 2026-08-31. diff --git a/build.bat b/build.bat deleted file mode 100644 index fac231e..0000000 --- a/build.bat +++ /dev/null @@ -1,21 +0,0 @@ -@echo off -echo Zupt v0.4 Build Script for Windows -where gcc >nul 2>nul -if %ERRORLEVEL% EQU 0 ( - gcc -Wall -Wextra -O2 -std=c11 -Iinclude ^ - src\zupt_main.c src\zupt_format.c src\zupt_lz.c src\zupt_lzh.c src\zupt_xxh.c ^ - src\zupt_sha256.c src\zupt_aes256.c src\zupt_crypto.c src\zupt_predict.c ^ - -lm -o zupt.exe - if %ERRORLEVEL% EQU 0 (echo [OK] zupt.exe) else (echo [FAIL]) - exit /b %ERRORLEVEL% -) -where cl >nul 2>nul -if %ERRORLEVEL% EQU 0 ( - cl /nologo /W4 /O2 /Iinclude /D_CRT_SECURE_NO_WARNINGS ^ - src\zupt_main.c src\zupt_format.c src\zupt_lz.c src\zupt_lzh.c src\zupt_xxh.c ^ - src\zupt_sha256.c src\zupt_aes256.c src\zupt_crypto.c src\zupt_predict.c ^ - /Fe:zupt.exe & del *.obj 2>nul - exit /b 0 -) -echo No C compiler found. -exit /b 1 diff --git a/completions/_vaptvupt b/completions/_vaptvupt deleted file mode 100644 index 5aab16e..0000000 --- a/completions/_vaptvupt +++ /dev/null @@ -1,136 +0,0 @@ -#compdef vaptvupt zupt -# SPDX-License-Identifier: AGPL-3.0-or-later -# Copyright (c) 2025-2026 Cristian Cezar Moisés -# -# Install: -# sudo install -m 644 completions/_zupt /usr/share/zsh/site-functions/_zupt -# or for a single user (anywhere in $fpath): -# cp completions/_zupt ~/.zsh/completion/_zupt -# # then in ~/.zshrc: -# # fpath=(~/.zsh/completion $fpath) -# # autoload -U compinit && compinit - -_zupt_levels() { - _values 'compression level' \ - '1[fastest, smallest window]' \ - '2[fast]' \ - '3[balanced (low)]' \ - '4[balanced]' \ - '5[balanced (high)]' \ - '6[high compression]' \ - '7[default; high]' \ - '8[maximum, 1MB window]' \ - '9[maximum, deep search]' -} - -_zupt_kdf() { - _values 'KDF' \ - 'argon2id[memory-hard, default since v2.4.1]' \ - 'pbkdf2[legacy 600k-iter PBKDF2-SHA256]' -} - -_zupt_threads() { - _values 'threads' '0[auto]' '1' '2' '4' '8' '16' '32' '64' -} - -_zupt_compress_opts() { - _arguments \ - '(-l --level)'{-l,--level}'[compression level]:level:_zupt_levels' \ - '(-b --block)'{-b,--block}'[block size in bytes]:size:' \ - '(-s --store)'{-s,--store}'[store without compression]' \ - '(-f --fast)'{-f,--fast}'[use fast LZ codec]' \ - '(--vv --vaptvupt)'{--vv,--vaptvupt}'[use VaptVupt codec]' \ - '--lzhp[use Zupt-LZHP codec (LZ77+Huffman, no SIMD)]' \ - '(-p --password)'{-p,--password}'[encrypt with password]:password:' \ - '--kdf[password KDF]:kdf:_zupt_kdf' \ - '(-c --comment)'{-c,--comment}'[embed archive comment]:text:' \ - '--comment-file[read comment from file]:file:_files' \ - '--pq[legacy PQ encryption]:pubkey:_files' \ - '--pq-sdk[PQ encryption via libzuptsdk]:pubkey:_files' \ - '(-D --dedup)'{-D,--dedup}'[block-level deduplication]' \ - '--solid[solid mode: single stream]' \ - '(-v --verbose)'{-v,--verbose}'[verbose output]' \ - '(-q --quiet)'{-q,--quiet}'[suppress non-error output]' \ - '(-t --threads)'{-t,--threads}'[thread count]:threads:_zupt_threads' \ - '*:files:_files' -} - -_zupt_extract_opts() { - _arguments \ - '(-o --output)'{-o,--output}'[output directory]:directory:_directories' \ - '(-p --password)'{-p,--password}'[decryption password]:password:' \ - '--pq[legacy PQ decryption]:privkey:_files' \ - '--pq-sdk[PQ decryption via libzuptsdk]:privkey:_files' \ - '(-v --verbose)'{-v,--verbose}'[verbose output]' \ - '(-t --threads)'{-t,--threads}'[thread count]:threads:_zupt_threads' \ - '*:archive:_files -g "*.zupt"' -} - -_zupt() { - local context curcontext="$curcontext" state line - local -a subcommands - - subcommands=( - 'compress:create an archive' - 'c:create an archive (alias)' - 'extract:extract an archive' - 'x:extract an archive (alias)' - 'list:list archive entries' - 'l:list archive entries (alias)' - 'test:verify archive integrity' - 't:verify archive integrity (alias)' - 'info:archive metadata (no key needed)' - 'bench:benchmark levels 1-9' - 'disk:full-disk backup/restore' - 'keygen:generate a key file' - 'version:print version info' - 'help:print help' - ) - - _arguments -C \ - '(-): :->command' \ - '(-)*:: :->args' - - case $state in - command) - _describe -t commands 'zupt subcommand' subcommands - ;; - args) - case $line[1] in - compress|c) - _zupt_compress_opts - ;; - extract|x) - _zupt_extract_opts - ;; - list|l|test|t) - _arguments \ - '(-p --password)'{-p,--password}'[password]:password:' \ - '--pq[legacy PQ privkey]:privkey:_files' \ - '--pq-sdk[PQ privkey]:privkey:_files' \ - '(-v --verbose)'{-v,--verbose}'[verbose]' \ - '*:archive:_files -g "*.zupt"' - ;; - info) - _arguments '*:archive:_files -g "*.zupt"' - ;; - disk) - _values 'disk action' 'backup' 'restore' - ;; - keygen) - _arguments \ - '--sdk[generate SDK v2 keypair]' \ - '--pq-sdk[same as --sdk]' \ - '-o[output keyfile]:file:_files' \ - '--pub[export public key from -k]' \ - '-k[source private key for --pub]:file:_files' - ;; - bench) - _arguments '*:files:_files' - ;; - esac - ;; - esac -} - -_zupt "$@" diff --git a/completions/_zupt b/completions/_zupt new file mode 100644 index 0000000..f1ae14a --- /dev/null +++ b/completions/_zupt @@ -0,0 +1,132 @@ +#compdef zupt +# SPDX-License-Identifier: AGPL-3.0-or-later + +local context state state_descr line +local -a _zupt_disk_legacy_options +typeset -A opt_args + +_zupt_password_options=( + '(-p --password)'{-p,--password}'[password in process arguments]:password:' + '--password-prompt[read password interactively without echo]' + '--pass-file[read password from first line of file]:password file:_files' + '--pass-fd[read password from inherited file descriptor]:file descriptor:' +) + +_zupt_pq_options=( + '--pq[native ML-KEM-768 + X25519 hybrid key]:key file:_files' + '--pq-only[native ML-KEM-768-only key]:key file:_files' + '--pq-sdk[optional system libvuptsdk key]:key file:_files' + '--pq-box[optional system libpqvaptvupt key]:key file:_files' +) + +_zupt_read_options=( + "${_zupt_password_options[@]}" + "${_zupt_pq_options[@]}" + '(-v --verbose)'{-v,--verbose}'[additional diagnostics]' + '--allow-legacy-no-ait[recover a trusted old archive without an integrity trailer]' +) + +_arguments -C \ + '1:command:->command' \ + '*::argument:->arguments' + +case $state in + command) + _values 'command' \ + 'compress:create an archive' 'c:create an archive' \ + 'extract:extract an archive' 'x:extract an archive' \ + 'list:list archive entries' 'l:list archive entries' \ + 'test:verify archive integrity' 't:verify archive integrity' \ + 'info:show framing metadata' 'i:show framing metadata' \ + 'bench:benchmark compression levels' 'b:benchmark compression levels' \ + 'disk:back up or restore a disk image' \ + 'keygen:generate or export a recipient key' \ + 'version:show version and build information' \ + 'help:show command help' '--version:show version and build information' \ + '-V:show version and build information' '--help:show command help' \ + '-h:show command help' + ;; + arguments) + case ${line[1]} in + compress|c) + _arguments \ + '(-l --level)'{-l,--level}'[compression level]:level:(1 2 3 4 5 6 7 8 9)' \ + '(-b --block)'{-b,--block}'[block size in bytes]:bytes:' \ + '(-s --store)'{-s,--store}'[store without compression]' \ + '(-f --fast)'{-f,--fast}'[use fast LZ codec]' \ + '(--vv --vaptvupt)'{--vv,--vaptvupt}'[force VaptVupt LZ + ANS codec]' \ + '--lzhp[force portable LZHP codec]' \ + "${_zupt_password_options[@]}" \ + '--kdf[password KDF]:KDF:(pbkdf2 argon2id)' \ + '(-c --comment)'{-c,--comment}'[archive comment]:comment:' \ + '--comment-file[read archive comment from file]:comment file:_files' \ + "${_zupt_pq_options[@]}" \ + '(-D --dedup)'{-D,--dedup}'[block-level deduplication]' \ + '(-S --solid)'{-S,--solid}'[solid single-stream compression]' \ + '(-y --force)'{-y,--force}'[overwrite an existing non-.zupt output]' \ + '(-v --verbose)'{-v,--verbose}'[additional progress output]' \ + '(-t --threads)'{-t,--threads}'[compression thread count]:threads:' \ + '1:output archive:_files -g "*.zupt"' \ + '*:input file or directory:_files' + ;; + extract|x) + _arguments \ + '(-o --output)'{-o,--output}'[output directory]:directory:_directories' \ + "${_zupt_read_options[@]}" \ + '(-t --threads)'{-t,--threads}'[decompression thread count]:threads:' \ + '1:archive:_files -g "*.zupt"' + ;; + list|l|test|t) + _arguments \ + "${_zupt_read_options[@]}" \ + '1:archive:_files -g "*.zupt"' + ;; + info|i) + _arguments '1:archive:_files -g "*.zupt"' + ;; + bench|b) + _arguments '--compare[compare available external compressors]' \ + '*:input file or directory:_files' + ;; + disk) + _zupt_disk_legacy_options=() + if [[ ${line[2]-} == restore ]]; then + _zupt_disk_legacy_options=( + '--allow-legacy-no-ait[recover a trusted old disk archive without an integrity trailer]' + ) + fi + _arguments -C \ + '1:disk command:(backup restore)' \ + '(-l --level)'{-l,--level}'[compression level]:level:(1 2 3 4 5 6 7 8 9)' \ + '(-b --block)'{-b,--block}'[block size in bytes]:bytes:' \ + '(-s --store)'{-s,--store}'[store without compression]' \ + '(--vv --vaptvupt)'{--vv,--vaptvupt}'[force VaptVupt LZ + ANS codec]' \ + '--lzhp[force portable LZHP codec]' \ + "${_zupt_password_options[@]}" \ + '--kdf[password KDF]:KDF:(pbkdf2 argon2id)' \ + '(-c --comment)'{-c,--comment}'[archive comment]:comment:' \ + '--comment-file[read archive comment from file]:comment file:_files' \ + '--pq[native hybrid key]:key file:_files' \ + '--pq-only[native ML-KEM-768-only key]:key file:_files' \ + '(-D --dedup)'{-D,--dedup}'[block-level deduplication]' \ + '(-v --verbose)'{-v,--verbose}'[additional progress output]' \ + '(-t --threads)'{-t,--threads}'[thread count]:threads:' \ + "${_zupt_disk_legacy_options[@]}" \ + '2:archive:_files' \ + '3:device or file:_files' + ;; + keygen) + _arguments \ + '(-o --output)'{-o,--output}'[output key file]:output file:_files' \ + '--pub[export a public key]' \ + '(-k --key)'{-k,--key}'[source private key]:private key:_files' \ + '(--pq-only --pqonly)'{--pq-only,--pqonly}'[native ML-KEM-768-only key format]' \ + '(--sdk --pq-sdk)'{--sdk,--pq-sdk}'[optional system libvuptsdk key format]' \ + '(--box --pq-box)'{--box,--pq-box}'[optional system libpqvaptvupt key format]' + ;; + esac + ;; +esac + +unset _zupt_password_options _zupt_pq_options _zupt_read_options +unset _zupt_disk_legacy_options diff --git a/completions/vaptvupt.bash b/completions/vaptvupt.bash deleted file mode 100644 index 967ee0a..0000000 --- a/completions/vaptvupt.bash +++ /dev/null @@ -1,160 +0,0 @@ -# bash completion for vaptvupt (with `zupt` legacy alias) -# SPDX-License-Identifier: AGPL-3.0-or-later -# Copyright (c) 2025-2026 Cristian Cezar Moisés -# -# Install (system-wide): -# sudo install -m 644 completions/vaptvupt.bash /usr/share/bash-completion/completions/vaptvupt -# sudo ln -sf vaptvupt /usr/share/bash-completion/completions/zupt -# or for a single user: -# cp completions/vaptvupt.bash ~/.local/share/bash-completion/completions/vaptvupt -# -# Reload your shell or `source` the file to pick up changes. - -_vaptvupt() { - local cur prev words cword - _init_completion -n = 2>/dev/null || { - # _init_completion missing on this host; fall back to manual setup. - local IFS=$' \t\n' - COMPREPLY=() - cur="${COMP_WORDS[COMP_CWORD]}" - prev="${COMP_WORDS[COMP_CWORD-1]}" - cword=$COMP_CWORD - words=("${COMP_WORDS[@]}") - } - - local subcommands="compress c extract x list l test t info bench disk keygen version help" - local global_opts="-v --verbose -q --quiet -t --threads -h --help" - - # First positional → subcommand - if [ "$cword" -eq 1 ]; then - COMPREPLY=( $(compgen -W "$subcommands" -- "$cur") ) - return 0 - fi - - local subcmd="${words[1]}" - - case "$prev" in - -p|--password) - # Don't complete passwords from filesystem - COMPREPLY=() - return 0 - ;; - -l|--level) - COMPREPLY=( $(compgen -W "1 2 3 4 5 6 7 8 9" -- "$cur") ) - return 0 - ;; - --kdf) - COMPREPLY=( $(compgen -W "argon2id pbkdf2" -- "$cur") ) - return 0 - ;; - -t|--threads) - COMPREPLY=( $(compgen -W "0 1 2 4 8 16 32" -- "$cur") ) - return 0 - ;; - -b|--block) - COMPREPLY=( $(compgen -W "65536 131072 262144 524288 1048576" -- "$cur") ) - return 0 - ;; - -o|--output) - _filedir -d - return 0 - ;; - --pq|--pq-sdk) - # Key files (no extension constraint) - _filedir - return 0 - ;; - --comment-file) - _filedir - return 0 - ;; - -c|--comment) - # Free-form text; no useful completion - COMPREPLY=() - return 0 - ;; - -k) - _filedir - return 0 - ;; - esac - - case "$subcmd" in - compress|c) - if [[ "$cur" == -* ]]; then - COMPREPLY=( $(compgen -W " - -l --level -b --block -s --store -f --fast - --vv --vaptvupt --lzhp - -p --password --kdf - -c --comment --comment-file - --pq --pq-sdk - --dedup -D --solid - -v --verbose -q --quiet -t --threads - $global_opts - " -- "$cur") ) - else - _filedir - fi - ;; - extract|x) - if [[ "$cur" == -* ]]; then - COMPREPLY=( $(compgen -W " - -o --output -p --password - --pq --pq-sdk - -v --verbose -t --threads - $global_opts - " -- "$cur") ) - else - _filedir 'zupt' - fi - ;; - list|l|test|t) - if [[ "$cur" == -* ]]; then - COMPREPLY=( $(compgen -W " - -p --password --pq --pq-sdk - -v --verbose - $global_opts - " -- "$cur") ) - else - _filedir 'zupt' - fi - ;; - info) - _filedir 'zupt' - ;; - disk) - if [ "$cword" -eq 2 ]; then - COMPREPLY=( $(compgen -W "backup restore" -- "$cur") ) - elif [[ "$cur" == -* ]]; then - COMPREPLY=( $(compgen -W " - -p --password --pq --pq-sdk - --kdf -c --comment --comment-file - -v --verbose - " -- "$cur") ) - else - _filedir - fi - ;; - keygen) - if [[ "$cur" == -* ]]; then - COMPREPLY=( $(compgen -W "--sdk --pq-sdk -o --pub -k" -- "$cur") ) - else - _filedir - fi - ;; - bench) - _filedir - ;; - version|help) - COMPREPLY=() - ;; - *) - _filedir - ;; - esac - return 0 -} - -complete -F _vaptvupt vaptvupt -# v3.0.0: legacy `zupt` name retained as an alias. -complete -F _vaptvupt zupt diff --git a/completions/vaptvupt.fish b/completions/vaptvupt.fish deleted file mode 100644 index c15fefa..0000000 --- a/completions/vaptvupt.fish +++ /dev/null @@ -1,112 +0,0 @@ -# Fish completions for zupt -# SPDX-License-Identifier: AGPL-3.0-or-later -# Copyright (c) 2025-2026 Cristian Cezar Moisés -# -# Install: -# sudo install -m 644 completions/zupt.fish /usr/share/fish/vendor_completions.d/ -# or for a single user: -# cp completions/zupt.fish ~/.config/fish/completions/ - -# ─── Subcommands ─── -complete -c vaptvupt -f -n '__fish_use_subcommand' -a 'compress c' -d 'Create an archive' -complete -c zupt -f -n '__fish_use_subcommand' -a 'compress c' -d 'Create an archive' -complete -c vaptvupt -f -n '__fish_use_subcommand' -a 'extract x' -d 'Extract an archive' -complete -c zupt -f -n '__fish_use_subcommand' -a 'extract x' -d 'Extract an archive' -complete -c vaptvupt -f -n '__fish_use_subcommand' -a 'list l' -d 'List archive entries' -complete -c zupt -f -n '__fish_use_subcommand' -a 'list l' -d 'List archive entries' -complete -c vaptvupt -f -n '__fish_use_subcommand' -a 'test t' -d 'Verify archive integrity' -complete -c zupt -f -n '__fish_use_subcommand' -a 'test t' -d 'Verify archive integrity' -complete -c vaptvupt -f -n '__fish_use_subcommand' -a 'info' -d 'Archive metadata (no key needed)' -complete -c zupt -f -n '__fish_use_subcommand' -a 'info' -d 'Archive metadata (no key needed)' -complete -c vaptvupt -f -n '__fish_use_subcommand' -a 'bench' -d 'Benchmark compression levels' -complete -c zupt -f -n '__fish_use_subcommand' -a 'bench' -d 'Benchmark compression levels' -complete -c vaptvupt -f -n '__fish_use_subcommand' -a 'disk' -d 'Full-disk backup/restore' -complete -c zupt -f -n '__fish_use_subcommand' -a 'disk' -d 'Full-disk backup/restore' -complete -c vaptvupt -f -n '__fish_use_subcommand' -a 'keygen' -d 'Generate a key file' -complete -c zupt -f -n '__fish_use_subcommand' -a 'keygen' -d 'Generate a key file' -complete -c vaptvupt -f -n '__fish_use_subcommand' -a 'version' -d 'Print version info' -complete -c zupt -f -n '__fish_use_subcommand' -a 'version' -d 'Print version info' -complete -c vaptvupt -f -n '__fish_use_subcommand' -a 'help' -d 'Print help' -complete -c zupt -f -n '__fish_use_subcommand' -a 'help' -d 'Print help' - -# Helper predicates -function __fish_zupt_using_subcommand - set -l cmd (commandline -opc) - if test (count $cmd) -gt 1 - contains -- $cmd[2] $argv - return $status - end - return 1 -end - -# ─── Compress options ─── -set -l compress_cmds compress c -complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -s l -l level -d 'Compression level (1-9, default 7)' -x -a '1 2 3 4 5 6 7 8 9' -complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -s l -l level -d 'Compression level (1-9, default 7)' -x -a '1 2 3 4 5 6 7 8 9' -complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -s b -l block -d 'Block size in bytes' -x -complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -s b -l block -d 'Block size in bytes' -x -complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -s s -l store -d 'Store without compression' -complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -s s -l store -d 'Store without compression' -complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -s f -l fast -d 'Use fast LZ codec' -complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -s f -l fast -d 'Use fast LZ codec' -complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -l vv -l vaptvupt -d 'Use VaptVupt codec' -complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -l vv -l vaptvupt -d 'Use VaptVupt codec' -complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -l lzhp -d 'Use Zupt-LZHP codec (no SIMD needed)' -complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -l lzhp -d 'Use Zupt-LZHP codec (no SIMD needed)' -complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -s p -l password -d 'Encrypt with password (prompted if empty)' -x -complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -s p -l password -d 'Encrypt with password (prompted if empty)' -x -complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -l kdf -d 'Password KDF' -x -a 'argon2id pbkdf2' -complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -l kdf -d 'Password KDF' -x -a 'argon2id pbkdf2' -complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -s c -l comment -d 'Embed archive comment (UTF-8, ≤4096 B)' -x -complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -s c -l comment -d 'Embed archive comment (UTF-8, ≤4096 B)' -x -complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -l comment-file -d 'Read comment from file' -r -complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -l comment-file -d 'Read comment from file' -r -complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -l pq -d 'Legacy PQ public key' -r -complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -l pq -d 'Legacy PQ public key' -r -complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -l pq-sdk -d 'PQ public key (libzuptsdk)' -r -complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -l pq-sdk -d 'PQ public key (libzuptsdk)' -r -complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -s D -l dedup -d 'Block-level deduplication' -complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -s D -l dedup -d 'Block-level deduplication' -complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -l solid -d 'Solid mode (single stream)' -complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -l solid -d 'Solid mode (single stream)' -complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -s v -l verbose -d 'Verbose output' -complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -s v -l verbose -d 'Verbose output' -complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -s q -l quiet -d 'Suppress non-error output' -complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -s q -l quiet -d 'Suppress non-error output' -complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -s t -l threads -d 'Thread count (0=auto)' -x -complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -s t -l threads -d 'Thread count (0=auto)' -x - -# ─── Extract / List / Test options ─── -set -l rw_cmds extract x list l test t -complete -c vaptvupt -n "__fish_zupt_using_subcommand $rw_cmds" -s o -l output -d 'Output directory' -x -a '(__fish_complete_directories)' -complete -c zupt -n "__fish_zupt_using_subcommand $rw_cmds" -s o -l output -d 'Output directory' -x -a '(__fish_complete_directories)' -complete -c vaptvupt -n "__fish_zupt_using_subcommand $rw_cmds" -s p -l password -d 'Decryption password' -x -complete -c zupt -n "__fish_zupt_using_subcommand $rw_cmds" -s p -l password -d 'Decryption password' -x -complete -c vaptvupt -n "__fish_zupt_using_subcommand $rw_cmds" -l pq -d 'Legacy PQ private key' -r -complete -c zupt -n "__fish_zupt_using_subcommand $rw_cmds" -l pq -d 'Legacy PQ private key' -r -complete -c vaptvupt -n "__fish_zupt_using_subcommand $rw_cmds" -l pq-sdk -d 'PQ private key (libzuptsdk)' -r -complete -c zupt -n "__fish_zupt_using_subcommand $rw_cmds" -l pq-sdk -d 'PQ private key (libzuptsdk)' -r -complete -c vaptvupt -n "__fish_zupt_using_subcommand $rw_cmds" -s v -l verbose -d 'Verbose output (surfaces top-MAC/SDK details on failure)' -complete -c zupt -n "__fish_zupt_using_subcommand $rw_cmds" -s v -l verbose -d 'Verbose output (surfaces top-MAC/SDK details on failure)' -complete -c vaptvupt -n "__fish_zupt_using_subcommand $rw_cmds" -s t -l threads -d 'Thread count' -x -complete -c zupt -n "__fish_zupt_using_subcommand $rw_cmds" -s t -l threads -d 'Thread count' -x - -# ─── Disk subcommand ─── -complete -c vaptvupt -f -n "__fish_zupt_using_subcommand disk; and not __fish_seen_subcommand_from backup restore" \ -complete -c zupt -f -n "__fish_zupt_using_subcommand disk; and not __fish_seen_subcommand_from backup restore" \ - -a 'backup' -d 'Read a block device into an archive' -complete -c vaptvupt -f -n "__fish_zupt_using_subcommand disk; and not __fish_seen_subcommand_from backup restore" \ -complete -c zupt -f -n "__fish_zupt_using_subcommand disk; and not __fish_seen_subcommand_from backup restore" \ - -a 'restore' -d 'Write an archive to a block device' - -# ─── Keygen options ─── -complete -c vaptvupt -n '__fish_zupt_using_subcommand keygen' -l sdk -d 'Generate SDK v2 keypair' -complete -c zupt -n '__fish_zupt_using_subcommand keygen' -l sdk -d 'Generate SDK v2 keypair' -complete -c vaptvupt -n '__fish_zupt_using_subcommand keygen' -l pq-sdk -d 'Same as --sdk' -complete -c zupt -n '__fish_zupt_using_subcommand keygen' -l pq-sdk -d 'Same as --sdk' -complete -c vaptvupt -n '__fish_zupt_using_subcommand keygen' -s o -d 'Output keyfile path' -r -complete -c zupt -n '__fish_zupt_using_subcommand keygen' -s o -d 'Output keyfile path' -r -complete -c vaptvupt -n '__fish_zupt_using_subcommand keygen' -l pub -d 'Export public key from -k' -complete -c zupt -n '__fish_zupt_using_subcommand keygen' -l pub -d 'Export public key from -k' -complete -c vaptvupt -n '__fish_zupt_using_subcommand keygen' -s k -d 'Source private keyfile' -r -complete -c zupt -n '__fish_zupt_using_subcommand keygen' -s k -d 'Source private keyfile' -r diff --git a/completions/zupt.bash b/completions/zupt.bash new file mode 100644 index 0000000..c5bb3b6 --- /dev/null +++ b/completions/zupt.bash @@ -0,0 +1,112 @@ +# bash completion for ZUPT +# SPDX-License-Identifier: AGPL-3.0-or-later + +_zupt() +{ + local cur prev command disk_command + COMPREPLY=() + cur=${COMP_WORDS[COMP_CWORD]} + prev=${COMP_WORDS[COMP_CWORD-1]} + command=${COMP_WORDS[1]-} + disk_command=${COMP_WORDS[2]-} + + case $prev in + -l|--level) + COMPREPLY=( $(compgen -W '1 2 3 4 5 6 7 8 9' -- "$cur") ) + return + ;; + -b|--block|-t|--threads|--pass-fd|-p|--password|-c|--comment) + return + ;; + --kdf) + COMPREPLY=( $(compgen -W 'pbkdf2 argon2id' -- "$cur") ) + return + ;; + -o|--output|-k|--key|--pass-file|--comment-file|--pq|--pq-only|--pq-sdk|--pq-box) + COMPREPLY=( $(compgen -f -- "$cur") ) + return + ;; + esac + + if (( COMP_CWORD == 1 )); then + COMPREPLY=( $(compgen -W \ + 'compress c extract x list l test t info i bench b disk keygen version help --version -V --help -h' \ + -- "$cur") ) + return + fi + + local password_options='-p --password --password-prompt --pass-file --pass-fd' + local pq_options='--pq --pq-only --pq-sdk --pq-box' + local legacy_read_option='--allow-legacy-no-ait' + local common_read_options="-v --verbose $password_options $pq_options $legacy_read_option" + + case $command in + compress|c) + if [[ $cur == -* ]]; then + COMPREPLY=( $(compgen -W \ + "-l --level -b --block -s --store -f --fast --vv --vaptvupt --lzhp + $password_options --kdf -c --comment --comment-file $pq_options + -D --dedup -S --solid -y --force -v --verbose -t --threads" \ + -- "$cur") ) + else + COMPREPLY=( $(compgen -f -- "$cur") ) + fi + ;; + extract|x) + if [[ $cur == -* ]]; then + COMPREPLY=( $(compgen -W \ + "-o --output $common_read_options -t --threads" -- "$cur") ) + else + COMPREPLY=( $(compgen -f -X '!*.zupt' -- "$cur") ) + fi + ;; + list|l|test|t) + if [[ $cur == -* ]]; then + COMPREPLY=( $(compgen -W "$common_read_options" -- "$cur") ) + else + COMPREPLY=( $(compgen -f -X '!*.zupt' -- "$cur") ) + fi + ;; + info|i) + COMPREPLY=( $(compgen -f -X '!*.zupt' -- "$cur") ) + ;; + bench|b) + if [[ $cur == -* ]]; then + COMPREPLY=( $(compgen -W '--compare' -- "$cur") ) + else + COMPREPLY=( $(compgen -f -- "$cur") ) + fi + ;; + disk) + if (( COMP_CWORD == 2 )); then + COMPREPLY=( $(compgen -W 'backup restore' -- "$cur") ) + elif [[ $cur == -* ]]; then + local disk_options + disk_options="-l --level -b --block -s --store --vv --vaptvupt --lzhp + $password_options --kdf -c --comment --comment-file + --pq --pq-only -D --dedup -v --verbose -t --threads" + if [[ $disk_command == restore ]]; then + disk_options+=" $legacy_read_option" + fi + COMPREPLY=( $(compgen -W \ + "$disk_options" \ + -- "$cur") ) + elif [[ $disk_command == restore ]]; then + COMPREPLY=( $(compgen -f -X '!*.zupt' -- "$cur") ) + else + COMPREPLY=( $(compgen -f -- "$cur") ) + fi + ;; + keygen) + if [[ $cur == -* ]]; then + COMPREPLY=( $(compgen -W \ + '-o --output --pub -k --key --pq-only --pqonly --sdk --pq-sdk --box --pq-box' \ + -- "$cur") ) + else + COMPREPLY=( $(compgen -f -- "$cur") ) + fi + ;; + esac +} + +complete -F _zupt zupt diff --git a/completions/zupt.fish b/completions/zupt.fish new file mode 100644 index 0000000..908a366 --- /dev/null +++ b/completions/zupt.fish @@ -0,0 +1,163 @@ +# fish completion for ZUPT +# SPDX-License-Identifier: AGPL-3.0-or-later + +function __fish_zupt_needs_command + set -l tokens (commandline -opc) + test (count $tokens) -eq 1 +end + +function __fish_zupt_using_command + set -l tokens (commandline -opc) + test (count $tokens) -gt 1; and contains -- $tokens[2] $argv +end + +function __fish_zupt_disk_needs_command + set -l tokens (commandline -opc) + test (count $tokens) -eq 2; and test "$tokens[2]" = disk +end + +function __fish_zupt_disk_using_command + set -l tokens (commandline -opc) + test (count $tokens) -gt 2; and test "$tokens[2]" = disk; and contains -- $tokens[3] $argv +end + +complete -c zupt -f -n __fish_zupt_needs_command \ + -a 'compress c' -d 'Create an archive' +complete -c zupt -f -n __fish_zupt_needs_command \ + -a 'extract x' -d 'Extract an archive' +complete -c zupt -f -n __fish_zupt_needs_command \ + -a 'list l' -d 'List archive entries' +complete -c zupt -f -n __fish_zupt_needs_command \ + -a 'test t' -d 'Verify archive integrity' +complete -c zupt -f -n __fish_zupt_needs_command \ + -a 'info i' -d 'Show archive framing metadata' +complete -c zupt -f -n __fish_zupt_needs_command \ + -a 'bench b' -d 'Benchmark compression levels' +complete -c zupt -f -n __fish_zupt_needs_command \ + -a disk -d 'Back up or restore a disk image' +complete -c zupt -f -n __fish_zupt_needs_command \ + -a keygen -d 'Generate or export a recipient key' +complete -c zupt -f -n __fish_zupt_needs_command \ + -a version -d 'Show version and build information' +complete -c zupt -f -n __fish_zupt_needs_command \ + -a help -d 'Show command help' +complete -c zupt -f -n __fish_zupt_needs_command \ + -a '--version -V' -d 'Show version and build information' +complete -c zupt -f -n __fish_zupt_needs_command \ + -a '--help -h' -d 'Show command help' + +set -l compress_condition '__fish_zupt_using_command compress c' +complete -c zupt -n "$compress_condition" -s l -l level \ + -d 'Compression level' -x -a '1 2 3 4 5 6 7 8 9' +complete -c zupt -n "$compress_condition" -s b -l block \ + -d 'Block size in bytes' -x +complete -c zupt -n "$compress_condition" -s s -l store \ + -d 'Store without compression' +complete -c zupt -n "$compress_condition" -s f -l fast \ + -d 'Use fast LZ codec' +complete -c zupt -n "$compress_condition" -l vv -l vaptvupt \ + -d 'Force VaptVupt LZ + ANS codec' +complete -c zupt -n "$compress_condition" -l lzhp \ + -d 'Force portable LZHP codec' +complete -c zupt -n "$compress_condition" -l kdf \ + -d 'Password KDF' -x -a 'pbkdf2 argon2id' +complete -c zupt -n "$compress_condition" -s c -l comment \ + -d 'Store an archive comment' -x +complete -c zupt -n "$compress_condition" -l comment-file \ + -d 'Read archive comment from file' -r +complete -c zupt -n "$compress_condition" -s D -l dedup \ + -d 'Enable block-level deduplication' +complete -c zupt -n "$compress_condition" -s S -l solid \ + -d 'Use a solid single compression stream' +complete -c zupt -n "$compress_condition" -s y -l force \ + -d 'Overwrite an existing non-.zupt output' +complete -c zupt -n "$compress_condition" -s t -l threads \ + -d 'Compression thread count' -x + +set -l read_condition '__fish_zupt_using_command compress c extract x list l test t' +complete -c zupt -n "$read_condition" -s p -l password \ + -d 'Password in process arguments' -x +complete -c zupt -n "$read_condition" -l password-prompt \ + -d 'Read password interactively without echo' +complete -c zupt -n "$read_condition" -l pass-file \ + -d 'Read password from first line of file' -r +complete -c zupt -n "$read_condition" -l pass-fd \ + -d 'Read password from inherited file descriptor' -x +complete -c zupt -n "$read_condition" -l pq \ + -d 'Native ML-KEM-768 + X25519 hybrid key' -r +complete -c zupt -n "$read_condition" -l pq-only \ + -d 'Native ML-KEM-768-only key' -r +complete -c zupt -n "$read_condition" -l pq-sdk \ + -d 'Optional system libvuptsdk key' -r +complete -c zupt -n "$read_condition" -l pq-box \ + -d 'Optional system libpqvaptvupt key' -r +complete -c zupt -n "$read_condition" -s v -l verbose \ + -d 'Additional progress or diagnostic output' + +set -l legacy_read_condition '__fish_zupt_using_command extract x list l test t' +complete -c zupt -n "$legacy_read_condition" -l allow-legacy-no-ait \ + -d 'Recover a trusted old archive without an integrity trailer' + +complete -c zupt -n '__fish_zupt_using_command extract x' \ + -s o -l output -d 'Output directory' -r +complete -c zupt -n '__fish_zupt_using_command extract x' \ + -s t -l threads -d 'Decompression thread count' -x +complete -c zupt -n '__fish_zupt_using_command bench b' \ + -l compare -d 'Compare available external compressors' + +complete -c zupt -f -n __fish_zupt_disk_needs_command \ + -a backup -d 'Create a disk-image archive' +complete -c zupt -f -n __fish_zupt_disk_needs_command \ + -a restore -d 'Restore a disk-image archive' +set -l disk_condition '__fish_zupt_using_command disk' +complete -c zupt -n "$disk_condition" -s l -l level \ + -d 'Compression level' -x -a '1 2 3 4 5 6 7 8 9' +complete -c zupt -n "$disk_condition" -s b -l block \ + -d 'Block size in bytes' -x +complete -c zupt -n "$disk_condition" -s s -l store \ + -d 'Store without compression' +complete -c zupt -n "$disk_condition" -l vv -l vaptvupt \ + -d 'Force VaptVupt LZ + ANS codec' +complete -c zupt -n "$disk_condition" -l lzhp \ + -d 'Force portable LZHP codec' +complete -c zupt -n "$disk_condition" -s p -l password \ + -d 'Password in process arguments' -x +complete -c zupt -n "$disk_condition" -l password-prompt \ + -d 'Read password interactively without echo' +complete -c zupt -n "$disk_condition" -l pass-file \ + -d 'Read password from first line of file' -r +complete -c zupt -n "$disk_condition" -l pass-fd \ + -d 'Read password from inherited file descriptor' -x +complete -c zupt -n "$disk_condition" -l kdf \ + -d 'Password KDF' -x -a 'pbkdf2 argon2id' +complete -c zupt -n "$disk_condition" -s c -l comment \ + -d 'Store an archive comment' -x +complete -c zupt -n "$disk_condition" -l comment-file \ + -d 'Read archive comment from file' -r +complete -c zupt -n "$disk_condition" -l pq \ + -d 'Native ML-KEM-768 + X25519 hybrid key' -r +complete -c zupt -n "$disk_condition" -l pq-only \ + -d 'Native ML-KEM-768-only key' -r +complete -c zupt -n "$disk_condition" -s D -l dedup \ + -d 'Enable block-level deduplication' +complete -c zupt -n "$disk_condition" -s v -l verbose \ + -d 'Additional progress or diagnostic output' +complete -c zupt -n "$disk_condition" -s t -l threads \ + -d 'Thread count' -x +complete -c zupt -n '__fish_zupt_disk_using_command restore' \ + -l allow-legacy-no-ait \ + -d 'Recover a trusted old disk archive without an integrity trailer' + +set -l keygen_condition '__fish_zupt_using_command keygen' +complete -c zupt -n "$keygen_condition" -s o -l output \ + -d 'Output key file' -r +complete -c zupt -n "$keygen_condition" -l pub \ + -d 'Export a public key' +complete -c zupt -n "$keygen_condition" -s k -l key \ + -d 'Source private key' -r +complete -c zupt -n "$keygen_condition" -l pq-only -l pqonly \ + -d 'Native ML-KEM-768-only key format' +complete -c zupt -n "$keygen_condition" -l sdk -l pq-sdk \ + -d 'Optional system libvuptsdk key format' +complete -c zupt -n "$keygen_condition" -l box -l pq-box \ + -d 'Optional system libpqvaptvupt key format' diff --git a/doc/vaptvupt-gui.1 b/doc/vaptvupt-gui.1 deleted file mode 100644 index be0b040..0000000 --- a/doc/vaptvupt-gui.1 +++ /dev/null @@ -1,127 +0,0 @@ -.\" Manpage for vaptvupt-gui (formerly zupt-gui; INPI Brasil trademark rename in v3.0.0) -.\" SPDX-License-Identifier: AGPL-3.0-or-later -.\" Copyright (c) 2025-2026 Cristian Cezar Moisés -.TH VAPTVUPT-GUI 1 "2026-06-11" "vaptvupt-gui 1.3.0" "User Commands" -.SH NAME -vaptvupt-gui \- graphical interface for the VaptVupt post-quantum backup utility -.SH SYNOPSIS -.B vaptvupt-gui -.RI [ ARCHIVE ] -.SH DESCRIPTION -.B vaptvupt-gui -is a graphical frontend for -.BR vaptvupt (1). -It provides tabs for compression, extraction, key management, and -full-disk backup. Both PQ encryption modes are exposed: -.B legacy --pq -and -.B SDK v2 --pq-sdk -(HKDF combiner, key commitment, HPKE binding, Argon2id). - -The legacy command name -.B zupt-gui -is preserved as a symlink for backward compatibility; both invocations -behave identically. - -If -.I ARCHIVE -is given on the command line, the GUI opens directly on the -extract tab with that archive preloaded. - -.B vaptvupt-gui -uses Qt 6. It works with either of the following Python Qt bindings, -auto-detected at startup in this order: -.IP \(bu 2 -PySide6 (Qt for Python) -.IP \(bu 2 -PyQt6 -.PP -If neither is installed, the GUI prints an instructive error and exits. - -.SH TABS -.TP -.B Compress -Select files or directories, choose codec, level, password and/or PQ -key. The -.B Mode -panel controls whether the SDK v2 path or the legacy path is used. -.TP -.B Extract -Open a .zupt archive, select output directory, provide password -and/or PQ private key. -.TP -.B Keygen -Generate ML-KEM-768 + X25519 keypair. The -.B SDK v2 format -checkbox controls whether the keypair is generated via -.B vaptvupt keygen --sdk -(producing -.IR file -and -.IR file.pub -in one step) or via the legacy -.BR "vaptvupt keygen" . -.TP -.B Disk -Full-disk backup and restore. Enumerates block devices with -human-readable sizes. Same encryption mode controls as Compress. - -.SH FILES -.TP -.I /usr/bin/vaptvupt-gui -Wrapper script that invokes the Python entry point (and the symlinked -legacy -.IR /usr/bin/zupt-gui ). -.TP -.I /usr/lib/vaptvupt-gui/zupt_gui.py -Main Python source. -.TP -.I /usr/share/applications/vaptvupt-gui.desktop -Desktop entry for menu integration. -.TP -.I /usr/share/icons/hicolor/256x256/apps/vaptvupt-gui.png -Application icon. - -.SH ENVIRONMENT -.TP -.B VAPTVUPT_BIN -Override the path to the -.B vaptvupt -binary (default: search -.IR PATH ). -The legacy name -.B ZUPT_BIN -is also honoured. -.TP -.B VAPTVUPT_DEBUG -Enable binary-discovery debug logging on stderr. The legacy name -.B ZUPT_DEBUG -is also honoured. - -.SH BUGS -Report at -.UR https://git.securityops.co/cristiancmoises/vaptvupt/issues -.UE . - -.SH AUTHOR -Cristian Cezar Moisés -.MT zupt@riseup.net -.ME - -.SH SEE ALSO -.BR vaptvupt (1). - -.SH LICENSE -.PP -vaptvupt-gui is licensed under the -.B GNU Affero General Public License version 3 or later -(AGPL-3.0-or-later). Commercial license available for relief from -copyleft terms; contact -.MT sac@securityops.co -.ME . - -.SH PROJECT -.PP -Home page: -.UR https://git.securityops.co/cristiancmoises/vaptvupt -.UE diff --git a/doc/vaptvupt.1 b/doc/vaptvupt.1 deleted file mode 100644 index 2b54d3c..0000000 --- a/doc/vaptvupt.1 +++ /dev/null @@ -1,618 +0,0 @@ -.\" Manpage for vaptvupt (formerly zupt; INPI Brasil trademark rename in v3.0.0) -.\" SPDX-License-Identifier: AGPL-3.0-or-later -.\" Copyright (c) 2025-2026 Cristian Cezar Moisés -.TH VAPTVUPT 1 "July 2026" "vaptvupt 4.1.0" "User Commands" - -.SH NAME -vaptvupt \- post-quantum backup compression utility (formerly zupt) - -.SH SYNOPSIS -.B vaptvupt compress -.RI [ options ] -.I out.zupt -.I files... -.br -.B vaptvupt extract -.RI [ options ] -.I archive.zupt -.br -.B vaptvupt list -.RI [ options ] -.I archive.zupt -.br -.B vaptvupt test -.RI [ options ] -.I archive.zupt -.br -.B vaptvupt info -.I archive.zupt -.br -.B vaptvupt bench -.I files/dirs... -.br -.B vaptvupt disk backup -.RI [ options ] -.I out.zupt -.I device -.br -.B vaptvupt disk restore -.RI [ options ] -.I archive.zupt -.I device -.br -.B vaptvupt keygen -.RI [ options ] -.br -.B vaptvupt version -.br -.B vaptvupt help - -.PP -The legacy command name -.B zupt -is preserved as an alias for backward compatibility; both invocations -behave identically. - -.SH DESCRIPTION -.B vaptvupt -compresses and encrypts files, directories, and whole block devices -into self-contained, authenticated archives with the -.B .zupt -extension. It targets long-lived backup storage where: - -.RS -.IP \(bu 2 -the archive is written once and restored under time pressure many years later; -.IP \(bu 2 -the encryption envelope must remain secure against a future cryptographically-relevant quantum computer (ML-KEM-768); -.IP \(bu 2 -every byte of the archive — header, footer, per-block metadata, comments — is authenticated, and a single bit-flip is rejected at restore time. -.RE - -.PP -The compression layer is the -.B VaptVupt LZ + ANS -codec (version 2.53.3), which prioritises decode speed and ratio over -encode speed. Aggregate decode throughput on this build is 1.27\(mu -zstd\-3; encode throughput is 0.2\(mu\(en0.5\(mu zstd\-3 depending on -content. See -.B PERFORMANCE -below. - -.PP -The on-disk format is v1.6 and has been wire-compatible since release -v2.3.1. The product was renamed from -.B Zupt -to -.B VaptVupt -in v3.0.0 because of a prior INPI Brasil trademark registration of the -name "Zupt" for unrelated software. The -.B .zupt -file extension and the -.B ZUPT -header magic bytes are unchanged: archives produced by any v2.x release -extract cleanly under v3.0.0 and vice versa. - -.SH COMMANDS - -.TP -.B compress -Create an archive. Default codec is VaptVupt (level 7). Compression -is multi-threaded; one worker per detected CPU by default. - -.TP -.B extract -Decompress an archive into the current directory (or -.BR -o " " \fIdir\fR ). -Refuses to write outside the destination directory (path-traversal -defence). Files are written with their original permissions and -mtime preserved. - -.TP -.B list -Print archive metadata: per-file path, size, mtime, mode, compressed -size, codec. With -.B --verbose -also prints per-block sizes and HMAC tags (first 8 bytes). - -.TP -.B test -Decompress all blocks in memory and verify HMAC tags + archive -integrity trailer. Does not write any files. Use to validate an -archive without restoring it. Exit code is non-zero on any failure. - -.TP -.B info -Print archive header metadata without requiring the decryption key. -Reports: format version, codec, encryption type (none / PBKDF2 / -Argon2id / ML-KEM-768+X25519), KDF iteration count, file count, -creation timestamp, archive UUID, AIT presence. Safe to run on an -untrusted archive. - -.TP -.B bench -Compare compression levels 1\(en9 on the supplied files; reports -ratio and encode/decode throughput per level. Useful when picking -the right -.B -l -for a given workload. - -.TP -.B disk backup -Read a block device and write a sparse-aware archive. Detects -all-zero regions and records them as runs rather than compressing -them. - -.TP -.B disk restore -Inverse of -.BR "disk backup" . -Writes the archive's contents back to a block device. Verifies -target device size before writing; refuses if the target is smaller -than the archived size. With -.B --sync -issues -.BR fsync (2) -after each block. - -.TP -.B keygen -Generate a key file for keyfile-mode encryption. With -.B --sdk -generates an ML-KEM-768 + X25519 hybrid keypair suitable for -.B --pq-sdk -mode. With -.B --box -generates a libpqvaptvupt sealed-box keypair (writes \fIfile\fR and -\fIfile\fR.pub) for -.B --pq-box -mode. With -.B --pub -extracts the public key from an existing private key. - -.SH GLOBAL OPTIONS - -.TP -.BR -v ", " --verbose -Print per-file and per-block details during compress/extract/list/test. - -.TP -.BR -q ", " --quiet -Suppress non-error output. - -.TP -.BR -j " " \fIN\fR ", " --jobs " " \fIN\fR -Worker thread count for parallel compression. Default: number of -online CPUs. - -.SH COMPRESS OPTIONS - -.TP -.BR -l " " \fI1..9\fR ", " --level " " \fI1..9\fR -Compression level. 1\(en2 = ultra-fast (~80 MB/s encode on typical -hardware, lower ratio). 3\(en7 = balanced (default 7). 8\(en9 = -extreme (optimal parsing, ~5\(en10\(mu slower encode, best ratio). - -.TP -.B --codec \fIid\fR -Force a specific codec by id. Accepted values: -.BR store " (0x0000), " -.BR vaptvupt-lz " (0x0008), " -.BR vaptvupt-lzh " (0x0009), " -.BR vaptvupt-lzhp " (0x000A), " -.BR vaptvupt " (0x0010 — default), " -.BR auto " (0xFFFF — pick at runtime)." - -.TP -.BR -p " " \fIpassword\fR -Enable password-based encryption (PBKDF2-SHA256 KDF; Argon2id is available -only in a WITH_SDK=1 build via \fB--kdf argon2id\fR). -Reading the password from a flag exposes it in -.BR ps (1) -output; prefer -.B --pass-file -or interactive prompt. - -.TP -.B --pass-file \fIpath\fR -Read password from the first line of the file. The file's permission -bits should be 0600. - -.TP -.B --pass-fd \fIN\fR -Read password from file descriptor N. - -.TP -.B --kdf \fIalgo\fR -Choose key-derivation function for password mode: -.BR argon2id " (default since v2.4.1; memory-hard) or " -.BR pbkdf2 " (SHA-256, 600 000 iter; needed for compatibility with v2.4.0 and earlier readers)." - -.TP -.B --keyfile \fIpath\fR -Use a 32-byte raw key file (generated with -.BR "vaptvupt keygen" ). - -.TP -.B --pq-box \fIpub\fR -Enable post-quantum sealed-box encryption via the vendored libpqvaptvupt -(v4.0.0+, envelope type 0x05). ML-KEM-768 + X25519 shared secrets are -combined through HKDF-SHA256 with a domain-separating info string -("pqvv-seal-v1"); the box carries AES-256-CTR + HMAC-SHA256 -Encrypt-then-MAC. The -.I pub -argument is the recipient's public-key file from -.B keygen --box -(magic-tagged; public and secret key files are not interchangeable). -On extraction, pass the secret key: -.B --pq-box -\fIpriv\fR. -.TP -.B --pq-sdk \fIpub\fR -Enable post-quantum hybrid encryption. Uses ML-KEM-768 + X25519 with -HKDF combiner, HPKE binding, and key commitment. The -.I pub -argument is the recipient's public-key file generated by -.BR "vaptvupt keygen --sdk" . - -.TP -.B -c \fItext\fR ", " --comment " " \fItext\fR -Embed an encrypted UTF-8 comment in the archive (up to 4096 bytes). -The comment is bound to the archive's frame-preface AAD; tampering -is detected at extract time. - -.TP -.B --comment-file \fIpath\fR -Read the comment from a file rather than the command line. - -.TP -.B -b \fIsize\fR ", " --block-size " " \fIsize\fR -Compression block size. Default 4 MiB. Smaller blocks improve -random-access decode but lose some ratio. - -.SH EXTRACT, LIST, TEST OPTIONS - -.TP -.BR -o " " \fIdir\fR ", " --output " " \fIdir\fR -Extract into -.IR dir -(created if it doesn't exist). Default: current directory. - -.TP -.B --no-mtime -Do not restore archived modification times; use current time instead. - -.TP -.B --strip-components \fIN\fR -Strip -.I N -leading path components from each entry, like -.BR tar 's -flag of the same name. - -.SH POST-QUANTUM ENCRYPTION - -.B vaptvupt -implements a hybrid KEM as specified in FIPS 203 (ML-KEM) combined -with X25519 (RFC 7748). The session key is derived as: - -.RS -.nf -ss_pq = ML-KEM-768.decaps(sk_pq, ct_pq) -ss_ec = X25519(sk_ec, pk_ec_peer) -session = HKDF-SHA256(ss_pq || ss_ec, - info = "vaptvupt-pq-sdk-v1", - salt = archive_uuid) -.fi -.RE - -The hybrid combiner means the session key is at least as strong as -the strongest of {ML-KEM-768, X25519}: an attacker must break both -to recover the key. - -.PP -.B Key commitment: -the ciphertext is bound to the exact public key it was encrypted to -via an HPKE-style derivation. An attacker cannot present a different -public key that decrypts to the same plaintext (this defeats the -"partitioning" attack class). - -.PP -.B Implementation notes: -the ML-KEM-768 implementation is vendored from a clean reference and -verified against the FIPS 203 KAT vectors. The X25519 implementation -uses 4\(mu64-bit field arithmetic with Jasmin-verified constant-time -field operations on x86_64. On other architectures the same routines -run in pure C, also constant-time by construction. - -.SH SECURITY - -.SS Threat model - -What -.B vaptvupt -.B protects against: - -.RS -.IP \(bu 2 -Confidentiality of archived data at rest (AES-256-CTR with HMAC-SHA256 EtM, or AEAD via libzuptsdk on the -.B --pq-sdk -path). -.IP \(bu 2 -End-to-end byte-level tamper detection on encrypted archives. The F-09 byte-sweep regression (1827 positions on a representative archive, 2000 trials, every run) shows zero silent-accept positions. -.IP \(bu 2 -Wrong-password and tampered-archive indistinguishability at the user-visible message layer (F-11). The default error wording is identical for both cases; only -.B --verbose -prints the distinguishing detail. This closes the "verbal probe-oracle" attack class where the error string leaked which check failed first. -.IP \(bu 2 -Post-quantum forward secrecy on archives encrypted with -.B --pq-sdk -(assuming ML-KEM-768 holds against future quantum attack). -.IP \(bu 2 -Archive-header and footer authentication via a 32-byte HMAC-SHA256 trailer (F-08). Tampering with the file count, comment offset, or timestamp is detected at archive open time. -.IP \(bu 2 -Path-traversal at extract time. Entries with absolute paths or -.B .. -components are refused or stripped. -.RE - -What it does -.B NOT -protect against: - -.RS -.IP \(bu 2 -Compromise of the endpoint that creates or restores the archive. If the host is compromised, the password, key file, or plaintext is accessible. -.IP \(bu 2 -Compromise of the key file or password. Key custody is the user's responsibility. -.IP \(bu 2 -A weak password. Argon2id with default parameters needs ~256 MiB and ~1 s to derive a key on commodity hardware; a 4-character password is still trivially crackable. -.IP \(bu 2 -Metadata leakage. File names, sizes, and modification times are encrypted, but the archive's total size and the count of compressed blocks are visible to an observer. -.IP \(bu 2 -Side channels on the compression layer (CRIME/BREACH-style). If the same archive contains both attacker-controlled and secret data and the attacker can observe the compressed size, length-based oracles may be possible. -.IP \(bu 2 -Denial-of-service via malformed input on the decoder. The decoder rejects malformed input cleanly (no crashes in the fuzz harness), but a very large compressed input can still consume CPU and memory proportional to its size. -.RE - -.SS Cryptographic primitives - -.TS -tab(|); -l l. -SHA-256 | FIPS 180-4 -SHA-3 / SHAKE | FIPS 202 -ML-KEM-768 | FIPS 203 -AES-256-CTR | NIST SP 800-38A -HMAC-SHA256 | RFC 2104 / FIPS 198-1 -X25519 | RFC 7748 -HKDF-SHA256 | RFC 5869 -PBKDF2-SHA256 | RFC 8018 -Argon2id | RFC 9106 -XXH64 | non-cryptographic; used only inside the AEAD envelope -.TE - -.SS Constant-time guarantees - -All secret-dependent comparisons and table lookups in the cryptographic -core are constant-time. On x86_64 the hot paths (HMAC equality compare, -ML-KEM Fujisaki-Okamoto implicit rejection) are implemented in Jasmin -and assembled with -.BR jasminc (1). -On other architectures the same routines run in portable C; the -constant-time property is preserved by source-level construction. - -.SH FILES - -.TP -.I ~/.config/vaptvupt/ -Per-user configuration directory (reserved; not used in v3.0.0). - -.TP -.I /etc/vaptvupt/ -System-wide configuration directory (reserved; not used in v3.0.0). - -.TP -.I /usr/share/bash-completion/completions/vaptvupt -Bash completion (and the symlinked legacy -.IR /usr/share/bash-completion/completions/zupt ). - -.TP -.I /usr/share/zsh/site-functions/_vaptvupt -zsh completion. - -.TP -.I /usr/share/fish/vendor_completions.d/vaptvupt.fish -fish completion. - -.SH ENVIRONMENT - -.TP -.B VAPTVUPT_BIN -Override the binary path used by the GUI front-end. Legacy -.B ZUPT_BIN -is also honoured. - -.TP -.B VAPTVUPT_DEBUG -If set to any non-empty value, the GUI front-end prints its binary-discovery log to stderr. - -.SH EXIT STATUS - -.TP -.B 0 -Success. - -.TP -.B 1 -General error (bad arguments, file not found, etc.). - -.TP -.B 2 -Authentication failed. Wrong password, wrong key file, or the archive has been tampered with. Use -.B --verbose -to see the distinguishing detail (subject to F-11's threat model: detailed messages may leak which failure cause fired first). - -.TP -.B 3 -Archive-format error (wrong magic bytes, unsupported format version, corrupted header). - -.TP -.B 4 -I/O error (disk full, permission denied, network failure). - -.TP -.B 5 -Compressed-data integrity error (per-block HMAC mismatch detected mid-stream). - -.SH PERFORMANCE - -Numbers below are from the v3.0.0 release benchmark (May 2026), run -on an Intel Xeon @ 2.8 GHz with the codec compiled with the -distribution's default optimisation level. - -.TS -tab(|); -l l l l l. -\fBFixture\fR | \fBTool\fR | \fBRatio\fR | \fBEnc MB/s\fR | \fBDec MB/s\fR -text 8 MB | vv-9 | 34.6% | 5.4 | 219 -text 8 MB | gzip-9 | 30.9% | 6.2 | 137 -text 8 MB | zstd-3 | 31.6% | 137 | 427 -text 8 MB | zstd-19 | 25.5% | 1.6 | 384 -source 670 KB | vv-9 | 25.1% | 11.4 | 128 -source 670 KB | gzip-9 | 23.2% | 10.0 | 107 -source 670 KB | zstd-3 | 24.1% | 91 | 160 -binary 2.4 MB | vv-9 | 44.7% | 7.7 | 153 -binary 2.4 MB | gzip-9 | 52.0% | 12.6 | 109 -binary 2.4 MB | zstd-3 | 77.3% | 164 | 382 -binary 2.4 MB | zstd-19 | 48.0% | 5.8 | 229 -random 5 MB | vv-9 | 100.0% | 11.1 | 477 -random 5 MB | zstd-3 | 100.0% | 397 | 681 -.TE - -.PP -Honest reading of these numbers: - -.RS -.IP \(bu 2 -On binary-structured data (game saves, mmap'd structures, struct arrays), -.B vaptvupt -beats zstd-3 by a wide margin on ratio (44.7% vs 77.3%) at the cost of being ~20\(mu slower to encode. For write-once / restore-often workloads this is the right trade. -.IP \(bu 2 -On text and source, zstd-19 beats -.B vaptvupt -on ratio. The fundamental codec difference is that zstd's reference encoder has had years of compiler-engineering attention that -.B vaptvupt -has not. -.IP \(bu 2 -Encode throughput is -.BR vaptvupt 's -weak axis. If encode latency matters more than ratio, use -.B -l 1 -or -.BR -l 2 . -.IP \(bu 2 -On random / already-compressed data, all codecs hit the incompressibility wall; comparing encode/decode throughput in that regime is mostly measuring memcpy speed plus framing overhead. -.RE - -.SH EXAMPLES - -.PP -Compress with default settings (PBKDF2-SHA256 password, VaptVupt level 7, multi-threaded): - -.RS -.nf -$ vaptvupt compress -p secret backup.zupt ~/Documents -.fi -.RE - -Compress with post-quantum hybrid encryption to a published public key: - -.RS -.nf -$ vaptvupt keygen --sdk -o ~/.config/vaptvupt-mykey -$ vaptvupt keygen -o ~/.config/vaptvupt-mykey -$ vaptvupt keygen --pub -o mykey.pub -k ~/.config/vaptvupt-mykey -$ vaptvupt compress --pq mykey.pub backup.zupt ~/Documents -.fi -.RE - -Backup a block device, sparse-aware: - -.RS -.nf -$ sudo vaptvupt disk backup -p secret system.zupt /dev/nvme0n1p2 -.fi -.RE - -Verify an archive without restoring: - -.RS -.nf -$ vaptvupt test -p secret backup.zupt -.fi -.RE - -Print archive metadata without supplying a password: - -.RS -.nf -$ vaptvupt info backup.zupt -.fi -.RE - -Compare compression levels: - -.RS -.nf -$ vaptvupt bench ~/Downloads/big-dataset.bin -.fi -.RE - -Run the GUI from a desktop session where /usr/bin isn't on PATH (the bug fixed in v3.0.0): - -.RS -.nf -$ VAPTVUPT_DEBUG=1 vaptvupt-gui 2> /tmp/discovery.log -.fi -.RE - -.SH STANDARDS - -ISO C11; POSIX.1-2017 for I/O and threading. The cryptographic -primitives implement the specifications listed in -.BR SECURITY -above. The on-disk archive format is documented in -.B FORMAT.md -in the source distribution. - -.SH AUTHORS -Cristian Cezar Moisés — primary author and maintainer. - -.SH BUGS -Report bugs at https://git.securityops.co/cristiancmoises/vaptvupt/issues -or by email to . - -.SH LICENSE -AGPL-3.0-or-later for the application; GPL-3.0-or-later for the -embedded VaptVupt codec. Dual-licensed: a commercial licence is -available from . - -.SH SEE ALSO -.BR vaptvupt-gui (1), -.BR zstd (1), -.BR gzip (1), -.BR xz (1), -.BR tar (1), -.BR cryptsetup (8), -.BR jasminc (1). -.PP -Project home: https://git.securityops.co/cristiancmoises/vaptvupt -.br -Threat model: see -.B THREAT_MODEL.md -in the source distribution. -.br -Archive format spec: see -.B FORMAT.md -in the source distribution. diff --git a/doc/zupt-gui.1 b/doc/zupt-gui.1 index be0b040..cecb070 100644 --- a/doc/zupt-gui.1 +++ b/doc/zupt-gui.1 @@ -1,127 +1,158 @@ -.\" Manpage for vaptvupt-gui (formerly zupt-gui; INPI Brasil trademark rename in v3.0.0) .\" SPDX-License-Identifier: AGPL-3.0-or-later .\" Copyright (c) 2025-2026 Cristian Cezar Moisés -.TH VAPTVUPT-GUI 1 "2026-06-11" "vaptvupt-gui 1.3.0" "User Commands" +.TH ZUPT-GUI 1 "2026-08-31" "ZUPT 5.2.8" "User Commands" .SH NAME -vaptvupt-gui \- graphical interface for the VaptVupt post-quantum backup utility +zupt-gui \- Qt interface for the ZUPT backup utility .SH SYNOPSIS -.B vaptvupt-gui -.RI [ ARCHIVE ] -.SH DESCRIPTION -.B vaptvupt-gui -is a graphical frontend for -.BR vaptvupt (1). -It provides tabs for compression, extraction, key management, and -full-disk backup. Both PQ encryption modes are exposed: -.B legacy --pq -and -.B SDK v2 --pq-sdk -(HKDF combiner, key commitment, HPKE binding, Argon2id). - -The legacy command name .B zupt-gui -is preserved as a symlink for backward compatibility; both invocations -behave identically. - -If -.I ARCHIVE -is given on the command line, the GUI opens directly on the -extract tab with that archive preloaded. - -.B vaptvupt-gui -uses Qt 6. It works with either of the following Python Qt bindings, -auto-detected at startup in this order: -.IP \(bu 2 -PySide6 (Qt for Python) -.IP \(bu 2 -PyQt6 +.RI [ ARCHIVE.zupt ] +.br +.B zupt-gui +.BI --compress " FILE ..." +.br +.B zupt-gui +.BI --extract " ARCHIVE.zupt" +.br +.B zupt-gui +.RB [ --help | --version | --selftest ] +.SH DESCRIPTION +.B zupt-gui +is a Python Qt 6 frontend for +.BR zupt (1). +It creates, inspects, verifies, and extracts archives by running the CLI as a +child process. It can also request CLI disk backup and restore operations. +The GUI does not implement an archive codec or cryptography itself. .PP -If neither is installed, the GUI prints an instructive error and exits. - +PySide6 is tried first and PyQt6 is used as a fallback. The selected +.B zupt +command is checked by executing +.BR "zupt version" . +The ZUPT command and environment variables are preferred; renamed-era names +are accepted only for compatibility with an existing installation. +.PP +When paired with the source-only baseline CLI, the frontend uses a build with +.B WITH_SDK=0 +and +.BR WITH_PQBOX=0 . +Native password, +.B --pq +(ML-KEM-768 plus X25519), and +.B --pq-only +(ML-KEM-768) modes remain available. The GUI parses the CLI's +.B Build integrations: +line and exposes +.B --pq-sdk +or +.B --pq-box +only when libvuptsdk or libpqvaptvupt is independently reported enabled. +These two optional integrations are detected separately. +.PP +The gated 5.2.8 GUI release set is limited to the architecture-independent DEB, +noarch/source RPM, and source-only portable ZIP named in the project README. +Package gates require exact checks and installed off-screen GUI/CLI integration. +The portable ZIP receives source scans, an exact safe-member allowlist, and an +extracted launcher test; it bundles no Python, Qt, CLI, or compiled runtime. +AppImage, AppDir and Flatpak bundles and Windows/macOS GUI installers are not +promoted; the Windows ZIP and macOS DMG are CLI-only. +.PP +The GUI does not expose the CLI's recovery-only +.B --allow-legacy-no-ait +option. A known, trusted pre-AIT archive must be recovered explicitly with the +CLI; untrusted trailerless archives must remain rejected. +.SH OPTIONS +.TP +.B --compress +Open the Compress tab with the remaining arguments selected as inputs. +.TP +.B --extract +Open the Extract tab with the following archive selected. +.TP +.B --selftest +Create the complete interface, run the event loop briefly, and exit. A display +backend (or a suitable off-screen Qt backend) is still required. +.TP +.BR --version , " -V" +Print the GUI, Qt binding, CLI version, and selected CLI path. +.TP +.BR --help , " -h" +Print command-line usage. .SH TABS .TP +.B Keys +Generate and export recipient keys. Mode choices follow CLI capability +detection, including independent SDK and PQ-box choices when enabled. +.TP .B Compress -Select files or directories, choose codec, level, password and/or PQ -key. The -.B Mode -panel controls whether the SDK v2 path or the legacy path is used. +Choose inputs, destination, codec options, password, and an optional recipient +public key. .TP .B Extract -Open a .zupt archive, select output directory, provide password -and/or PQ private key. +Choose an archive, output directory, and any required password or private key. +The GUI uses +.B zupt info +to auto-detect supported archive protection modes. That framing inspection is +unauthenticated and is only a mode-selection hint; the subsequent CLI extract +or test operation performs the required AIT and content validation. .TP -.B Keygen -Generate ML-KEM-768 + X25519 keypair. The -.B SDK v2 format -checkbox controls whether the keypair is generated via -.B vaptvupt keygen --sdk -(producing -.IR file -and -.IR file.pub -in one step) or via the legacy -.BR "vaptvupt keygen" . +.B Verify +Inspect an archive header or run the CLI integrity test with the detected +credential type. .TP .B Disk -Full-disk backup and restore. Enumerates block devices with -human-readable sizes. Same encryption mode controls as Compress. - -.SH FILES +Request full-device or image backup and restore through the CLI. Raw devices +may require operating-system privileges. Restore overwrites its selected target +and requires explicit confirmation in the GUI. .TP -.I /usr/bin/vaptvupt-gui -Wrapper script that invokes the Python entry point (and the symlinked -legacy -.IR /usr/bin/zupt-gui ). -.TP -.I /usr/lib/vaptvupt-gui/zupt_gui.py -Main Python source. -.TP -.I /usr/share/applications/vaptvupt-gui.desktop -Desktop entry for menu integration. -.TP -.I /usr/share/icons/hicolor/256x256/apps/vaptvupt-gui.png -Application icon. - +.B About +Show the detected CLI version and build information. .SH ENVIRONMENT .TP -.B VAPTVUPT_BIN -Override the path to the -.B vaptvupt -binary (default: search -.IR PATH ). -The legacy name .B ZUPT_BIN -is also honoured. +Absolute or executable path to the preferred +.B zupt +command. It must pass the CLI version liveness check. .TP -.B VAPTVUPT_DEBUG -Enable binary-discovery debug logging on stderr. The legacy name .B ZUPT_DEBUG -is also honoured. - +Print command-discovery diagnostics to standard error when non-empty. +.TP +.B ZUPT_NO_XCB_FALLBACK +Disable the guarded XWayland relaunch used when a Wayland window is never +exposed. +.TP +.BR VAPTVUPT_BIN , " VAPTVUPT_DEBUG" , " VAPTVUPT_NO_XCB_FALLBACK" +Renamed-era compatibility aliases for the corresponding ZUPT variables. +New integrations should use the ZUPT names. +.SH FILES +.TP +.I /usr/bin/zupt-gui +Installed launcher. +.TP +.I /usr/lib/zupt-gui/zupt_gui.py +GUI source location used by the DEB or source installer. The noarch RPM may use +an architecture-independent shared-data directory instead. +.TP +.I /usr/share/applications/zupt-gui.desktop +Desktop entry. +.PP +Distribution packages do not install a +.B vaptvupt-gui +alias. The source installer can create one only with its explicit +.B --legacy-alias +option. The optional alias has no separate manual page. .SH BUGS -Report at -.UR https://git.securityops.co/cristiancmoises/vaptvupt/issues +Report reproducible issues at +.UR https://github.com/cristiancmoises/zupt/issues +the ZUPT issue tracker .UE . - .SH AUTHOR Cristian Cezar Moisés -.MT zupt@riseup.net -.ME - -.SH SEE ALSO -.BR vaptvupt (1). - .SH LICENSE -.PP -vaptvupt-gui is licensed under the -.B GNU Affero General Public License version 3 or later -(AGPL-3.0-or-later). Commercial license available for relief from -copyleft terms; contact -.MT sac@securityops.co -.ME . - -.SH PROJECT -.PP -Home page: -.UR https://git.securityops.co/cristiancmoises/vaptvupt -.UE +The current integrated GUI source carries AGPL-3.0-or-later notices. Published +historical revisions include MIT grants that remain applicable to the exact +material distributed under them. See +.I gui/LICENSE-GUI +and the 5.2.2 licensing erratum in +.I CHANGELOG.md +for scope and repository evidence. +.SH SEE ALSO +.BR zupt (1) diff --git a/doc/zupt.1 b/doc/zupt.1 deleted file mode 120000 index f888e38..0000000 --- a/doc/zupt.1 +++ /dev/null @@ -1 +0,0 @@ -vaptvupt.1 \ No newline at end of file diff --git a/doc/zupt.1 b/doc/zupt.1 new file mode 100644 index 0000000..d871e25 --- /dev/null +++ b/doc/zupt.1 @@ -0,0 +1,636 @@ +.\" SPDX-License-Identifier: AGPL-3.0-or-later +.\" Copyright (c) 2025-2026 Cristian Cezar Moisés +.TH ZUPT 1 "2026-08-31" "ZUPT 5.2.8" "User Commands" +. +.SH NAME +zupt \- source-built backup compression and authenticated-encryption utility +. +.SH SYNOPSIS +.B zupt compress +.RI [ options ] +.I output.zupt input... +.br +.B zupt extract +.RI [ options ] +.I archive.zupt +.br +.B zupt list +.RI [ options ] +.I archive.zupt +.br +.B zupt test +.RI [ options ] +.I archive.zupt +.br +.B zupt info +.I archive.zupt +.br +.B zupt bench +.RB [ --compare ] +.I input... +.br +.B zupt disk backup +.RI [ options ] +.I output.zupt device-or-file +.br +.B zupt disk restore +.RI [ options ] +.I archive.zupt target-device-or-file +.br +.B zupt keygen +.RI [ key-options ] +.B -o +.I output +.br +.B zupt +.RB { help | --help | -h | version | --version | -V } +. +.SH DESCRIPTION +.B zupt +creates self-contained backup archives with the historical +.B .zupt +extension. Version 5.2.2 restores the original product name, ZUPT, and the +primary installed command is +.BR zupt . +The archive extension and +.B ZUPT +format magic, codec identifiers, and archive compatibility remain unchanged. +. +.PP +Plain archives provide compression checksums for accidental-corruption +detection. They do not provide cryptographic authentication against an attacker +who can rewrite an archive. Encrypted archives use AES-256-CTR with +HMAC-SHA256 and authenticate current per-block framing, logical frame position, +and archive metadata. The validating read paths require an archive-integrity +trailer by default; a no-trailer archive fails closed unless the caller selects +the explicit trusted-legacy override described below. +See +.B SECURITY.md +and +.B THREAT_MODEL.md +for the exact boundary and historical-format limitations. +. +.PP +The bundled compression codec is VaptVupt codec 2.65.3. +Automatic codec selection uses VaptVupt where the supported AVX2 or NEON path +is available and uses the portable LZHP codec otherwise. Use a codec-selection +option only when a specific choice is required. +. +.PP +The renamed-era +.B vaptvupt +command is an optional compatibility alias and is not installed by default. +Distribution packages, including the openSUSE main package, expose +.B zupt +as the canonical command. +. +.PP +Git and the upstream source tarball are source-only. Separately built CLI DEB, +binary RPM, SRPM, notice-bearing Linux tar.xz, Windows ZIP, and macOS DMG assets +may be published from the immutable tag only after their target-specific gates +pass; they never enter Git or the source tarball. An AppImage is not promoted +for 5.2.8; neither are AppDir/Flatpak bundles, GUI platform installers, or bare +Linux/Windows executables. The Python/Qt frontend remains available as source; +its gated architecture-independent DEB, noarch/source RPM, and source-only +portable ZIP are included in the release claim. The portable ZIP contains no +Python, Qt, CLI, or compiled runtime. Windows and macOS artifacts remain +CLI-only. +. +.SH COMMANDS +.TP +.BR compress , " c" +Create an archive from one or more files or directories. Directories are +traversed recursively. All options must precede +.IR output.zupt . +A literal input name beginning with a hyphen can follow a +.B -- +separator after the output name. +. +.TP +.BR extract , " x" +Extract regular-file contents below the current directory or the directory +selected by +.BR -o . +Entry names are validated and resolved below a pinned destination. Existing +destination files are not overwritten. Decoded data is published from a +private temporary file only after its expected size and checksum are verified. +ZUPT does not restore ownership, ACLs, extended attributes, original mode, +or modification time. +. +.TP +.BR list , " l" +Open and verify the archive as required, then print its format, block and +protection flags followed by each entry's path, original size, compressed size, +and compression ratio. No file is extracted. +. +.TP +.BR test , " t" +Decode and verify the archive without writing extracted files. Exit status is +nonzero on an authentication, integrity, format, or I/O failure. +. +.TP +.BR info , " i" +Read non-secret framing metadata without a password or private key. Current +output includes archive size, format version, integrity-trailer type, UUID, +creation timestamp, block count when a footer is found, encryption/PQ mode, +selected global feature flags, and whether a comment is present. This command +does not decrypt, list, or extract entries. It does not validate the trailer or +archive contents: a reported trailer is framing information, and successful +execution is not an integrity result. Its input must still be treated as +untrusted. +. +.TP +.BR bench , " b" +Run the built-in compression-level benchmark on supplied input. With +.BR --compare , +also compare external compressors available on the host. Results depend on the +input, compiler, CPU, storage, and system load and are not release support +claims. +. +.TP +.B disk backup +Read a device or regular file sequentially and create a sparse-aware archive. +Zero regions are represented without storing their full contents. +. +.TP +.B disk restore +Restore a disk-image archive to a device or regular file. The target is written +destructively; verify both operands and use the least privilege required. The +archive is first copied to a private, auto-deleted scratch file, and validation +and restoration consume that same snapshot before the target is opened. Set +.B ZUPT_TMPDIR +to an existing private scratch directory when needed; an invalid override fails +without fallback. A raw block device is rejected before the first write when +its capacity cannot be determined or is smaller than the restored image. +. +.TP +.B keygen +Generate a native ML-KEM-768 plus X25519 hybrid private key by default. Other +key formats require the matching key-generation option and, for optional SDK or +PQBOX modes, a build with the corresponding system integration enabled. +. +.TP +.BR version , " --version" , " -V" +Print the program, archive-format and codec versions, compiled optional +integrations, runtime CPU acceleration, license scopes, and canonical project +URL. +. +.TP +.BR help , " --help" , " -h" +Print command usage and the options compiled into the program. +. +.SH PASSWORD INPUT +The password options are accepted by +.BR compress , +.BR extract , +.BR list , +.BR test , +and both +.B disk +subcommands. For +.B compress +and +.BR "disk backup" , +the interactive form asks for confirmation. +. +.TP +.BR -p " " password , " --password " password +Read a password directly from the next process argument. This is compatible +with older command lines but can expose the password through shell history or +process inspection. Prefer one of the non-argv forms below. +. +.TP +.B --password-prompt +Read the password from the terminal without echo. This explicit form avoids +the optional-argument ambiguity of historical +.BR -p . +On POSIX, handled interruptions restore the terminal settings saved before the +prompt. +. +.TP +.BI --pass-file " file" +Read the first line from +.IR file . +The trailing LF and an optional preceding CR are removed. Empty input, an +embedded NUL, or an overlong password is rejected. Protect the file with +restrictive permissions and remove it securely when it is no longer needed. +. +.TP +.BI --pass-fd " fd" +Read the first line from the inherited numeric file descriptor +.IR fd . +ZUPT duplicates the descriptor for reading and does not close the caller's +original descriptor. The duplicate shares the underlying stream and offset; +buffered input may consume beyond the password line, so dedicate the descriptor +to this read. The same input validation as +.B --pass-file +applies. +. +.SH COMPRESSION OPTIONS +These options are accepted by +.B compress +unless stated otherwise. +. +.TP +.BR -l " " 1..9 , " --level " 1..9 +Set the compression level. The default is 7. Automatic block sizes are 128 KiB +for levels 1-2, 1 MiB for levels 3-4, 2 MiB for levels 5-6, 4 MiB for level 7, +and 8 MiB for levels 8-9. Deduplication uses a smaller automatic granularity. +. +.TP +.BR -b " " bytes , " --block " bytes +Set the block size in bytes. Values are constrained to the supported range of +64 KiB through 256 MiB. +. +.TP +.BR -s , " --store" +Store data without compression. +. +.TP +.BR -f , " --fast" +Select the fast LZ codec. +. +.TP +.BR --vv , " --vaptvupt" +Force the bundled VaptVupt LZ plus ANS codec. +. +.TP +.B --lzhp +Force the portable LZHP codec. +. +.TP +.BI --kdf " algorithm" +Select the password KDF. The default source-only build supports and defaults to +.BR pbkdf2 , +using PBKDF2-SHA256 with 600,000 iterations. A +.B WITH_SDK=1 +build additionally supports +.B argon2id +and uses it by default. A build must reject a requested KDF that it cannot +provide rather than silently changing algorithms. +. +.TP +.BR -c " " text , " --comment " text +Store a comment of up to 4095 CLI bytes. In an encrypted archive the comment is +encrypted and authenticated with the archive; in a plain archive it receives +only the plain archive's non-cryptographic integrity treatment. +. +.TP +.BI --comment-file " file" +Read the comment from +.IR file . +Trailing CR/LF characters are removed. +. +.TP +.BR -D , " --dedup" +Enable block-level deduplication. Encrypted deduplicated blocks still use fresh +per-block nonces in current archives. DATA and DEDUP_REF frames are bound to +their logical positions. An authenticated reference also carries the source +position required to authenticate the referenced DATA frame. +. +.TP +.BR -S , " --solid" +Use one solid compression stream. Solid mode is single-threaded. +. +.TP +.BR -y , " --force" +Allow +.B compress +to overwrite an existing output whose name does not end in +.BR .zupt . +This does not relax extraction's no-overwrite policy. +. +.TP +.BR -t " " count , " --threads " count +Set the compression or extraction thread count. Zero selects automatic +detection; explicit values are limited to 64. This option is accepted by +.B compress +and +.BR extract , +and by disk backup/restore, but not by +.B list +or +.BR test . +. +.TP +.BR -v , " --verbose" +Enable additional progress or diagnostic output where the selected command +implements it. Authentication failures remain intentionally generic unless +verbose diagnostics are requested, reducing the default verbal probe-oracle. +. +.SH POST-QUANTUM OPTIONS +.TP +.BI --pq " key" +Use the native hybrid envelope: ML-KEM-768 plus X25519 with the archive-key +combiner documented in +.BR THREAT_MODEL.md . +Use the recipient public key for creation and the matching private key for +reading. This is the recommended native PQ mode. +. +.TP +.BI --pq-only " key" +Use native ML-KEM-768 without the X25519 hedge. Choose it only when a policy +requires a single post-quantum KEM. Its keys are generated with +.BR "zupt keygen --pq-only" . +. +.TP +.BI --pq-sdk " key" +Use the optional system +.B libvuptsdk +integration. This option is unavailable unless ZUPT was built with +.BR WITH_SDK=1 . +It is not enabled by the default source-only distribution build. +. +.TP +.BI --pq-box " key" +Use the optional system +.B libpqvaptvupt +sealed-box integration. This option is unavailable unless ZUPT was built +with +.BR WITH_PQBOX=1 . +It is not enabled by the default source-only distribution build. +. +.SH EXTRACTION OPTIONS +.TP +.BR -o " " directory , " --output " directory +Extract below +.IR directory . +The directory is created when needed. The default is the current directory. +. +.PP +Extraction also accepts the password, PQ, +.BR -v , +and +.B -t +options described above. Unlike compression, extract/list/test options may +appear before or after the single archive operand. +. +.SH TRUSTED LEGACY READ OPTION +.TP +.B --allow-legacy-no-ait +Permit recovery of a known, trusted historical archive that predates the +archive-integrity trailer. By default the validating read commands reject an +archive without an AIT, without trusting its unauthenticated header flags. This +option emits a downgrade warning and is accepted only by +.BR extract , +.BR list , +.BR test , +and +.BR "disk restore" . +It is rejected by compression and disk backup, which always write a current +trailer. +. +.PP +Do not use this option for an archive obtained from untrusted or +attacker-writable storage. It does not authenticate the legacy header or footer. +After recovery, verify the restored data and create a new current archive. +. +.SH DISK OPTIONS +Disk options must precede the archive and device/file operands. Both disk +subcommands accept +.BR -l / --level , +.BR -b / --block , +.BR -s / --store , +.BR --vv / --vaptvupt , +.BR --lzhp , +the password input options, +.BR --pq , +.BR --pq-only , +.BR -D / --dedup , +.BR -c / --comment , +.BR --comment-file , +.BR --kdf , +.BR -t / --threads , +and +.BR -v / --verbose . +The optional SDK/PQBOX envelope options and solid mode are not disk-command +options. +. +.PP +.B --allow-legacy-no-ait +is accepted by disk restore only. +. +.SH KEY GENERATION OPTIONS +.TP +.BR -o " " file , " --output " file +Write the generated private key or exported public key to +.IR file . +This option is required. +. +.TP +.BR --pub +Export a public key from the private key selected by +.BR -k . +Use the same mode option as the private key. +. +.TP +.BR -k " " file , " --key " file +Read the source private key used by +.BR --pub . +. +.TP +.BR --pq-only , " --pqonly" +Generate or export a native ML-KEM-768-only key. +. +.TP +.BR --sdk , " --pq-sdk" +Generate an SDK-format key through the optional +.B libvuptsdk +integration. +. +.TP +.BR --box , " --pq-box" +Generate a sealed-box key through the optional +.B libpqvaptvupt +integration. +. +.PP +With no mode option, +.B keygen +generates or exports the native ML-KEM-768 plus X25519 hybrid format used by +.BR --pq . +.PP +Private-key output uses no-replace creation, POSIX mode 0600, or a Windows +current-user-only DACL. A write, flush, or close failure leaves the invalid +exclusive partial for manual removal rather than unlinking a possibly replaced +pathname. Native ZKEY and ZPQK inputs must have a valid checksum, version, +flags, reserved bytes, exact size, and public/private role; malformed or +role-confused keys are rejected. +. +.SH SECURITY NOTES +Use encrypted archives when an attacker may modify storage. Plain checksums are +not authentication. Keep private keys separate from archives, use high-entropy +passwords, and prefer +.BR --password-prompt , +.BR --pass-file , +or +.B --pass-fd +over argv passwords. +.PP +Archive comments remain untrusted display data even when authenticated. ZUPT +renders control bytes without emitting raw terminal-control sequences. +. +.PP +Extract untrusted archives as a dedicated unprivileged user into a new empty +local directory. POSIX builds canonicalize the user-selected output root once, +then traverse below a pinned directory descriptor with no-follow operations. +Windows builds use handle-relative traversal and +no-replace publication for normal local Win32 destinations. Extended-length and +device-namespace paths, raw UNC output roots, and mapped/network-drive output +are not supported in 5.2.8. Cross-compilation and Wine results are not native +Windows evidence; the native Windows package gate, including its Unicode round +trip, is separate and mandatory before publication. +. +.PP +Current encrypted archives protect ciphertext, canonical framing metadata, +logical frame position, and current header/footer metadata. The default AIT +requirement prevents a silent downgrade to a trailerless layout; the explicit +legacy override intentionally leaves old header/footer metadata outside that +authenticated boundary. ZUPT does not protect a compromised endpoint, a +disclosed credential, archive rollback or deletion, traffic analysis, +compression-length side channels, or every compiler and microarchitectural side +channel. Native PQ archive encryption is at-rest encryption, not session +forward secrecy. +. +.SH EXIT STATUS +.TP +.B 0 +The requested operation completed successfully. +. +.TP +.B 1 +Invalid arguments or an operational, format, authentication, integrity, or I/O +failure. +. +.SH FILES +.TP +.I /usr/bin/zupt +Distribution-installed command. +. +.TP +.I /usr/share/bash-completion/completions/zupt +Bash completion for the current command. +. +.TP +.I /usr/share/zsh/site-functions/_zupt +zsh completion for the current command. +. +.TP +.I /usr/share/fish/vendor_completions.d/zupt.fish +fish completion for the current command. +. +.SH EXAMPLES +Create a plain archive: +.PP +.RS +.B zupt compress backup.zupt Documents/ +.RE +. +.PP +Create and restore a password-protected archive without placing the password in +argv: +.PP +.RS +.B zupt compress --password-prompt secure.zupt Documents/ +.br +.B zupt test --password-prompt secure.zupt +.br +.B zupt extract --password-prompt -o restored secure.zupt +.RE +. +.PP +Use a password supplied on file descriptor 3: +.PP +.RS +.B zupt test --pass-fd 3 secure.zupt 3 - VaptVupt GUI (formerly Zupt GUI; parent application renamed in v3.0.0 - due to a prior INPI Brasil trademark registration of "Zupt") is free + ZUPT GUI is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - VaptVupt GUI is distributed in the hope that it will be useful, but + ZUPT GUI is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. @@ -23,21 +22,26 @@ ───────────────────────────────────────────────────────────────────── - PRIOR LICENSE NOTE + HISTORICAL LICENSE NOTE - Earlier copies of this file may have stated "MIT License" — that was - a packaging mistake inherited from a template. The GUI source code's - SPDX-License-Identifier header has always been AGPL-3.0-or-later; - the file-level license here is corrected to match. There is no - historical MIT-licensed release of VaptVupt GUI; do not assume MIT - grant from any prior tarball that contained this file. + Published repository history includes earlier copies of this file + under the MIT License. In particular, commit d4660e6539c8b6eeba81751c018217d978fdd618 + and the v2.2.2 source tag contain an MIT-form gui/LICENSE-GUI. Those + permissions remain applicable to the exact material distributed under + them; this file does not revoke or reinterpret a historical grant. + + The current GUI source carries AGPL-3.0-or-later SPDX notices. Apply + the notices shipped with the exact source revision being used. See the + 5.2.2 licensing erratum in CHANGELOG.md for the repository record. ───────────────────────────────────────────────────────────────────── COMMERCIAL LICENSING - The VaptVupt GUI may be commercially relicensed by the author. If - you require relief from copyleft terms (proprietary derivatives, - closed-source bundling, etc.), contact: + The applicable copyright holder may offer controlled first-party + rights under a separately executed commercial agreement. This notice + is not a commercial license grant, does not revoke a public license, + and cannot relicense rights that the licensor does not control. For + inquiries, contact: sac@securityops.co diff --git a/gui/README.md b/gui/README.md index 624bf7a..378fda5 100644 --- a/gui/README.md +++ b/gui/README.md @@ -1,123 +1,149 @@ -# VaptVupt GUI +# ZUPT GUI -Desktop application for [vaptvupt](https://git.securityops.co/cristiancmoises/vaptvupt) backup compression with ML-KEM-768 + X25519 post-quantum hybrid encryption. +The ZUPT GUI is a Python/Qt front end for the ZUPT 5.2.8 command-line +program. It starts the CLI as a subprocess; compression, archive parsing, and +cryptography remain in the C program. -Works on GNU/Linux, BSD, macOS, and Windows. +The canonical project repository is +`https://github.com/cristiancmoises/zupt`. -## Install +## Requirements -### Linux (recommended) +- a working `zupt` CLI from the same release, available on `PATH` or through + the `ZUPT_BIN` environment variable; +- Python 3.9 or newer; +- PySide6 or PyQt6; +- a graphical session for normal use. -```bash -tar xzf vaptvupt-gui.tar.gz && cd vaptvupt-gui -./vaptvupt-gui # auto-creates venv, installs PySide6 -./install.sh --user # adds right-click menu integration +Install and test the source-only CLI first: + +```sh +make clean +make -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf 1)" \ + WITH_SDK=0 WITH_PQBOX=0 +make WITH_SDK=0 WITH_PQBOX=0 check +./zupt --version ``` -### Windows +The GUI detects the capabilities reported by that binary. Native `--pq` and +`--pq-only` are available in the default build. SDK and PQBOX controls are +usable only when the CLI was built explicitly against the corresponding system +development libraries; no precompiled optional library is shipped in Git. -**Option A — Installer (recommended):** +## Run from the source tree -Download `VaptVuptGUI-1.3.0-Setup.exe` and run it. Installs to Program Files, adds Start Menu shortcut, desktop shortcut, right-click context menus, and .zupt file association. Includes uninstaller. +Using a virtual environment keeps Python packages outside the repository: -**Option B — Build from source:** - -```cmd -cd packaging\windows -build-windows.bat +```sh +python3 -m venv ~/.local/share/zupt-gui-venv +~/.local/share/zupt-gui-venv/bin/pip install PySide6 +ZUPT_BIN="$PWD/zupt" \ + ~/.local/share/zupt-gui-venv/bin/python gui/src/zupt_gui.py ``` -Requires Python 3.9+, NSIS 3.x, and a compiled `vaptvupt.exe`. +Installing PySide6 can access the Python package index. Do that as an explicit +setup step; upstream CLI builds, package builds, and checks do not download +dependencies. -**Option C — Run directly:** +For noninteractive checks: -```cmd -pip install PySide6 -python src\zupt_gui.py +```sh +python3 gui/src/zupt_gui.py --version +python3 gui/src/zupt_gui.py --selftest ``` -### macOS / BSD +The first command does not prove that a full desktop session works. Test the +actual windows and archive operations on every platform for which a GUI package +is published. -```bash -pip3 install PySide6 -python3 src/zupt_gui.py +## Functions + +| Area | Function | +|---|---| +| Keys | Generate keys supported by the selected CLI build | +| Compress | Select input, compression settings, and an available encryption mode | +| Extract | Detect archive encryption, request the needed credential, and extract | +| Verify | Run archive integrity validation without extraction | +| Info | Display metadata reported by the CLI | +| Disk | Front end for the CLI disk backup and restore commands | + +Disk operations can require additional operating-system privileges. Run only +the specific CLI operation that needs them; do not run the whole desktop session +as root. + +## Desktop integration + +`gui/install.sh --user` installs the integration supported by that script for +the current user. Review the script and its destination paths before running +it. File-manager menus and file associations differ across desktops and +operating systems and must be tested on the target system. + +## Packaged GUI builds + +Release pages provide only these GUI artifacts after their separate package and +installed off-screen GUI/CLI integration gates pass: + +- `zupt-gui_5.2.8_all.deb`; +- `zupt-gui-5.2.8-1.noarch.rpm`; +- `zupt-gui-5.2.8-1.src.rpm`; +- `zupt-gui-5.2.8-portable.zip`. + +The DEB/RPM packages install the Python/Qt source and depend on the matching +`zupt` CLI package. The portable ZIP contains source, launchers, icons, licenses, +and provenance only; it bundles no Python, Qt, CLI, or compiled runtime. Its +gate scans the assembled and extracted trees, enforces an exact safe-member +allowlist, and runs the extracted launcher off-screen against the tested CLI. +An absent artifact did not pass its gate and must not be inferred from another +format's result. + +GUI AppImage, AppDir and Flatpak bundles, and Windows/macOS GUI installers are +not promoted by the upstream 5.2.8 release gates. +`packaging/build-gui-appimage.sh` is a downstream-only helper and fails unless +its operator supplies the exact verified runtime plus a complete +license/source-relink notice through `APPIMAGE_RUNTIME_COMPLIANCE_FILE`; that +material is included in the resulting AppDir. + +The downstream Windows GUI helper similarly requires +`ZUPT_WINDOWS_RUNTIME_NOTICES_DIR` with a `MANIFEST.txt` that identifies +the exact Python, PyInstaller, Qt and PySide/PyQt runtime inputs and their +notices. It fails unless the directory also has non-empty +`PYTHON-NOTICE.txt`, `PYINSTALLER-NOTICE.txt`, `QT-NOTICE.txt`, and either +`PYSIDE6-NOTICE.txt` or `PYQT6-NOTICE.txt`. The installer includes that +directory together with every ZUPT license and notice. This requirement does +not make the untested GUI installer a 5.2.8 release asset. The promoted Windows +ZIP and macOS DMG are CLI-only. + +Packaging recipes and scripts under `gui/packaging/` and `packaging/` are build +inputs, not evidence that a package has been accepted by a distribution. They +must build the CLI from the immutable source tag with +`WITH_SDK=0 WITH_PQBOX=0` unless source-built system dependencies are declared. +Generated packages, application bundles, and executables must remain outside +Git and outside upstream source archives. + +The former standalone `gui/setup.py` sdist/wheel route is intentionally absent: +its outputs did not carry the complete project license payload. Use +`gui/install.sh` or the reviewed distribution helpers so the AGPL text and +artwork provenance are installed with the GUI. + +## Troubleshooting + +Verify the exact interpreter and CLI used by the GUI: + +```sh +python3 -c 'import PySide6.QtWidgets' +zupt --version +ZUPT_BIN=/absolute/path/to/zupt \ + python3 gui/src/zupt_gui.py --selftest ``` -### AppImage (universal Linux) - -```bash -chmod +x VaptVupt-GUI-1.3.0-x86_64.AppImage -./VaptVupt-GUI-1.3.0-x86_64.AppImage -``` - -### Flatpak - -```bash -flatpak-builder --install build packaging/flatpak/dev.zupt.gui.yml -flatpak run dev.zupt.gui -``` - -## Features - -| Tab | Function | -|-----|----------| -| Keys | Generate ML-KEM-768 + X25519 hybrid keypairs | -| Compress | All codecs, levels 1-9, dedup, solid, password, PQ keys | -| Extract | Decrypt and extract .zupt archives | -| Verify | Check block checksums, view archive metadata | -| Disk | Full-disk backup and restore | -| About | Version, cryptographic stack, credits | - -All tabs support drag-and-drop. Drop a .zupt file anywhere on the window to extract it. Drop any other file to compress it. - -## System Integration - -### Linux (Nemo / Cinnamon) - -After `./install.sh --user`: -- Right-click any file: **Compress with VaptVupt** -- Right-click .zupt file: **Extract with VaptVupt** -- Double-click .zupt: opens in VaptVupt GUI - -### Windows (after installer) - -- Right-click any file: **Compress with VaptVupt** -- Right-click any folder: **Compress with VaptVupt** -- Double-click .zupt: opens in VaptVupt GUI -- Right-click .zupt: **Verify Integrity** - -## Architecture - -``` -VaptVupt GUI (PySide6, Python) - | - |-- subprocess.Popen() with streaming stderr - | - v -vaptvupt CLI (Pure C11 binary) - ML-KEM-768 + X25519 + AES-256-CTR - VaptVupt / LZHP / Store codecs - Block deduplication, full-disk backup -``` - -The GUI calls the vaptvupt CLI binary — all cryptography runs in native C, not Python. - -## Packaging - -| Platform | Format | Tool | -|----------|--------|------| -| Any | pip | `pip install .` | -| Debian/Ubuntu/Mint | .deb | `packaging/deb/control` | -| Fedora/RHEL | .rpm | `rpmbuild -ba packaging/rpm/vaptvupt.spec` | -| Universal Linux | .AppImage | `packaging/appimage/build-appimage.sh` | -| Sandboxed Linux | .flatpak | `packaging/flatpak/dev.zupt.gui.yml` | -| Windows | .exe installer | `packaging/windows/build-windows.bat` | -| Windows | NSIS .exe | `packaging/windows/zupt-installer.nsi` | - -## Credits - -- **vaptvupt** v4.1.0 — Cristian Cezar Moisés ([github](https://git.securityops.co/cristiancmoises/vaptvupt)) +If no window appears, run the GUI from a terminal and check the display/Wayland +or X11 error. When reporting a problem, include OS and desktop versions, Python +and Qt binding versions, `zupt --version`, and the non-sensitive error +message. Never attach passwords, private keys, tokens, or confidential archives. ## License -AGPL-3.0-or-later +The current GUI source is AGPL-3.0-or-later. Published historical revisions +include MIT notices whose grants remain applicable to the exact material +distributed under them. See `gui/LICENSE-GUI`, the 5.2.2 licensing erratum in +`CHANGELOG.md`, and the repository-level license notices. diff --git a/gui/assets/README.md b/gui/assets/README.md new file mode 100644 index 0000000..678c559 --- /dev/null +++ b/gui/assets/README.md @@ -0,0 +1,23 @@ +# GUI image assets + +These files are runtime data used by the graphical interface and its packaging; +they are not executable code or compiler output. + +| File | Purpose | +| --- | --- | +| `zupt-icon.png` | Main 48 px application icon used by the GUI and packaging. | +| `zupt-128.png` | 128 px application icon. | +| `zupt.png` | 256 px application/AppDir icon. | +| `zupt.ico` | Windows application icon container. | + +Git provenance: all four files were first added by Cristian Cezar Moisés in +ZUPT repository commit `d4660e6539c8b6eeba81751c018217d978fdd618`; the repository +records no earlier or external origin. Their current Git blobs are byte-for-byte +the same blobs present in that commit. That revision distributed them with MIT +license notices, whose permissions remain available for those exact files. + +The current GUI tree also carries AGPL-3.0-or-later notices; apply the license +option appropriate to the exact material and revision being redistributed and +preserve `gui/LICENSE-GUI` and the repository notices. Their historical +filenames are retained because build and desktop-integration files refer to +them. diff --git a/gui/install.sh b/gui/install.sh index 73e8a2c..15f77fd 100755 --- a/gui/install.sh +++ b/gui/install.sh @@ -1,90 +1,130 @@ -#!/bin/bash +#!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés -# Install Zupt GUI + desktop integration -# Run: sudo ./install.sh (or ./install.sh --user for per-user install) -set -e -DIR="$(cd "$(dirname "$0")" && pwd)" -USER_INSTALL=0 -[ "$1" = "--user" ] && USER_INSTALL=1 +# Install the integrated ZUPT GUI from the checked-out source tree. +# This script never downloads Python modules or operating-system packages. -if [ "$USER_INSTALL" -eq 1 ]; then - BIN="$HOME/.local/bin" - APPS="$HOME/.local/share/applications" - NEMO="$HOME/.local/share/nemo/actions" - MIME="$HOME/.local/share/mime" - NAUTILUS="$HOME/.local/share/nautilus/scripts" -else - BIN="/usr/local/bin" - APPS="/usr/share/applications" - NEMO="/usr/share/nemo/actions" - MIME="/usr/share/mime" - NAUTILUS="" +set -Eeuo pipefail + +die() { + printf 'zupt-gui install: %s\n' "$*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: gui/install.sh [OPTIONS] + + --user install below $HOME/.local + --prefix DIR installation prefix (default: /usr/local) + --destdir DIR staging root for package builds + --legacy-alias install opt-in vaptvupt-gui compatibility symlink + -h, --help show this help + +Python 3.9+ and either PySide6 or PyQt6 must already be installed. The +zupt CLI must also be installed or selected with ZUPT_BIN at runtime. +VAPTVUPT_BIN remains a renamed-era compatibility fallback. +EOF +} + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +repo_root=$(cd -- "$script_dir/.." && pwd -P) +prefix=/usr/local +destdir=${DESTDIR:-} +legacy_alias=0 + +while (($#)); do + case $1 in + --user) + [[ -n ${HOME:-} ]] || die 'HOME is unset; cannot use --user' + prefix=$HOME/.local + ;; + --prefix) + (($# >= 2)) || die '--prefix requires a directory' + prefix=$2 + shift + ;; + --destdir) + (($# >= 2)) || die '--destdir requires a directory' + destdir=$2 + shift + ;; + --legacy-alias) legacy_alias=1 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac + shift +done + +[[ $prefix == /* ]] || die '--prefix must be an absolute path' +[[ -z $destdir || $destdir == /* ]] || die '--destdir must be an absolute path' + +bindir=${BINDIR:-$prefix/bin} +libexecdir=${LIBEXECDIR:-$prefix/lib/zupt-gui} +datadir=${DATADIR:-$prefix/share} + +for source_file in \ + "$script_dir/src/zupt_gui.py" \ + "$script_dir/assets/zupt-icon.png" \ + "$script_dir/packaging/zupt-gui.desktop" \ + "$repo_root/doc/zupt-gui.1" \ + "$repo_root/LICENSE" \ + "$repo_root/LICENSE-AGPL-3.0" \ + "$script_dir/LICENSE-GUI" \ + "$script_dir/assets/README.md"; do + [[ -f $source_file ]] || die "required source file is missing: $source_file" +done + +stage_bindir=$destdir$bindir +stage_libexecdir=$destdir$libexecdir +stage_datadir=$destdir$datadir +install -d -- "$stage_bindir" "$stage_libexecdir" \ + "$stage_datadir/applications" \ + "$stage_datadir/icons/hicolor/256x256/apps" \ + "$stage_datadir/man/man1" \ + "$stage_datadir/licenses/zupt-gui" + +install -m 0644 -- "$script_dir/src/zupt_gui.py" "$stage_libexecdir/zupt_gui.py" +install -m 0644 -- "$script_dir/packaging/zupt-gui.desktop" \ + "$stage_datadir/applications/zupt-gui.desktop" +install -m 0644 -- "$script_dir/assets/zupt-icon.png" \ + "$stage_datadir/icons/hicolor/256x256/apps/zupt-gui.png" +install -m 0644 -- "$repo_root/doc/zupt-gui.1" \ + "$stage_datadir/man/man1/zupt-gui.1" +install -m 0644 -- "$repo_root/LICENSE" \ + "$stage_datadir/licenses/zupt-gui/LICENSE" +install -m 0644 -- "$repo_root/LICENSE-AGPL-3.0" \ + "$stage_datadir/licenses/zupt-gui/LICENSE-AGPL-3.0" +install -m 0644 -- "$script_dir/LICENSE-GUI" \ + "$stage_datadir/licenses/zupt-gui/LICENSE-GUI" +install -m 0644 -- "$script_dir/assets/README.md" \ + "$stage_datadir/licenses/zupt-gui/ASSET-PROVENANCE.md" + +# Quote the installed module path for a POSIX shell without embedding DESTDIR. +quoted_libexec=${libexecdir//\'/\'\\\'\'} +launcher_tmp=$(mktemp "${TMPDIR:-/tmp}/zupt-gui-launcher.XXXXXXXX") +trap 'rm -f -- "$launcher_tmp"' EXIT HUP INT TERM +cat >"$launcher_tmp" < "$BIN/zupt-gui" << LAUNCHER -#!/bin/bash -DIR="$DIR" -VENV="\$DIR/.venv" -PY="\$VENV/bin/python3" -[ ! -x "\$PY" ] && python3 -m venv "\$VENV" && "\$VENV/bin/pip" install PySide6 -q -exec "\$PY" "\$DIR/src/zupt_gui.py" "\$@" -LAUNCHER -chmod +x "$BIN/zupt-gui" -echo "Installed: $BIN/zupt-gui" - -# ── Desktop entry ── -cp "$DIR/packaging/zupt-gui.desktop" "$APPS/" -echo "Installed: $APPS/zupt-gui.desktop" - -# ── Nemo actions (Linux Mint / Cinnamon) ── -if [ -d "$(dirname "$NEMO")" ] || [ "$USER_INSTALL" -eq 1 ]; then - mkdir -p "$NEMO" - cp "$DIR/packaging/desktop-integration/nemo/"*.nemo_action "$NEMO/" 2>/dev/null && \ - echo "Installed: Nemo right-click actions" || true -fi - -# ── Nautilus scripts (GNOME) ── -if [ -n "$NAUTILUS" ]; then - mkdir -p "$NAUTILUS" - cat > "$NAUTILUS/Compress with Zupt" << 'NSCRIPT' -#!/bin/bash -zupt-gui --compress $NAUTILUS_SCRIPT_SELECTED_FILE_PATHS -NSCRIPT - chmod +x "$NAUTILUS/Compress with Zupt" - echo "Installed: Nautilus script" -fi - -# ── MIME type for .zupt files ── -MIME_XML="$MIME/packages/zupt.xml" -if [ ! -f "$MIME_XML" ]; then - mkdir -p "$(dirname "$MIME_XML")" - cat > "$MIME_XML" << 'MIMEXML' - - - - Zupt Archive - - - - -MIMEXML - if command -v update-mime-database >/dev/null; then - update-mime-database "$MIME" 2>/dev/null +if [[ -z $destdir ]]; then + if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "$datadir/applications" >/dev/null 2>&1 || true + fi + if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -q "$datadir/icons/hicolor" >/dev/null 2>&1 || true fi - echo "Registered: .zupt MIME type" fi -# ── Associate .zupt files with zupt-gui ── -if command -v xdg-mime >/dev/null; then - xdg-mime default zupt-gui.desktop application/x-zupt 2>/dev/null - echo "Associated: .zupt files open with Zupt GUI" +printf 'Installed zupt-gui below %s%s\n' "$destdir" "$prefix" +if ((!legacy_alias)); then + printf 'Legacy vaptvupt-gui alias was not installed (use --legacy-alias to opt in).\n' fi - -echo "" -echo "Done. Right-click any file in your file manager to see Zupt options." -echo "Double-click any .zupt file to open it in Zupt GUI." diff --git a/gui/packaging/appimage/build-appimage.sh b/gui/packaging/appimage/build-appimage.sh index c0847b2..3efc0e6 100755 --- a/gui/packaging/appimage/build-appimage.sh +++ b/gui/packaging/appimage/build-appimage.sh @@ -1,51 +1,7 @@ -#!/bin/bash +#!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-or-later -# Copyright (c) 2025-2026 Cristian Cezar Moisés -# Build Zupt GUI AppImage -# Requires: appimagetool, python3, pip -set -e -APP="zupt-gui" -VERSION="1.0.0" -APPDIR="${APP}.AppDir" - -rm -rf "$APPDIR" "${APP}-${VERSION}-x86_64.AppImage" -mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/share/zupt-gui" "$APPDIR/usr/share/applications" "$APPDIR/usr/share/icons/hicolor/256x256/apps" - -# Install Python + deps into AppDir -python3 -m venv "$APPDIR/usr/python" -"$APPDIR/usr/python/bin/pip" install PySide6 --quiet - -# Copy app -cp ../../src/zupt_gui.py "$APPDIR/usr/share/zupt-gui/" -cp ../../assets/zupt.png "$APPDIR/usr/share/icons/hicolor/256x256/apps/zupt-gui.png" 2>/dev/null || true - -# Create launcher -cat > "$APPDIR/AppRun" << 'APPRUN' -#!/bin/bash -HERE="$(dirname "$(readlink -f "$0")")" -export PATH="$HERE/usr/bin:$HERE/usr/python/bin:$PATH" -exec python3 "$HERE/usr/share/zupt-gui/zupt_gui.py" "$@" -APPRUN -chmod +x "$APPDIR/AppRun" - -# Desktop file -cat > "$APPDIR/${APP}.desktop" << DESKTOP -[Desktop Entry] -Type=Application -Name=Zupt GUI -Comment=Post-Quantum Backup Utility -Exec=zupt-gui -Icon=zupt-gui -Categories=Utility;Archiving;Security; -Terminal=false -DESKTOP - -# Build AppImage -if command -v appimagetool >/dev/null; then - ARCH=x86_64 appimagetool "$APPDIR" "${APP}-${VERSION}-x86_64.AppImage" - echo "Built: ${APP}-${VERSION}-x86_64.AppImage" -else - echo "appimagetool not found. Install from https://github.com/AppImage/AppImageKit" - echo "AppDir ready at: $APPDIR/" -fi +# Compatibility entry point for the canonical source-only GUI builder. +set -Eeuo pipefail +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd -P) +exec "$repo_root/packaging/build-gui-appimage.sh" "$@" diff --git a/gui/packaging/build-gui-deb.sh b/gui/packaging/build-gui-deb.sh index 8cd7cc6..8f937ff 100755 --- a/gui/packaging/build-gui-deb.sh +++ b/gui/packaging/build-gui-deb.sh @@ -1,94 +1,7 @@ -#!/bin/bash +#!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-or-later -# Copyright (c) 2025-2026 Cristian Cezar Moisés -# Build zupt-gui .deb package -set -e -cd "$(dirname "$0")/.." -VERSION="${VERSION:-2.2.1}" -ARCH="all" -PKG="zupt-gui_${VERSION}_${ARCH}" -ROOT="/tmp/$PKG" - -rm -rf "$ROOT" -mkdir -p "$ROOT/DEBIAN" \ - "$ROOT/usr/bin" \ - "$ROOT/usr/lib/python3/dist-packages" \ - "$ROOT/usr/share/applications" \ - "$ROOT/usr/share/icons/hicolor/256x256/apps" \ - "$ROOT/usr/share/doc/zupt-gui" - -# Python module -install -m 644 src/zupt_gui.py "$ROOT/usr/lib/python3/dist-packages/zupt_gui.py" - -# Launcher -cat > "$ROOT/usr/bin/zupt-gui" <<'LAUNCH' -#!/usr/bin/env python3 -import sys -sys.path.insert(0, "/usr/lib/python3/dist-packages") -from zupt_gui import main -sys.exit(main()) -LAUNCH -chmod +x "$ROOT/usr/bin/zupt-gui" - -# Desktop file -install -m 644 packaging/zupt-gui.desktop "$ROOT/usr/share/applications/" 2>/dev/null || cat > "$ROOT/usr/share/applications/zupt-gui.desktop" <<'DESK' -[Desktop Entry] -Name=Zupt GUI -GenericName=Post-Quantum Backup Utility -Comment=Compress and encrypt files with hybrid PQ crypto -Exec=zupt-gui -Terminal=false -Type=Application -Categories=Utility;Archiving;Security; -Icon=zupt-gui -DESK - -# Icon (use a real one if assets exist) -if [ -f assets/zupt-256.png ]; then - install -m 644 assets/zupt-256.png "$ROOT/usr/share/icons/hicolor/256x256/apps/zupt-gui.png" -elif [ -d ../assets ] && [ -f ../assets/zupt-256.png ]; then - install -m 644 ../assets/zupt-256.png "$ROOT/usr/share/icons/hicolor/256x256/apps/zupt-gui.png" -else - # 1×1 placeholder - printf '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xfc\xcf\xc0\x00\x00\x00\x05\x00\x01\xa5\xf6E@\x00\x00\x00\x00IEND\xaeB`\x82' > "$ROOT/usr/share/icons/hicolor/256x256/apps/zupt-gui.png" -fi - -# Docs -install -m 644 README.md "$ROOT/usr/share/doc/zupt-gui/" -gzip -9n -c ../CHANGELOG.md > "$ROOT/usr/share/doc/zupt-gui/changelog.gz" 2>/dev/null || true - -cat > "$ROOT/usr/share/doc/zupt-gui/copyright" <<'COPYRIGHT' -Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ -Upstream-Name: zupt-gui -Upstream-Contact: Cristian Cezar Moisés -Source: https://git.securityops.co/cristiancmoises/zupt - -Files: * -Copyright: 2025-2026 Cristian Cezar Moisés -License: AGPL-3.0+ -COPYRIGHT - -INSTALLED_SIZE=$(du -sk "$ROOT" | cut -f1) -cat > "$ROOT/DEBIAN/control" <= 3.9), python3-pyside6, zupt (>= 2.2.0) -Maintainer: Cristian Cezar Moisés -Installed-Size: $INSTALLED_SIZE -Homepage: https://git.securityops.co/cristiancmoises/zupt -Description: Zupt GUI — Post-Quantum Backup Utility - Cross-platform graphical interface for the zupt backup compression - utility with post-quantum hybrid encryption (ML-KEM-768 + X25519). - . - v2.2+ uses libzuptsdk under the hood for HKDF-SHA3 hybrid combiner, - 32-byte key commitment, HPKE binding (RFC 9180), and anti-fault - double-decapsulation. Supports legacy archives via auto-detection. -EOF - -dpkg-deb --build --root-owner-group "$ROOT" "/tmp/$PKG.deb" -echo "Built: /tmp/$PKG.deb" -dpkg-deb --info "/tmp/$PKG.deb" | head -12 +# Compatibility entry point for the canonical source-only GUI builder. +set -Eeuo pipefail +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P) +exec "$repo_root/packaging/build-gui-deb.sh" "$@" diff --git a/gui/packaging/deb/control b/gui/packaging/deb/control index 4cc0b9c..65765c6 100644 --- a/gui/packaging/deb/control +++ b/gui/packaging/deb/control @@ -1,13 +1,12 @@ Package: zupt-gui -Version: 1.0.0 +Version: 5.2.8 Section: utils Priority: optional Architecture: all -Depends: python3 (>= 3.9), python3-pyside6, zupt (>= 2.1.6) -Maintainer: Cristian Cezar Moises +Depends: python3 (>= 3.9), python3-pyqt6 | python3-pyside6.qtwidgets, zupt (= 5.2.8) +Maintainer: Cristian Cezar Moisés Homepage: https://github.com/cristiancmoises/zupt -Description: Zupt GUI — Post-Quantum Backup Utility - Cross-platform graphical interface for the zupt backup compression - utility with post-quantum hybrid encryption (ML-KEM-768 + X25519), - hardware-adaptive codecs, block-level deduplication, and full-disk - backup/restore support. +Description: Qt graphical interface for the ZUPT backup utility + The GUI creates, inspects, verifies, and extracts .zupt archives through the + separately packaged ZUPT command. Native post-quantum modes are available + in the baseline build; SDK and PQ-box controls follow CLI capability detection. diff --git a/gui/packaging/desktop-integration/nemo/zupt-compress.nemo_action b/gui/packaging/desktop-integration/nemo/zupt-compress.nemo_action index 0977094..0b17109 100644 --- a/gui/packaging/desktop-integration/nemo/zupt-compress.nemo_action +++ b/gui/packaging/desktop-integration/nemo/zupt-compress.nemo_action @@ -1,7 +1,8 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later [Nemo Action] -Name=Compress with Zupt -Comment=Create encrypted .zupt archive +Name=Compress with ZUPT +Comment=Create a .zupt archive with ZUPT GUI Exec=zupt-gui --compress %F -Icon-Name=package-x-generic +Icon-Name=zupt-gui Selection=Any Extensions=any; diff --git a/gui/packaging/desktop-integration/nemo/zupt-extract.nemo_action b/gui/packaging/desktop-integration/nemo/zupt-extract.nemo_action index e4201d3..f1074dc 100644 --- a/gui/packaging/desktop-integration/nemo/zupt-extract.nemo_action +++ b/gui/packaging/desktop-integration/nemo/zupt-extract.nemo_action @@ -1,7 +1,8 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later [Nemo Action] -Name=Extract with Zupt -Comment=Decrypt and extract .zupt archive +Name=Extract with ZUPT +Comment=Extract a .zupt archive with ZUPT GUI Exec=zupt-gui --extract %F -Icon-Name=package-x-generic +Icon-Name=zupt-gui Selection=S Extensions=zupt; diff --git a/gui/packaging/flatpak/dev.zupt.gui.yml b/gui/packaging/flatpak/dev.zupt.gui.yml index 31d70f1..c126aee 100644 --- a/gui/packaging/flatpak/dev.zupt.gui.yml +++ b/gui/packaging/flatpak/dev.zupt.gui.yml @@ -1,39 +1,42 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2026 Cristian Cezar Moisés + +# Offline build manifest for the integrated source tree. The Qt/Python base and +# SDK are resolved by Flatpak before the build; no command below uses pip or +# downloads project dependencies. app-id: dev.zupt.gui -runtime: org.freedesktop.Platform -runtime-version: '24.08' -sdk: org.freedesktop.Sdk +runtime: org.kde.Platform +runtime-version: '6.8' +sdk: org.kde.Sdk +base: io.qt.PySide.BaseApp +base-version: '6.8' command: zupt-gui finish-args: - --share=ipc - - --socket=x11 + - --socket=fallback-x11 - --socket=wayland - --filesystem=home - - --device=all # For disk backup (block devices) modules: - - name: python3-pyside6 - buildsystem: simple - build-commands: - - pip3 install --prefix=/app PySide6 - - name: zupt buildsystem: simple build-commands: - - make - - install -Dm755 zupt /app/bin/zupt - sources: - - type: git - url: https://git.securityops.co/cristiancmoises/zupt - tag: v2.1.6 - - - name: zupt-gui - buildsystem: simple - build-commands: - - install -Dm755 src/zupt_gui.py /app/bin/zupt-gui - - install -Dm644 packaging/zupt-gui.desktop /app/share/applications/dev.zupt.gui.desktop + - make -j${FLATPAK_BUILDER_N_JOBS} WITH_SDK=0 WITH_PQBOX=0 + - make WITH_SDK=0 WITH_PQBOX=0 check + - make PREFIX=/app WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 install + - install -Dm644 gui/src/zupt_gui.py /app/bin/zupt_gui.py + - install -Dm755 packaging/portable/zupt-gui.sh /app/bin/zupt-gui + - install -Dm644 gui/packaging/zupt-gui.desktop /app/share/applications/dev.zupt.gui.desktop + - sed -i 's/^Icon=.*/Icon=dev.zupt.gui/' /app/share/applications/dev.zupt.gui.desktop + - install -Dm644 gui/assets/zupt-icon.png /app/share/icons/hicolor/256x256/apps/dev.zupt.gui.png + - install -Dm644 doc/zupt-gui.1 /app/share/man/man1/zupt-gui.1 + - install -d /app/share/licenses/zupt + - install -m 0644 LICENSE LICENSE-AGPL-3.0 LICENSE-GPL-3.0 LICENSE-BSD-2-Clause LICENSE-BSD-3-Clause LICENSE-CC0-1.0 NOTICE THIRD-PARTY-NOTICES.md /app/share/licenses/zupt/ + - install -d /app/share/licenses/zupt-gui + - install -m 0644 LICENSE-AGPL-3.0 /app/share/licenses/zupt-gui/LICENSE-AGPL-3.0 + - install -m 0644 gui/LICENSE-GUI /app/share/licenses/zupt-gui/LICENSE-GUI + - install -m 0644 gui/assets/README.md /app/share/licenses/zupt-gui/ASSET-PROVENANCE.md sources: - type: dir - path: . + path: ../../.. diff --git a/gui/packaging/windows/build-windows.bat b/gui/packaging/windows/build-windows.bat index 500ea73..36c23d6 100644 --- a/gui/packaging/windows/build-windows.bat +++ b/gui/packaging/windows/build-windows.bat @@ -1,92 +1,102 @@ @echo off -REM ══════════════════════════════════════════════════ -REM Zupt GUI — Windows Build Script -REM Creates: ZuptGUI-2.1.6-Setup.exe -REM -REM Prerequisites: -REM 1. Python 3.9+ (python.org) -REM 2. NSIS 3.x (nsis.sourceforge.io) -REM 3. zupt.exe (compiled zupt CLI binary for Windows) -REM -REM Usage: -REM cd packaging\windows -REM build-windows.bat -REM ══════════════════════════════════════════════════ -setlocal +rem SPDX-License-Identifier: AGPL-3.0-or-later +rem Build the ZUPT GUI installer without downloading dependencies. +rem +rem Prerequisites must already be installed: Python 3.9+, PySide6, PyInstaller, +rem Inno Setup 6, and a source-built/tested zupt.exe. The CLI path can be +rem selected with ZUPT_CLI_EXE. Final output goes to ZUPT_DIST_DIR, +rem which defaults to a directory below %%TEMP%% (outside the Git checkout). +rem ZUPT_WINDOWS_RUNTIME_NOTICES_DIR is mandatory and must contain the +rem license/notices for the exact Python, PyInstaller, Qt and PySide/PyQt +rem runtime files embedded by this local build. -echo. -echo Zupt GUI — Windows Build -echo ════════════════════════ -echo. +setlocal EnableExtensions +for %%I in ("%~dp0\..\..\..") do set "REPO_ROOT=%%~fI" +set "VERSION=%~1" +if not defined VERSION set "VERSION=5.2.8" +if not defined ZUPT_DIST_DIR set "ZUPT_DIST_DIR=%TEMP%\zupt-release" +if not defined ZUPT_CLI_EXE set "ZUPT_CLI_EXE=%REPO_ROOT%\zupt.exe" +set "WORK=%TEMP%\zupt-gui-build-%RANDOM%-%RANDOM%" +set "RC=1" -REM ── Step 1: Install Python deps ── -echo [1/4] Installing dependencies... -pip install PySide6 pyinstaller --quiet --upgrade -if errorlevel 1 ( - echo ERROR: pip install failed. Is Python in PATH? - pause - exit /b 1 +where pyinstaller >nul 2>nul || ( + echo ERROR: PyInstaller is required and is not downloaded by this script.>&2 + goto :cleanup +) +where ISCC.exe >nul 2>nul || ( + echo ERROR: Inno Setup 6 ISCC.exe is required.>&2 + goto :cleanup +) +if not exist "%ZUPT_CLI_EXE%" ( + echo ERROR: source-built CLI not found: %ZUPT_CLI_EXE%>&2 + goto :cleanup +) +if not defined ZUPT_WINDOWS_RUNTIME_NOTICES_DIR ( + echo ERROR: set ZUPT_WINDOWS_RUNTIME_NOTICES_DIR for the exact bundled runtime.>&2 + goto :cleanup +) +if not exist "%ZUPT_WINDOWS_RUNTIME_NOTICES_DIR%\MANIFEST.txt" ( + echo ERROR: runtime notice directory must contain MANIFEST.txt.>&2 + goto :cleanup +) +for %%I in ("%ZUPT_WINDOWS_RUNTIME_NOTICES_DIR%\MANIFEST.txt") do if %%~zI LEQ 0 ( + echo ERROR: runtime notice MANIFEST.txt must not be empty.>&2 + goto :cleanup +) +for %%N in (PYTHON-NOTICE.txt PYINSTALLER-NOTICE.txt QT-NOTICE.txt) do ( + if not exist "%ZUPT_WINDOWS_RUNTIME_NOTICES_DIR%\%%N" ( + echo ERROR: runtime notice directory is missing %%N.>&2 + goto :cleanup + ) + for %%I in ("%ZUPT_WINDOWS_RUNTIME_NOTICES_DIR%\%%N") do if %%~zI LEQ 0 ( + echo ERROR: runtime notice %%N must not be empty.>&2 + goto :cleanup + ) +) +set "QT_BINDING_NOTICE_FOUND=" +for %%N in (PYSIDE6-NOTICE.txt PYQT6-NOTICE.txt) do if exist "%ZUPT_WINDOWS_RUNTIME_NOTICES_DIR%\%%N" ( + for %%I in ("%ZUPT_WINDOWS_RUNTIME_NOTICES_DIR%\%%N") do if %%~zI GTR 0 set "QT_BINDING_NOTICE_FOUND=1" +) +if not defined QT_BINDING_NOTICE_FOUND ( + echo ERROR: runtime notices need non-empty PYSIDE6-NOTICE.txt or PYQT6-NOTICE.txt.>&2 + goto :cleanup ) -REM ── Step 2: Build .exe with PyInstaller ── -echo [2/4] Building ZuptGUI.exe... -if exist dist rmdir /s /q dist -if exist build rmdir /s /q build +mkdir "%WORK%" || goto :cleanup +if not exist "%ZUPT_DIST_DIR%" mkdir "%ZUPT_DIST_DIR%" || goto :cleanup -pyinstaller --onefile --windowed ^ - --name "ZuptGUI" ^ - --icon "..\..\assets\zupt.ico" ^ - --add-data "..\..\assets\zupt.ico;assets" ^ - --add-data "..\..\assets\zupt.png;assets" ^ - "..\..\src\zupt_gui.py" - -if not exist "dist\ZuptGUI.exe" ( - echo ERROR: PyInstaller build failed. - pause - exit /b 1 +"%ZUPT_CLI_EXE%" version >"%WORK%\cli-version.txt" 2>&1 || goto :cleanup +findstr /b /c:"zupt %VERSION%" "%WORK%\cli-version.txt" >nul || ( + echo ERROR: CLI version does not match %VERSION%.>&2 + goto :cleanup ) -echo Built: dist\ZuptGUI.exe +"%ZUPT_CLI_EXE%" help >nul 2>&1 || goto :cleanup -REM ── Step 3: Check for zupt.exe ── -echo [3/4] Checking for zupt.exe... -if not exist "zupt.exe" ( - echo. - echo WARNING: zupt.exe not found in this directory. - echo The installer needs zupt.exe to bundle the CLI tool. - echo Options: - echo a) Copy zupt.exe here and re-run this script - echo b) Build zupt from source with MSYS2/MinGW: - echo pacman -S mingw-w64-x86_64-gcc make - echo cd zupt-2.1.6 ^&^& make - echo cp zupt.exe packaging/windows/ - echo. -) +pyinstaller --noconfirm --clean --onefile --windowed ^ + --name zupt-gui ^ + --icon "%REPO_ROOT%\gui\assets\zupt.ico" ^ + --add-data "%REPO_ROOT%\gui\assets\zupt.ico;assets" ^ + --add-data "%REPO_ROOT%\gui\assets\zupt-icon.png;assets" ^ + --distpath "%WORK%\dist" ^ + --workpath "%WORK%\build" ^ + --specpath "%WORK%" ^ + "%REPO_ROOT%\gui\src\zupt_gui.py" || goto :cleanup -REM ── Step 4: Build NSIS installer ── -echo [4/4] Building installer... -where makensis >nul 2>&1 -if errorlevel 1 ( - echo. - echo NSIS not found. Install from: https://nsis.sourceforge.io - echo Then run: makensis zupt-installer.nsi - echo. - echo Standalone exe ready at: dist\ZuptGUI.exe - pause - exit /b 0 -) +set "GUI_EXE=%WORK%\dist\zupt-gui.exe" +if not exist "%GUI_EXE%" goto :cleanup +set "ZUPT_BIN=%ZUPT_CLI_EXE%" +"%GUI_EXE%" --version >"%WORK%\gui-version.txt" 2>&1 || goto :cleanup +findstr /b /c:"zupt-gui %VERSION%" "%WORK%\gui-version.txt" >nul || goto :cleanup -makensis zupt-installer.nsi -if errorlevel 1 ( - echo ERROR: NSIS build failed. - pause - exit /b 1 -) +ISCC.exe "/DAppVersion=%VERSION%" "/DGuiExecutable=%GUI_EXE%" ^ + "/DCliExecutable=%ZUPT_CLI_EXE%" "/DBuildOutputDir=%ZUPT_DIST_DIR%" ^ + "/DRuntimeNoticesDir=%ZUPT_WINDOWS_RUNTIME_NOTICES_DIR%" ^ + "%REPO_ROOT%\packaging\windows\zupt-gui.iss" || goto :cleanup -echo. -echo ════════════════════════════════════════════ -echo Build complete! -echo Standalone: dist\ZuptGUI.exe -echo Installer: ZuptGUI-2.1.6-Setup.exe -echo ════════════════════════════════════════════ -echo. -pause +if not exist "%ZUPT_DIST_DIR%\ZUPT-Setup-%VERSION%.exe" goto :cleanup +echo PASS: built %ZUPT_DIST_DIR%\ZUPT-Setup-%VERSION%.exe +set "RC=0" + +:cleanup +if exist "%WORK%" rmdir /s /q "%WORK%" +endlocal & exit /b %RC% diff --git a/gui/packaging/windows/zupt-installer.nsi b/gui/packaging/windows/zupt-installer.nsi deleted file mode 100644 index 48dcba9..0000000 --- a/gui/packaging/windows/zupt-installer.nsi +++ /dev/null @@ -1,132 +0,0 @@ -; Zupt GUI — NSIS Installer Script -; Builds: ZuptGUI-Setup.exe -; -; Prerequisites on the build machine: -; 1. NSIS 3.x installed (https://nsis.sourceforge.io) -; 2. Run build-windows.bat first to create dist/ZuptGUI.exe -; 3. Place zupt.exe in this directory -; 4. Then: makensis zupt-installer.nsi - -!include "MUI2.nsh" -!include "FileFunc.nsh" - -; ── Config ── -!define APPNAME "Zupt" -!define APPVERSION "2.1.6" -!define GUIVERSION "1.0.0" -!define PUBLISHER "Cristian Cezar Moises" -!define HELPURL "https://github.com/cristiancmoises/zupt" -!define EXE "ZuptGUI.exe" -!define CLI "zupt.exe" - -Name "${APPNAME} ${APPVERSION}" -OutFile "ZuptGUI-${APPVERSION}-Setup.exe" -InstallDir "$PROGRAMFILES\${APPNAME}" -InstallDirRegKey HKLM "Software\${APPNAME}" "InstallDir" -RequestExecutionLevel admin - -; ── UI ── -!define MUI_ICON "..\..\assets\zupt.ico" -!define MUI_UNICON "..\..\assets\zupt.ico" -!define MUI_ABORTWARNING -!define MUI_WELCOMEPAGE_TITLE "Install ${APPNAME} ${APPVERSION}" -!define MUI_WELCOMEPAGE_TEXT "Post-quantum backup compression with ML-KEM-768 + X25519 hybrid encryption.$\r$\n$\r$\nThis will install the Zupt GUI and CLI tools." - -!insertmacro MUI_PAGE_WELCOME -!insertmacro MUI_PAGE_LICENSE "..\..\LICENSE" -!insertmacro MUI_PAGE_DIRECTORY -!insertmacro MUI_PAGE_INSTFILES -!insertmacro MUI_PAGE_FINISH - -!insertmacro MUI_UNPAGE_CONFIRM -!insertmacro MUI_UNPAGE_INSTFILES - -!insertmacro MUI_LANGUAGE "English" - -; ── Install ── -Section "Install" - SetOutPath $INSTDIR - - ; Copy files - File "dist\${EXE}" - File "${CLI}" - File "..\..\assets\zupt.ico" - File "..\..\LICENSE" - File "..\..\README.md" - - ; Write uninstaller - WriteUninstaller "$INSTDIR\Uninstall.exe" - - ; Start Menu - CreateDirectory "$SMPROGRAMS\${APPNAME}" - CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" "$INSTDIR\${EXE}" "" "$INSTDIR\zupt.ico" - CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" "$INSTDIR\Uninstall.exe" - - ; Desktop shortcut - CreateShortcut "$DESKTOP\${APPNAME}.lnk" "$INSTDIR\${EXE}" "" "$INSTDIR\zupt.ico" - - ; Add to PATH (so zupt.exe is available system-wide) - EnVar::AddValue "PATH" "$INSTDIR" - - ; Register .zupt file association - WriteRegStr HKCR ".zupt" "" "ZuptArchive" - WriteRegStr HKCR "ZuptArchive" "" "Zupt Archive" - WriteRegStr HKCR "ZuptArchive\DefaultIcon" "" "$INSTDIR\zupt.ico" - WriteRegStr HKCR "ZuptArchive\shell\open\command" "" '"$INSTDIR\${EXE}" --extract "%1"' - WriteRegStr HKCR "ZuptArchive\shell\verify\command" "" '"$INSTDIR\${CLI}" test "%1"' - WriteRegStr HKCR "ZuptArchive\shell\verify" "" "Verify Integrity" - - ; Right-click "Compress with Zupt" on any file - WriteRegStr HKCR "*\shell\ZuptCompress" "" "Compress with Zupt" - WriteRegStr HKCR "*\shell\ZuptCompress\Icon" "" "$INSTDIR\zupt.ico" - WriteRegStr HKCR "*\shell\ZuptCompress\command" "" '"$INSTDIR\${EXE}" --compress "%1"' - - ; Right-click on directories - WriteRegStr HKCR "Directory\shell\ZuptCompress" "" "Compress with Zupt" - WriteRegStr HKCR "Directory\shell\ZuptCompress\Icon" "" "$INSTDIR\zupt.ico" - WriteRegStr HKCR "Directory\shell\ZuptCompress\command" "" '"$INSTDIR\${EXE}" --compress "%1"' - - ; Add/Remove Programs entry - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "DisplayName" "${APPNAME} — Post-Quantum Backup" - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "UninstallString" "$INSTDIR\Uninstall.exe" - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "DisplayIcon" "$INSTDIR\zupt.ico" - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "Publisher" "${PUBLISHER}" - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "DisplayVersion" "${APPVERSION}" - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "URLInfoAbout" "${HELPURL}" - - ; Calculate installed size - ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 - IntFmt $0 "0x%08X" $0 - WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "EstimatedSize" "$0" -SectionEnd - -; ── Uninstall ── -Section "Uninstall" - ; Remove files - Delete "$INSTDIR\${EXE}" - Delete "$INSTDIR\${CLI}" - Delete "$INSTDIR\zupt.ico" - Delete "$INSTDIR\LICENSE" - Delete "$INSTDIR\README.md" - Delete "$INSTDIR\Uninstall.exe" - RMDir "$INSTDIR" - - ; Remove shortcuts - Delete "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" - Delete "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" - RMDir "$SMPROGRAMS\${APPNAME}" - Delete "$DESKTOP\${APPNAME}.lnk" - - ; Remove from PATH - EnVar::DeleteValue "PATH" "$INSTDIR" - - ; Remove file associations - DeleteRegKey HKCR ".zupt" - DeleteRegKey HKCR "ZuptArchive" - DeleteRegKey HKCR "*\shell\ZuptCompress" - DeleteRegKey HKCR "Directory\shell\ZuptCompress" - - ; Remove Add/Remove Programs entry - DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" - DeleteRegKey HKLM "Software\${APPNAME}" -SectionEnd diff --git a/gui/packaging/zupt-gui.desktop b/gui/packaging/zupt-gui.desktop index 3236ff2..e81b54c 100644 --- a/gui/packaging/zupt-gui.desktop +++ b/gui/packaging/zupt-gui.desktop @@ -1,12 +1,13 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later [Desktop Entry] Type=Application -Name=Zupt GUI -GenericName=Post-Quantum Backup -Comment=Compress, encrypt, and backup with quantum-resistant cryptography -Exec=zupt-gui +Name=ZUPT GUI +GenericName=Backup and Compression Utility +Comment=Create, inspect, verify, and extract ZUPT archives +Exec=zupt-gui %f Icon=zupt-gui -Categories=Utility;Archiving;Security; -Keywords=backup;compress;encrypt;quantum;zupt; +Categories=Utility;Archiving;Compression; +Keywords=backup;archive;compression;encryption;post-quantum;zupt; Terminal=false StartupNotify=true MimeType=application/x-zupt; diff --git a/gui/setup.py b/gui/setup.py deleted file mode 100644 index ca76764..0000000 --- a/gui/setup.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: AGPL-3.0-or-later -# Copyright (c) 2025-2026 Cristian Cezar Moisés -from setuptools import setup, find_packages - -setup( - name="zupt-gui", - version="1.1.1", - description="Zupt GUI — Cross-Platform Post-Quantum Backup Utility", - long_description=open("README.md").read(), - long_description_content_type="text/markdown", - author="Cristian Cezar Moisés", - url="https://git.securityops.co/cristiancmoises/zupt", - license="AGPL-3.0-or-later", - packages=find_packages(where="src"), - package_dir={"": "src"}, - py_modules=["zupt_gui"], - python_requires=">=3.9", - install_requires=["PySide6>=6.5"], - entry_points={ - "console_scripts": ["zupt-gui=zupt_gui:main"], - "gui_scripts": ["zupt-gui=zupt_gui:main"], - }, - classifiers=[ - "Development Status :: 4 - Beta", - "Environment :: X11 Applications :: Qt", - "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Topic :: Security :: Cryptography", - "Topic :: System :: Archiving :: Compression", - ], -) diff --git a/gui/src/zupt_gui.py b/gui/src/zupt_gui.py index edd99d1..4a46a4c 100644 --- a/gui/src/zupt_gui.py +++ b/gui/src/zupt_gui.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés -"""VaptVupt GUI — Cross-Platform Post-Quantum Backup. +"""ZUPT GUI — Cross-platform post-quantum backup. -Renamed from "Zupt" in v3.0.0 due to INPI Brasil trademark. -The .zupt file extension is preserved. +The original ZUPT product name was restored in 5.2.2. The .zupt archive +extension, format, codec identifiers, and compatibility remain unchanged. Tries PySide6 first (preferred), falls back to PyQt6 if PySide6 is not installed. PyQt6 is the default available package on Debian/Ubuntu @@ -23,7 +23,7 @@ try: QTextEdit, QProgressBar, QTabWidget, QFrame, QCheckBox, QSpinBox, QMessageBox, QStatusBar, QScrollArea ) - from PySide6.QtCore import Qt, Signal, QObject, QThread + from PySide6.QtCore import Qt, Signal, QObject, QThread, QTimer, QEvent from PySide6.QtGui import QPalette, QColor, QIcon, QPixmap QT_BINDING = "PySide6" except ImportError: @@ -34,37 +34,39 @@ except ImportError: QTextEdit, QProgressBar, QTabWidget, QFrame, QCheckBox, QSpinBox, QMessageBox, QStatusBar, QScrollArea ) - from PyQt6.QtCore import Qt, pyqtSignal as Signal, QObject, QThread + from PyQt6.QtCore import Qt, pyqtSignal as Signal, QObject, QThread, QTimer, QEvent from PyQt6.QtGui import QPalette, QColor, QIcon, QPixmap QT_BINDING = "PyQt6" except ImportError: - sys.stderr.write( - "ERROR: vaptvupt-gui requires PySide6 or PyQt6. Install one of:\n" - " Debian/Ubuntu: sudo apt install python3-pyqt6\n" - " Fedora/RHEL: sudo dnf install python3-pyqt6\n" - " pip (any OS): pip install PySide6\n" - ) + if sys.stderr is not None: # None under PyInstaller --windowed + sys.stderr.write( + "ERROR: zupt-gui requires PySide6 or PyQt6. Install one of:\n" + " Debian/Ubuntu: sudo apt install python3-pyqt6\n" + " Fedora/RHEL: sudo dnf install python3-pyqt6\n" + " pip (any OS): pip install PySide6\n" + ) sys.exit(1) -# ── Find vaptvupt binary ── +# ── Find the ZUPT binary ── # -# v3.0.0 rename: the binary is now `vaptvupt`; older installations -# (1.x/2.x) ship `zupt`. We try the new name first, fall back to the -# old name, and on every candidate verify it's actually executable +# ZUPT 5.2.2 restores `zupt` as the primary command. Renamed-era +# installations may still provide `vaptvupt`, so discovery accepts it as a +# compatibility fallback. Every candidate is verified as actually executable # (not just present). After picking a candidate, we run a quick # `version` liveness check — this catches the case where the binary # exists but can't load its shared library (the original bug report: # "GUI doesn't find zupt; copying to /usr/local/bin fixes it"). # -# Diagnostic output goes to stderr so users can `vaptvupt-gui 2>log` +# Diagnostic output goes to stderr so users can `zupt-gui 2>log` # to see exactly which path was tried and why each failed. _DISCOVERY_LOG = [] def _discovery_log(msg): _DISCOVERY_LOG.append(msg) - # Echo to stderr if VAPTVUPT_DEBUG or ZUPT_DEBUG is set - if os.environ.get("VAPTVUPT_DEBUG") or os.environ.get("ZUPT_DEBUG"): + # Echo to stderr if ZUPT_DEBUG or its renamed-era alias is set. + if ((os.environ.get("ZUPT_DEBUG") or os.environ.get("VAPTVUPT_DEBUG")) + and sys.stderr is not None): # None under PyInstaller --windowed sys.stderr.write(f" [discovery] {msg}\n") def _is_runnable(path): @@ -77,7 +79,7 @@ def _is_runnable(path): # Liveness check — catches missing shared libraries, broken rpath, # ABI mismatch, etc. 3-second cap so we never hang the GUI startup. try: - r = subprocess.run([p, "version"], capture_output=True, timeout=3) + r = subprocess.run([p, "version"], capture_output=True, stdin=subprocess.DEVNULL, timeout=3) if r.returncode != 0: err = r.stderr.decode("utf-8", errors="replace").strip() return False, f"exit {r.returncode}: {err.splitlines()[0] if err else 'no stderr'}" @@ -89,9 +91,9 @@ def _is_runnable(path): except OSError as e: return False, f"OSError: {e}" -def _find_vaptvupt(): +def _find_zupt(): # 1. Explicit env override - for env in ("VAPTVUPT_BIN", "ZUPT_BIN"): + for env in ("ZUPT_BIN", "VAPTVUPT_BIN"): p = os.environ.get(env) if p: ok, reason = _is_runnable(p) @@ -100,18 +102,18 @@ def _find_vaptvupt(): return p # 2. Local project tree (running from a source checkout) - # Try BOTH names (vaptvupt is v3.0.0+, zupt is legacy). + # Prefer the canonical name, then the renamed-era compatibility name. here = Path(getattr(sys, "_MEIPASS", Path(__file__).parent)) for parent in (here.parent.parent, here.parent, here): - for name in ("vaptvupt", "zupt", "vaptvupt.exe", "zupt.exe"): + for name in ("zupt", "vaptvupt", "zupt.exe", "vaptvupt.exe"): c = parent / name ok, reason = _is_runnable(c) _discovery_log(f"local {c}: {reason}") if ok: return str(c.resolve()) - # 3. System PATH — try new name first, then legacy - for name in ("vaptvupt", "zupt"): + # 3. System PATH — try the canonical name first, then compatibility. + for name in ("zupt", "vaptvupt"): found = shutil.which(name) if found: ok, reason = _is_runnable(found) @@ -125,17 +127,17 @@ def _find_vaptvupt(): # from a desktop session with a minimal PATH that omits /usr/bin" # scenario reported against v2.4.8. common = [ - # New name (v3.0.0+) - "/usr/local/bin/vaptvupt", "/usr/bin/vaptvupt", - "/opt/vaptvupt/bin/vaptvupt", "/opt/homebrew/bin/vaptvupt", - # Legacy name (1.x/2.x) + # Canonical name "/usr/local/bin/zupt", "/usr/bin/zupt", "/opt/zupt/bin/zupt", "/opt/homebrew/bin/zupt", + # Renamed-era compatibility name + "/usr/local/bin/vaptvupt", "/usr/bin/vaptvupt", + "/opt/vaptvupt/bin/vaptvupt", "/opt/homebrew/bin/vaptvupt", # Termux (Android) install path - "/data/data/com.termux/files/usr/bin/vaptvupt", "/data/data/com.termux/files/usr/bin/zupt", + "/data/data/com.termux/files/usr/bin/vaptvupt", # Flatpak sandbox runtime path - "/app/bin/vaptvupt", "/app/bin/zupt", + "/app/bin/zupt", "/app/bin/vaptvupt", ] for path in common: ok, reason = _is_runnable(path) @@ -143,15 +145,13 @@ def _find_vaptvupt(): if ok: return path - # 5. Last resort — return "vaptvupt" and let exec fail loudly later. + # 5. Last resort — return "zupt" and let exec fail loudly later. # A caller-visible error is better than silently returning a path # that doesn't work. - _discovery_log("FAILED: no runnable vaptvupt/zupt binary found") - return "vaptvupt" + _discovery_log("FAILED: no runnable zupt/vaptvupt binary found") + return "zupt" -# Backward-compat: code elsewhere in this file still uses `ZUPT`. -VAPTVUPT = _find_vaptvupt() -ZUPT = VAPTVUPT # legacy alias used throughout the rest of zupt_gui.py +ZUPT_CLI = _find_zupt() # ── Query version ONCE at import (cached) ── # @@ -172,11 +172,11 @@ ZUPT = VAPTVUPT # legacy alias used throughout the rest of zupt_gui.py _VERSION_RE = re.compile(r'^(?:vaptvupt|zupt)\s+(\d+\.\d+\.\d+(?:[._A-Za-z0-9-]*)?)') def _get_version(): - short = "vaptvupt (not found)" + short = "zupt (not found)" number = "?" full = "" try: - r = subprocess.run([VAPTVUPT, "version"], capture_output=True, text=True, timeout=5) + r = subprocess.run([ZUPT_CLI, "version"], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=5) if r.returncode == 0: full = r.stdout.strip() lines = full.split("\n") @@ -190,6 +190,126 @@ def _get_version(): ZUPT_VER_SHORT, ZUPT_VER_NUMBER, ZUPT_VER_FULL = _get_version() +# ── Detect build capabilities from `version` (and `help` as fallback) ── +# +# The default build is SOURCE-ONLY: the system-lib-backed modes (Argon2id, +# --pq-sdk and --pq-box) are absent and fail with exit 1. Offering them in +# the UI is the #1 reason "functions don't work". We detect what THIS binary +# actually supports and build the encryption UI around it: +# - SDK_AVAILABLE : --pq-sdk / Argon2id compiled in (WITH_SDK=1) +# - PQBOX_AVAILABLE: --pq-box compiled in (WITH_PQBOX=1) +# - PQONLY_AVAILABLE: native --pq-only (full post-quantum, v4.2.0+) +# - DEFAULT_KDF : the password KDF this build actually uses +# The `version` banner carries a machine-readable "Build integrations:" line; +# for older binaries we fall back to `help` text and default SDK off (safe: +# the native --pq / --pq-only / password modes work on every build). +def _get_caps(): + sdk = False + pqbox = False + pqonly = False + default_kdf = "PBKDF2-SHA256" + blob = ZUPT_VER_FULL or "" + for line in blob.splitlines(): + low = line.lower() + if low.startswith("build integrations:"): + sdk = "libvuptsdk=enabled" in low + pqbox = "libpqvaptvupt=enabled" in low + elif low.startswith("build:"): + # Compatibility with the pre-5.2.2 combined build banner. + sdk = ("full" in low) and ("vuptsdk" in low) + elif low.startswith("kdf:"): + default_kdf = "Argon2id" if "argon2id (default)" in low else "PBKDF2-SHA256" + if "--pq-only" in line: + pqonly = True + if not pqonly: + try: + h = subprocess.run([ZUPT_CLI, "help"], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=5) + txt = (h.stdout or "") + (h.stderr or "") + if "--pq-only" in txt: + pqonly = True + except Exception: + pass + return sdk, pqbox, pqonly, default_kdf + +SDK_AVAILABLE, PQBOX_AVAILABLE, PQONLY_AVAILABLE, DEFAULT_KDF = _get_caps() + +# Post-quantum recipient modes offered in the UI, keyed to CLI flags. +# token -> (label, keygen-flag-list, compress/extract-flag) +# keygen-flags are extra flags added to `keygen` (private) and `keygen --pub`. +def pq_mode_options(include_auto=False): + """Return [(label, token)] for a PQ-mode dropdown given this build.""" + opts = [] + if include_auto: + opts.append(("Auto-detect from archive", "auto")) + opts.append(("Hybrid — ML-KEM-768 + X25519 (recommended)", "pq")) + if PQONLY_AVAILABLE: + opts.append(("Full PQ — ML-KEM-768 only", "pqonly")) + if SDK_AVAILABLE: + opts.append(("SDK v2 — HKDF + commitment + HPKE", "sdk")) + if PQBOX_AVAILABLE: + opts.append(("PQ sealed box — system libpqvaptvupt", "box")) + return opts + +# token -> (extra keygen flags, encrypt/decrypt flag) +_PQ_FLAG = { + "pq": ([], "--pq"), + "pqonly": (["--pq-only"], "--pq-only"), + "sdk": (["--sdk"], "--pq-sdk"), + "box": (["--box"], "--pq-box"), +} + +def _archive_info_text(archive): + """Return the `info` output for an archive (no password/key needed), or "".""" + try: + r = subprocess.run([ZUPT_CLI, "info", archive], capture_output=True, + stdin=subprocess.DEVNULL, text=True, timeout=15) + return (r.stdout or "") + (r.stderr or "") + except Exception: + return "" + +def _detect_archive_pq(archive): + """Inspect an archive's `info` and return the matching PQ token, or None.""" + low = _archive_info_text(archive).lower() + if "pq box" in low or "pq-box" in low or "sealed box" in low or "sealed-box" in low: + return "box" + if "ml-kem-768 only" in low or "no classical" in low: + return "pqonly" + if "sdk v2" in low or "hpke" in low: + return "sdk" + if "ml-kem-768" in low or "hybrid" in low or "x25519" in low: + return "pq" + return None + +def _detect_archive_enc(archive): + """Detect how an archive is protected, reading only its header (`info`, no + credential). Returns (kind, human_label): + kind: "none" | "password" | "pq" | "pqonly" | "sdk" | "box" | "unknown" + Used to guide the user (which credential to supply) and to pick the right + decrypt flag automatically instead of relying on a mode dropdown.""" + txt = _archive_info_text(archive) + if not txt: + return "unknown", "unknown" + low = txt.lower() + # The `info` "Encrypted:" line is authoritative: "no" vs "YES". + encrypted = None + for line in low.splitlines(): + if "encrypted:" in line: + encrypted = ("yes" in line) + break + if encrypted is False: + return "none", "not encrypted" + if "pq box" in low or "pq-box" in low or "sealed box" in low or "sealed-box" in low: + return "box", "PQ sealed box (system libpqvaptvupt)" + if "ml-kem-768 only" in low or "no classical" in low: + return "pqonly", "full post-quantum (ML-KEM-768)" + if "sdk v2" in low or "hpke" in low: + return "sdk", "SDK v2 (HKDF + HPKE)" + if "ml-kem-768" in low or "hybrid" in low or "x25519" in low: + return "pq", "hybrid post-quantum (ML-KEM-768 + X25519)" + if encrypted: + return "password", "password (AES-256)" + return "unknown", "unknown" + # ── Find icon file ── def _find_icon(): here = Path(getattr(sys, '_MEIPASS', Path(__file__).parent)) @@ -253,27 +373,84 @@ QFrame#sep { background: #1a2a30; max-height: 1px; } def run_zupt(args, timeout=30): try: - r = subprocess.run([ZUPT]+list(args), capture_output=True, text=True, timeout=timeout) + r = subprocess.run([ZUPT_CLI]+list(args), capture_output=True, text=True, + stdin=subprocess.DEVNULL, timeout=timeout) return r.returncode, r.stdout, r.stderr - except FileNotFoundError: return -1, "", f"vaptvupt not found: {VAPTVUPT}\n\nDiscovery log:\n" + "\n".join(_DISCOVERY_LOG[-10:]) + except FileNotFoundError: return -1, "", f"zupt not found: {ZUPT_CLI}\n\nDiscovery log:\n" + "\n".join(_DISCOVERY_LOG[-10:]) except subprocess.TimeoutExpired: return -1, "", "Timed out" class Worker(QObject): done = Signal(int, str, str) log = Signal(str) - def __init__(self, args): super().__init__(); self.args = args + pct = Signal(int) + + # The CLI paints live progress as "\r [##### ] 42%" frames — + # carriage returns only, no newline until 100%. A line-based reader yields + # NOTHING for the entire job, so the GUI looked frozen on any file larger + # than one block ("app is stuck"). Parse the \r frames into a percentage. + _PCT_RE = re.compile(r"(\d{1,3})%\s*$") + + def __init__(self, args): + super().__init__(); self.args = args; self.proc = None; self._cancelled = False def run(self): - self.log.emit(f"$ {Path(VAPTVUPT).name} {' '.join(self.args)}") + self.log.emit(f"$ {Path(ZUPT_CLI).name} {' '.join(self.args)}") try: - proc = subprocess.Popen([ZUPT]+self.args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - err_lines = [] - for line in proc.stderr: - line = line.rstrip('\n') - if line: err_lines.append(line); self.log.emit(line) - stdout, _ = proc.communicate(timeout=7200) - self.done.emit(proc.returncode, stdout or "", "\n".join(err_lines)) - except FileNotFoundError: self.done.emit(-1, "", f"vaptvupt not found: {VAPTVUPT}") + # stdin=DEVNULL: the CLI prompts on a terminal for some inputs + # (e.g. bare -p); a child that reads stdin inherited from the GUI's + # terminal would block forever. /dev/null makes prompts fail fast. + # stderr is merged into stdout so ONE stream carries everything + # (the CLI's human output is on stderr; stdout is empty) — no + # second pipe that could fill while we drain the first. + self.proc = proc = subprocess.Popen( + [ZUPT_CLI]+self.args, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL) + if self._cancelled: # cancel() ran before Popen finished (see below) + proc.kill() + lines = [] + buf = "" + last_pct = -1 + while True: + # read1: return whatever bytes are available (>=1) instead of + # blocking for a full buffer — required for live \r progress. + chunk = proc.stdout.read1(65536) + if not chunk: + break + buf += chunk.decode("utf-8", errors="replace") + # Split on BOTH \n (real lines) and \r (progress frames); + # keep the trailing partial segment in the buffer. + segs = re.split(r"(\r\n|\n|\r)", buf) + buf = segs[-1] + for i in range(0, len(segs) - 1, 2): + seg, sep = segs[i], segs[i + 1] + if sep == "\r" or (seg and self._PCT_RE.search(seg) and "[" in seg): + m = self._PCT_RE.search(seg) + if m: + p = min(100, int(m.group(1))) + if p != last_pct: + last_pct = p; self.pct.emit(p) + continue # progress frames stay out of the log + if seg.strip(): + lines.append(seg); self.log.emit(seg) + proc.wait(timeout=7200) + if buf.strip() and not self._PCT_RE.search(buf): + lines.append(buf); self.log.emit(buf) + self.done.emit(proc.returncode, "", "\n".join(lines)) + except FileNotFoundError: self.done.emit(-1, "", f"zupt not found: {ZUPT_CLI}") except subprocess.TimeoutExpired: proc.kill(); self.done.emit(-1, "", "Timed out") + except Exception as exc: + # Any escape from this slot would strand the job forever (done never + # fires -> button stays disabled; fatal under PyQt6). Always report. + self.done.emit(-1, "", f"{type(exc).__name__}: {exc}") + def cancel(self): + """Kill the child CLI process (called from the GUI thread on window + close). run() then sees EOF/exit and finishes the thread normally. + The flag closes the startup race: if cancel() runs before run() has + assigned self.proc, run() kills the child right after spawning it.""" + self._cancelled = True + p = self.proc + if p is not None and p.poll() is None: + try: p.kill() + except OSError: pass # ── Widgets ── @@ -323,19 +500,99 @@ class PathField(QWidget): def scrollable(w): sa = QScrollArea(); sa.setWidgetResizable(True); sa.setWidget(w); sa.setFrameShape(QFrame.Shape.NoFrame); return sa -def run_async(parent, cmd, btn, log, progress=None): - log.clear(); btn.setEnabled(False) - if progress: progress.show() - t = QThread(); w = Worker(cmd); w.moveToThread(t) - w.log.connect(log.append) - def finish(code, out, err): - btn.setEnabled(True) - if progress: progress.hide() - log.append("\nDone." if code == 0 else f"\nFailed (exit {code}).") - t.quit() - w.done.connect(finish) - t.started.connect(w.run); t.start() - parent._thread, parent._worker = t, w +class _Job(QObject): + """Controller for one async CLI run. + + CRITICAL threading contract: this object is parented to a GUI-thread widget, + so it LIVES in the GUI thread, and every slot below (on_log/on_pct/on_done/ + on_finished) is a bound method of a GUI-thread QObject. Qt therefore auto- + marshals the worker's signals to the GUI thread (QueuedConnection). + + The previous design connected plain Python CLOSURES (finish/on_pct/release) + to signals emitted from the worker thread. PySide6 runs a plain-closure slot + in the EMITTING thread regardless of the requested connection type — even an + explicit Qt.QueuedConnection — because a bare functor has no receiver QObject + to give it thread affinity (verified empirically). Those closures then + touched QProgressBar / QPushButton / QTextEdit internals from the worker + thread: cross-thread QWidget access, which is undefined behaviour and crashed + the app under real X11/Wayland rendering ("the app closes when I compress"). + It only survived offscreen tests, which tolerate the race. Bound methods of a + GUI-thread QObject are the fix.""" + def __init__(self, parent, cmd, btn, log, progress, ok_msg="Done.", fail_msg=None): + super().__init__(parent) + self._parent = parent + self.btn, self.log, self.progress = btn, log, progress + self.ok_msg, self.fail_msg = ok_msg, fail_msg + self.thread = QThread(self) # QThread object lives in GUI thread + self.worker = Worker(cmd) # no parent — it moves to self.thread + self.worker.moveToThread(self.thread) + self.worker.log.connect(self.on_log) + self.worker.pct.connect(self.on_pct) + self.worker.done.connect(self.on_done) + self.thread.finished.connect(self.on_finished) + self.thread.started.connect(self.worker.run) + + def start(self): + self.thread.start() + + def on_log(self, line): + self.log.append(line) + + def on_pct(self, p): + if self.progress is not None: + if self.progress.maximum() != 100: + self.progress.setRange(0, 100) + self.progress.setValue(p) + + def on_done(self, code, out, err): + self.btn.setEnabled(True) + if self.progress is not None: + self.progress.hide() + if code == 0: + self.log.append("\n" + self.ok_msg) + else: + self.log.append("\n" + (self.fail_msg or f"Failed (exit {code}).")) + self.thread.quit() + + def on_finished(self): + # Runs on the GUI thread AFTER the QThread has emitted finished(); the + # wait() joins the last native teardown so dropping the last Python ref + # can't collect a still-running QThread (that aborts with "QThread: + # Destroyed while thread is still running"). + self.thread.wait() + try: + self._parent._jobs.remove(self) + except (AttributeError, ValueError): + pass + + def cancel_and_join(self, ms=3000): + """GUI thread: kill the child CLI and join the worker thread.""" + self.worker.cancel() + self.thread.quit() + return self.thread.wait(ms) + + +def run_async(parent, cmd, btn, log, progress=None, info=None, + ok_msg="Done.", fail_msg=None, clear=True): + if clear: + log.clear() + if info: # e.g. an auto-detect note; appended AFTER the clear so it survives + log.append(info) + btn.setEnabled(False) + if progress: + progress.setRange(0, 0) # indeterminate until the CLI reports a % + progress.setValue(0) + progress.show() + # Keep a LIST of live jobs on the parent. Tabs with more than one action + # button (Disk: backup + restore) previously shared a single slot, so + # starting a second op dropped the only Python reference to the first + # still-running QThread and Python GC'd it mid-run. The list holds every + # in-flight job (and keeps the QThread alive). + if not hasattr(parent, "_jobs"): + parent._jobs = [] + job = _Job(parent, cmd, btn, log, progress, ok_msg=ok_msg, fail_msg=fail_msg) + parent._jobs.append(job) + job.start() # ── Tabs ── @@ -345,21 +602,31 @@ class KeysTab(QWidget): inner = QWidget() v = QVBoxLayout(inner); v.setContentsMargins(24,24,24,24); v.setSpacing(10) - v.addWidget(QLabel("Generate or export ML-KEM-768 + X25519 hybrid keys.")) + v.addWidget(QLabel("Generate or export post-quantum keys (ML-KEM-768).")) + v.addWidget(Sep()) + + # Key type governs both generate and export so the two stay consistent. + v.addWidget(H("Key type")) + self.mode = QComboBox() + self._modes = pq_mode_options() + for label, _tok in self._modes: + self.mode.addItem(label) + self.mode.setToolTip("Hybrid (--pq) is recommended. Full PQ (--pq-only) drops the\n" + "classical X25519 layer for PQ-only compliance postures. Both use\n" + "in-tree crypto and work on every build.") + v.addWidget(self.mode) v.addWidget(Sep()) # Section 1: Generate new keypair v.addWidget(H("Generate new keypair")) - v.addWidget(QLabel("Creates both private and public key files.")) + v.addWidget(QLabel("Writes a private key and its matching public key.")) v.addWidget(H("Private key output")) self.gen_priv = PathField("e.g. ~/zupt_private.key", "save", "Key (*.key);;All (*)") v.addWidget(self.gen_priv) - - self.gen_sdk = QCheckBox("SDK v2 format (HKDF combiner + commitment + HPKE — recommended)") - self.gen_sdk.setChecked(True) - self.gen_sdk.setToolTip("Generates a libzuptsdk-format keypair. Use --pq-sdk in CLI or 'SDK v2' checkbox in compress to use these keys. Disable for legacy --pq compatibility.") - v.addWidget(self.gen_sdk) + v.addWidget(H("Public key output")) + self.gen_pub = PathField("e.g. ~/zupt_public.key", "save", "Key (*.key);;All (*)") + v.addWidget(self.gen_pub) self.gen_btn = QPushButton("Generate Keypair") self.gen_btn.clicked.connect(self._generate) @@ -370,7 +637,8 @@ class KeysTab(QWidget): # Section 2: Export public key from existing private key v.addWidget(H("Export public key from private key")) - v.addWidget(QLabel("Extract the public key from an existing private key file.")) + v.addWidget(QLabel("Extract the public key from an existing private key file " + "(uses the key type selected above).")) v.addWidget(H("Existing private key")) self.exp_priv = PathField("Select private key", "open", "Key (*.key);;All (*)") @@ -389,27 +657,32 @@ class KeysTab(QWidget): v.addStretch() lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.addWidget(scrollable(inner)) + def _token(self): + return self._modes[self.mode.currentIndex()][1] + + def _default_pub(self, priv): + return (priv.rsplit(".", 1)[0] + "_public.key") if "." in priv else priv + ".pub" + def _generate(self): p = self.gen_priv.path() or str(Path.home() / "zupt_private.key") self.gen_priv.edit.setText(p) + pub = self.gen_pub.path() or self._default_pub(p) + self.gen_pub.edit.setText(pub) + tok = self._token() + kflags, _ = _PQ_FLAG[tok] self.gen_log.clear(); self.gen_btn.setEnabled(False) - if self.gen_sdk.isChecked(): - # SDK keygen creates both files in one step. + if tok == "sdk": + # SDK keygen writes the private key and .pub in one step. code, _, err = run_zupt(["keygen", "--sdk", "-o", p]) self.gen_log.append(err.strip()) - if code == 0: - self.gen_log.append(f"\nPrivate key: {p}\nPublic key: {p}.pub") - else: - self.gen_log.append("\nFailed.") + self.gen_log.append(f"\nPrivate key: {p}\nPublic key: {p}.pub" if code == 0 else "\nFailed.") else: - code, _, err = run_zupt(["keygen", "-o", p]) + code, _, err = run_zupt(["keygen"] + kflags + ["-o", p]) self.gen_log.append(err.strip()) if code == 0: - pub = p.rsplit(".", 1)[0] + "_public.key" if "." in p else p + ".pub" - c2, _, e2 = run_zupt(["keygen", "--pub", "-o", pub, "-k", p]) + c2, _, e2 = run_zupt(["keygen", "--pub"] + kflags + ["-o", pub, "-k", p]) self.gen_log.append(e2.strip()) - if c2 == 0: - self.gen_log.append(f"\nPrivate key: {p}\nPublic key: {pub}") + self.gen_log.append(f"\nPrivate key: {p}\nPublic key: {pub}" if c2 == 0 else "\nFailed to export public key.") else: self.gen_log.append("\nFailed.") self.gen_btn.setEnabled(True) @@ -417,24 +690,21 @@ class KeysTab(QWidget): def _export(self): priv = self.exp_priv.path() pub = self.exp_pub.path() - if not priv: QMessageBox.warning(self, "VaptVupt", "Select the private key file."); return + if not priv: QMessageBox.warning(self, "ZUPT", "Select the private key file."); return if not pub: - pub = priv.rsplit(".", 1)[0] + "_public.key" if "." in priv else priv + ".pub" - self.exp_pub.edit.setText(pub) + pub = self._default_pub(priv); self.exp_pub.edit.setText(pub) + tok = self._token() + kflags, _ = _PQ_FLAG[tok] self.exp_log.clear(); self.exp_btn.setEnabled(False) - code, _, err = run_zupt(["keygen", "--pub", "-o", pub, "-k", priv]) + code, _, err = run_zupt(["keygen", "--pub"] + kflags + ["-o", pub, "-k", priv]) self.exp_log.append(err.strip()) - if code == 0: - self.exp_log.append(f"\nPublic key: {pub}") - else: - self.exp_log.append("\nFailed.") + self.exp_log.append(f"\nPublic key: {pub}" if code == 0 else "\nFailed.") self.exp_btn.setEnabled(True) class CompressTab(QWidget): def __init__(self, initial=None): super().__init__() - self._thread = self._worker = None inner = QWidget() v = QVBoxLayout(inner); v.setContentsMargins(24,24,24,24); v.setSpacing(10) v.addWidget(QLabel("Compress files into an encrypted .zupt archive.")) @@ -442,7 +712,7 @@ class CompressTab(QWidget): v.addWidget(H("Source files / directory")) self.src = PathField("Drop files here or browse", "multi"); v.addWidget(self.src) v.addWidget(H("Output archive")) - self.dst = PathField("e.g. backup.zupt", "save", "VaptVupt archive (*.zupt);;All (*)"); v.addWidget(self.dst) + self.dst = PathField("e.g. backup.zupt", "save", "ZUPT archive (*.zupt);;All (*)"); v.addWidget(self.dst) row = QHBoxLayout(); row.setSpacing(16) for label, widget in [("Codec", self._mk_codec()), ("Level", self._mk_level())]: c = QVBoxLayout(); c.addWidget(H(label)); c.addWidget(widget); row.addLayout(c) @@ -454,10 +724,14 @@ class CompressTab(QWidget): enc = QHBoxLayout(); enc.setSpacing(16) pw = QVBoxLayout(); pw.addWidget(H("Password")); self.pw = PwField("AES-256"); pw.addWidget(self.pw); enc.addLayout(pw) pq = QVBoxLayout(); pq.addWidget(H("PQ public key")); self.pq = PathField("Optional .key", filters="Key (*.key *.pub);;All (*)"); pq.addWidget(self.pq); enc.addLayout(pq) - sdk_box = QVBoxLayout(); sdk_box.addWidget(H("Mode")) - self.sdk = QCheckBox("Use SDK v2 (HKDF + commitment + HPKE)"); self.sdk.setChecked(True) - self.sdk.setToolTip("v2.2+ uses libzuptsdk: HKDF-SHA3 combiner, key commitment, HPKE binding, Argon2id. Disable for legacy --pq compatibility.") - sdk_box.addWidget(self.sdk); sdk_box.addStretch(); enc.addLayout(sdk_box) + mode_box = QVBoxLayout(); mode_box.addWidget(H("PQ mode")) + self.pqmode = QComboBox() + self._pqmodes = pq_mode_options() + for label, _tok in self._pqmodes: + self.pqmode.addItem(label) + self.pqmode.setToolTip("Applies when a PQ public key is set. Must match the key type\n" + "you generated. Hybrid (--pq) is recommended.") + mode_box.addWidget(self.pqmode); mode_box.addStretch(); enc.addLayout(mode_box) v.addLayout(enc) self.btn = QPushButton("Compress"); self.btn.clicked.connect(self._run); v.addWidget(self.btn) self.progress = QProgressBar(); self.progress.setRange(0,0); self.progress.hide(); v.addWidget(self.progress) @@ -472,7 +746,7 @@ class CompressTab(QWidget): def _run(self): srcs = self.src.paths() - if not srcs or not srcs[0]: QMessageBox.warning(self, "VaptVupt", "Select files."); return + if not srcs or not srcs[0]: QMessageBox.warning(self, "ZUPT", "Select files."); return dst = self.dst.path() or srcs[0] + ".zupt"; self.dst.edit.setText(dst) cmd = ["compress", "-l", str(self.level.value())] cm = {"AUTO": None, "VaptVupt": "--vv", "LZHP": "--lzhp", "Store": "-s"} @@ -481,7 +755,8 @@ class CompressTab(QWidget): if self.solid.isChecked(): cmd.append("--solid") if self.pw.text(): cmd += ["-p", self.pw.text()] if self.pq.path(): - flag = "--pq-sdk" if self.sdk.isChecked() else "--pq" + tok = self._pqmodes[self.pqmode.currentIndex()][1] + _, flag = _PQ_FLAG[tok] cmd += [flag, self.pq.path()] cmd.append(dst); cmd.extend(srcs) run_async(self, cmd, self.btn, self.log, self.progress) @@ -490,20 +765,23 @@ class CompressTab(QWidget): class ExtractTab(QWidget): def __init__(self, initial=None): super().__init__() - self._thread = self._worker = None inner = QWidget() v = QVBoxLayout(inner); v.setContentsMargins(24,24,24,24); v.setSpacing(10) v.addWidget(QLabel("Extract and decrypt a .zupt archive.")) v.addWidget(Sep()) - v.addWidget(H("Archive")); self.arc = PathField("Drop .zupt here", filters="VaptVupt archive (*.zupt);;All (*)"); v.addWidget(self.arc) + v.addWidget(H("Archive")); self.arc = PathField("Drop .zupt here", filters="ZUPT archive (*.zupt);;All (*)"); v.addWidget(self.arc) v.addWidget(H("Output directory")); self.out = PathField("Same as archive", "dir"); v.addWidget(self.out) enc = QHBoxLayout(); enc.setSpacing(16) pw = QVBoxLayout(); pw.addWidget(H("Password")); self.pw = PwField(); pw.addWidget(self.pw); enc.addLayout(pw) pq = QVBoxLayout(); pq.addWidget(H("PQ private key")); self.pq = PathField("Optional .key", filters="Key (*.key);;All (*)"); pq.addWidget(self.pq); enc.addLayout(pq) - sdk_box = QVBoxLayout(); sdk_box.addWidget(H("Mode")) - self.sdk = QCheckBox("Auto-detect (SDK or legacy)"); self.sdk.setChecked(True) - self.sdk.setToolTip("Tries --pq-sdk first, falls back to --pq for legacy archives.") - sdk_box.addWidget(self.sdk); sdk_box.addStretch(); enc.addLayout(sdk_box) + mode_box = QVBoxLayout(); mode_box.addWidget(H("PQ mode")) + self.pqmode = QComboBox() + self._pqmodes = pq_mode_options(include_auto=True) + for label, _tok in self._pqmodes: + self.pqmode.addItem(label) + self.pqmode.setToolTip("Auto-detect reads the archive header (zupt info) to pick the\n" + "right mode. Or choose it explicitly to match your private key.") + mode_box.addWidget(self.pqmode); mode_box.addStretch(); enc.addLayout(mode_box) v.addLayout(enc) self.btn = QPushButton("Extract"); self.btn.setObjectName("green"); self.btn.clicked.connect(self._run); v.addWidget(self.btn) self.progress = QProgressBar(); self.progress.setRange(0,0); self.progress.hide(); v.addWidget(self.progress) @@ -513,18 +791,38 @@ class ExtractTab(QWidget): def _run(self): arc = self.arc.path() - if not arc: QMessageBox.warning(self, "VaptVupt", "Select an archive."); return + if not arc: QMessageBox.warning(self, "ZUPT", "Select an archive."); return + if not os.path.isfile(arc): + self.log.clear(); self.log.append(f"No such file: {arc}"); return + # Read the header (no credential) so we can guide the user instead of + # letting the CLI dump a raw decrypt error for a missing password/key. + kind, label = _detect_archive_enc(arc) + if kind == "password" and not self.pw.text(): + self.log.clear() + self.log.append("This archive is password-encrypted.\n" + "Enter the password above, then click Extract again.") + return + if kind in ("pq", "pqonly", "sdk", "box") and not self.pq.path(): + self.log.clear() + self.log.append(f"This archive uses {label} encryption.\n" + "Select the matching private key above, then click Extract again.") + return cmd = ["extract"] + info = None if self.out.path(): cmd += ["-o", self.out.path()] if self.pw.text(): cmd += ["-p", self.pw.text()] if self.pq.path(): - # Auto-detect: zupt's extract auto-discovers enc type from header, - # so passing --pq-sdk works for both SDK and legacy keyfiles when - # the archive is SDK-encoded; --pq is needed for legacy archives. - flag = "--pq-sdk" if self.sdk.isChecked() else "--pq" + # Prefer the header-detected mode; fall back to the dropdown for an + # unreadable header. Auto-detect can't pick the wrong flag this way. + tok = kind if kind in ("pq", "pqonly", "sdk", "box") else self._pqmodes[self.pqmode.currentIndex()][1] + if tok == "auto": + tok = _detect_archive_pq(arc) or "pq" + _, flag = _PQ_FLAG[tok] + info = f"[detected] {label}" cmd += [flag, self.pq.path()] cmd.append(arc) - run_async(self, cmd, self.btn, self.log, self.progress) + run_async(self, cmd, self.btn, self.log, self.progress, info=info, + ok_msg="Done.", fail_msg="Extraction failed.") class VerifyTab(QWidget): @@ -535,27 +833,64 @@ class VerifyTab(QWidget): v.addWidget(QLabel("Verify checksums or inspect archive metadata.")) v.addWidget(Sep()) v.addWidget(H("Verify integrity")) - self.varc = PathField("Archive to verify", filters="VaptVupt archive (*.zupt);;All (*)"); v.addWidget(self.varc) - v.addWidget(H("Password (if encrypted)")) - self.vpw = PwField("Leave empty if not encrypted"); v.addWidget(self.vpw) + self.varc = PathField("Archive to verify", filters="ZUPT archive (*.zupt);;All (*)"); v.addWidget(self.varc) + enc = QHBoxLayout(); enc.setSpacing(16) + pw = QVBoxLayout(); pw.addWidget(H("Password (if encrypted)")); self.vpw = PwField("Leave empty if not encrypted"); pw.addWidget(self.vpw); enc.addLayout(pw) + pq = QVBoxLayout(); pq.addWidget(H("PQ private key (if post-quantum)")); self.vpq = PathField("Auto-detected; needed for --pq / --pq-only archives", filters="Key (*.key);;All (*)"); pq.addWidget(self.vpq); enc.addLayout(pq) + v.addLayout(enc) + # The encryption type is read from the archive header (no PQ-mode picker + # to get wrong): Verify auto-detects password vs hybrid vs full-PQ and + # uses the matching flag; it only asks for the credential the archive + # actually needs. self.vbtn = QPushButton("Verify"); self.vbtn.setObjectName("amber"); self.vbtn.clicked.connect(self._verify); v.addWidget(self.vbtn) + self.vprogress = QProgressBar(); self.vprogress.setRange(0,0); self.vprogress.hide(); v.addWidget(self.vprogress) self.vlog = Log(120); v.addWidget(self.vlog) v.addWidget(Sep()) v.addWidget(H("Archive info (no password needed)")) - self.iarc = PathField("Archive to inspect", filters="VaptVupt archive (*.zupt);;All (*)"); v.addWidget(self.iarc) + self.iarc = PathField("Archive to inspect", filters="ZUPT archive (*.zupt);;All (*)"); v.addWidget(self.iarc) self.ibtn = QPushButton("Show Info"); self.ibtn.clicked.connect(self._info); v.addWidget(self.ibtn) self.ilog = Log(140); v.addWidget(self.ilog); v.addStretch() lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.addWidget(scrollable(inner)) def _verify(self): arc = self.varc.path() - if not arc: return + if not arc: + QMessageBox.warning(self, "ZUPT", "Select an archive to verify."); return + if not os.path.isfile(arc): + self.vlog.clear(); self.vlog.append(f"No such file: {arc}"); return + self.vlog.clear() + # Read the header (no credential) to decide what Verify needs, so the + # user can't pick the wrong PQ mode and doesn't get a raw decrypt error + # for a missing password/key. + kind, label = _detect_archive_enc(arc) cmd = ["test"] - if self.vpw.text(): cmd += ["-p", self.vpw.text()] - cmd.append(arc); self.vlog.clear() - code, out, err = run_zupt(cmd, timeout=600) - self.vlog.append((err + "\n" + out).strip()) - self.vlog.append("\nAll checksums passed." if code == 0 else "\nVerification failed.") + info = None + if kind == "password": + if not self.vpw.text(): + self.vlog.append("This archive is password-encrypted.\n" + "Enter the password above, then click Verify again.") + return + cmd += ["-p", self.vpw.text()] + elif kind in ("pq", "pqonly", "sdk", "box"): + if not self.vpq.path(): + self.vlog.append(f"This archive uses {label} encryption.\n" + "Select the matching private key above, then click Verify again.") + return + _, flag = _PQ_FLAG[kind] + cmd += [flag, self.vpq.path()] + info = f"[detected] {label} — verifying with {flag}" + elif kind == "unknown": + # Couldn't read the header (not a .zupt? truncated?). Fall back to a + # plain test using whatever the user supplied, and let the CLI speak. + if self.vpw.text(): cmd += ["-p", self.vpw.text()] + if self.vpq.path(): + tok = _detect_archive_pq(arc) or "pq" + _, flag = _PQ_FLAG[tok]; cmd += [flag, self.vpq.path()] + # kind == "none": not encrypted, no credential needed. + cmd.append(arc) + # Run asynchronously so a large archive doesn't freeze the window. + run_async(self, cmd, self.vbtn, self.vlog, self.vprogress, info=info, + ok_msg="All checksums passed.", fail_msg="Verification failed.") def _info(self): arc = self.iarc.path() @@ -568,7 +903,6 @@ class VerifyTab(QWidget): class DiskTab(QWidget): def __init__(self): super().__init__() - self._thread = self._worker = None inner = QWidget() v = QVBoxLayout(inner); v.setContentsMargins(24,24,24,24); v.setSpacing(10) v.addWidget(QLabel("Full-disk or partition backup and restore.")) @@ -576,7 +910,7 @@ class DiskTab(QWidget): v.addWidget(H("Backup — source device or image")) self.bsrc = PathField("/dev/sdX or disk.img"); v.addWidget(self.bsrc) v.addWidget(H("Backup — output archive")) - self.bout = PathField("backup.zupt", "save", "VaptVupt archive (*.zupt);;All (*)"); v.addWidget(self.bout) + self.bout = PathField("backup.zupt", "save", "ZUPT archive (*.zupt);;All (*)"); v.addWidget(self.bout) bopt = QHBoxLayout(); bopt.setSpacing(16) oc = QVBoxLayout(); oc.addWidget(H("Options")); self.bdedup = QCheckBox("Block deduplication"); oc.addWidget(self.bdedup); bopt.addLayout(oc) pc = QVBoxLayout(); pc.addWidget(H("Password")); self.bpw = PwField("Optional — AES-256"); pc.addWidget(self.bpw); bopt.addLayout(pc) @@ -585,7 +919,7 @@ class DiskTab(QWidget): self.blog = Log(100); v.addWidget(self.blog) v.addWidget(Sep()) v.addWidget(H("Restore — archive")) - self.rarc = PathField("backup.zupt", filters="VaptVupt archive (*.zupt);;All (*)"); v.addWidget(self.rarc) + self.rarc = PathField("backup.zupt", filters="ZUPT archive (*.zupt);;All (*)"); v.addWidget(self.rarc) v.addWidget(H("Restore — target device or file")) self.rtgt = PathField("/dev/sdX or output.img", "save"); v.addWidget(self.rtgt) v.addWidget(H("Restore — password")) @@ -596,7 +930,7 @@ class DiskTab(QWidget): def _backup(self): s, o = self.bsrc.path(), self.bout.path() - if not s or not o: QMessageBox.warning(self, "VaptVupt", "Set source and output."); return + if not s or not o: QMessageBox.warning(self, "ZUPT", "Set source and output."); return cmd = ["disk", "backup"] if self.bdedup.isChecked(): cmd.append("--dedup") if self.bpw.text(): cmd += ["-p", self.bpw.text()] @@ -604,7 +938,7 @@ class DiskTab(QWidget): def _restore(self): a, t = self.rarc.path(), self.rtgt.path() - if not a or not t: QMessageBox.warning(self, "VaptVupt", "Set archive and target."); return + if not a or not t: QMessageBox.warning(self, "ZUPT", "Set archive and target."); return SB = QMessageBox.StandardButton if QMessageBox.warning(self, "Confirm", f"OVERWRITE {t}?", SB.Yes|SB.Cancel) != SB.Yes: return cmd = ["disk", "restore"] @@ -618,42 +952,43 @@ class AboutTab(QWidget): inner = QWidget() v = QVBoxLayout(inner); v.setContentsMargins(24,24,24,24); v.setSpacing(4) for text, style in [ - ("VAPTVUPT", "color:#00dde0;font-size:10px;font-weight:700;letter-spacing:2px;font-family:monospace;"), + ("ZUPT", "color:#00dde0;font-size:10px;font-weight:700;letter-spacing:2px;font-family:monospace;"), (ZUPT_VER_NUMBER, "color:white;font-size:28px;font-weight:800;font-family:monospace;"), ("", ""), - ("Post-quantum backup compression with ML-KEM-768 + X25519", "color:#6a8898;font-size:13px;"), - ("hybrid encryption, Argon2id KDF, and block deduplication.", "color:#6a8898;font-size:13px;"), - ("Renamed from Zupt in v3.0.0 (INPI Brasil trademark); .zupt", "color:#6a8898;font-size:13px;"), - ("archive extension and v1.6 wire format are unchanged.", "color:#6a8898;font-size:13px;"), + ("Post-quantum backup compression with ML-KEM-768: --pq hybrid", "color:#6a8898;font-size:13px;"), + (f"(+ X25519) or --pq-only (pure). {DEFAULT_KDF} password KDF,", "color:#6a8898;font-size:13px;"), + ("block deduplication, and full-disk backup.", "color:#6a8898;font-size:13px;"), + ("Original ZUPT name restored in 5.2.2; the .zupt extension", "color:#6a8898;font-size:13px;"), + ("and v1.6 version byte remain; 5.2.2 adds flag-gated records.", "color:#6a8898;font-size:13px;"), ("", ""), ("CRYPTOGRAPHIC STACK", "color:#00dde0;font-size:10px;font-weight:700;letter-spacing:2px;font-family:monospace;"), ("ML-KEM-768 FIPS 203 Post-Quantum KEM", "color:#5a7a88;font-size:12px;font-family:monospace;"), ("X25519 RFC 7748 Elliptic Curve DH (hybrid w/ ML-KEM)", "color:#5a7a88;font-size:12px;font-family:monospace;"), - ("AES-256-CTR FIPS 197 Symmetric Cipher", "color:#5a7a88;font-size:12px;font-family:monospace;"), + ("AES-256-CTR FIPS 197 Symmetric Cipher (fresh per-block nonce)", "color:#5a7a88;font-size:12px;font-family:monospace;"), ("HMAC-SHA256 RFC 2104 Authentication (Encrypt-then-MAC)", "color:#5a7a88;font-size:12px;font-family:monospace;"), - ("Argon2id RFC 9106 Password KDF (default since 2.4.1)", "color:#5a7a88;font-size:12px;font-family:monospace;"), - ("PBKDF2 RFC 8018 Password KDF (legacy; --kdf pbkdf2)", "color:#5a7a88;font-size:12px;font-family:monospace;"), + ("PBKDF2-SHA256 RFC 8018 Password KDF (default, 600k iterations)", "color:#5a7a88;font-size:12px;font-family:monospace;"), + ("Argon2id RFC 9106 Password KDF (WITH_SDK=1 builds only)", "color:#5a7a88;font-size:12px;font-family:monospace;"), + ("SHA3-512 FIPS 202 PQ key derivation (--pq / --pq-only)", "color:#5a7a88;font-size:12px;font-family:monospace;"), ("HKDF RFC 5869 Key Derivation Function", "color:#5a7a88;font-size:12px;font-family:monospace;"), ("SHA3/SHAKE FIPS 202 Hash / XOF", "color:#5a7a88;font-size:12px;font-family:monospace;"), ("XXH64 (non-crypto) Per-block checksum (inside AEAD)", "color:#5a7a88;font-size:12px;font-family:monospace;"), ("", ""), ("COMPRESSION CODEC", "color:#00dde0;font-size:10px;font-weight:700;letter-spacing:2px;font-family:monospace;"), - ("VaptVupt LZ + ANS 2.48.5 LZ77 + tabled ANS entropy", "color:#5a7a88;font-size:12px;font-family:monospace;"), - ("AVX2 / NEON SIMD acceleration; 1.27x zstd-3 decode aggregate", "color:#5a7a88;font-size:12px;font-family:monospace;"), + ("VaptVupt LZ + ANS 2.65.3 LZ77 + tabled ANS entropy", "color:#5a7a88;font-size:12px;font-family:monospace;"), + ("AVX2 / NEON SIMD acceleration; portable scalar fallbacks", "color:#5a7a88;font-size:12px;font-family:monospace;"), ("", ""), ("CREDITS", "color:#00dde0;font-size:10px;font-weight:700;letter-spacing:2px;font-family:monospace;"), - ("VaptVupt application Cristian Cezar Moisés", "color:#5a7a88;font-size:12px;font-family:monospace;"), - (" License: AGPL-3.0-or-later (commercial license available)", "color:#5a7a88;font-size:12px;font-family:monospace;"), - (" git.securityops.co/cristiancmoises/zupt", "color:#3a5868;font-size:11px;font-family:monospace;"), + ("ZUPT application Cristian Cezar Moisés", "color:#5a7a88;font-size:12px;font-family:monospace;"), + (" License: AGPL-3.0-or-later (commercial terms may be available)", "color:#5a7a88;font-size:12px;font-family:monospace;"), + (" github.com/cristiancmoises/zupt", "color:#3a5868;font-size:11px;font-family:monospace;"), ("", ""), ("VaptVupt LZ + ANS codec Cristian Cezar Moisés", "color:#5a7a88;font-size:12px;font-family:monospace;"), - (" License: GPL-3.0-or-later (commercial license available)", "color:#5a7a88;font-size:12px;font-family:monospace;"), - (" git.securityops.co/cristiancmoises/vaptvupt", "color:#3a5868;font-size:11px;font-family:monospace;"), + (" License: GPL-3.0-or-later (commercial terms may be available)", "color:#5a7a88;font-size:12px;font-family:monospace;"), + (" github.com/cristiancmoises/zupt", "color:#3a5868;font-size:11px;font-family:monospace;"), ("", ""), ("WEBSITE & CONTACT", "color:#00dde0;font-size:10px;font-weight:700;letter-spacing:2px;font-family:monospace;"), - ("https://zupt.securityops.co", "color:#5a7a88;font-size:12px;font-family:monospace;"), - ("sac@securityops.co (commercial licensing)", "color:#5a7a88;font-size:12px;font-family:monospace;"), - ("zupt@riseup.net (general / bugs)", "color:#5a7a88;font-size:12px;font-family:monospace;"), + ("https://github.com/cristiancmoises/zupt", "color:#5a7a88;font-size:12px;font-family:monospace;"), + ("sac@securityops.co (commercial-terms inquiries)", "color:#5a7a88;font-size:12px;font-family:monospace;"), ("", ""), (ZUPT_VER_SHORT, "color:#3a5868;font-size:11px;font-family:monospace;"), ]: @@ -670,7 +1005,7 @@ class AboutTab(QWidget): class ZuptWindow(QMainWindow): def __init__(self, compress_files=None, extract_file=None): super().__init__() - self.setWindowTitle(f"VaptVupt {ZUPT_VER_NUMBER}") + self.setWindowTitle(f"ZUPT {ZUPT_VER_NUMBER}") self.setMinimumSize(720, 500) self.resize(880, 640) self.setAcceptDrops(True) @@ -685,7 +1020,7 @@ class ZuptWindow(QMainWindow): # Header hdr = QFrame(); hdr.setStyleSheet("background:#050a0e;border-bottom:1px solid #1a2a30;") hl = QHBoxLayout(hdr); hl.setContentsMargins(20,10,20,10) - title = QLabel("VAPTVUPT"); title.setStyleSheet("color:white;font-size:15px;font-weight:800;letter-spacing:3px;") + title = QLabel("ZUPT"); title.setStyleSheet("color:white;font-size:15px;font-weight:800;letter-spacing:3px;") hl.addWidget(title) sub = QLabel("Post-Quantum Backup"); sub.setStyleSheet("color:#3a5868;font-size:10px;font-weight:600;letter-spacing:1px;margin-left:8px;") hl.addWidget(sub); hl.addStretch() @@ -708,7 +1043,7 @@ class ZuptWindow(QMainWindow): layout.addWidget(self.tabs) sb = QStatusBar() - sb.showMessage(f"VaptVupt {ZUPT_VER_NUMBER} | {VAPTVUPT}") + sb.showMessage(f"ZUPT {ZUPT_VER_NUMBER} | {ZUPT_CLI}") self.setStatusBar(sb) def dragEnterEvent(self, e): @@ -721,18 +1056,66 @@ class ZuptWindow(QMainWindow): else: self.compress_tab.src.edit.setText("|".join(ps)); self.tabs.setCurrentIndex(1) + def closeEvent(self, e): + # Join in-flight worker threads before the window goes away: kill each + # child CLI process (the worker then sees EOF and finishes) and wait + # for its QThread. Otherwise interpreter teardown collects live + # QThreads and aborts the process instead of exiting cleanly. + jobs = [j for i in range(self.tabs.count()) + for j in list(getattr(self.tabs.widget(i), "_jobs", []))] + if jobs: + # Aborting mid-job can be destructive (a killed `disk restore` + # leaves the target half-written), so never do it silently. + SB = QMessageBox.StandardButton + if QMessageBox.warning( + self, "ZUPT", + "An operation is still running.\nQuit and abort it?", + SB.Yes | SB.Cancel) != SB.Yes: + e.ignore(); return + for j in jobs: + if not j.cancel_and_join(3000): + # Thread stuck past the kill (child in D-state / pipe held by a + # grandchild). Letting teardown destroy a live QThread aborts + # with SIGABRT; exiting hard here is the clean way out. + if sys.stderr is not None: + try: + sys.stderr.write("A worker did not stop in time; " + "forcing exit.\n") + sys.stderr.flush() + except OSError: + pass + os._exit(0) + super().closeEvent(e) + def main(): - compress_files = extract_file = None args = sys.argv[1:] - if args: + + # Lightweight non-GUI flags first, so `zupt-gui --version|--help|--selftest` + # work with no display and aren't mistaken for files to compress. `--selftest` + # is a headless-friendly smoke test: it builds the whole UI and spins the event + # loop once, then exits 0 — the reliable way to confirm the GUI stack launches + # on a machine where the window itself is hard to see (tiling WM, remote, CI). + if args and args[0] in ("--version", "-V", "version"): + print(f"zupt-gui {ZUPT_VER_NUMBER}") + return 0 + if args and args[0] in ("--help", "-h", "help"): + print("usage: zupt-gui [ARCHIVE.zupt | --extract ARCHIVE.zupt |\n" + " --compress FILE [FILE ...]]\n" + " zupt-gui --selftest # verify the GUI launches (no window kept)\n" + " zupt-gui --version") + return 0 + + compress_files = extract_file = None + selftest = ("--selftest" in args[:1]) + if args and not selftest: if args[0] == "--compress" and len(args) > 1: compress_files = args[1:] elif args[0] == "--extract" and len(args) > 1: extract_file = args[1] elif args[0].endswith(".zupt"): extract_file = args[0] else: compress_files = args app = QApplication(sys.argv) - app.setApplicationName("VaptVupt") + app.setApplicationName("ZUPT") if ICON_PATH: app.setWindowIcon(QIcon(ICON_PATH)) app.setStyle("Fusion") app.setStyleSheet(STYLE) @@ -747,8 +1130,113 @@ def main(): pal.setColor(role, QColor(c)) app.setPalette(pal) win = ZuptWindow(compress_files=compress_files, extract_file=extract_file) + + if selftest: + win.show() + QTimer.singleShot(400, app.quit) + rc = app.exec() + print(f"selftest OK — {QT_BINDING}: window + {win.tabs.count()} tabs built, " + f"event loop ran (rc={rc}); CLI={ZUPT_CLI}") + return rc + + # Center + raise + focus ONLY on X11 (xcb), where a stacking WM may place + # the window off-screen or leave it unfocused. On Wayland the compositor + # owns placement and focus, and these calls (self-move / xdg restack / + # xdg-activation) SEGFAULT some Qt-Wayland builds — including PySide6 6.9 as + # shipped on Guix — so they must not run there. Plain show() is what + # --selftest exercises and is stable; the compositor maps and focuses the + # new toplevel itself. On Windows/macOS Qt's automatic placement centers + # first windows and the OS foregrounds a freshly launched app, so skipping + # is safe there too. Strict == "xcb" keeps wayland-egl etc. on the safe path. + is_x11 = app.platformName() == "xcb" + if is_x11: + scr = app.primaryScreen() + if scr is not None: + fg = win.frameGeometry() + fg.moveCenter(scr.availableGeometry().center()) + win.move(fg.topLeft()) win.show() - sys.exit(app.exec()) + if is_x11: + win.raise_() + win.activateWindow() + + # Wayland map watchdog. On some compositor/toolkit combos (seen live on + # Sway 1.12 + Qt 6.9: a handshake deadlock where Qt never sends the initial + # wl_surface.commit, so the compositor never sends configure) the event + # loop runs but the window NEVER maps — the app looks "started" yet nothing + # appears. In that state no Expose event is ever delivered, so LATCH the + # first expose; do NOT sample isExposed() at the deadline (a healthy window + # that is merely hidden — other workspace, scratchpad, locker — reads + # unexposed ~100 ms after frame callbacks stop and would misfire). If no + # expose ever arrived, relaunch this same process on XWayland (xcb), which + # is unaffected. The sentinel env var prevents any relaunch loop (e.g. + # "-platform wayland" in argv outranks the env override and would come up + # wayland again). Nothing auto-starts jobs before the deadline, so the exec + # cannot interrupt real work. Opt out with ZUPT_NO_XCB_FALLBACK=1. + if app.platformName().startswith("wayland"): + class _ExposeLatch(QObject): + exposed_once = False + def eventFilter(self, obj, ev): + if ev.type() == QEvent.Type.Expose and obj.isExposed(): + self.exposed_once = True + return False + latch = _ExposeLatch() + handle = win.windowHandle() + if handle is not None: + handle.installEventFilter(latch) + def _wayland_map_check(): + if latch.exposed_once or (handle is not None and handle.isExposed()): + return + no_fallback = (os.environ.get("ZUPT_NO_XCB_FALLBACK") + or os.environ.get("VAPTVUPT_NO_XCB_FALLBACK")) + fallback_done = (os.environ.get("ZUPT_XCB_FALLBACK_DONE") + or os.environ.get("VAPTVUPT_XCB_FALLBACK_DONE")) + can_fallback = (os.environ.get("DISPLAY") + and sys.executable + and no_fallback != "1" + and fallback_done != "1") + if sys.stderr is not None: + try: + sys.stderr.write( + "Window was not exposed within 4 s (the compositor may " + "never have mapped it); " + + ("relaunching on XWayland (xcb)...\n" if can_fallback + else "leaving the Wayland window as-is (no X11 " + "fallback: DISPLAY unset, opted out, or " + "already tried).\n")) + sys.stderr.flush() + except OSError: + pass + if can_fallback: + env = dict(os.environ, QT_QPA_PLATFORM="xcb", + ZUPT_XCB_FALLBACK_DONE="1") + argv = (list(sys.argv) if getattr(sys, "frozen", False) + else [sys.executable] + sys.argv) + try: + os.execve(sys.executable, argv, env) + except OSError as exc: + if sys.stderr is not None: + try: + sys.stderr.write(f"XWayland relaunch failed ({exc});" + " window will not appear.\n") + sys.stderr.flush() + except OSError: + pass + QTimer.singleShot(4000, _wayland_map_check) + + # A GUI blocks the launching shell, so a working launch otherwise looks like + # a "stuck" terminal. Emit one line to stderr so it's unambiguous. Guarded: + # PyInstaller --windowed sets sys.stderr to None (any write would raise and + # kill the window we just showed), and a dead pipe raises OSError on flush — + # a courtesy notice must never take the GUI down. + if sys.stderr is not None: + try: + sys.stderr.write(f"ZUPT {ZUPT_VER_NUMBER} GUI started — " + f"window open (close it to exit).\n") + sys.stderr.flush() + except OSError: + pass + return app.exec() if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/gui/zupt-gui b/gui/zupt-gui index 0d86491..6733e3f 100755 --- a/gui/zupt-gui +++ b/gui/zupt-gui @@ -1,46 +1,31 @@ -#!/bin/bash -set -e +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later -DIR="$(cd "$(dirname "$0")" && pwd)" -VENV="$DIR/.venv" -PY="$VENV/bin/python3" -PIP="$VENV/bin/pip" -GUI="$DIR/src/zupt_gui.py" +# Source-tree launcher for ZUPT GUI. +# It performs no package installation and never downloads dependencies. +set -Eeuo pipefail -# ─── System deps (Qt xcb needs these on Debian/Mint/Ubuntu) ─── -NEED_APT=0 -for pkg in libxcb-cursor0 libxcb-xinerama0 libxkbcommon-x11-0 libegl1 python3-full; do - dpkg -s "$pkg" >/dev/null 2>&1 || NEED_APT=1 -done -if [ "$NEED_APT" -eq 1 ]; then - echo "Installing system dependencies..." - sudo apt-get update -qq - sudo apt-get install -y python3-full python3-venv \ - libxcb-cursor0 libxcb-xinerama0 libxkbcommon-x11-0 libegl1 2>/dev/null -fi +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +gui=$script_dir/src/zupt_gui.py +[[ -f $gui ]] || { + printf 'zupt-gui: GUI source is missing: %s\n' "$gui" >&2 + exit 1 +} -# ─── Venv ─── -if [ ! -x "$PY" ]; then - rm -rf "$VENV" - python3 -m venv "$VENV" -fi -if ! "$PY" -c "import PySide6" 2>/dev/null; then - echo "Installing PySide6..." - "$PIP" install --upgrade pip -q 2>/dev/null - "$PIP" install PySide6 -q -fi - -# ─── Find zupt — local build FIRST, then system ─── -if [ -z "$ZUPT_BIN" ]; then - # Check project tree first (gui/ is inside zupt-2.1.6/) - for p in "$DIR/../zupt" "$DIR/../../zupt" "$DIR/zupt"; do - [ -x "$p" ] && export ZUPT_BIN="$(readlink -f "$p")" && break - done - # Then system PATH - if [ -z "$ZUPT_BIN" ]; then - p="$(command -v zupt 2>/dev/null)" - [ -x "$p" ] && export ZUPT_BIN="$p" +if [[ -z ${ZUPT_BIN:-} ]]; then + if [[ -n ${VAPTVUPT_BIN:-} ]]; then + export ZUPT_BIN=$VAPTVUPT_BIN + elif [[ -x $script_dir/../zupt ]]; then + export ZUPT_BIN=$script_dir/../zupt + elif command -v zupt >/dev/null 2>&1; then + ZUPT_BIN=$(command -v zupt) + export ZUPT_BIN + elif [[ -x $script_dir/../vaptvupt ]]; then + export ZUPT_BIN=$script_dir/../vaptvupt + elif command -v vaptvupt >/dev/null 2>&1; then + ZUPT_BIN=$(command -v vaptvupt) + export ZUPT_BIN fi fi -exec "$PY" "$GUI" "$@" +exec python3 "$gui" "$@" diff --git a/include/vaptvupt_api.h b/include/vaptvupt_api.h index f19c85f..63761ff 100644 --- a/include/vaptvupt_api.h +++ b/include/vaptvupt_api.h @@ -1,9 +1,9 @@ /* - * VaptVupt — Zupt Integration API + * VaptVupt — VaptVupt Integration API * SPDX-License-Identifier: GPL-3.0-or-later * Copyright 2026 Cristian. * - * ZUPT-COMPAT: This is the API that Zupt calls. It wraps the internal + * EMBED-COMPAT: This is the API that a host application calls. It wraps the internal * VaptVupt API with sensible defaults for backup workloads: * - Checksum always enabled (data integrity is critical for backups) * - Adaptive window selection (auto-detect optimal wlog per file) diff --git a/include/zupt.h b/include/zupt.h index c295c6c..4136770 100644 --- a/include/zupt.h +++ b/include/zupt.h @@ -1,5 +1,5 @@ /* - * Zupt — Backup-oriented compression with AES-256 encryption + * ZUPT — Backup-oriented compression with AES-256 encryption * Copyright (c) 2026 Cristian Cezar Moisés * SPDX-License-Identifier: AGPL-3.0-or-later */ @@ -15,12 +15,79 @@ #include #include #include +#include #ifdef _WIN32 #include #include + #include + #include #define ZUPT_PATH_SEP '\\' - #define zupt_mkdir(p) _mkdir(p) + +static inline wchar_t *zupt_win_utf8_to_wide_alloc(const char *text) { + if (!text) return NULL; + int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, + text, -1, NULL, 0); + if (length <= 0) return NULL; + wchar_t *wide = (wchar_t *)malloc((size_t)length * sizeof(wchar_t)); + if (!wide || !MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, + text, -1, wide, length)) { + free(wide); + return NULL; + } + return wide; +} + +static inline char *zupt_win_wide_to_utf8_alloc(const wchar_t *text) { + if (!text) return NULL; + int length = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, + text, -1, NULL, 0, NULL, NULL); + if (length <= 0) return NULL; + char *utf8 = (char *)malloc((size_t)length); + if (!utf8 || !WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, + text, -1, utf8, length, NULL, NULL)) { + free(utf8); + return NULL; + } + return utf8; +} + +static inline FILE *zupt_win_fopen_utf8(const char *path, const char *mode) { + wchar_t *wide_path = zupt_win_utf8_to_wide_alloc(path); + wchar_t *wide_mode = zupt_win_utf8_to_wide_alloc(mode); + if (!wide_path || !wide_mode) { + free(wide_path); + free(wide_mode); + return NULL; + } + FILE *stream = _wfopen(wide_path, wide_mode); + free(wide_path); + free(wide_mode); + return stream; +} + +static inline DWORD zupt_win_get_attributes_utf8(const char *path) { + wchar_t *wide = zupt_win_utf8_to_wide_alloc(path); + if (!wide) return INVALID_FILE_ATTRIBUTES; + DWORD attributes = GetFileAttributesW(wide); + free(wide); + return attributes; +} + +static inline int zupt_win_mkdir_utf8(const char *path) { + wchar_t *wide = zupt_win_utf8_to_wide_alloc(path); + if (!wide) return -1; + int result = _wmkdir(wide); + free(wide); + return result; +} + + /* Project path strings are UTF-8 on every platform. Call this wrapper + * explicitly; never rewrite the C library's fopen in consumer code. */ + static inline FILE *zupt_fopen_path(const char *path, const char *mode) { + return zupt_win_fopen_utf8(path, mode); + } + #define zupt_mkdir(p) zupt_win_mkdir_utf8(p) #else #include #include @@ -28,32 +95,38 @@ #include #define ZUPT_PATH_SEP '/' #define zupt_mkdir(p) mkdir(p, 0755) + static inline FILE *zupt_fopen_path(const char *path, const char *mode) { + return fopen(path, mode); + } #endif /* ─── Product identity ───────────────────────────────────────────── * - * v3.0.0 (INPI Brasil trademark rename): - * - Product name is now "VaptVupt" (was "Zupt"). The earlier name - * conflicted with a software trademark already registered at INPI - * Brasil under "Zupt". - * - File extension stays `.zupt` for archive-format continuity: - * v1.0–v2.4.x archives remain readable, the magic bytes - * `\x5A\x55\x50\x54\x1A\x00` ("ZUPT" + sub-version) are unchanged. - * - C identifier prefix stays `zupt_` / `ZUPT_` for ABI continuity - * with libzuptsdk and existing callers. Only user-visible strings - * (binary name, banner, help text, package names) change. - * - The binary is now `vaptvupt`. Distro packages may ship a - * compatibility symlink `zupt -> vaptvupt` for one major version. + * v5.2.2 (product identity restored): + * - The public product and primary command are again "ZUPT" and `zupt`. + * - On-disk compatibility is deliberately unchanged: magic remains + * "ZUPT", the archive extension remains .zupt, and format version + * remains 1.6. + * - Internal zupt_* symbols, SDK identifiers, codec IDs, and the bundled + * VaptVupt codec ABI remain unchanged. + * - Distributors may offer `vaptvupt -> zupt` only as an explicit + * compatibility alias for scripts written for releases 3.0.0--5.2.1. */ -#define ZUPT_PRODUCT_NAME "VaptVupt" -#define ZUPT_PRODUCT_NAME_LC "vaptvupt" /* lowercase: binary name */ +#define ZUPT_PRODUCT_NAME "ZUPT" +#define ZUPT_PRODUCT_NAME_LC "zupt" /* lowercase: binary name */ #define ZUPT_PRODUCT_EXTENSION ".zupt" /* on-disk archive extension (kept stable) */ #define ZUPT_PRODUCT_TAGLINE "Post-quantum backup compression" -#define ZUPT_VERSION_STRING "4.1.0" +/* v5.2.3 corrects release packaging and CI; archive format remains v1.6. */ +/* v5.2.4 makes package metadata checks CRLF-safe; format remains v1.6. */ +/* v5.2.5 corrects the OBS service harness cwd; format remains v1.6. */ +/* v5.2.6 corrects native release-gate portability; format remains v1.6. */ +/* v5.2.7 corrects native test-harness portability; format remains v1.6. */ +/* v5.2.8 hardens three path-race boundaries; format remains v1.6. */ +#define ZUPT_VERSION_STRING "5.2.8" /* Vendored codec release (upstream tag) — single source for display strings. * The codec's own VV_VERSION_* is its internal API version, not the release. */ -#define ZUPT_CODEC_RELEASE "2.60.4" +#define ZUPT_CODEC_RELEASE "2.65.3" #define ZUPT_FORMAT_MAJOR 1 #define ZUPT_FORMAT_MINOR 6 @@ -67,10 +140,13 @@ * to keep the field stable across format-version transitions. Both bytes are * structurally validated by read_footer(). * - * Read path falls back to v1.4 layout (no trailer) when the footer magic is - * found at EOF-32 instead of EOF-64. */ + * The reader can identify a legacy v1.4 layout at EOF-32, but refuses it by + * default because the missing trailer is indistinguishable from an integrity + * downgrade. Trusted old archives require --allow-legacy-no-ait. */ #define ZUPT_AIT_SIZE 32 -#define ZUPT_AIT_MAC_INPUT_LEN (sizeof(zupt_archive_header_t) + 24) +#define ZUPT_ARCHIVE_HEADER_SIZE 64u +#define ZUPT_FOOTER_SIZE 32u +#define ZUPT_AIT_MAC_INPUT_LEN (ZUPT_ARCHIVE_HEADER_SIZE + 24u) #define ZUPT_MAGIC_0 0x5A #define ZUPT_MAGIC_1 0x55 @@ -83,6 +159,11 @@ #define ZUPT_MAX_PATH 4096 #define ZUPT_MAX_FILES 2000000 +/* A decoded index entry contains a fixed-size path buffer. Cap aggregate + * allocation independently of the wire count so a compact malicious index + * cannot request several gigabytes of zeroed memory. */ +#define ZUPT_MAX_INDEX_ALLOC_BYTES (256u * 1024u * 1024u) +#define ZUPT_MIN_INDEX_ENTRY_BYTES 47u #define ZUPT_DEFAULT_BLOCK_SZ (4 * 1024 * 1024) #define ZUPT_MIN_BLOCK_SZ (64 * 1024) #define ZUPT_MAX_BLOCK_SZ (256 * 1024 * 1024) @@ -97,13 +178,16 @@ #define ZUPT_FLAG_DEDUP (1u << 7) /* Block-level deduplication enabled */ #define ZUPT_FLAG_AAD_SEQ (1u << 8) /* MAC binds block_seq as AAD (anti-reorder) */ #define ZUPT_FLAG_AAD_PREFACE (1u << 9) /* v1.6: MAC also binds per-block frame preface (F-09) */ +#define ZUPT_FLAG_AUTH_DEDUP_REFS (1u << 10) /* Dedup offsets carry per-block authentication */ +#define ZUPT_FLAG_DISK_CONTENT_HASH (1u << 11) /* Disk index hashes restored bytes */ /* Encryption types (stored in encryption header block) */ #define ZUPT_ENC_PBKDF2 0x01 /* Password-based: PBKDF2 → AES-256-CTR + HMAC */ #define ZUPT_ENC_PQ_HYBRID 0x02 /* ML-KEM-768 + X25519 hybrid KEM (legacy XOR+SHA3) */ -#define ZUPT_ENC_PQ_SDK_V2 0x03 /* libzuptsdk v2 header: HKDF combiner + commitment + HPKE binding */ -#define ZUPT_ENC_PW_ARGON2 0x04 /* Password-based via libzuptsdk: Argon2id + XChaCha20-Poly1305 */ +#define ZUPT_ENC_PQ_SDK_V2 0x03 /* libvuptsdk v2 header: HKDF combiner + commitment + HPKE binding */ +#define ZUPT_ENC_PW_ARGON2 0x04 /* Password-based via libvuptsdk: Argon2id + XChaCha20-Poly1305 */ #define ZUPT_ENC_PQ_BOX_V1 0x05 /* libpqvaptvupt sealed box: HKDF-SHA256 domain-separated combiner */ +#define ZUPT_ENC_PQ_ONLY 0x06 /* Full post-quantum: ML-KEM-768 only (no X25519), SHA3-512 KDF (v4.2.0) */ /* Argon2id KDF profile descriptor (v3.4.0). * @@ -122,12 +206,12 @@ * descriptor is covered by the archive-integrity trailer (F-08), so it * cannot be stripped or forged without failing authentication. * - * Profile 0 (implicit, absent byte) == the historical libzuptsdk + * Profile 0 (implicit, absent byte) == the historical libvuptsdk * "MODERATE" Argon2id preset reached via zuptsdk_easy_derive_key. * Profile 1 is the same derivation with the descriptor made explicit so * future profiles (should the cost change) get distinct IDs. */ #define ZUPT_ARGON2_PROFILE_LEGACY 0x00 /* implicit: pre-3.4.0, no descriptor byte */ -#define ZUPT_ARGON2_PROFILE_MODERATE 0x01 /* explicit: libzuptsdk MODERATE preset */ +#define ZUPT_ARGON2_PROFILE_MODERATE 0x01 /* explicit: libvuptsdk MODERATE preset */ #define ZUPT_ARGON2_HDR_LEN_V1 33 /* [type|salt16|nonce16] */ #define ZUPT_ARGON2_HDR_LEN_V2 34 /* + [profile1] */ @@ -135,7 +219,7 @@ #define ZUPT_BLOCK_DATA 0x00 #define ZUPT_BLOCK_INDEX 0x02 #define ZUPT_BLOCK_ENC_HEADER 0x03 -#define ZUPT_BLOCK_DEDUP_REF 0x04 /* Dedup reference: payload = 8B offset of original block */ +#define ZUPT_BLOCK_DEDUP_REF 0x04 /* Dedup reference; authenticated v5.2.2 payload also carries source AAD sequence */ #define ZUPT_BLOCK_COMMENT 0x05 /* v2.4.3: free-form UTF-8 comment, encrypted same as data blocks */ #define ZUPT_MAX_COMMENT_LEN 4096 /* Maximum comment payload size (bytes). */ @@ -269,8 +353,9 @@ typedef struct { int level; uint32_t block_size; uint16_t codec_id; int verbose, encrypt, quiet, solid, threads; int pq_mode; /* 1 = post-quantum hybrid KEM mode */ - int sdk_mode; /* 1 = use libzuptsdk-backed v3 crypto (HKDF combiner + commitment + HPKE) */ + int sdk_mode; /* 1 = use libvuptsdk-backed v3 crypto (HKDF combiner + commitment + HPKE) */ int box_mode; /* 1 = libpqvaptvupt sealed-box mode (ZUPT_ENC_PQ_BOX_V1) */ + int pqonly_mode; /* 1 = full post-quantum mode: ML-KEM-768 only (ZUPT_ENC_PQ_ONLY) */ int dedup; /* 1 = block-level deduplication enabled */ int kdf_legacy_pbkdf2; /* v2.4.1: 1 = force PBKDF2-SHA256 enc-header (compat with v2.4.0 and older readers). Default 0 = Argon2id. */ char password[256]; @@ -343,7 +428,7 @@ static inline void zupt_secure_wipe(void *ptr, size_t len) { static inline int zupt_is_regular_file(const char *path) { #ifdef _WIN32 - DWORD attr = GetFileAttributesA(path); + DWORD attr = zupt_win_get_attributes_utf8(path); if (attr == INVALID_FILE_ATTRIBUTES) return 0; return !(attr & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_DEVICE | FILE_ATTRIBUTE_REPARSE_POINT)); @@ -394,10 +479,11 @@ void zupt_hmac_sha256_init(zupt_hmac_ctx *c, const uint8_t *key, size_t klen); void zupt_hmac_sha256_update(zupt_hmac_ctx *c, const uint8_t *data, size_t dlen); void zupt_hmac_sha256_final(zupt_hmac_ctx *c, uint8_t mac[32]); -/* Constant-time buffer equality. Returns 1 if equal, 0 otherwise, in - * time dependent only on n (not contents / mismatch position). The single - * audited MAC-tag comparison primitive; timing-verified by the - * dudect-style test in tests/test_ct_timing.c. CT-REQUIRED. */ +/* Constant-time-intended buffer equality. Returns 1 if equal, 0 otherwise. + * The source has a fixed-length OR-accumulate loop without an intended + * content-dependent exit or access. The dudect-style regression in + * tests/test_ct_timing.c measures exact builds when its control is conclusive; + * it is not a formal guarantee about compiler output. CT-REQUIRED. */ int zupt_ct_memeq(const void *a, const void *b, size_t n); void zupt_pbkdf2_sha256(const uint8_t *pw, size_t pwlen, const uint8_t *salt, size_t slen, uint32_t iter, uint8_t *out, size_t olen); void zupt_aes256_ctr(const uint8_t key[32], const uint8_t nonce[16], const uint8_t *in, uint8_t *out, size_t len); @@ -475,8 +561,20 @@ zupt_error_t zupt_compress_files(const char *out, const char **arc, const char * zupt_error_t zupt_extract_archive(const char *arc, const char *dir, zupt_options_t *opts); zupt_error_t zupt_list_archive(const char *arc, zupt_options_t *opts); zupt_error_t zupt_test_archive(const char *arc, zupt_options_t *opts); +/* Internal stream variant used when a caller has pinned a private snapshot. + * It never closes stream; the caller retains ownership. */ +zupt_error_t zupt_test_archive_stream(FILE *stream, zupt_options_t *opts); +zupt_error_t zupt_open_archive_internal(FILE *stream, zupt_options_t *opts, + zupt_archive_header_t *header, + zupt_footer_t *footer, + zupt_index_entry_t **entries, + int *num_entries); /* ─── Hybrid PQ KEM (ML-KEM-768 + X25519) ─── */ +/* Internal no-replace writer shared by the native and optional pq-box key + * formats. Private material receives platform-specific restrictive access. */ +int zupt_keyfile_write_new(const char *path, const uint8_t *data, size_t length, + int private_material); int zupt_hybrid_keygen(const char *keyfile); int zupt_hybrid_export_pubkey(const char *privfile, const char *pubfile); int zupt_hybrid_encrypt_init(zupt_keyring_t *kr, const char *pubkeyfile, @@ -484,14 +582,22 @@ int zupt_hybrid_encrypt_init(zupt_keyring_t *kr, const char *pubkeyfile, int zupt_hybrid_decrypt_init(zupt_keyring_t *kr, const char *privkeyfile, const uint8_t *enc_hdr, size_t enc_hdr_len); -/* ─── SDK-backed crypto (zupt v2.2+, libzuptsdk under the hood) ─── */ +/* ─── Full post-quantum crypto: ML-KEM-768 only, no X25519 (v4.2.0) ─── */ +int zupt_pq_keygen(const char *keyfile); +int zupt_pq_export_pubkey(const char *privfile, const char *pubfile); +int zupt_pq_encrypt_init(zupt_keyring_t *kr, const char *pubkeyfile, + uint8_t *enc_hdr, size_t *enc_hdr_len); +int zupt_pq_decrypt_init(zupt_keyring_t *kr, const char *privkeyfile, + const uint8_t *enc_hdr, size_t enc_hdr_len); + +/* ─── SDK-backed crypto (zupt v2.2+, optional libvuptsdk) ─── */ int zupt_sdk_hybrid_keygen(const char *privkeyfile, const char *pubkeyfile); int zupt_sdk_hybrid_encrypt_init(zupt_keyring_t *kr, const char *pubkeyfile, uint8_t *enc_hdr, size_t *enc_hdr_len); int zupt_sdk_hybrid_decrypt_init(zupt_keyring_t *kr, const char *privkeyfile, const uint8_t *enc_hdr, size_t enc_hdr_len); -/* pq-box mode (ZUPT_ENC_PQ_BOX_V1, vendored libpqvaptvupt) */ +/* pq-box mode (ZUPT_ENC_PQ_BOX_V1, optional system libpqvaptvupt) */ int zupt_pqbox_keygen(const char *privkeyfile, const char *pubkeyfile); int zupt_pqbox_encrypt_init(zupt_keyring_t *kr, const char *pubkeyfile, uint8_t *enc_hdr, size_t *enc_hdr_len); @@ -509,7 +615,7 @@ void zupt_format_size(uint64_t bytes, char *buf, size_t cap); /* Resolve ZUPT_CODEC_AUTO to a concrete codec based on hardware. * On x86_64 with AVX2: VaptVupt (fast ANS+SIMD decode). - * On all other arches: Zupt-LZHP (no SIMD dependency). + * On all other arches: ZUPT-LZHP (no SIMD dependency). * Decompression of ALL codecs works on ALL architectures. */ uint16_t zupt_resolve_auto_codec(void); @@ -528,18 +634,58 @@ zupt_error_t zupt_disk_restore(const char *archive_path, const char *target_path zupt_options_t *opts); /* ─── Internal Block I/O (used by format + disk modules) ─── */ +typedef struct zupt_atomic_output zupt_atomic_output_t; + +/* Create an archive in a private file next to OUTPUT_PATH. finish(..., 1) + * atomically replaces only the final directory entry; it never follows a + * symlink/reparse point at the leaf. finish(..., 0) removes the temporary. */ +zupt_atomic_output_t *zupt_atomic_output_open(const char *output_path, + FILE **stream_out); +int zupt_atomic_output_finish(zupt_atomic_output_t *output, int publish); + zupt_error_t read_block(FILE *f, zupt_block_t *b); zupt_error_t read_enc_header(FILE *f, zupt_archive_header_t *hdr, zupt_options_t *opts); zupt_error_t decompress_block(const zupt_block_t *b, const zupt_keyring_t *kr, uint64_t block_seq, uint8_t **out, size_t *olen); + +/* Published 5.2.1 encrypted+dedup disk images bound DATA authentication to + * each frame's linear sequence, while legacy references stored only offsets. + * Readers build this private offset-to-sequence map before restoring them. */ +typedef struct { + uint64_t offset; + uint64_t aad_seq; +} zupt_legacy_disk_aad_entry_t; + +typedef struct { + zupt_legacy_disk_aad_entry_t *entries; + size_t count; + size_t capacity; +} zupt_legacy_disk_aad_map_t; + +zupt_error_t zupt_legacy_disk_aad_map_build( + FILE *stream, uint64_t first_block_offset, uint32_t block_count, + zupt_legacy_disk_aad_map_t *map); +int zupt_legacy_disk_aad_map_lookup( + const zupt_legacy_disk_aad_map_t *map, uint64_t offset, + uint64_t *aad_seq); +void zupt_legacy_disk_aad_map_free(zupt_legacy_disk_aad_map_t *map); + zupt_error_t write_enc_header(FILE *out, zupt_archive_header_t *hdr, zupt_options_t *opts); int zupt_w8(FILE *f, uint8_t v); int zupt_w16le(FILE *f, uint16_t v); int zupt_w64le(FILE *f, uint64_t v); +void zupt_serialize_archive_header(const zupt_archive_header_t *header, + uint8_t out[ZUPT_ARCHIVE_HEADER_SIZE]); +void zupt_serialize_footer(const zupt_footer_t *footer, + uint8_t out[ZUPT_FOOTER_SIZE]); +int zupt_write_archive_header(FILE *stream, + const zupt_archive_header_t *header); +int zupt_write_footer(FILE *stream, const zupt_footer_t *footer); /* ─── Block-Level Deduplication ─── */ -#define ZUPT_DEDUP_MAX_ENTRIES (2 * 1024 * 1024) /* 2M entries, ~48MB RAM */ +#define ZUPT_DEDUP_MAX_ENTRIES (2 * 1024 * 1024) /* 2M entries, ~80MB RAM */ +#define ZUPT_DEDUP_DIGEST_SIZE 16 /* SHA-256 prefix paired with XXH64 */ typedef struct zupt_dedup_ctx zupt_dedup_ctx_t; @@ -556,6 +702,17 @@ void zupt_dedup_stats(const zupt_dedup_ctx_t *ctx, uint64_t *bytes_saved); int zupt_dedup_write_ref(FILE *out, uint64_t ref_offset, uint32_t orig_size, uint64_t orig_checksum); +int zupt_dedup_write_ref_secure(FILE *out, uint64_t ref_offset, + uint32_t orig_size, uint64_t orig_checksum, + uint64_t current_aad_seq, + uint64_t referenced_aad_seq, + const zupt_keyring_t *keyring); +zupt_error_t zupt_dedup_read_ref(const zupt_block_t *block, + const zupt_keyring_t *keyring, + int require_authentication, + uint64_t current_aad_seq, + uint64_t *ref_offset, + uint64_t *referenced_aad_seq); /* ─── Archive Info (read-only metadata inspection) ─── */ zupt_error_t zupt_archive_info(const char *path); diff --git a/include/zupt_acsl.h b/include/zupt_acsl.h index 8973783..6031b29 100644 --- a/include/zupt_acsl.h +++ b/include/zupt_acsl.h @@ -1,7 +1,7 @@ /* * SPDX-License-Identifier: AGPL-3.0-or-later * Copyright (c) 2025-2026 Cristian Cezar Moisés - * Zupt — ACSL Custom Predicates for Frama-C/WP + * ZUPT — ACSL Custom Predicates for Frama-C/WP * Copyright (c) 2026 Cristian Cezar Moisés — AGPL-3.0-or-later * * Usage: frama-c -wp -wp-rte -wp-model Typed+Cast diff --git a/include/zupt_cpuid.h b/include/zupt_cpuid.h index 5f3e75e..5a228ef 100644 --- a/include/zupt_cpuid.h +++ b/include/zupt_cpuid.h @@ -1,7 +1,7 @@ /* * SPDX-License-Identifier: AGPL-3.0-or-later * Copyright (c) 2025-2026 Cristian Cezar Moisés - * Zupt — CPU Feature Detection + * ZUPT — CPU Feature Detection * Copyright (c) 2026 Cristian Cezar Moisés — AGPL-3.0-or-later */ #ifndef ZUPT_CPUID_H diff --git a/include/zupt_jasmin.h b/include/zupt_jasmin.h index f0b8278..8236497 100644 --- a/include/zupt_jasmin.h +++ b/include/zupt_jasmin.h @@ -1,16 +1,18 @@ /* * SPDX-License-Identifier: AGPL-3.0-or-later * Copyright (c) 2025-2026 Cristian Cezar Moisés - * Zupt — Jasmin Verified Crypto Declarations + * ZUPT — optional x86_64 crypto assembly declarations * Copyright (c) 2026 Cristian Cezar Moisés — AGPL-3.0-or-later * - * Extern declarations for Jasmin-compiled assembly functions. - * These replace C fallbacks when built with -DZUPT_USE_JASMIN. + * Four declarations below correspond to checked-in jasminc output. The + * zupt_aes256_ctr4 implementation is separately identified hand-written + * assembly matching an algorithm-only .jazz description. These functions + * replace C fallbacks when built with -DZUPT_USE_JASMIN. * * Calling convention: System V AMD64 ABI. * Pointer args passed in RDI, RSI, RDX, RCX, R8, R9. * - * v2.0.0: All 4 Jasmin functions wired and active. + * All five optional declarations are wired when the feature is enabled. */ #ifndef ZUPT_JASMIN_H #define ZUPT_JASMIN_H @@ -18,24 +20,25 @@ #ifdef ZUPT_USE_JASMIN #include -/* JASMIN-VERIFIED: CT MAC comparison (4×u64 XOR accumulation). +/* JASMIN PATH: CT-intended MAC comparison (4×u64 XOR accumulation). * Returns 0 if all 32 bytes match, nonzero if any differ. * Replaces XOR loop in zupt_decrypt_buffer(). */ extern uint64_t zupt_mac_verify_ct(const void *expected, const void *actual); -/* JASMIN-VERIFIED: CT conditional select (4×u64 masked select). +/* JASMIN PATH: CT-intended conditional select (4×u64 masked select). * if cond==0: copies a→out. if cond!=0: copies b→out. * Replaces cmov in zupt_mlkem768_decaps(). */ extern void zupt_ct_select_32(void *out, const void *a, const void *b, uint64_t cond); -/* JASMIN-VERIFIED: CT conditional swap (4×u64 masked XOR swap). +/* JASMIN PATH: CT-intended conditional swap (4×u64 masked XOR swap). * if cond==0: no-op. if cond==1: swaps a↔b in place. * Replaces fe_cswap in zupt_x25519.c. - * NOTE: Requires 4×u64 field element layout (donna64). */ + * Operates on exactly four consecutive u64 values; the X25519 caller handles + * its fifth 51-bit limb separately. */ extern void zupt_fe_cswap(void *a, void *b, uint64_t cond); -/* JASMIN-VERIFIED: AES-256 single-block encrypt via AES-NI. +/* JASMIN PATH: AES-256 single-block encrypt via AES-NI. * out = AES-256-ECB(key, ctr) XOR in. * FIX v2.0.0: Stack offset bug resolved — round keys at correct * 16-byte aligned offsets. Requires AES-NI (checked via CPUID). @@ -49,7 +52,7 @@ extern void zupt_fe_cswap(void *a, void *b, uint64_t cond); extern void zupt_aes256_blk(void *out, const void *in, const void *key, const void *ctr); -/* JASMIN-VERIFIED: AES-256-CTR 4-block pipeline via AES-NI. +/* HAND-WRITTEN ASSEMBLY PATH: AES-256-CTR 4-block pipeline via AES-NI. * Processes nblocks×16 bytes with 4-way interleaving. * Counter is updated in-place (big-endian increment in bytes [8..15]). * Requires AES-NI. Falls back to zupt_aes256_blk for remaining 1-3 blocks. diff --git a/include/zupt_keccak.h b/include/zupt_keccak.h index 56ba0e9..1d5f120 100644 --- a/include/zupt_keccak.h +++ b/include/zupt_keccak.h @@ -1,5 +1,5 @@ /* - * Zupt — Backup-oriented compression with AES-256 encryption + * ZUPT — Backup-oriented compression with AES-256 encryption * Copyright (c) 2026 Cristian Cezar Moisés * SPDX-License-Identifier: AGPL-3.0-or-later * diff --git a/include/zupt_mlkem.h b/include/zupt_mlkem.h index 1d3576c..5ab20d5 100644 --- a/include/zupt_mlkem.h +++ b/include/zupt_mlkem.h @@ -1,5 +1,5 @@ /* - * Zupt — Backup-oriented compression with AES-256 encryption + * ZUPT — Backup-oriented compression with AES-256 encryption * Copyright (c) 2026 Cristian Cezar Moisés * SPDX-License-Identifier: AGPL-3.0-or-later * @@ -64,7 +64,7 @@ int zupt_mlkem768_decaps(uint8_t ss[MLKEM_SSBYTES], /* Self-test: NTT/iNTT roundtrip + CBD-sampler range invariants. * Returns 1 on pass, 0 on fail. Called from test_vectors.c case 14 - * (F-04, Zupt 2.2.4). */ + * (F-04, ZUPT 2.2.4). */ int zupt_mlkem768_selftest(void); #endif diff --git a/include/zupt_x25519.h b/include/zupt_x25519.h index ea62a95..7228595 100644 --- a/include/zupt_x25519.h +++ b/include/zupt_x25519.h @@ -1,10 +1,11 @@ /* - * Zupt — Backup-oriented compression with AES-256 encryption + * ZUPT — Backup-oriented compression with AES-256 encryption * Copyright (c) 2026 Cristian Cezar Moisés * SPDX-License-Identifier: AGPL-3.0-or-later * * X25519 Diffie-Hellman key agreement (RFC 7748). - * Montgomery ladder — constant-time by construction. + * Fixed-iteration Montgomery ladder, designed without secret-dependent + * branches or table lookups; compiled timing remains platform-dependent. */ #ifndef ZUPT_X25519_H #define ZUPT_X25519_H @@ -12,7 +13,8 @@ #include /* X25519(scalar, point) → result. All inputs/outputs are 32 bytes. - * CT-REQUIRED: Montgomery ladder is inherently constant-time. */ + * CT-REQUIRED: keep the ladder free of intended secret-dependent branches and + * memory access. This source-level property is not a compiled timing proof. */ void zupt_x25519(uint8_t out[32], const uint8_t scalar[32], const uint8_t point[32]); /* X25519 with the standard basepoint (9). diff --git a/install.sh b/install.sh index dd29dfb..d7e22d5 100644 --- a/install.sh +++ b/install.sh @@ -1,29 +1,33 @@ #!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés -# Fast Installer for VaptVupt - GNU/Linux +# Fast installer for ZUPT - GNU/Linux -set -e +set -Eeuo pipefail +umask 077 -echo "🔧 Installing VaptVupt..." +VERSION=${VERSION:-5.2.8} +PREFIX=${PREFIX:-/usr/local} + +echo "🔧 Installing ZUPT..." # Create temporary directory -TMP_DIR=$(mktemp -d) +TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/zupt-install.XXXXXXXX") +trap 'chmod -R u+rwX "$TMP_DIR" 2>/dev/null || true; rm -rf -- "$TMP_DIR"' EXIT HUP INT TERM # Clone and build -git clone https://git.securityops.co/cristiancmoises/vaptvupt.git "$TMP_DIR/vaptvupt" -cd "$TMP_DIR/vaptvupt" +git clone --depth 1 --branch "v$VERSION" \ + https://github.com/cristiancmoises/zupt.git "$TMP_DIR/zupt" +cd "$TMP_DIR/zupt" make clean -make +make -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf 2)" \ + WITH_SDK=0 WITH_PQBOX=0 +make WITH_SDK=0 WITH_PQBOX=0 check # Install -sudo make install +sudo make PREFIX="$PREFIX" WITH_SDK=0 WITH_PQBOX=0 \ + INSTALL_LEGACY_ALIAS=0 install -echo "✅ VaptVupt successfully installed to /usr/local/bin/vaptvupt" -echo "🔒 You can now run: vaptvupt (legacy 'zupt' symlink also installed)" - -# Cleanup -cd ~ -rm -rf "$TMP_DIR" -echo "🧹 Cleanup completed" +echo "✅ ZUPT $VERSION successfully installed to $PREFIX/bin/zupt" +echo "🔒 You can now run: zupt" diff --git a/jasmin/zupt_aes_ctr.jazz b/jasmin/zupt_aes_ctr.jazz index 6f017d3..50d6105 100644 --- a/jasmin/zupt_aes_ctr.jazz +++ b/jasmin/zupt_aes_ctr.jazz @@ -1,8 +1,9 @@ -/* Zupt — AES-256 Single Block Encrypt via AES-NI (Jasmin) +/* ZUPT — AES-256 Single Block Encrypt via AES-NI (Jasmin) * Copyright (c) 2026 Cristian Cezar Moisés * SPDX-License-Identifier: AGPL-3.0-or-later * - * CT-REQUIRED: AES-NI has no data-dependent timing. + * CT-REQUIRED: designed without intended secret-dependent branches or memory + * access. Compiled and microarchitectural timing is not proven here. * * FIX v2.0.0: replaced `stack u128[15] rk` with 15 individual * `stack u128` variables. The array form uses byte-offset indexing diff --git a/jasmin/zupt_aes_ctr4.jazz b/jasmin/zupt_aes_ctr4.jazz index bb886f7..72da294 100644 --- a/jasmin/zupt_aes_ctr4.jazz +++ b/jasmin/zupt_aes_ctr4.jazz @@ -1,15 +1,14 @@ -/* Zupt — AES-256-CTR 4-Block Pipeline via AES-NI (Jasmin) +/* ZUPT — AES-256-CTR 4-Block Pipeline via AES-NI (Jasmin) * Copyright (c) 2026 Cristian Cezar Moisés * SPDX-License-Identifier: AGPL-3.0-or-later * - * CT-REQUIRED: AES-NI has no data-dependent timing. + * CT-REQUIRED: designed without intended secret-dependent branches or memory + * access. Compiled and microarchitectural timing is not proven here. * * Interleaves 4 independent counter blocks through the AES round * pipeline. AES-NI has 4-cycle latency, 1-cycle throughput — so * 4 independent blocks saturate the pipeline for ~4× throughput. * - * Expected: ~3.5 GB/s AES-256-CTR on modern x86-64 (Zen3/Alder Lake). - * * Interface: * zupt_aes256_ctr4(out, in, key, ctr, nblocks) * Encrypts nblocks×16 bytes. Counter is incremented in the last 8 bytes diff --git a/jasmin/zupt_aes_ctr4.s b/jasmin/zupt_aes_ctr4.s index eb0f91d..3e4069e 100644 --- a/jasmin/zupt_aes_ctr4.s +++ b/jasmin/zupt_aes_ctr4.s @@ -1,6 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2026 Cristian Cezar Moisés -# Generated from jasmin/zupt_aes_ctr4.jazz by jasminc. +# Hand-written production assembly matching the algorithm documented in +# jasmin/zupt_aes_ctr4.jazz; this file is not jasminc output. .intel_syntax noprefix .text .p2align 5 diff --git a/jasmin/zupt_mac_verify.jazz b/jasmin/zupt_mac_verify.jazz index 6f6884a..672c3f1 100644 --- a/jasmin/zupt_mac_verify.jazz +++ b/jasmin/zupt_mac_verify.jazz @@ -1,4 +1,4 @@ -/* Zupt — Constant-Time MAC Comparison (Jasmin) +/* ZUPT — Constant-Time MAC Comparison (Jasmin) * Copyright (c) 2026 Cristian Cezar Moisés * SPDX-License-Identifier: AGPL-3.0-or-later * diff --git a/jasmin/zupt_mlkem_select.jazz b/jasmin/zupt_mlkem_select.jazz index 1004d02..b84f44d 100644 --- a/jasmin/zupt_mlkem_select.jazz +++ b/jasmin/zupt_mlkem_select.jazz @@ -1,4 +1,4 @@ -/* Zupt — ML-KEM Constant-Time Select (Jasmin) +/* ZUPT — ML-KEM Constant-Time Select (Jasmin) * Copyright (c) 2026 Cristian Cezar Moisés * SPDX-License-Identifier: AGPL-3.0-or-later * diff --git a/jasmin/zupt_x25519_fe.jazz b/jasmin/zupt_x25519_fe.jazz index 461db22..cef2bec 100644 --- a/jasmin/zupt_x25519_fe.jazz +++ b/jasmin/zupt_x25519_fe.jazz @@ -1,11 +1,11 @@ -/* Zupt — X25519 Constant-Time Conditional Swap (Jasmin) +/* ZUPT — X25519 Constant-Time Conditional Swap (Jasmin) * Copyright (c) 2026 Cristian Cezar Moisés * SPDX-License-Identifier: AGPL-3.0-or-later * * CT-REQUIRED: fe_cswap must not leak cond via timing. * This is the only CT-critical field operation in X25519. - * fe_add/fe_sub/fe_mul use C fallback (data-independent timing - * on x86-64 — ADD/MUL have fixed latency). + * fe_add/fe_sub/fe_mul use the C fallback. No fixed-latency claim is made for + * every compiler, x86-64 CPU, or resulting binary. * * 4 × u64 limbs, pure register operations, no intrinsics needed. */ diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index f893a82..5cec646 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -1,64 +1,60 @@ # Maintainer: Cristian Cezar Moisés +# SPDX-License-Identifier: AGPL-3.0-or-later # # AUR submission instructions: # 1. Run `make dist` upstream to produce zupt-VERSION.tar.gz. -# 2. Upload that tarball somewhere stable (GitHub release / git.securityops.co). +# 2. Upload that tarball to the canonical GitHub release. # 3. Update `source=()` URL and `sha256sums=()` below. # 4. Run `makepkg --printsrcinfo > .SRCINFO` in this directory. -# 5. Commit and push to ssh://aur@aur.archlinux.org/zupt.git +# 5. Commit and push to the separately maintained AUR package repository. # -# Test locally: `makepkg -s` in this directory after dropping a copy of the -# zupt-VERSION.tar.gz alongside the PKGBUILD. +# Test locally with `makepkg -s` after the release archive is published. -pkgname=vaptvupt -pkgver=4.1.0 +pkgname=zupt +pkgver=5.2.8 pkgrel=1 -provides=('zupt') -replaces=('zupt') -conflicts=('zupt') pkgdesc='Pure-C11 post-quantum backup compression utility (AES-256-CTR + HMAC-SHA256 + ML-KEM-768 + X25519)' -arch=('x86_64' 'aarch64') -url='https://git.securityops.co/cristiancmoises/zupt' -license=('AGPL-3.0-or-later') +arch=('x86_64') +url='https://github.com/cristiancmoises/zupt' +license=('AGPL-3.0-or-later AND GPL-3.0-or-later AND BSD-2-Clause AND BSD-3-Clause AND CC0-1.0') depends=('glibc') -makedepends=('gcc') +makedepends=('gcc' 'git' 'make') checkdepends=('python') -# Replace SHA256 placeholder with output of: -# sha256sum /tmp/zupt-2.4.4.tar.gz -source=("${pkgname}-${pkgver}.tar.gz::https://git.securityops.co/cristiancmoises/zupt/releases/download/v${pkgver}/${pkgname}-${pkgver}.tar.gz") -sha256sums=('SKIP') +source=("${pkgname}-${pkgver}.tar.gz::https://github.com/cristiancmoises/zupt/releases/download/v${pkgver}/${pkgname}-${pkgver}.tar.gz") +# Byte-reproducible upstream v5.2.8 source archive. +sha256sums=('378b9506211545b9594cf0d38ac8955d9b1cac34eb6b379ae0ec26b84edb65f7') build() { cd "${pkgname}-${pkgver}" - # Strict-warning build that the project's own §6 verification matrix uses. + # Source-only build (WITH_SDK=0) with the project's strict warning set. CFLAGS="${CFLAGS:--O2 -std=c11} -Wall -Wextra -Wpedantic" \ - make -j"$(nproc)" + make WITH_SDK=0 WITH_PQBOX=0 -j"$(nproc)" } check() { cd "${pkgname}-${pkgver}" - # Project regression suite — F-06 HMAC, F-08 top-MAC, F-09 byte sweep, etc. - make test + # Distro-safe quick, traversal, integrity, codec and NIST/RFC checks. + make WITH_SDK=0 WITH_PQBOX=0 check } package() { cd "${pkgname}-${pkgver}" - make DESTDIR="${pkgdir}" PREFIX=/usr install + # Source-only build (no vendored libraries); `make install` places the + # binary, man page and shell completions under the public zupt name. + make DESTDIR="${pkgdir}" PREFIX=/usr WITH_SDK=0 WITH_PQBOX=0 \ + INSTALL_LEGACY_ALIAS=0 install # Docs that aren't part of `make install` - install -Dm644 README.md "${pkgdir}/usr/share/doc/${pkgname}/README.md" - install -Dm644 SECURITY.md "${pkgdir}/usr/share/doc/${pkgname}/SECURITY.md" - install -Dm644 CHANGELOG.md "${pkgdir}/usr/share/doc/${pkgname}/CHANGELOG.md" - install -Dm644 AUDIT.md "${pkgdir}/usr/share/doc/${pkgname}/AUDIT.md" - install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" - - # Vendored libzuptsdk shipped alongside the binary because the binary - # is linked with -Wl,-rpath,$ORIGIN/vendor/zuptsdk. For system install - # we move it to /usr/lib/zupt/ and the binary's rpath remains relative. - install -d "${pkgdir}/usr/lib/${pkgname}" - install -Dm755 vendor/zuptsdk/libzuptsdk.so.2.0.0 \ - "${pkgdir}/usr/lib/${pkgname}/libzuptsdk.so.2.0.0" - ln -sf libzuptsdk.so.2.0.0 "${pkgdir}/usr/lib/${pkgname}/libzuptsdk.so.2" - ln -sf libzuptsdk.so.2.0.0 "${pkgdir}/usr/lib/${pkgname}/libzuptsdk.so" + install -Dm644 README.md "${pkgdir}/usr/share/doc/${pkgname}/README.md" + install -Dm644 SECURITY.md "${pkgdir}/usr/share/doc/${pkgname}/SECURITY.md" + install -Dm644 CHANGELOG.md "${pkgdir}/usr/share/doc/${pkgname}/CHANGELOG.md" + install -Dm644 LICENSE-AGPL-3.0 "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE-AGPL-3.0" + install -Dm644 LICENSE-GPL-3.0 "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE-GPL-3.0" + install -Dm644 LICENSE-BSD-2-Clause "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE-BSD-2-Clause" + install -Dm644 LICENSE-BSD-3-Clause "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE-BSD-3-Clause" + install -Dm644 LICENSE-CC0-1.0 "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE-CC0-1.0" + install -Dm644 NOTICE "${pkgdir}/usr/share/licenses/${pkgname}/NOTICE" + install -Dm644 THIRD-PARTY-NOTICES.md \ + "${pkgdir}/usr/share/licenses/${pkgname}/THIRD-PARTY-NOTICES.md" } diff --git a/packaging/build-appimage.sh b/packaging/build-appimage.sh index e794fa9..875c17c 100755 --- a/packaging/build-appimage.sh +++ b/packaging/build-appimage.sh @@ -1,64 +1,165 @@ -#!/bin/bash +#!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés -# Build vaptvupt CLI as AppImage (portable single-file binary). -# Includes a legacy `zupt` symlink so AppDir users can invoke either name. -set -e -cd "$(dirname "$0")/.." -VERSION="${VERSION:-3.0.0}" -ARCH="${ARCH:-x86_64}" -PKGNAME="vaptvupt" -LEGACY="zupt" -NAME="$PKGNAME-$VERSION-$ARCH" -OUT="/tmp/${NAME}.AppDir" +set -Eeuo pipefail -rm -rf "$OUT" -mkdir -p "$OUT/usr/bin" "$OUT/usr/lib" "$OUT/usr/share/applications" "$OUT/usr/share/icons/hicolor/256x256/apps" +umask 022 +export LC_ALL=C -install -m 755 $PKGNAME "$OUT/usr/bin/$PKGNAME" -ln -sf $PKGNAME "$OUT/usr/bin/$LEGACY" -install -m 644 vendor/zuptsdk/libzuptsdk.so.2.0.0 "$OUT/usr/lib/" -ln -sf libzuptsdk.so.2.0.0 "$OUT/usr/lib/libzuptsdk.so.2" -ln -sf libzuptsdk.so.2 "$OUT/usr/lib/libzuptsdk.so" -install -m 644 vendor/pqvaptvupt/libpqvaptvupt.so.0.6.0 "$OUT/usr/lib/" -ln -sf libpqvaptvupt.so.0.6.0 "$OUT/usr/lib/libpqvaptvupt.so.0" -ln -sf libpqvaptvupt.so.0 "$OUT/usr/lib/libpqvaptvupt.so" +die() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} -cat > "$OUT/AppRun" < "$OUT/$PKGNAME.desktop" < "$OUT/$PKGNAME.png" -cp "$OUT/$PKGNAME.png" "$OUT/usr/share/icons/hicolor/256x256/apps/$PKGNAME.png" +header_version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) +version=${VERSION:-$header_version} +[[ -n $version && $version == "$header_version" ]] || \ + die "VERSION '$version' does not match include/zupt.h '$header_version'" +[[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "invalid package version: $version" -if command -v appimagetool >/dev/null 2>&1; then - ARCH=$ARCH appimagetool "$OUT" "/tmp/${NAME}.AppImage" 2>&1 | tail -5 - echo "Built: /tmp/${NAME}.AppImage" +case $(uname -m) in + x86_64|amd64) native_arch=x86_64 ;; + aarch64|arm64) native_arch=aarch64 ;; + *) die "unsupported native AppImage architecture: $(uname -m)" ;; +esac +case ${ARCH:-$native_arch} in + x86_64|amd64) arch=x86_64 ;; + aarch64|arm64) arch=aarch64 ;; + *) die "unsupported AppImage architecture: ${ARCH:-$native_arch}" ;; +esac +[[ $arch == "$native_arch" ]] || \ + die "ARCH=$arch does not match the native build architecture $native_arch" + +appimagetool=${APPIMAGETOOL:-appimagetool} +if [[ $appimagetool == */* ]]; then + [[ -x $appimagetool ]] || die "APPIMAGETOOL is not executable: $appimagetool" + appimagetool=$(cd -- "$(dirname -- "$appimagetool")" && pwd -P)/$(basename -- "$appimagetool") +else + appimagetool=$(command -v -- "$appimagetool" || true) + [[ -n $appimagetool ]] || die 'appimagetool not found; set APPIMAGETOOL to a verified local executable' +fi +runtime_file=${APPIMAGE_RUNTIME_FILE:-} +[[ -n $runtime_file && -s $runtime_file ]] || \ + die 'set APPIMAGE_RUNTIME_FILE to a locally verified type-2 runtime (network downloads are not performed)' +runtime_file=$(cd -- "$(dirname -- "$runtime_file")" && pwd -P)/$(basename -- "$runtime_file") +runtime_compliance_file=${APPIMAGE_RUNTIME_COMPLIANCE_FILE:-} +[[ -n $runtime_compliance_file && -s $runtime_compliance_file ]] || \ + die 'set APPIMAGE_RUNTIME_COMPLIANCE_FILE to the audited runtime license/source-compliance notice' +runtime_compliance_file=$(cd -- "$(dirname -- "$runtime_compliance_file")" && pwd -P)/$(basename -- "$runtime_compliance_file") + +dist_dir=${DIST_DIR:-${TMPDIR:-/tmp}/zupt-release} +mkdir -p -- "$dist_dir" +dist_dir=$(cd -- "$dist_dir" && pwd -P) +output=$dist_dir/zupt-${version}-linux-${arch}.AppImage +[[ ! -e $output ]] || die "refusing to overwrite existing output: $output" + +for command_name in make readelf file sha256sum; do + command -v -- "$command_name" >/dev/null 2>&1 || die "required command not found: $command_name" +done +run_checks=${RUN_CHECKS:-1} +[[ $run_checks == 0 || $run_checks == 1 ]] || die 'RUN_CHECKS must be 0 or 1' +if [[ $run_checks == 1 ]]; then + command -v git >/dev/null 2>&1 || die 'git is required when RUN_CHECKS=1' fi -# Always produce the AppDir tarball as well -- some environments (no FUSE, -# strict execve policies, etc.) cannot run the .AppImage directly. The -# tarball is the universal fallback: extract and run AppRun. -cd /tmp -tar -czf "${NAME}.AppDir.tar.gz" "$(basename "$OUT")" -echo "Built: /tmp/${NAME}.AppDir.tar.gz" -echo "Users can run: tar xzf ${NAME}.AppDir.tar.gz && ./${NAME}.AppDir/AppRun version" +jobs=${JOBS:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf '1')} +work=$(mktemp -d "${TMPDIR:-/tmp}/zupt-appimage.XXXXXXXX") +appdir=$work/ZUPT.AppDir +image_tmp=$work/$(basename -- "$output") + +cleanup() { + make -C "$repo_root" clean >/dev/null 2>&1 || true + chmod -R u+rwX "$work" 2>/dev/null || true + rm -rf -- "$work" +} +trap cleanup EXIT HUP INT TERM + +printf '[AppImage] source-only build of ZUPT %s (%s)\n' "$version" "$arch" +make clean +make -j"$jobs" V=1 WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 +if [[ $run_checks == 1 ]]; then + make V=1 WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 check +fi +make DESTDIR="$appdir" PREFIX=/usr WITH_SDK=0 WITH_PQBOX=0 \ + INSTALL_LEGACY_ALIAS=0 install + +binary=$appdir/usr/bin/zupt +[[ -x $binary ]] || die 'AppDir executable is missing' +[[ ! -e $appdir/usr/bin/vaptvupt ]] || die 'legacy vaptvupt alias must not be packaged' +if readelf -d "$binary" 2>/dev/null | grep -Eq '(RPATH|RUNPATH)'; then + readelf -d "$binary" | grep -E '(RPATH|RUNPATH)' >&2 + die 'AppDir executable contains RPATH/RUNPATH' +fi +if readelf -d "$binary" 2>/dev/null | grep -Eqi '(vendor/|libvuptsdk|libpqvaptvupt)'; then + die 'AppDir executable references a vendored optional library' +fi + +mkdir -p -- "$appdir/usr/share/applications" \ + "$appdir/usr/share/doc/zupt" \ + "$appdir/usr/share/icons/hicolor/128x128/apps" \ + "$appdir/usr/share/licenses/zupt" +install -m 0644 README.md CHANGELOG.md SECURITY.md THREAT_MODEL.md \ + "$appdir/usr/share/doc/zupt/" +install -m 0644 LICENSE LICENSE-AGPL-3.0 LICENSE-GPL-3.0 \ + LICENSE-BSD-2-Clause LICENSE-BSD-3-Clause LICENSE-CC0-1.0 NOTICE \ + THIRD-PARTY-NOTICES.md "$appdir/usr/share/licenses/zupt/" +install -m 0644 gui/LICENSE-GUI \ + "$appdir/usr/share/licenses/zupt/GUI-LICENSE.txt" +install -m 0644 gui/assets/README.md \ + "$appdir/usr/share/licenses/zupt/GUI-ASSET-PROVENANCE.md" +install -m 0644 "$runtime_compliance_file" \ + "$appdir/usr/share/licenses/zupt/AppImage-runtime-compliance.txt" +install -m 0644 gui/assets/zupt-128.png \ + "$appdir/usr/share/icons/hicolor/128x128/apps/zupt.png" +cp -- "$appdir/usr/share/icons/hicolor/128x128/apps/zupt.png" "$appdir/zupt.png" +ln -s -- zupt.png "$appdir/.DirIcon" + +desktop_file=dev.zupt.cli.desktop +cat > "$appdir/$desktop_file" <<'EOF' +[Desktop Entry] +Type=Application +Name=ZUPT +Comment=Backup compression with authenticated and post-quantum encryption +Exec=zupt +Icon=zupt +Terminal=true +Categories=Utility;Archiving; +EOF +cp -- "$appdir/$desktop_file" "$appdir/usr/share/applications/$desktop_file" + +cat > "$appdir/AppRun" <<'EOF' +#!/bin/sh +set -eu +appdir=$(CDPATH= cd -P "$(dirname "$0")" && pwd -P) +exec "$appdir/usr/bin/zupt" "$@" +EOF +chmod 0755 "$appdir/AppRun" + +forbidden=$(find "$appdir" -type f \( \ + -name '*.o' -o -name '*.obj' -o -name '*.a' -o -name '*.so' -o \ + -name '*.so.*' -o -name '*.dll' -o -name '*.dylib' \ + \) -print) +[[ -z $forbidden ]] || { + printf '%s\n' "$forbidden" >&2 + die 'compiled library or object found in AppDir' +} + +bash scripts/test-installed-zupt.sh "$appdir/AppRun" + +export ARCH=$arch +export VERSION=$version +export APPIMAGE_EXTRACT_AND_RUN=1 +"$appimagetool" --runtime-file "$runtime_file" "$appdir" "$image_tmp" +chmod 0755 "$image_tmp" +file "$image_tmp" | grep -q 'ELF' || die 'generated AppImage does not have ELF magic' +bash scripts/test-installed-zupt.sh "$image_tmp" + +mv -- "$image_tmp" "$output" +sha256sum "$output" +printf 'PASS: built and executed-package-tested %s\n' "$output" diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index 9489abb..c25e922 100755 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -1,134 +1,139 @@ -#!/bin/bash +#!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés -# -# Build self-contained vaptvupt CLI .deb package. -# -# v3.0.0 rename: the binary is now `vaptvupt`; we install it at -# /usr/bin/vaptvupt and create /usr/bin/zupt → /usr/bin/vaptvupt as -# a legacy symlink for one major version cycle. The package name -# is `vaptvupt` with Provides/Replaces/Conflicts on `zupt` so -# `apt install zupt` still resolves cleanly. -# -# Bundles libzuptsdk.so.2 under /usr/lib/vaptvupt/ so users do NOT -# need to separately install the libzuptsdk package. -set -e -cd "$(dirname "$0")/.." +set -Eeuo pipefail -VERSION="${VERSION:-3.0.0}" -ARCH="${ARCH:-amd64}" -PKGNAME="vaptvupt" -LEGACY="zupt" +umask 022 +export LC_ALL=C -PKG="${PKGNAME}_${VERSION}_${ARCH}" -ROOT="/tmp/$PKG" - -# Vendored libzuptsdk path (relative to project root) -SDK_LIB="vendor/zuptsdk/libzuptsdk.so.2.0.0" -if [ ! -f "$SDK_LIB" ]; then - echo "ERROR: $SDK_LIB not found. Vendor the libzuptsdk shared object first." >&2 - exit 1 -fi -PQVV_LIB="vendor/pqvaptvupt/libpqvaptvupt.so.0.6.0" -if [ ! -f "$PQVV_LIB" ]; then - echo "ERROR: $PQVV_LIB not found. Vendor the libpqvaptvupt shared object first." >&2 +die() { + printf 'FAIL: %s\n' "$*" >&2 exit 1 +} + +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +cd -- "$repo_root" + +header_version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) +version=${VERSION:-$header_version} +[[ -n $version && $version == "$header_version" ]] || \ + die "VERSION '$version' does not match include/zupt.h '$header_version'" +[[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "invalid package version: $version" + +native_arch=$(dpkg --print-architecture) +arch=${ARCH:-$native_arch} +[[ $arch == "$native_arch" ]] || \ + die "ARCH=$arch does not match the native dpkg architecture $native_arch" +dist_dir=${DIST_DIR:-${TMPDIR:-/tmp}/zupt-release} +mkdir -p -- "$dist_dir" +dist_dir=$(cd -- "$dist_dir" && pwd -P) +output=$dist_dir/zupt_${version}_${arch}.deb +[[ ! -e $output ]] || die "refusing to overwrite existing output: $output" + +for command_name in make dpkg dpkg-deb readelf sha256sum; do + command -v -- "$command_name" >/dev/null 2>&1 || die "required command not found: $command_name" +done +run_checks=${RUN_CHECKS:-1} +[[ $run_checks == 0 || $run_checks == 1 ]] || die 'RUN_CHECKS must be 0 or 1' +if [[ $run_checks == 1 ]]; then + command -v git >/dev/null 2>&1 || die 'git is required when RUN_CHECKS=1' fi -echo "[deb] Building vaptvupt" -make clean >/dev/null 2>&1 || true -make -j"$(nproc)" >/dev/null +jobs=${JOBS:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf '1')} +work=$(mktemp -d "${TMPDIR:-/tmp}/zupt-deb.XXXXXXXX") +stage=$work/stage +extract=$work/extract -echo "[deb] Patching rpath -> /usr/lib/$PKGNAME:/usr/lib64/$PKGNAME" -patchelf --set-rpath "/usr/lib/$PKGNAME:/usr/lib64/$PKGNAME" $PKGNAME +cleanup() { + make -C "$repo_root" clean >/dev/null 2>&1 || true + chmod -R u+rwX "$work" 2>/dev/null || true + rm -rf -- "$work" +} +trap cleanup EXIT HUP INT TERM -if ! readelf -d $PKGNAME | grep -q "RUNPATH.*\[/usr/lib/$PKGNAME:/usr/lib64/$PKGNAME\]"; then - echo "ERROR: built $PKGNAME does not have correct RUNPATH" >&2 - readelf -d $PKGNAME | grep -E "RPATH|RUNPATH" - exit 1 +printf '[deb] source-only build of ZUPT %s (%s)\n' "$version" "$arch" +make clean +make -j"$jobs" V=1 WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 +if [[ $run_checks == 1 ]]; then + make V=1 WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 check fi -rm -rf "$ROOT" -mkdir -p "$ROOT/DEBIAN" \ - "$ROOT/usr/bin" \ - "$ROOT/usr/lib/$PKGNAME" \ - "$ROOT/usr/share/doc/$PKGNAME" \ - "$ROOT/usr/share/man/man1" \ - "$ROOT/usr/share/bash-completion/completions" \ - "$ROOT/usr/share/zsh/site-functions" \ - "$ROOT/usr/share/fish/vendor_completions.d" +make DESTDIR="$stage" PREFIX=/usr WITH_SDK=0 WITH_PQBOX=0 \ + INSTALL_LEGACY_ALIAS=0 install -# Binary + legacy symlink -install -m 755 $PKGNAME "$ROOT/usr/bin/$PKGNAME" -ln -sf $PKGNAME "$ROOT/usr/bin/$LEGACY" +binary=$stage/usr/bin/zupt +[[ -x $binary ]] || die 'staged /usr/bin/zupt is missing' +[[ ! -e $stage/usr/bin/vaptvupt ]] || die 'legacy /usr/bin/vaptvupt must not be packaged' -# Bundled libzuptsdk -install -m 755 "$SDK_LIB" "$ROOT/usr/lib/$PKGNAME/libzuptsdk.so.2.0.0" -ln -sf libzuptsdk.so.2.0.0 "$ROOT/usr/lib/$PKGNAME/libzuptsdk.so.2" -ln -sf libzuptsdk.so.2.0.0 "$ROOT/usr/lib/$PKGNAME/libzuptsdk.so" -install -m 755 "$PQVV_LIB" "$ROOT/usr/lib/$PKGNAME/libpqvaptvupt.so.0.6.0" -ln -sf libpqvaptvupt.so.0.6.0 "$ROOT/usr/lib/$PKGNAME/libpqvaptvupt.so.0" -ln -sf libpqvaptvupt.so.0.6.0 "$ROOT/usr/lib/$PKGNAME/libpqvaptvupt.so" - -# Manpage (gzip-compressed); install + legacy alias -if [ -f doc/vaptvupt.1 ]; then - gzip -9n -c doc/vaptvupt.1 > "$ROOT/usr/share/man/man1/$PKGNAME.1.gz" - ln -sf $PKGNAME.1.gz "$ROOT/usr/share/man/man1/$LEGACY.1.gz" +if readelf -d "$binary" 2>/dev/null | grep -Eq '(RPATH|RUNPATH)'; then + readelf -d "$binary" | grep -E '(RPATH|RUNPATH)' >&2 + die 'staged executable contains RPATH/RUNPATH' +fi +if readelf -d "$binary" 2>/dev/null | grep -Eqi '(vendor/|libvuptsdk|libpqvaptvupt)'; then + die 'staged executable references a vendored optional library' fi -# Shell completions -if [ -f completions/vaptvupt.bash ]; then - install -m 0644 completions/vaptvupt.bash "$ROOT/usr/share/bash-completion/completions/$PKGNAME" - ln -sf $PKGNAME "$ROOT/usr/share/bash-completion/completions/$LEGACY" -fi -if [ -f completions/_vaptvupt ]; then - install -m 0644 completions/_vaptvupt "$ROOT/usr/share/zsh/site-functions/_$PKGNAME" - ln -sf _$PKGNAME "$ROOT/usr/share/zsh/site-functions/_$LEGACY" -fi -if [ -f completions/vaptvupt.fish ]; then - install -m 0644 completions/vaptvupt.fish "$ROOT/usr/share/fish/vendor_completions.d/$PKGNAME.fish" +forbidden=$(find "$stage" -type f \( \ + -name '*.o' -o -name '*.obj' -o -name '*.a' -o -name '*.so' -o \ + -name '*.so.*' -o -name '*.dll' -o -name '*.dylib' -o -name '*.exe' \ + \) -print) +[[ -z $forbidden ]] || { + printf '%s\n' "$forbidden" >&2 + die 'compiled library or object found in package staging tree' +} + +docdir=$stage/usr/share/doc/zupt +mkdir -p -- "$docdir" +install -m 0644 README.md CHANGELOG.md SECURITY.md "$docdir/" +for document in THREAT_MODEL.md NOTICE THIRD-PARTY-NOTICES.md LICENSE-AGPL-3.0 LICENSE-GPL-3.0 LICENSE-BSD-2-Clause LICENSE-BSD-3-Clause LICENSE-CC0-1.0; do + [[ ! -f $document ]] || install -m 0644 "$document" "$docdir/" +done +install -m 0644 LICENSE "$docdir/copyright" + +mkdir -p -- "$work/debian" "$stage/DEBIAN" +if [[ -n ${DEB_DEPENDS:-} ]]; then + depends=$DEB_DEPENDS +else + command -v dpkg-shlibdeps >/dev/null 2>&1 || \ + die 'dpkg-shlibdeps is required unless DEB_DEPENDS is explicitly set' + printf 'Source: zupt\nPackage: zupt\n' > "$work/debian/control" + shlib_line=$(cd -- "$work" && dpkg-shlibdeps -O -e"$binary") + depends=${shlib_line#shlibs:Depends=} + [[ -n $depends && $depends != "$shlib_line" ]] || \ + die 'dpkg-shlibdeps did not determine runtime dependencies' fi -# Docs -install -m 0644 README.md "$ROOT/usr/share/doc/$PKGNAME/README.md" -install -m 0644 LICENSE "$ROOT/usr/share/doc/$PKGNAME/copyright" -[ -f SECURITY.md ] && install -m 0644 SECURITY.md "$ROOT/usr/share/doc/$PKGNAME/SECURITY.md" -[ -f CHANGELOG.md ] && install -m 0644 CHANGELOG.md "$ROOT/usr/share/doc/$PKGNAME/CHANGELOG.md" -[ -f THREAT_MODEL.md ] && install -m 0644 THREAT_MODEL.md "$ROOT/usr/share/doc/$PKGNAME/THREAT_MODEL.md" - -# DEBIAN/control -INSTALLED_KB=$(du -sk "$ROOT/usr" | awk '{print $1}') -cat > "$ROOT/DEBIAN/control" < "$stage/DEBIAN/control" < -Homepage: https://git.securityops.co/cristiancmoises/zupt -Description: Post-quantum backup compression utility (formerly zupt) - VaptVupt (renamed from Zupt in v3.0.0 due to a prior INPI Brasil - trademark on the name) is a pure-C11 backup compression utility - featuring post-quantum hybrid encryption (ML-KEM-768 + X25519, - FIPS 203), AES-256-CTR + HMAC-SHA256 authenticated encryption, - Argon2id KDF (PBKDF2-SHA256 via --kdf pbkdf2), multi-threaded - compression with the VaptVupt LZ + ANS codec 2.48.5, full-disk - backup with sparse-region detection, and end-to-end byte-level - tamper detection on encrypted archives (F-09: 0/1827 silent - accepts). - . - The .zupt archive extension is unchanged; v2.x and v3.0.0 - archives are bidirectionally compatible. The legacy /usr/bin/zupt - symlink is preserved for one major version cycle. +Homepage: https://github.com/cristiancmoises/zupt +Description: Backup compression with authenticated and post-quantum encryption + ZUPT creates compressed backup archives with optional password encryption + or ML-KEM-768 and X25519 hybrid key encapsulation. This package is built from + source with the optional libvuptsdk and libpqvaptvupt integrations disabled. EOF -DEB_OUT="/tmp/${PKGNAME}_${VERSION}_${ARCH}.deb" -dpkg-deb --build --root-owner-group "$ROOT" "$DEB_OUT" >/dev/null -echo "Built: $DEB_OUT ($(du -h "$DEB_OUT" | cut -f1))" -dpkg-deb -I "$DEB_OUT" | sed -n '1,20p' +package_tmp=$work/$(basename -- "$output") +dpkg-deb --build --root-owner-group "$stage" "$package_tmp" >/dev/null +dpkg-deb --info "$package_tmp" >/dev/null +dpkg-deb --contents "$package_tmp" > "$work/contents.txt" +if grep -Eq '(/usr/bin/vaptvupt$|\.(o|obj|a|so|so\.[^/]+|dll|dylib)$)' "$work/contents.txt"; then + cat "$work/contents.txt" >&2 + die 'forbidden alias or compiled library/object found in .deb contents' +fi + +mkdir -p -- "$extract" +dpkg-deb --extract "$package_tmp" "$extract" +bash scripts/test-installed-zupt.sh "$extract/usr/bin/zupt" + +mv -- "$package_tmp" "$output" +sha256sum "$output" +printf 'PASS: built and extracted-package-tested %s\n' "$output" diff --git a/packaging/build-dmg.sh b/packaging/build-dmg.sh index 04bfce3..bb214c5 100755 --- a/packaging/build-dmg.sh +++ b/packaging/build-dmg.sh @@ -1,188 +1,228 @@ -#!/bin/bash +#!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés -# -# Build a macOS .dmg installer for the Zupt CLI. -# -# This script MUST be run on macOS — `hdiutil` is required and only -# ships with macOS. There is no portable way to produce a .dmg from -# Linux that Apple's installer will mount cleanly (libdmg-hfsplus and -# dmg2img exist but produce read-only images that some macOS versions -# reject). -# -# On macOS: -# xcode-select --install # one-time, for clang -# make # build the zupt binary -# VERSION=2.4.7 bash packaging/build-dmg.sh -# -# Produces: /tmp/Zupt-VERSION.dmg with: -# - zupt binary (universal2 if built with -arch x86_64 -arch arm64) -# - libzuptsdk dylib alongside the binary at @loader_path -# - install.command (drag-to-install script) -# - README.md, LICENSE -# - Optional: code-signed and notarized if APPLE_DEV_ID env is set -# -# For Homebrew installation, prefer packaging/homebrew/zupt.rb instead. -# The .dmg is for users who don't want to install Homebrew. -set -e -cd "$(dirname "$0")/.." +set -Eeuo pipefail -VERSION="${VERSION:-2.4.7}" -ARCH="${ARCH:-$(uname -m)}" # x86_64 or arm64 -NAME="Zupt-${VERSION}-${ARCH}" -STAGE="/tmp/${NAME}.app/Contents" +umask 022 +export LC_ALL=C -# ── Platform check ── -if [ "$(uname)" != "Darwin" ]; then - cat >&2 <&2 exit 1 +} + +[[ $(uname -s) == Darwin ]] || die 'DMG packages must be built and tested on macOS' + +test_macos_binary() ( + set -Eeuo pipefail + + local candidate=$1 binary test_root archive_size + if [[ $candidate == */* ]]; then + [[ -x $candidate ]] || die "executable not found: $candidate" + binary=$(cd "$(dirname "$candidate")" && pwd -P)/$(basename "$candidate") + else + binary=$(command -v "$candidate" || true) + [[ -n $binary ]] || die "executable not found on PATH: $candidate" + fi + + for command_name in cmp dd diff find grep shasum sort; do + command -v "$command_name" >/dev/null 2>&1 || \ + die "required smoke-test command not found: $command_name" + done + + test_root=$(mktemp -d "${TMPDIR:-/tmp}/zupt-macos-smoke.XXXXXX") + trap 'chmod -R u+rwX "$test_root" 2>/dev/null || true; rm -rf "$test_root"' \ + EXIT HUP INT TERM + mkdir -p "$test_root/input/subdir" "$test_root/output" \ + "$test_root/password-output" "$test_root/escape-output" "$test_root/outside" + printf 'ZUPT macOS package smoke test\n' > "$test_root/input/text file.txt" + printf 'conteúdo UTF-8\n' > "$test_root/input/subdir/café-安全.txt" + : > "$test_root/input/empty file" + dd if=/dev/urandom of="$test_root/input/subdir/random.bin" \ + bs=4096 count=8 >/dev/null 2>&1 + printf 'do-not-overwrite\n' > "$test_root/outside/sentinel" + + "$binary" --version > "$test_root/version.log" 2>&1 + grep -q '^zupt ' "$test_root/version.log" + "$binary" --help > "$test_root/help.log" 2>&1 + grep -q '^Usage:' "$test_root/help.log" + if "$binary" --definitely-invalid-option >/dev/null 2>&1; then + die 'invalid option returned success' + fi + + ( + cd "$test_root" + "$binary" compress plain.zupt input + "$binary" test plain.zupt + "$binary" extract -o output plain.zupt + ) + diff -r "$test_root/input" "$test_root/output/input" + ( + cd "$test_root/input" + find . -type f -exec shasum -a 256 {} \; | sort + ) > "$test_root/original.sha256" + ( + cd "$test_root/output/input" + find . -type f -exec shasum -a 256 {} \; | sort + ) > "$test_root/extracted.sha256" + cmp "$test_root/original.sha256" "$test_root/extracted.sha256" + + ( + cd "$test_root" + "$binary" compress -p 'ZUPT-test-password-2026!' \ + password.zupt 'input/text file.txt' + "$binary" test -p 'ZUPT-test-password-2026!' password.zupt + "$binary" extract -p 'ZUPT-test-password-2026!' \ + -o password-output password.zupt + ) + cmp "$test_root/input/text file.txt" \ + "$test_root/password-output/input/text file.txt" + if "$binary" extract -p incorrect-password -o "$test_root/wrong-password" \ + "$test_root/password.zupt" >/dev/null 2>&1; then + die 'incorrect password returned success' + fi + + archive_size=$(wc -c < "$test_root/plain.zupt") + ((archive_size > 32)) || die 'archive unexpectedly small' + dd if="$test_root/plain.zupt" of="$test_root/corrupt.zupt" bs=1 \ + count="$((archive_size - 17))" >/dev/null 2>&1 + if "$binary" test "$test_root/corrupt.zupt" >/dev/null 2>&1; then + die 'truncated archive returned success' + fi + + ln -s "$test_root/outside" "$test_root/escape-output/input" + "$binary" extract -o "$test_root/escape-output" \ + "$test_root/plain.zupt" >/dev/null 2>&1 || true + [[ $(<"$test_root/outside/sentinel") == do-not-overwrite ]] || \ + die 'extraction overwrote outside sentinel' + [[ ! -e $test_root/outside/text\ file.txt && ! -e $test_root/outside/subdir ]] || \ + die 'extraction escaped through a destination symlink' + + [[ $(id -u) -ne 0 ]] || die 'macOS package smoke test unexpectedly ran as root' + printf 'PASS: native macOS package functional test suite\n' +) + +if [[ ${1:-} == --test-binary ]]; then + (($# == 2)) || die 'usage: build-dmg.sh --test-binary PATH' + test_macos_binary "$2" + exit 0 +elif (($# != 0)); then + die 'usage: build-dmg.sh [--test-binary PATH]' fi -# ── Build zupt (universal binary if possible) ── -echo "[dmg] Building zupt" +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +cd -- "$repo_root" + +header_version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) +version=${VERSION:-$header_version} +[[ -n $version && $version == "$header_version" ]] || \ + die "VERSION '$version' does not match include/zupt.h '$header_version'" +[[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "invalid package version: $version" +native_arch=$(uname -m) +arch=${ARCH:-$native_arch} +[[ $arch == "$native_arch" ]] || \ + die "ARCH=$arch does not match the native macOS architecture $native_arch" + +dist_dir=${DIST_DIR:-${TMPDIR:-/tmp}/zupt-release} +mkdir -p "$dist_dir" +dist_dir=$(cd "$dist_dir" && pwd -P) +output=$dist_dir/ZUPT-${version}-macOS-${arch}.dmg +[[ ! -e $output ]] || die "refusing to overwrite existing output: $output" + +for command_name in make clang hdiutil otool plutil shasum; do + command -v -- "$command_name" >/dev/null 2>&1 || die "required command not found: $command_name" +done +run_checks=${RUN_CHECKS:-1} +[[ $run_checks == 0 || $run_checks == 1 ]] || die 'RUN_CHECKS must be 0 or 1' +if [[ $run_checks == 1 ]]; then + command -v git >/dev/null 2>&1 || die 'git is required when RUN_CHECKS=1' +fi + +jobs=${JOBS:-$(sysctl -n hw.ncpu 2>/dev/null || printf '1')} +work=$(mktemp -d "${TMPDIR:-/tmp}/zupt-dmg.XXXXXXXX") +app=$work/ZUPT.app +contents=$app/Contents +dmg_root=$work/dmg-root +dmg_tmp=$work/$(basename "$output") +mkdir -p "$contents/MacOS" "$contents/Resources" "$dmg_root" + +cleanup() { + make -C "$repo_root" clean >/dev/null 2>&1 || true + chmod -R u+rwX "$work" 2>/dev/null || true + rm -rf "$work" +} +trap cleanup EXIT HUP INT TERM + +printf '[dmg] source-only build of ZUPT %s (%s)\n' "$version" "$arch" make clean -if xcrun --sdk macosx clang -dM -E - &2 + die 'macOS executable contains LC_RPATH' +fi +if otool -L "$contents/MacOS/zupt" | grep -Eqi \ + '(vendor/|libvuptsdk|libpqvaptvupt|/home/|/Users/[^/]+/|/opt/(homebrew|local)/|/usr/local/)'; then + otool -L "$contents/MacOS/zupt" >&2 + die 'macOS executable references a build path or vendored optional library' fi -# ── Stage the .app bundle ── -echo "[dmg] Staging .app bundle" -rm -rf "/tmp/${NAME}.app" -mkdir -p "$STAGE/MacOS" "$STAGE/Resources" "$STAGE/Frameworks" - -install -m 755 zupt "$STAGE/MacOS/zupt" - -# Vendored libzuptsdk — on macOS it'd be .dylib, but if the vendored -# build is Linux-style .so, ship that and warn. A proper macOS build -# would produce libzuptsdk.2.0.0.dylib. -if [ -f vendor/zuptsdk/libzuptsdk.2.0.0.dylib ]; then - install -m 755 vendor/zuptsdk/libzuptsdk.2.0.0.dylib "$STAGE/Frameworks/" - install_name_tool -id "@loader_path/../Frameworks/libzuptsdk.2.0.0.dylib" \ - "$STAGE/Frameworks/libzuptsdk.2.0.0.dylib" - install_name_tool -change "vendor/zuptsdk/libzuptsdk.so.2" \ - "@loader_path/../Frameworks/libzuptsdk.2.0.0.dylib" \ - "$STAGE/MacOS/zupt" -elif [ -f vendor/zuptsdk/libzuptsdk.so.2.0.0 ]; then - cat >&2 < "$STAGE/Info.plist" < "$contents/Info.plist" < - + - CFBundleIdentifier - co.securityops.zupt - CFBundleName - Zupt - CFBundleDisplayName - Zupt - CFBundleVersion - ${VERSION} - CFBundleShortVersionString - ${VERSION} - CFBundleExecutable - zupt - CFBundlePackageType - APPL - NSHighResolutionCapable - - LSMinimumSystemVersion - 11.0 + CFBundleIdentifierdev.zupt.cli + CFBundleNameZUPT + CFBundleDisplayNameZUPT + CFBundleExecutablezupt + CFBundlePackageTypeAPPL + CFBundleVersion$version + CFBundleShortVersionString$version -PLIST +EOF +plutil -lint "$contents/Info.plist" -cp README.md "$STAGE/Resources/" 2>/dev/null || true -cp LICENSE "$STAGE/Resources/" 2>/dev/null || true - -# ── Drag-to-install command file ── -cat > "/tmp/${NAME}-install.command" <<'INSTALL' -#!/bin/bash -# Drag-installer for Zupt CLI. Copies the binary to /usr/local/bin -# (or the user's ~/bin if /usr/local isn't writable). -set -e -DIR="$(cd "$(dirname "$0")" && pwd)" -APP="$DIR/Zupt.app" -TARGET="/usr/local/bin" -if [ ! -w "$TARGET" ]; then - TARGET="$HOME/bin" - mkdir -p "$TARGET" - echo "Installing to $TARGET (add to PATH if missing)" -fi -cp "$APP/Contents/MacOS/zupt" "$TARGET/zupt" -chmod 755 "$TARGET/zupt" -# Bundle the dylib alongside under a stable path -LIBDIR="/usr/local/lib/zupt" -[ -w /usr/local/lib ] || LIBDIR="$HOME/lib/zupt" -mkdir -p "$LIBDIR" -if [ -d "$APP/Contents/Frameworks" ]; then - cp -P "$APP/Contents/Frameworks"/* "$LIBDIR/" 2>/dev/null || true -fi -echo "Installed: $TARGET/zupt" -"$TARGET/zupt" version -INSTALL -chmod 755 "/tmp/${NAME}-install.command" - -# ── Optional: code sign ── -if [ -n "${APPLE_DEV_ID:-}" ]; then - echo "[dmg] Code-signing with Developer ID: $APPLE_DEV_ID" - codesign --force --options runtime --sign "$APPLE_DEV_ID" \ - --entitlements packaging/macos/entitlements.plist \ - "$STAGE/MacOS/zupt" 2>&1 || echo " (no entitlements file — proceeding unsigned for hardening)" - codesign --force --sign "$APPLE_DEV_ID" "/tmp/${NAME}.app" || true +if [[ -n ${CODESIGN_IDENTITY:-} ]]; then + codesign --force --options runtime --timestamp --sign "$CODESIGN_IDENTITY" "$app" + codesign --verify --deep --strict "$app" fi -# ── Build .dmg ── -echo "[dmg] Building disk image" -DMG="/tmp/${NAME}.dmg" -rm -f "$DMG" - -# Stage a directory tree that becomes the .dmg root -DMGSRC="/tmp/${NAME}-dmgsrc" -rm -rf "$DMGSRC" -mkdir -p "$DMGSRC" -cp -R "/tmp/${NAME}.app" "$DMGSRC/Zupt.app" -cp "/tmp/${NAME}-install.command" "$DMGSRC/Install Zupt.command" -[ -f README.md ] && cp README.md "$DMGSRC/" -[ -f LICENSE ] && cp LICENSE "$DMGSRC/" - -hdiutil create -fs HFS+ -srcfolder "$DMGSRC" -volname "Zupt ${VERSION}" \ - -format UDZO -ov "$DMG" - -# ── Optional: notarize ── -if [ -n "${APPLE_DEV_ID:-}" ] && [ -n "${APPLE_NOTARIZE_KEY:-}" ]; then - echo "[dmg] Submitting for notarization" - xcrun notarytool submit "$DMG" --apple-id "$APPLE_DEV_ID" \ - --password "$APPLE_NOTARIZE_KEY" --wait - xcrun stapler staple "$DMG" +cp -R "$app" "$dmg_root/ZUPT.app" +cat > "$dmg_root/Install ZUPT.command" <<'EOF' +#!/usr/bin/env bash +set -Eeuo pipefail +installer_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +source_binary=$installer_dir/ZUPT.app/Contents/MacOS/zupt +target_dir=/usr/local/bin +if [[ ! -d $target_dir || ! -w $target_dir ]]; then + target_dir=${XDG_BIN_HOME:-$HOME/.local/bin} + mkdir -p "$target_dir" fi +install -m 0755 "$source_binary" "$target_dir/zupt" +printf 'Installed %s\n' "$target_dir/zupt" +"$target_dir/zupt" --version +EOF +chmod 0755 "$dmg_root/Install ZUPT.command" +for document in README.md CHANGELOG.md LICENSE LICENSE-AGPL-3.0 LICENSE-GPL-3.0 LICENSE-BSD-2-Clause LICENSE-BSD-3-Clause LICENSE-CC0-1.0 NOTICE THIRD-PARTY-NOTICES.md; do + [[ ! -f $document ]] || install -m 0644 "$document" "$dmg_root/" +done -echo "" -echo "Built: $DMG ($(du -h "$DMG" | cut -f1))" -echo "Users mount and drag 'Zupt.app' or double-click 'Install Zupt.command'." +hdiutil create -fs HFS+ -srcfolder "$dmg_root" -volname "ZUPT $version" \ + -format UDZO -ov "$dmg_tmp" +hdiutil verify "$dmg_tmp" + +mv "$dmg_tmp" "$output" +shasum -a 256 "$output" +printf 'PASS: built and native-binary-tested %s\n' "$output" diff --git a/packaging/build-gui-appimage.sh b/packaging/build-gui-appimage.sh index 283ba81..1cc0dbc 100755 --- a/packaging/build-gui-appimage.sh +++ b/packaging/build-gui-appimage.sh @@ -1,121 +1,140 @@ -#!/bin/bash +#!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés -# Build zupt-gui AppImage. Since the GUI is pure Python + Qt, the AppDir -# bundles only the Python source and metadata; it relies on system -# python3 + PyQt6/PySide6 at runtime. This keeps the AppImage tiny -# (~50 KB) and lets it work on any Linux with Qt6 Python bindings. -# -# For a true self-contained AppImage with bundled Python interpreter, -# use python-appimage (https://github.com/niess/python-appimage) on -# the build host — it produces a ~80 MB AppImage. The portable variant -# below is the better tradeoff for most distributions. -set -e -cd "$(dirname "$0")/.." -VERSION="${VERSION:-1.2.0}" -APPDIR="/tmp/vaptvupt-gui.AppDir" +# Build a dependency-light GUI AppImage. The ZUPT CLI is compiled from this +# tree and bundled; Python 3 plus PySide6 or PyQt6 remain host requirements. -rm -rf "$APPDIR" -mkdir -p "$APPDIR/usr/bin" \ - "$APPDIR/usr/lib/vaptvupt-gui" \ - "$APPDIR/usr/share/applications" \ - "$APPDIR/usr/share/icons/hicolor/256x256/apps" +set -Eeuo pipefail +umask 022 +export LC_ALL=C -# Python source -install -m 644 gui/src/zupt_gui.py "$APPDIR/usr/lib/vaptvupt-gui/" +die() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } -# Wrapper -cat > "$APPDIR/usr/bin/vaptvupt-gui" <<'WRAP' +[[ $(uname -s) == Linux ]] || die 'AppImage packages must be built on Linux' +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +cd -- "$repo_root" +header_version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) +version=${VERSION:-$header_version} +[[ $version == "$header_version" && $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || \ + die "VERSION '$version' does not match include/zupt.h '$header_version'" + +case $(uname -m) in + x86_64|amd64) native_arch=x86_64 ;; + aarch64|arm64) native_arch=aarch64 ;; + *) die "unsupported native AppImage architecture: $(uname -m)" ;; +esac +case ${ARCH:-$native_arch} in + x86_64|amd64) arch=x86_64 ;; + aarch64|arm64) arch=aarch64 ;; + *) die "unsupported AppImage architecture: ${ARCH:-$native_arch}" ;; +esac +[[ $arch == "$native_arch" ]] || \ + die "ARCH=$arch does not match the native build architecture $native_arch" + +dist_dir=${DIST_DIR:-${TMPDIR:-/tmp}/zupt-release} +mkdir -p -- "$dist_dir" +dist_dir=$(cd -- "$dist_dir" && pwd -P) +case $dist_dir/ in "$repo_root"/*) die 'DIST_DIR must be outside the repository' ;; esac +output=$dist_dir/ZUPT-GUI-$version-linux-$arch.AppImage +[[ ! -e $output ]] || die "refusing to overwrite existing output: $output" + +appimagetool=${APPIMAGETOOL:-appimagetool} +appimagetool=$(command -v -- "$appimagetool" 2>/dev/null || true) +[[ -n $appimagetool ]] || die 'appimagetool not found; no network fallback is performed' +runtime_file=${APPIMAGE_RUNTIME_FILE:-} +[[ -n $runtime_file && -s $runtime_file ]] || \ + die 'set APPIMAGE_RUNTIME_FILE to a non-empty verified local type-2 runtime' +runtime_file=$(cd -- "$(dirname -- "$runtime_file")" && pwd -P)/$(basename -- "$runtime_file") +runtime_compliance_file=${APPIMAGE_RUNTIME_COMPLIANCE_FILE:-} +[[ -n $runtime_compliance_file && -s $runtime_compliance_file ]] || \ + die 'set APPIMAGE_RUNTIME_COMPLIANCE_FILE to the audited runtime license/source-compliance notice' +runtime_compliance_file=$(cd -- "$(dirname -- "$runtime_compliance_file")" && pwd -P)/$(basename -- "$runtime_compliance_file") + +for command_name in make python3 readelf file sha256sum; do + command -v -- "$command_name" >/dev/null 2>&1 || \ + die "required command not found: $command_name" +done +python3 -c 'import PySide6.QtWidgets' 2>/dev/null || \ +python3 -c 'import PyQt6.QtWidgets' 2>/dev/null || \ + die 'the build/test host needs PySide6 or PyQt6; the AppImage does not download it' + +jobs=${JOBS:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf '1')} +work=$(mktemp -d "${TMPDIR:-/tmp}/zupt-gui-appimage.XXXXXXXX") +appdir=$work/ZUPT-GUI.AppDir +image_tmp=$work/$(basename -- "$output") +cleanup() { + make -C "$repo_root" clean >/dev/null 2>&1 || true + chmod -R u+rwX "$work" 2>/dev/null || true + rm -rf -- "$work" +} +trap cleanup EXIT HUP INT TERM + +make clean +make -j"$jobs" V=1 WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 +make V=1 WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 check +make DESTDIR="$appdir" PREFIX=/usr WITH_SDK=0 WITH_PQBOX=0 \ + INSTALL_LEGACY_ALIAS=0 install + +binary=$appdir/usr/bin/zupt +[[ -x $binary ]] || die 'source-built CLI is missing from AppDir' +[[ ! -e $appdir/usr/bin/vaptvupt ]] || die 'legacy vaptvupt alias must not be packaged' +if readelf -d "$binary" 2>/dev/null | grep -Eq '(RPATH|RUNPATH|libvuptsdk|libpqvaptvupt|vendor/)'; then + readelf -d "$binary" >&2 + die 'CLI has RPATH/RUNPATH or an optional-library reference' +fi + +install -Dm0644 gui/src/zupt_gui.py "$appdir/usr/lib/zupt-gui/zupt_gui.py" +install -Dm0644 gui/assets/zupt-icon.png \ + "$appdir/usr/share/icons/hicolor/256x256/apps/zupt-gui.png" +install -Dm0644 gui/packaging/zupt-gui.desktop \ + "$appdir/usr/share/applications/zupt-gui.desktop" +install -d "$appdir/usr/share/licenses/zupt" +install -m 0644 LICENSE LICENSE-AGPL-3.0 LICENSE-GPL-3.0 \ + LICENSE-BSD-2-Clause LICENSE-BSD-3-Clause LICENSE-CC0-1.0 NOTICE \ + THIRD-PARTY-NOTICES.md "$appdir/usr/share/licenses/zupt/" +install -d "$appdir/usr/share/licenses/zupt-gui" +install -m 0644 LICENSE-AGPL-3.0 \ + "$appdir/usr/share/licenses/zupt-gui/LICENSE-AGPL-3.0" +install -m 0644 gui/LICENSE-GUI \ + "$appdir/usr/share/licenses/zupt-gui/LICENSE-GUI" +install -m 0644 gui/assets/README.md \ + "$appdir/usr/share/licenses/zupt-gui/ASSET-PROVENANCE.md" +install -Dm0644 "$runtime_compliance_file" \ + "$appdir/usr/share/licenses/zupt/AppImage-runtime-compliance.txt" +cp -- "$appdir/usr/share/icons/hicolor/256x256/apps/zupt-gui.png" \ + "$appdir/zupt-gui.png" + +cat >"$appdir/usr/bin/zupt-gui" <<'WRAP' #!/bin/sh -exec python3 "$(dirname "$0")/../lib/vaptvupt-gui/zupt_gui.py" "$@" +here=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) +export ZUPT_BIN=$here/bin/zupt +exec python3 "$here/lib/zupt-gui/zupt_gui.py" "$@" WRAP -chmod 755 "$APPDIR/usr/bin/vaptvupt-gui" - -# Desktop file -cat > "$APPDIR/vaptvupt-gui.desktop" <<'DESKTOP' -[Desktop Entry] -Type=Application -Name=VaptVupt GUI -GenericName=Backup and Compression Utility -Comment=Post-quantum backup with HKDF combiner, key commitment, HPKE binding -Exec=vaptvupt-gui %f -Icon=vaptvupt-gui -Terminal=false -Categories=Utility;Archiving;Compression;Security; -StartupNotify=true -DESKTOP -cp "$APPDIR/vaptvupt-gui.desktop" "$APPDIR/usr/share/applications/" - -# Icon -if [ -f gui/assets/zupt-icon.png ]; then - cp gui/assets/zupt-icon.png "$APPDIR/vaptvupt-gui.png" - cp gui/assets/zupt-icon.png "$APPDIR/usr/share/icons/hicolor/256x256/apps/vaptvupt-gui.png" -else - python3 -c " -import struct, zlib -def png(w, h, color): - raw = b''.join(b'\\0' + bytes(color) * w for _ in range(h)) - def chunk(t, d): return struct.pack('>I', len(d)) + t + d + struct.pack('>I', zlib.crc32(t+d) & 0xffffffff) - return b'\\x89PNG\\r\\n\\x1a\\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 0)) + chunk(b'IDAT', zlib.compress(raw)) + chunk(b'IEND', b'') -open('$APPDIR/zupt-gui.png','wb').write(png(256, 256, (88, 92, 215))) -" - cp "$APPDIR/vaptvupt-gui.png" "$APPDIR/usr/share/icons/hicolor/256x256/apps/vaptvupt-gui.png" -fi - -# AppRun — sets PATH so zupt-gui finds the bundled wrapper, falls -# back to system zupt CLI if not present in /usr/bin alongside. -cat > "$APPDIR/AppRun" <<'APPRUN' +chmod 0755 "$appdir/usr/bin/zupt-gui" +cat >"$appdir/AppRun" <<'APPRUN' #!/bin/sh -HERE="$(dirname "$(readlink -f "$0")")" -export PATH="$HERE/usr/bin:$PATH" - -# Pre-flight check: is python3 available? Is a Qt6 binding installed? -if ! command -v python3 >/dev/null 2>&1; then - cat >&2 </dev/null \ - && ! python3 -c 'import PySide6.QtWidgets' 2>/dev/null; then - cat >&2 </dev/null 2>&1 && ! command -v zupt >/dev/null 2>&1; then - cat >&2 </dev/null 2>&1; then - ARCH=x86_64 appimagetool "$APPDIR" "/tmp/VaptVupt-GUI-$VERSION-x86_64.AppImage" 2>&1 | tail -5 - echo "Built: /tmp/VaptVupt-GUI-$VERSION-x86_64.AppImage" -else - cd /tmp - rm -f "VaptVupt-GUI-$VERSION-x86_64.AppDir.tar.gz" - tar -czf "VaptVupt-GUI-$VERSION-x86_64.AppDir.tar.gz" vaptvupt-gui.AppDir - cd - >/dev/null - echo "appimagetool unavailable; portable AppDir tarball at:" - echo " /tmp/VaptVupt-GUI-$VERSION-x86_64.AppDir.tar.gz" - echo "Run via: tar -xzf ... && ./vaptvupt-gui.AppDir/AppRun" - echo "Convert to AppImage on a host with appimagetool:" - echo " ARCH=x86_64 appimagetool vaptvupt-gui.AppDir VaptVupt-GUI-$VERSION-x86_64.AppImage" -fi +forbidden=$(find "$appdir" -type f \( \ + -name '*.o' -o -name '*.obj' -o -name '*.a' -o -name '*.so' -o \ + -name '*.so.*' -o -name '*.dll' -o -name '*.dylib' \ + \) -print) +[[ -z $forbidden ]] || { printf '%s\n' "$forbidden" >&2; die 'compiled library/object in AppDir'; } + +QT_QPA_PLATFORM=offscreen "$appdir/AppRun" --version | grep -Fq "zupt-gui $version" || \ + die 'AppDir GUI/CLI integration check failed' +export ARCH=$arch APPIMAGE_EXTRACT_AND_RUN=1 +"$appimagetool" --runtime-file "$runtime_file" "$appdir" "$image_tmp" +chmod 0755 "$image_tmp" +file "$image_tmp" | grep -q ELF || die 'generated AppImage does not have ELF magic' +QT_QPA_PLATFORM=offscreen "$image_tmp" --version | grep -Fq "zupt-gui $version" || \ + die 'generated AppImage execution check failed' + +mv -- "$image_tmp" "$output" +sha256sum "$output" +printf 'PASS: built and execution-tested %s\n' "$output" diff --git a/packaging/build-gui-deb.sh b/packaging/build-gui-deb.sh index c9d5c3b..ff577f6 100755 --- a/packaging/build-gui-deb.sh +++ b/packaging/build-gui-deb.sh @@ -1,181 +1,113 @@ -#!/bin/bash +#!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés -# Build zupt-gui .deb (Python/Qt GUI). Works with PyQt6 OR PySide6. -set -e -cd "$(dirname "$0")/.." -VERSION="${VERSION:-1.2.0}" -ARCH="all" -PKG="vaptvupt-gui_${VERSION}_${ARCH}" -ROOT="/tmp/$PKG" +# Build the architecture-independent GUI package from tracked source. The CLI +# dependency is built and tested in baseline mode but is packaged separately. -rm -rf "$ROOT" -mkdir -p "$ROOT/DEBIAN" \ - "$ROOT/usr/bin" \ - "$ROOT/usr/lib/vaptvupt-gui" \ - "$ROOT/usr/share/applications" \ - "$ROOT/usr/share/icons/hicolor/256x256/apps" \ - "$ROOT/usr/share/man/man1" \ - "$ROOT/usr/share/doc/vaptvupt-gui" +set -Eeuo pipefail +umask 022 +export LC_ALL=C -# Source files -install -m 644 gui/src/zupt_gui.py "$ROOT/usr/lib/vaptvupt-gui/" +die() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } -# Wrapper script in /usr/bin -cat > "$ROOT/usr/bin/vaptvupt-gui" <<'WRAP' -#!/bin/sh -exec python3 /usr/lib/vaptvupt-gui/zupt_gui.py "$@" -WRAP -chmod 755 "$ROOT/usr/bin/vaptvupt-gui" -# v3.0.0: legacy zupt-gui symlink -ln -sf vaptvupt-gui "$ROOT/usr/bin/zupt-gui" +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +cd -- "$repo_root" -# Desktop entry -cat > "$ROOT/usr/share/applications/vaptvupt-gui.desktop" <<'DESKTOP' -[Desktop Entry] -Type=Application -Name=VaptVupt GUI -GenericName=Backup and Compression Utility -Comment=Post-quantum backup with HKDF combiner, key commitment, HPKE binding -Exec=vaptvupt-gui %f -Icon=vaptvupt-gui -Terminal=false -Categories=Utility;Archiving;Compression;Security; -StartupNotify=true -MimeType=application/x-zupt; -Keywords=archive;compression;encryption;post-quantum;backup; -DESKTOP +header_version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) +version=${VERSION:-$header_version} +[[ $version == "$header_version" && $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || \ + die "VERSION '$version' does not match include/zupt.h '$header_version'" -# Man page -if [ -f doc/vaptvupt-gui.1 ]; then - install -m 644 doc/vaptvupt-gui.1 "$ROOT/usr/share/man/man1/vaptvupt-gui.1" - gzip -9n "$ROOT/usr/share/man/man1/vaptvupt-gui.1" -fi +dist_dir=${DIST_DIR:-${TMPDIR:-/tmp}/zupt-release} +mkdir -p -- "$dist_dir" +dist_dir=$(cd -- "$dist_dir" && pwd -P) +case $dist_dir/ in "$repo_root"/*) die 'DIST_DIR must be outside the repository' ;; esac +output=$dist_dir/zupt-gui_${version}_all.deb +[[ ! -e $output ]] || die "refusing to overwrite existing output: $output" -# Icon -if [ -f gui/assets/zupt-icon.png ]; then - cp gui/assets/zupt-icon.png "$ROOT/usr/share/icons/hicolor/256x256/apps/vaptvupt-gui.png" -else - python3 -c " -import struct, zlib -def png(w, h, color): - raw = b''.join(b'\\0' + bytes(color) * w for _ in range(h)) - def chunk(t, d): return struct.pack('>I', len(d)) + t + d + struct.pack('>I', zlib.crc32(t+d) & 0xffffffff) - return b'\\x89PNG\\r\\n\\x1a\\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 0)) + chunk(b'IDAT', zlib.compress(raw)) + chunk(b'IEND', b'') -open('$ROOT/usr/share/icons/hicolor/256x256/apps/zupt-gui.png','wb').write(png(256, 256, (88, 92, 215))) -" -fi +for command_name in make python3 dpkg-deb gzip sha256sum; do + command -v -- "$command_name" >/dev/null 2>&1 || \ + die "required command not found: $command_name" +done -# Docs -install -m 644 gui/README.md "$ROOT/usr/share/doc/vaptvupt-gui/" 2>/dev/null || true -gzip -9n -c CHANGELOG.md > "$ROOT/usr/share/doc/vaptvupt-gui/changelog.gz" +jobs=${JOBS:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf '1')} +work=$(mktemp -d "${TMPDIR:-/tmp}/zupt-gui-deb.XXXXXXXX") +stage=$work/stage +extract=$work/extract +cleanup() { + make -C "$repo_root" clean >/dev/null 2>&1 || true + chmod -R u+rwX "$work" 2>/dev/null || true + rm -rf -- "$work" +} +trap cleanup EXIT HUP INT TERM -cat > "$ROOT/usr/share/doc/vaptvupt-gui/copyright" <<'COPYRIGHT' -Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ -Upstream-Name: vaptvupt-gui -Upstream-Contact: Cristian Cezar Moisés -Source: https://git.securityops.co/cristiancmoises/zupt +printf '[GUI deb] validating source-only CLI dependency %s\n' "$version" +make clean +make -j"$jobs" V=1 WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 +make V=1 WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 check +./zupt version | grep -Fq "zupt $version" || die 'CLI version check failed' -Files: * -Copyright: 2025-2026 Cristian Cezar Moisés -License: AGPL-3.0+ - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as - published by the Free Software Foundation, either version 3 of the - License, or (at your option) any later version. - . - On Debian systems, the complete text of the GNU Affero General Public - License version 3 can be found in /usr/share/common-licenses/AGPL-3. -COPYRIGHT +PYTHONDONTWRITEBYTECODE=1 python3 - <<'PY' +from pathlib import Path +source = Path("gui/src/zupt_gui.py").read_text(encoding="utf-8") +compile(source, "gui/src/zupt_gui.py", "exec") +PY -# Control -INSTALLED_SIZE=$(du -sk "$ROOT" | cut -f1) -cat > "$ROOT/DEBIAN/control" <"$stage/usr/share/doc/zupt-gui/changelog.gz" +install -m 0644 -- LICENSE-AGPL-3.0 "$stage/usr/share/doc/zupt-gui/copyright" +gzip -9n -- "$stage/usr/share/man/man1/zupt-gui.1" + +installed_kib=$(du -sk "$stage/usr" | awk '{print $1}') +cat >"$stage/DEBIAN/control" <= 3.9), python3-pyqt6 | python3-pyside6, vaptvupt (>= 3.0.0) | zupt (>= 2.2.3) -Provides: zupt-gui (= ${VERSION}) -Replaces: zupt-gui (<< 1.2.0) -Conflicts: zupt-gui (<< 1.2.0) -Maintainer: Cristian Cezar Moisés -Installed-Size: $INSTALLED_SIZE -Homepage: https://git.securityops.co/cristiancmoises/zupt -Description: Graphical interface for VaptVupt post-quantum backup utility - PySide6/PyQt6 frontend for VaptVupt (formerly zupt-gui in 1.x). Supports compression, extraction, key - management, and full disk backup/restore. Exposes both legacy --pq - and new --pq-sdk (libzuptsdk: HKDF combiner, key commitment, HPKE - binding, Argon2id) encryption modes. +Architecture: all +Depends: python3 (>= 3.9), python3-pyqt6 | python3-pyside6.qtwidgets, zupt (= $version) +Installed-Size: $installed_kib +Maintainer: Cristian Cezar Moisés +Homepage: https://github.com/cristiancmoises/zupt +Description: Qt graphical interface for the ZUPT backup utility + The GUI creates, inspects, verifies, and extracts .zupt archives through the + separately packaged zupt command. Optional SDK and PQ-box controls are + shown only when the installed command reports those integrations enabled. EOF -# Postinst: refresh icon cache + desktop database, print first-run guidance -cat > "$ROOT/DEBIAN/postinst" <<'POSTINST' -#!/bin/sh -set -e -if [ -x /usr/bin/update-desktop-database ]; then - update-desktop-database -q /usr/share/applications || true -fi -if [ -x /usr/bin/gtk-update-icon-cache ]; then - gtk-update-icon-cache -q /usr/share/icons/hicolor || true +forbidden=$(find "$stage" -type f \( \ + -name '*.o' -o -name '*.obj' -o -name '*.a' -o -name '*.so' -o \ + -name '*.so.*' -o -name '*.dll' -o -name '*.dylib' -o -name '*.exe' \ + \) -print) +[[ -z $forbidden ]] || { printf '%s\n' "$forbidden" >&2; die 'compiled artifact in GUI package'; } + +package_tmp=$work/$(basename -- "$output") +source_epoch=${SOURCE_DATE_EPOCH:-$(sed -n '1p' .source-date-epoch 2>/dev/null || true)} +[[ $source_epoch =~ ^[0-9]+$ ]] || die 'SOURCE_DATE_EPOCH is not available' +SOURCE_DATE_EPOCH=$source_epoch dpkg-deb -Zxz --build --root-owner-group \ + "$stage" "$package_tmp" >/dev/null +dpkg-deb --info "$package_tmp" >/dev/null +dpkg-deb --contents "$package_tmp" >"$work/contents.txt" +grep -q './usr/bin/zupt-gui' "$work/contents.txt" || die 'GUI launcher missing from .deb' +if grep -Eq '(/usr/bin/vaptvupt-gui|\.(o|obj|a|so|so\.[^/]+|dll|dylib|exe)$)' "$work/contents.txt"; then + cat "$work/contents.txt" >&2 + die 'forbidden compatibility alias or compiled artifact in .deb' fi -# Friendly first-run check: warn the user if no Qt6 binding is installed. -# We don't fail the install (deb deps already enforce this); we just print -# clear guidance for users who saw "unmet dependencies" earlier. -if ! python3 -c 'import PyQt6.QtWidgets' 2>/dev/null \ - && ! python3 -c 'import PySide6.QtWidgets' 2>/dev/null; then - cat << 'MSG' +mkdir -p -- "$extract" +dpkg-deb --extract "$package_tmp" "$extract" +PYTHONDONTWRITEBYTECODE=1 python3 - </dev/null 2>&1 && ! command -v zupt >/dev/null 2>&1; then - cat << 'MSG' - -────────────────────────────────────────────────────────────────────── -vaptvupt-gui needs the 'vaptvupt' CLI to function. Install it: - - Debian/Ubuntu/Mint: sudo dpkg -i vaptvupt_3.0.0_amd64.deb - (followed by: sudo apt --fix-broken install) -────────────────────────────────────────────────────────────────────── - -MSG -fi -exit 0 -POSTINST -chmod 755 "$ROOT/DEBIAN/postinst" - -cat > "$ROOT/DEBIAN/postrm" <<'POSTRM' -#!/bin/sh -set -e -if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then - if [ -x /usr/bin/update-desktop-database ]; then - update-desktop-database -q /usr/share/applications || true - fi - if [ -x /usr/bin/gtk-update-icon-cache ]; then - gtk-update-icon-cache -q /usr/share/icons/hicolor || true - fi -fi -POSTRM -chmod 755 "$ROOT/DEBIAN/postrm" - -dpkg-deb -Zxz --build --root-owner-group "$ROOT" "/tmp/$PKG.deb" -echo "Built: /tmp/$PKG.deb" -dpkg-deb --info "/tmp/$PKG.deb" | head -12 +mv -- "$package_tmp" "$output" +sha256sum "$output" +printf 'PASS: built and content-validated %s\n' "$output" diff --git a/packaging/build-gui-rpm.sh b/packaging/build-gui-rpm.sh index 71ffc5c..b916472 100755 --- a/packaging/build-gui-rpm.sh +++ b/packaging/build-gui-rpm.sh @@ -1,151 +1,175 @@ -#!/bin/bash +#!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés -# Build zupt-gui RPM. Falls back to SRPM-equivalent tarball if rpmbuild absent. -set -e -cd "$(dirname "$0")/.." -VERSION="${VERSION:-1.2.0}" -RPMROOT="/tmp/rpmbuild-vaptvupt-gui" -rm -rf "$RPMROOT" -mkdir -p "$RPMROOT"/{BUILD,RPMS,SOURCES,SPECS,SRPMS} +# Build a real noarch RPM and source RPM. Run this in a native RPM build +# environment; there is deliberately no --nodeps or tarball fallback. -TMP="/tmp/vaptvupt-gui-$VERSION" -rm -rf "$TMP" && mkdir -p "$TMP/src" "$TMP/doc" "$TMP/assets" -cp gui/src/zupt_gui.py "$TMP/src/" -cp doc/vaptvupt-gui.1 "$TMP/doc/" 2>/dev/null || true -cp gui/README.md "$TMP/" 2>/dev/null || true -cp LICENSE "$TMP/" 2>/dev/null || true -[ -f gui/assets/zupt-icon.png ] && cp gui/assets/zupt-icon.png "$TMP/assets/" -tar -czf "$RPMROOT/SOURCES/vaptvupt-gui-$VERSION.tar.gz" -C /tmp "vaptvupt-gui-$VERSION" +set -Eeuo pipefail +umask 022 +export LC_ALL=C -cat > "$RPMROOT/SPECS/vaptvupt-gui.spec" <&2; exit 1; } + +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +cd -- "$repo_root" +header_version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) +version=${VERSION:-$header_version} +[[ $version == "$header_version" && $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || \ + die "VERSION '$version' does not match include/zupt.h '$header_version'" + +dist_dir=${DIST_DIR:-${TMPDIR:-/tmp}/zupt-release} +mkdir -p -- "$dist_dir" +dist_dir=$(cd -- "$dist_dir" && pwd -P) +case $dist_dir/ in "$repo_root"/*) die 'DIST_DIR must be outside the repository' ;; esac + +for command_name in make python3 rpmbuild rpm rpm2cpio cpio tar sha256sum; do + command -v -- "$command_name" >/dev/null 2>&1 || \ + die "required command not found: $command_name" +done + +jobs=${JOBS:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf '1')} +work=$(mktemp -d "${TMPDIR:-/tmp}/zupt-gui-rpm.XXXXXXXX") +top=$work/rpmbuild +tree=$work/zupt-gui-$version +extract=$work/extract +mkdir -p -- "$top"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} \ + "$tree"/{src,assets,doc} "$extract" +cleanup() { + make -C "$repo_root" clean >/dev/null 2>&1 || true + chmod -R u+rwX "$work" 2>/dev/null || true + rm -rf -- "$work" +} +trap cleanup EXIT HUP INT TERM + +printf '[GUI rpm] validating source-only CLI dependency %s\n' "$version" +make clean +make -j"$jobs" V=1 WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 +make V=1 WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 check +./zupt version | grep -Fq "zupt $version" || die 'CLI version check failed' + +install -m 0644 gui/src/zupt_gui.py "$tree/src/" +install -m 0644 gui/assets/zupt-icon.png "$tree/assets/" +install -m 0644 gui/packaging/zupt-gui.desktop "$tree/" +install -m 0644 doc/zupt-gui.1 "$tree/doc/" +install -m 0644 gui/README.md "$tree/README.md" +install -m 0644 LICENSE LICENSE-AGPL-3.0 "$tree/" +install -m 0644 gui/LICENSE-GUI "$tree/LICENSE-GUI" +install -m 0644 gui/assets/README.md "$tree/ASSET-PROVENANCE.md" + +source_epoch=${SOURCE_DATE_EPOCH:-$(sed -n '1p' .source-date-epoch 2>/dev/null || true)} +[[ $source_epoch =~ ^[0-9]+$ ]] || die 'SOURCE_DATE_EPOCH is not available' +source_tar=$top/SOURCES/zupt-gui-$version.tar.gz +tar --sort=name --mtime="@$source_epoch" --owner=0 --group=0 --numeric-owner \ + -czf "$source_tar" -C "$work" "zupt-gui-$version" + +cat >"$top/SPECS/zupt-gui.spec" <= 3.9 Requires: python3 >= 3.9 Requires: (python3-qt6 or python3-pyside6 or python3-pyqt6) -Requires: (vaptvupt >= 3.0.0 or zupt >= 2.2.3) -Provides: zupt-gui = %{version}-%{release} -Obsoletes: zupt-gui < 1.2.0 -Conflicts: zupt-gui < 1.2.0 +Requires: zupt >= %{version} %description -PySide6/PyQt6 frontend for VaptVupt (renamed from zupt-gui in 1.x). Supports compression, extraction, key -management, and full disk backup/restore. Exposes both legacy --pq and -new --pq-sdk (libzuptsdk: HKDF combiner, key commitment, HPKE binding, -Argon2id) encryption modes. Auto-detects whichever Qt6 binding is -installed at startup. +ZUPT GUI creates, inspects, verifies, and extracts .zupt archives through +the separately packaged zupt command. Optional SDK and PQ-box controls are +shown only when that command reports the corresponding integration enabled. %prep %autosetup %build -# nothing to build; pure Python + +%check +python3 -c 'from pathlib import Path; p=Path("src/zupt_gui.py"); compile(p.read_text(encoding="utf-8"), str(p), "exec")' %install +install -Dm0644 src/zupt_gui.py %{buildroot}%{_datadir}/zupt-gui/zupt_gui.py +install -Dm0644 zupt-gui.desktop %{buildroot}%{_datadir}/applications/zupt-gui.desktop +install -Dm0644 assets/zupt-icon.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/zupt-gui.png +install -Dm0644 doc/zupt-gui.1 %{buildroot}%{_mandir}/man1/zupt-gui.1 mkdir -p %{buildroot}%{_bindir} -mkdir -p %{buildroot}%{_libdir}/vaptvupt-gui -mkdir -p %{buildroot}%{_datadir}/applications -mkdir -p %{buildroot}%{_datadir}/icons/hicolor/256x256/apps -mkdir -p %{buildroot}%{_mandir}/man1 - -install -m 644 src/zupt_gui.py %{buildroot}%{_libdir}/vaptvupt-gui/ - -cat > %{buildroot}%{_bindir}/vaptvupt-gui <<'WRAP' +cat >%{buildroot}%{_bindir}/zupt-gui <<'WRAP' #!/bin/sh -exec python3 %{_libdir}/vaptvupt-gui/zupt_gui.py "\$@" +exec python3 %{_datadir}/zupt-gui/zupt_gui.py "\$@" WRAP -chmod 755 %{buildroot}%{_bindir}/vaptvupt-gui -# v3.0.0: legacy zupt-gui symlink for one major version cycle -ln -sf vaptvupt-gui %{buildroot}%{_bindir}/zupt-gui - -cat > %{buildroot}%{_datadir}/applications/vaptvupt-gui.desktop <<'DESKTOP' -[Desktop Entry] -Type=Application -Name=VaptVupt GUI -GenericName=Backup and Compression Utility -Comment=Post-quantum backup with HKDF combiner, key commitment, HPKE binding -Exec=vaptvupt-gui %f -Icon=vaptvupt-gui -Terminal=false -Categories=Utility;Archiving;Compression;Security; -StartupNotify=true -DESKTOP - -[ -f doc/vaptvupt-gui.1 ] && install -m 644 doc/vaptvupt-gui.1 %{buildroot}%{_mandir}/man1/ -[ -f assets/zupt-icon.png ] && install -m 644 assets/zupt-icon.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/vaptvupt-gui.png || true - -# Generate placeholder icon if no real one exists -if [ ! -f %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/vaptvupt-gui.png ]; then - python3 -c " -import struct, zlib -def png(w, h, color): - raw = b''.join(b'\\0' + bytes(color) * w for _ in range(h)) - def chunk(t, d): return struct.pack('>I', len(d)) + t + d + struct.pack('>I', zlib.crc32(t+d) & 0xffffffff) - return b'\\x89PNG\\r\\n\\x1a\\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 0)) + chunk(b'IDAT', zlib.compress(raw)) + chunk(b'IEND', b'') -open('%{buildroot}%{_datadir}/icons/hicolor/256x256/apps/vaptvupt-gui.png','wb').write(png(256, 256, (88, 92, 215))) -" -fi - -%post -if [ -x /usr/bin/update-desktop-database ]; then - update-desktop-database -q /usr/share/applications || : -fi -if [ -x /usr/bin/gtk-update-icon-cache ]; then - gtk-update-icon-cache -q /usr/share/icons/hicolor || : -fi - -%postun -if [ \$1 -eq 0 ]; then - if [ -x /usr/bin/update-desktop-database ]; then - update-desktop-database -q /usr/share/applications || : - fi -fi +chmod 0755 %{buildroot}%{_bindir}/zupt-gui %files -%doc README.md -%license LICENSE -%{_bindir}/vaptvupt-gui +%license LICENSE LICENSE-AGPL-3.0 LICENSE-GUI +%doc README.md ASSET-PROVENANCE.md %{_bindir}/zupt-gui -%{_libdir}/vaptvupt-gui/zupt_gui.py -%{_datadir}/applications/vaptvupt-gui.desktop -%{_datadir}/icons/hicolor/256x256/apps/vaptvupt-gui.png +%{_datadir}/zupt-gui/zupt_gui.py +%{_datadir}/applications/zupt-gui.desktop +%{_datadir}/icons/hicolor/256x256/apps/zupt-gui.png +%{_mandir}/man1/zupt-gui.1* %changelog -* Sun May 25 2026 Cristian Cezar Moisés - $VERSION-1 -- v1.2.0: package renamed zupt-gui → vaptvupt-gui (parent CLI also - renamed; INPI Brasil trademark on "Zupt"). Legacy /usr/bin/zupt-gui - symlink preserved. GUI binary-discovery bug fix: _find_vaptvupt - with liveness check + discovery log via VAPTVUPT_DEBUG=1. -* Mon Apr 27 2026 Cristian Cezar Moisés - 1.1.1-1 -- Cross-binding (PySide6 OR PyQt6 auto-detected) -- SDK v2 mode toggles in compress/extract/keygen tabs -- Man page added +* Mon Aug 31 2026 Cristian Cezar Moisés - $version-1 +- Package the integrated GUI under its restored ZUPT identity. +- Require the separately built source-only baseline CLI package. EOF -if command -v rpmbuild >/dev/null 2>&1; then - # On Debian/Ubuntu, the host's `rpm` doesn't see `python3` as an RPM - # (it's a deb), so the BuildRequires check would fail. Use --nodeps - # since the runtime check on the target system is what actually - # matters. The Requires: lines still apply on install. - rpmbuild --define "_topdir $RPMROOT" --nodeps -bb "$RPMROOT/SPECS/vaptvupt-gui.spec" 2>&1 | tail -3 - if [ -f "$RPMROOT/RPMS/noarch/vaptvupt-gui-${VERSION}-1.noarch.rpm" ]; then - cp "$RPMROOT/RPMS/noarch/vaptvupt-gui-${VERSION}-1.noarch.rpm" \ - "/tmp/vaptvupt-gui-${VERSION}-1.noarch.rpm" - echo "Built: /tmp/vaptvupt-gui-${VERSION}-1.noarch.rpm" - fi - cp "$RPMROOT/RPMS/noarch/zupt-gui-$VERSION-1."*.rpm /tmp/ 2>/dev/null || true - ls /tmp/zupt-gui-$VERSION-*.rpm 2>/dev/null -else - SRPM_TAR="/tmp/zupt-gui-$VERSION.srpm.tar.gz" - tar -czf "$SRPM_TAR" -C "$RPMROOT" SPECS SOURCES - echo "rpmbuild unavailable; SRPM-equivalent at: $SRPM_TAR" +rpmbuild --define "_topdir $top" -ba "$top/SPECS/zupt-gui.spec" + +mapfile -t main_rpms < <(find "$top/RPMS" -type f -name "zupt-gui-$version-*.noarch.rpm" -print | sort) +mapfile -t source_rpms < <(find "$top/SRPMS" -type f -name "zupt-gui-$version-*.src.rpm" -print | sort) +[[ ${#main_rpms[@]} -eq 1 ]] || die "expected one GUI RPM, found ${#main_rpms[@]}" +[[ ${#source_rpms[@]} -eq 1 ]] || die "expected one GUI source RPM, found ${#source_rpms[@]}" + +main_rpm=${main_rpms[0]} +source_rpm=${source_rpms[0]} +[[ $(rpm -qp --qf '%{NAME}' "$main_rpm") == zupt-gui ]] || \ + die 'GUI binary RPM name metadata is not zupt-gui' +[[ $(rpm -qp --qf '%{VERSION}' "$main_rpm") == "$version" ]] || \ + die 'GUI binary RPM version metadata does not match the release' +[[ $(rpm -qp --qf '%{RELEASE}' "$main_rpm") == 1 ]] || \ + die 'GUI binary RPM release metadata is not 1' +[[ $(rpm -qp --qf '%{SOURCEPACKAGE}' "$main_rpm") == '(none)' ]] || \ + die 'GUI binary RPM is marked as a source package' +[[ $(rpm -qp --qf '%{SOURCERPM}' "$main_rpm") == "$(basename -- "$source_rpm")" ]] || \ + die 'GUI binary RPM does not reference the matching source RPM' +[[ $(rpm -qp --qf '%{NAME}' "$source_rpm") == zupt-gui ]] || \ + die 'GUI source RPM name metadata is not zupt-gui' +[[ $(rpm -qp --qf '%{VERSION}' "$source_rpm") == "$version" ]] || \ + die 'GUI source RPM version metadata does not match the release' +[[ $(rpm -qp --qf '%{RELEASE}' "$source_rpm") == 1 ]] || \ + die 'GUI source RPM release metadata is not 1' +[[ $(rpm -qp --qf '%{SOURCEPACKAGE}' "$source_rpm") == 1 ]] || \ + die 'GUI source RPM is not marked as a source package' +[[ $(rpm -qp --qf '%{SOURCERPM}' "$source_rpm") == '(none)' ]] || \ + die 'GUI source RPM unexpectedly references another source RPM' +mapfile -t source_members < <(rpm -qpl "$source_rpm" | sort) +expected_source_members=("zupt-gui-${version}.tar.gz" zupt-gui.spec) +mapfile -t expected_source_members < <(printf '%s\n' "${expected_source_members[@]}" | sort) +[[ ${#source_members[@]} -eq 2 && \ + ${source_members[*]} == "${expected_source_members[*]}" ]] || \ + die 'GUI source RPM payload is not the exact Source0/spec pair' + +rpm -qpl "$main_rpm" >"$work/contents.txt" +grep -q '^/usr/bin/zupt-gui$' "$work/contents.txt" || die 'GUI launcher missing from RPM' +if grep -Eq '(^/usr/bin/vaptvupt-gui$|\.(o|obj|a|so|so\.[^/]+|dll|dylib|exe)$)' "$work/contents.txt"; then + cat "$work/contents.txt" >&2 + die 'forbidden compatibility alias or compiled artifact in GUI RPM' fi +(cd -- "$extract" && rpm2cpio "$main_rpm" | cpio -idm --quiet) +PYTHONDONTWRITEBYTECODE=1 python3 - <I', v & 0xFFFFFFFF)) - elif typ == RPM_INT16_TYPE: - if not isinstance(value, list): - value = [value] - count = len(value) - while len(store) % 2: store.append(0) - offset = len(store) - for v in value: - store.extend(struct.pack('>H', v & 0xFFFF)) - elif typ == RPM_BIN_TYPE: - count = len(value) - offset = len(store) - store.extend(value) - elif typ == RPM_NULL_TYPE: - count = 1 - offset = 0 - else: - raise ValueError(f"Unsupported type {typ}") - index.append(struct.pack('>IIII', tag, typ, offset, count)) - - index_bytes = b''.join(index) - # Header magic + reserved + index count + store size - out = struct.pack('>3sBI4sII', b'\x8e\xad\xe8', 1, 0, b'\x00\x00\x00\x00', - len(self.entries), len(store)) - out += index_bytes + bytes(store) - return out - -def make_cpio(file_list, source_root, payload_size_out): - """Build a cpio archive (newc format) of the files.""" - out = io.BytesIO() - inode = 1 - total = 0 - for arc_path, src_path, mode, is_dir, link_target in file_list: - if is_dir: - data = b'' - file_size = 0 - elif link_target is not None: - data = link_target.encode('utf-8') - file_size = len(data) - else: - with open(src_path, 'rb') as f: - data = f.read() - file_size = len(data) - total += file_size - - name = ('.' + arc_path).encode('utf-8') + b'\x00' - # newc header: 110 bytes - header = ( - b'070701' - + format(inode, '08x').encode('ascii') - + format(mode, '08x').encode('ascii') - + b'00000000' # uid - + b'00000000' # gid - + b'00000001' # nlink - + format(int(time.time()), '08x').encode('ascii') - + format(file_size, '08x').encode('ascii') - + b'00000000' * 4 # devmajor/minor + rdevmajor/minor - + format(len(name), '08x').encode('ascii') - + b'00000000' # check - ) - out.write(header) - out.write(name) - # pad to 4 - pad = (4 - ((len(header) + len(name)) % 4)) % 4 - out.write(b'\x00' * pad) - out.write(data) - # pad data to 4 - pad = (4 - (file_size % 4)) % 4 - out.write(b'\x00' * pad) - inode += 1 - - # Trailer - trailer_name = b'TRAILER!!!\x00' - out.write(b'070701' + b'0' * 8 + b'0' * 8 + b'0' * 8 + b'0' * 8 + b'00000001' - + b'0' * 8 + b'0' * 8 + b'0' * 8 + b'0' * 8 + b'0' * 8 + b'0' * 8 - + format(len(trailer_name), '08x').encode('ascii') + b'0' * 8) - out.write(trailer_name) - pad = (4 - ((110 + len(trailer_name)) % 4)) % 4 - out.write(b'\x00' * pad) - payload_size_out[0] = total - return out.getvalue() - -def main(): - # Files to include (source_path inside our deb tree) - deb_root = f'/tmp/zupt_{VERSION}_amd64' - files = [] # (arc_path, source_path, mode, is_dir, link_target) - - for root, dirs, fnames in os.walk(deb_root): - for d in sorted(dirs): - full = os.path.join(root, d) - arc = full[len(deb_root):] - files.append((arc, full, 0o40755, True, None)) - for fn in sorted(fnames): - full = os.path.join(root, fn) - arc = full[len(deb_root):] - if 'DEBIAN' in arc: - continue - if os.path.islink(full): - files.append((arc, full, 0o120777, False, os.readlink(full))) - else: - mode = 0o100755 if os.access(full, os.X_OK) else 0o100644 - files.append((arc, full, mode, False, None)) - - # Sort and build basename/dirname/dirindex arrays - files.sort(key=lambda x: x[0]) - - basenames = [] - dirnames_set = [] - dirname_to_idx = {} - dirindexes = [] - filesizes = [] - filemodes = [] - filemtimes = [] - filedigests = [] - filelinktos = [] - filerdevs = [] - fileflags = [] - fileuser = [] - filegroup = [] - - for arc, src, mode, is_dir, link in files: - d, b = os.path.split(arc) - d = d + '/' - if d not in dirname_to_idx: - dirname_to_idx[d] = len(dirnames_set) - dirnames_set.append(d) - basenames.append(b or '.') - dirindexes.append(dirname_to_idx[d]) - if is_dir: - filesizes.append(0) - filedigests.append('') - filelinktos.append('') - elif link: - filesizes.append(len(link)) - filedigests.append('') - filelinktos.append(link) - else: - filesizes.append(os.path.getsize(src)) - with open(src, 'rb') as f: - filedigests.append(hashlib.sha256(f.read()).hexdigest()) - filelinktos.append('') - filemodes.append(mode) - filemtimes.append(int(time.time())) - filerdevs.append(0) - fileflags.append(0) - fileuser.append('root') - filegroup.append('root') - - payload_size = [0] - cpio_data = make_cpio(files, deb_root, payload_size) - # Compress payload with gzip - gz_payload = gzip.compress(cpio_data) - - # Build main header - h = Header() - h.add(RPMTAG_NAME, RPM_STRING_TYPE, NAME) - h.add(RPMTAG_VERSION, RPM_STRING_TYPE, VERSION) - h.add(RPMTAG_RELEASE, RPM_STRING_TYPE, RELEASE) - h.add(RPMTAG_SUMMARY, RPM_STRING_ARRAY_TYPE, ['Post-quantum backup compression utility']) - h.add(RPMTAG_DESCRIPTION, RPM_STRING_ARRAY_TYPE, [ - 'Zupt provides hybrid post-quantum encryption (ML-KEM-768 + X25519)\n' - 'with multi-threaded compression and full-disk backup support.\n' - 'Bundled with libzuptsdk for HKDF-SHA3 hybrid KDF, key commitment,\n' - 'HPKE binding, and anti-fault decapsulation.' - ]) - h.add(RPMTAG_BUILDTIME, RPM_INT32_TYPE, int(time.time())) - h.add(RPMTAG_BUILDHOST, RPM_STRING_TYPE, 'localhost') - h.add(RPMTAG_SIZE, RPM_INT32_TYPE, sum(filesizes)) - h.add(RPMTAG_LICENSE, RPM_STRING_TYPE, 'AGPL-3.0-or-later') - h.add(RPMTAG_PACKAGER, RPM_STRING_TYPE, 'Cristian Cezar Moises ') - h.add(RPMTAG_GROUP, RPM_STRING_ARRAY_TYPE, ['Applications/Archiving']) - h.add(RPMTAG_URL, RPM_STRING_TYPE, 'https://git.securityops.co/cristiancmoises/zupt') - h.add(RPMTAG_OS, RPM_STRING_TYPE, 'linux') - h.add(RPMTAG_ARCH, RPM_STRING_TYPE, ARCH) - h.add(RPMTAG_POSTIN, RPM_STRING_TYPE, '/sbin/ldconfig\n') - h.add(RPMTAG_POSTUN, RPM_STRING_TYPE, '/sbin/ldconfig\n') - h.add(RPMTAG_BASENAMES, RPM_STRING_ARRAY_TYPE, basenames) - h.add(RPMTAG_DIRNAMES, RPM_STRING_ARRAY_TYPE, dirnames_set) - h.add(RPMTAG_DIRINDEXES, RPM_INT32_TYPE, dirindexes) - h.add(RPMTAG_FILESIZES, RPM_INT32_TYPE, filesizes) - h.add(RPMTAG_FILEMODES, RPM_INT16_TYPE, filemodes) - h.add(RPMTAG_FILEMTIMES, RPM_INT32_TYPE, filemtimes) - h.add(RPMTAG_FILEDIGESTS, RPM_STRING_ARRAY_TYPE, filedigests) - h.add(RPMTAG_FILELINKTOS, RPM_STRING_ARRAY_TYPE, filelinktos) - h.add(RPMTAG_FILEFLAGS, RPM_INT32_TYPE, fileflags) - h.add(RPMTAG_FILERDEVS, RPM_INT16_TYPE, filerdevs) - h.add(RPMTAG_FILEUSERNAME, RPM_STRING_ARRAY_TYPE, fileuser) - h.add(RPMTAG_FILEGROUPNAME, RPM_STRING_ARRAY_TYPE, filegroup) - h.add(RPMTAG_PROVIDENAME, RPM_STRING_ARRAY_TYPE, [NAME]) - h.add(RPMTAG_REQUIRENAME, RPM_STRING_ARRAY_TYPE, ['libargon2.so.1()(64bit)', 'libcrypto.so.3()(64bit)', 'libc.so.6()(64bit)']) - h.add(RPMTAG_REQUIREFLAGS, RPM_INT32_TYPE, [0, 0, 0]) - h.add(RPMTAG_REQUIREVERSION, RPM_STRING_ARRAY_TYPE, ['', '', '']) - h.add(RPMTAG_PAYLOADFORMAT, RPM_STRING_TYPE, 'cpio') - h.add(RPMTAG_PAYLOADCOMPRESSOR, RPM_STRING_TYPE, 'gzip') - h.add(RPMTAG_FILEDIGESTALGO, RPM_INT32_TYPE, 8) # SHA-256 - - main_hdr = h.serialize() - - # Signature header (minimal: just size of payload after sig hdr) - sig = Header() - sig_payload = main_hdr + gz_payload - sig.add(1000, RPM_INT32_TYPE, len(sig_payload)) # SIZE - sig.add(1004, RPM_BIN_TYPE, hashlib.md5(sig_payload).digest()) # MD5 - sig_bytes = sig.serialize() - # Pad sig hdr to 8-byte boundary - pad = (8 - (len(sig_bytes) % 8)) % 8 - sig_bytes += b'\x00' * pad - - # Lead (96 bytes) - lead = struct.pack('>4sBBhh66sHH16s', - b'\xed\xab\xee\xdb', # magic - 3, 0, # major, minor - 0, # type (binary) - 1, # archnum - NAME.encode().ljust(66, b'\x00'), - 1, # osnum - 5, # signature_type - b'\x00' * 16) - - out_path = f'/tmp/{NAME}-{VERSION}-{RELEASE}.{ARCH}.rpm' - with open(out_path, 'wb') as f: - f.write(lead) - f.write(sig_bytes) - f.write(main_hdr) - f.write(gz_payload) - - print(f'Built: {out_path} ({os.path.getsize(out_path)} bytes)') - # Try rpm -Kvv to verify if rpm is installed - try: - result = subprocess.run(['rpm', '-qpi', out_path], capture_output=True, text=True, timeout=5) - if result.returncode == 0: - print(result.stdout[:500]) - except Exception: - pass - -if __name__ == '__main__': - main() diff --git a/packaging/build-rpm.sh b/packaging/build-rpm.sh index c5e41a3..3888a07 100755 --- a/packaging/build-rpm.sh +++ b/packaging/build-rpm.sh @@ -1,195 +1,154 @@ -#!/bin/bash +#!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés -# -# Build self-contained vaptvupt RPM (formerly zupt). Bundles -# libzuptsdk.so.2 under /usr/lib/vaptvupt/ so users do NOT need -# a separate libzuptsdk package. Installs a legacy /usr/bin/zupt -# symlink for one major version cycle. -set -e -cd "$(dirname "$0")/.." +set -Eeuo pipefail -VERSION="${VERSION:-3.0.0}" -ARCH="${ARCH:-x86_64}" -RELEASE="1" -PKGNAME="vaptvupt" -LEGACY="zupt" +umask 022 +export LC_ALL=C -SDK_LIB="vendor/zuptsdk/libzuptsdk.so.2.0.0" -if [ ! -f "$SDK_LIB" ]; then - echo "ERROR: $SDK_LIB not found." >&2 +die() { + printf 'FAIL: %s\n' "$*" >&2 exit 1 -fi +} -echo "[rpm] Building $PKGNAME" -make clean >/dev/null 2>&1 || true -make -j"$(nproc)" >/dev/null -echo "[rpm] Patching rpath -> /usr/lib/$PKGNAME:/usr/lib64/$PKGNAME" -patchelf --set-rpath "/usr/lib/$PKGNAME:/usr/lib64/$PKGNAME" $PKGNAME -if ! readelf -d $PKGNAME | grep -q "RUNPATH.*\[/usr/lib/$PKGNAME:/usr/lib64/$PKGNAME\]"; then - echo "ERROR: $PKGNAME does not have correct RUNPATH" >&2 - exit 1 -fi +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +cd -- "$repo_root" -if ! command -v rpmbuild >/dev/null 2>&1; then - echo "[rpm] rpmbuild not found; install rpm package to proceed" - exit 1 -fi +header_version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) +version=${VERSION:-$header_version} +[[ -n $version && $version == "$header_version" ]] || \ + die "VERSION '$version' does not match include/zupt.h '$header_version'" +[[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "invalid package version: $version" -RPMROOT="/tmp/rpmbuild-$PKGNAME" -rm -rf "$RPMROOT" -mkdir -p "$RPMROOT"/{BUILD,RPMS,SOURCES,SPECS,SRPMS} +spec=packaging/opensuse/zupt.spec +[[ -f $spec ]] || die "spec file not found: $spec" +spec_version=$(sed -n 's/^Version:[[:space:]]*//p' "$spec" | head -n 1) +[[ $spec_version == "$version" ]] || die "spec version '$spec_version' does not match '$version'" -STAGE="/tmp/$PKGNAME-rpm-stage-${VERSION}" -rm -rf "$STAGE" -mkdir -p "$STAGE/$PKGNAME-${VERSION}/completions" +dist_dir=${DIST_DIR:-${TMPDIR:-/tmp}/zupt-release} +mkdir -p -- "$dist_dir" +dist_dir=$(cd -- "$dist_dir" && pwd -P) -cp $PKGNAME "$STAGE/$PKGNAME-${VERSION}/$PKGNAME" -cp "$SDK_LIB" "$STAGE/$PKGNAME-${VERSION}/libzuptsdk.so.2.0.0" -cp "vendor/pqvaptvupt/libpqvaptvupt.so.0.6.0" "$STAGE/$PKGNAME-${VERSION}/libpqvaptvupt.so.0.6.0" -cp README.md CHANGELOG.md SECURITY.md AUDIT.md LICENSE "$STAGE/$PKGNAME-${VERSION}/" -[ -f doc/vaptvupt.1 ] && cp doc/vaptvupt.1 "$STAGE/$PKGNAME-${VERSION}/$PKGNAME.1" -[ -f completions/vaptvupt.bash ] && cp completions/vaptvupt.bash "$STAGE/$PKGNAME-${VERSION}/completions/" -[ -f completions/_vaptvupt ] && cp completions/_vaptvupt "$STAGE/$PKGNAME-${VERSION}/completions/" -[ -f completions/vaptvupt.fish ] && cp completions/vaptvupt.fish "$STAGE/$PKGNAME-${VERSION}/completions/" -tar -czf "$RPMROOT/SOURCES/$PKGNAME-${VERSION}.tar.gz" -C "$STAGE" "$PKGNAME-${VERSION}" +for command_name in make git rpmbuild rpm rpm2cpio cpio date readelf sha256sum tar; do + command -v -- "$command_name" >/dev/null 2>&1 || die "required command not found: $command_name" +done -cat > "$RPMROOT/SPECS/$PKGNAME.spec" </dev/null || true + rm -rf -- "$work" +} +trap cleanup EXIT HUP INT TERM -Requires: libargon2 -Requires: openssl-libs >= 3.0 -AutoReqProv: no +source_tar=$top/SOURCES/zupt-${version}.tar.gz +printf '[rpm] creating audited source archive for ZUPT %s\n' "$version" +make DIST_TARBALL="$source_tar" WITH_SDK=0 WITH_PQBOX=0 dist +archive_version=$(tar -xOf "$source_tar" "zupt-${version}/include/zupt.h" | \ + sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p') +[[ $archive_version == "$version" ]] || die "source archive version is '$archive_version', expected '$version'" -%global debug_package %{nil} -%global __os_install_post %{nil} -%global _build_id_links none +install -m 0644 "$spec" "$top/SPECS/zupt.spec" +# OBS converts zupt.changes into RPM changelog metadata. Standalone +# rpmbuild does not, so add an equivalent release entry only to the temporary +# spec used for this release artifact. +changelog_sections=$(grep -Ec '^%changelog[[:space:]]*$' "$top/SPECS/zupt.spec" || true) +[[ $changelog_sections -eq 1 ]] || \ + die "expected exactly one %changelog section, found $changelog_sections" +source_epoch=${SOURCE_DATE_EPOCH:-$(sed -n '1p' .source-date-epoch 2>/dev/null || true)} +[[ $source_epoch =~ ^[0-9]+$ ]] || die 'SOURCE_DATE_EPOCH is not available' +changelog_date=$(date -u --date="@$source_epoch" '+%a %b %d %Y') +cat >> "$top/SPECS/zupt.spec" < - $VERSION-$RELEASE -- v3.0.0: Renamed from "Zupt" to "VaptVupt" because of a prior INPI - Brasil trademark on "Zupt". Archive extension .zupt is preserved; - v2.x and v3.0.0 archives are bidirectionally compatible. Legacy - /usr/bin/zupt is installed as a symlink to /usr/bin/vaptvupt. -- Integrated VaptVupt LZ + ANS codec 2.48.5: fixes csz==0 heap- - buffer-overflow READ in vv_dstream_decompress_chunk (libFuzzer- - found, medium severity), UBSan-safe pointer arithmetic in - vv_copy_match. -- Enhanced manpage (597 lines, was 422): POST-QUANTUM ENCRYPTION, - PERFORMANCE table, SECURITY/threat-model, ENVIRONMENT and - EXIT STATUS sections. -- Fixed GUI binary-discovery bug (PATH-missing-/usr/bin scenario); - GUI now does liveness check + logs discovery to stderr with - VAPTVUPT_DEBUG=1. -- 91/91 distro-safe regression suite green; F-09 byte sweep - 0/1827 silent accepts; F-06 HMAC fuzz 0/2000 silent accepts. +* $changelog_date Cristian Cezar Moisés - $version-0 +- Build the release package from audited source with optional SDK and PQBOX + features disabled. EOF +rpmbuild --define "_topdir $top" -ba "$top/SPECS/zupt.spec" -rpmbuild --define "_topdir $RPMROOT" \ - --define "_binary_payload w2.gzdio" \ - -bb "$RPMROOT/SPECS/$PKGNAME.spec" 2>&1 | tail -5 +mapfile -t main_rpms < <(find "$top/RPMS" -type f -name "zupt-${version}-*.rpm" \ + ! -name '*-debuginfo-*' ! -name '*-debugsource-*' -print | sort) +[[ ${#main_rpms[@]} -eq 1 ]] || die "expected one main RPM, found ${#main_rpms[@]}" +main_rpm=${main_rpms[0]} -RPM_PATH=$(find "$RPMROOT/RPMS" -name "$PKGNAME-${VERSION}-*.rpm" | head -1) -if [ -n "$RPM_PATH" ]; then - cp "$RPM_PATH" "/tmp/$PKGNAME-${VERSION}-${RELEASE}.${ARCH}.rpm" - echo "" - echo "Built: /tmp/$PKGNAME-${VERSION}-${RELEASE}.${ARCH}.rpm ($(du -h "/tmp/$PKGNAME-${VERSION}-${RELEASE}.${ARCH}.rpm" | cut -f1))" - rpm -qpi "/tmp/$PKGNAME-${VERSION}-${RELEASE}.${ARCH}.rpm" 2>&1 | head -15 +mapfile -t source_rpms < <(find "$top/SRPMS" -type f -name "zupt-${version}-*.src.rpm" -print | sort) +[[ ${#source_rpms[@]} -eq 1 ]] || die "expected one source RPM, found ${#source_rpms[@]}" +source_rpm=${source_rpms[0]} + +[[ $(rpm -qp --qf '%{NAME}' "$main_rpm") == zupt ]] || \ + die 'binary RPM name metadata is not zupt' +[[ $(rpm -qp --qf '%{VERSION}' "$main_rpm") == "$version" ]] || \ + die 'binary RPM version metadata does not match the release' +[[ $(rpm -qp --qf '%{RELEASE}' "$main_rpm") == 0 ]] || \ + die 'binary RPM release metadata is not 0' +[[ $(rpm -qp --qf '%{SOURCEPACKAGE}' "$main_rpm") == '(none)' ]] || \ + die 'binary RPM is marked as a source package' +[[ $(rpm -qp --qf '%{SOURCERPM}' "$main_rpm") == "$(basename -- "$source_rpm")" ]] || \ + die 'binary RPM does not reference the matching source RPM' +[[ $(rpm -qp --qf '%{NAME}' "$source_rpm") == zupt ]] || \ + die 'source RPM name metadata is not zupt' +[[ $(rpm -qp --qf '%{VERSION}' "$source_rpm") == "$version" ]] || \ + die 'source RPM version metadata does not match the release' +[[ $(rpm -qp --qf '%{RELEASE}' "$source_rpm") == 0 ]] || \ + die 'source RPM release metadata is not 0' +[[ $(rpm -qp --qf '%{SOURCEPACKAGE}' "$source_rpm") == 1 ]] || \ + die 'source RPM is not marked as a source package' +[[ $(rpm -qp --qf '%{SOURCERPM}' "$source_rpm") == '(none)' ]] || \ + die 'source RPM unexpectedly references another source RPM' +mapfile -t source_members < <(rpm -qpl "$source_rpm" | sort) +expected_source_members=("zupt-${version}.tar.gz" zupt.spec) +mapfile -t expected_source_members < <(printf '%s\n' "${expected_source_members[@]}" | sort) +[[ ${#source_members[@]} -eq 2 && \ + ${source_members[*]} == "${expected_source_members[*]}" ]] || \ + die 'source RPM payload is not the exact Source0/spec pair' + +rpm -qpi "$main_rpm" >/dev/null +rpm -qpl "$main_rpm" > "$work/contents.txt" +if grep -Eq '(^/usr/bin/vaptvupt$|\.(o|obj|a|so|so\.[^/]+|dll|dylib)$)' "$work/contents.txt"; then + cat "$work/contents.txt" >&2 + die 'forbidden alias or compiled library/object found in RPM contents' fi +if grep -q '^/usr/local/' "$work/contents.txt"; then + cat "$work/contents.txt" >&2 + die 'RPM contains files below /usr/local' +fi + +(cd -- "$extract" && rpm2cpio "$main_rpm" | cpio -idm --quiet) +binary=$extract/usr/bin/zupt +[[ -x $binary ]] || die 'RPM does not contain executable /usr/bin/zupt' +if ! readelf -h "$binary" 2>/dev/null | grep -Eq 'Type:[[:space:]]+DYN'; then + die 'RPM executable is not a position-independent executable (PIE)' +fi +if ! readelf -W -l "$binary" 2>/dev/null | grep -q 'GNU_RELRO'; then + die 'RPM executable lacks a GNU_RELRO segment' +fi +stack_segment=$(readelf -W -l "$binary" 2>/dev/null | grep 'GNU_STACK' || true) +[[ -n $stack_segment && $stack_segment != *RWE* ]] || \ + die 'RPM executable has a missing or executable GNU_STACK segment' +if readelf -d "$binary" 2>/dev/null | grep -Eq '(RPATH|RUNPATH)'; then + readelf -d "$binary" | grep -E '(RPATH|RUNPATH)' >&2 + die 'RPM executable contains RPATH/RUNPATH' +fi +if readelf -d "$binary" 2>/dev/null | grep -Eqi '(vendor/|libvuptsdk|libpqvaptvupt)'; then + die 'RPM executable references a vendored optional library' +fi +bash scripts/test-installed-zupt.sh "$binary" + +artifacts=("$main_rpm" "$source_rpm") +for artifact in "${artifacts[@]}"; do + destination=$dist_dir/$(basename -- "$artifact") + [[ ! -e $destination ]] || die "refusing to overwrite existing output: $destination" +done +for artifact in "${artifacts[@]}"; do + destination=$dist_dir/$(basename -- "$artifact") + cp -- "$artifact" "$destination" + sha256sum "$destination" +done + +printf 'PASS: built and extracted-package-tested %s\n' "$dist_dir/$(basename -- "$main_rpm")" +printf 'PASS: built source RPM %s\n' "$dist_dir/$(basename -- "$source_rpm")" diff --git a/packaging/debian/changelog b/packaging/debian/changelog index 45a1118..cbed093 100644 --- a/packaging/debian/changelog +++ b/packaging/debian/changelog @@ -1,3 +1,148 @@ +zupt (5.2.8-1) UNRELEASED; urgency=medium + + * Close CodeQL High path-race findings in SDK key publication, disk-restore + target handling, and benchmark workspace cleanup. + * Treat a filesystem refusal to create the macOS raw-C1 scanner fixture as + an explicit skip; reject redirected Windows prompts before _getch; and run + sdk-test in the release and hosted Linux gates. + * Preserve immutable, unpromoted v5.2.7 run 33445470664: 13 jobs succeeded, + macOS failed the raw-C1 fixture, and Windows was cancelled after the hosted + job stalled; a MinGW/Wine reproduction isolated redirected _getch entry. + Require fresh 5.2.8 evidence. + + -- Cristian Cezar Moisés Mon, 31 Aug 2026 23:30:00 +0000 + +zupt (5.2.7-1) UNRELEASED; urgency=medium + + * Scope SHA-NI test helpers to supported x86 builds so macOS arm64 strict + compilation does not fail on unused declarations. + * Preserve safe UTF-8 fixture bytes across the Windows argv boundary. + * Preserve the immutable, unpromoted 5.2.6 history and require fresh 5.2.7 + package, checksum, native-platform, OBS, and promotion gates. + + -- Cristian Cezar Moisés Mon, 31 Aug 2026 23:00:00 +0000 + +zupt (5.2.6-1) UNRELEASED; urgency=medium + + * Use the compiler-resistant volatile wipe fallback on macOS and NetBSD, and + make the source scanner's empty-array handling compatible with Bash 3.2. + * Preserve hostile archive-path fixture bytes exactly on Windows. + * Preserve the immutable, unpromoted 5.2.5 history and require fresh 5.2.6 + package, checksum, native-platform, OBS, and promotion gates. + + -- Cristian Cezar Moisés Mon, 31 Aug 2026 21:30:00 +0000 + +zupt (5.2.5-1) UNRELEASED; urgency=medium + + * Run the standalone OBS source-service chain from its isolated working + directory and add a packaging-policy regression for that contract. + * Preserve the immutable, unpromoted 5.2.4 history and require fresh 5.2.5 + package, checksum, native-platform, and promotion gates. + + -- Cristian Cezar Moisés Mon, 31 Aug 2026 19:55:00 +0000 + +zupt (5.2.4-1) UNRELEASED; urgency=medium + + * Make the static Windows GUI package-version check robust to canonical + CRLF checkouts. + * Advance source-only package metadata and prepare final archive checksums. + + -- Cristian Cezar Moisés Mon, 31 Aug 2026 18:55:00 +0000 + +zupt (5.2.3-1) UNRELEASED; urgency=medium + + * Derive package checks from the upstream version header and stabilize the + GUI version output consumed by package gates. + * Replace busybox-gawk before installing the native openSUSE RPM toolchain. + + -- Cristian Cezar Moisés Mon, 31 Aug 2026 18:15:00 +0000 + +zupt (5.2.2-1) UNRELEASED; urgency=medium + + * Prepare a source-only upstream release and remove incomplete vendored SDK + and PQBOX inputs together with every precompiled-library fallback. + * Make optional integrations explicit system dependencies, disabled by + default, and preserve distribution compiler/linker flags and DESTDIR. + * Add the reusable source scanner and openSUSE/OBS source packaging. + * Restore the ZUPT/zupt application, package, executable, documentation, and + artifact names; build and test with WITH_SDK=0 WITH_PQBOX=0. + * Add explicit password prompt, file, and inherited-descriptor inputs. + * Correct the licensing record without revoking historical MIT grants present + in earlier repository revisions; current files follow current SPDX notices. + * Preserve Yann Collet's BSD-2-Clause notice for the two xxHash-derived + XXH64 source units and include it in package license metadata. + * Record the CC0-1.0 option for pq-crystals/kyber-derived ML-KEM portions + and ship the complete license text in every binary bundle. + * Preserve the BSD-3-Clause notice for curve25519-donna-derived X25519 + portions and document their provenance without inventing a revision. + * Promote only license-complete release assets: Windows is ZIP-only and the + AppImage remains downstream-only pending a complete runtime source/relink + compliance handoff. + * Qualify older changelog statements about formally verified or + constant-time assembly: 5.2.2 retains source, generated output and runtime + regressions, but no reproducible formal-proof certificate for those paths. + + -- Cristian Cezar Moisés Mon, 31 Aug 2026 00:00:00 +0000 + +vaptvupt (5.0.0-1) UNRELEASED; urgency=high + + * ML-KEM-768 is now genuinely FIPS 203-conformant. Earlier releases shipped + round-3 CRYSTALS-Kyber under a "FIPS 203" label; it was secure but not + interoperable. Fixed a transposed matrix-A sampling convention (keygen + + encrypt), the round-3 KDF, and the implicit-rejection domain. Validated + byte-for-byte against OpenSSL 3.5's FIPS 203 ML-KEM-768 in both + cross-decapsulation directions (tests/test_mlkem_fips203.sh, in make check). + * BREAKING: --pq / --pq-only keys and archives from <= 4.2.1 no longer decrypt + (the KEM math changed). Regenerate keys and re-encrypt. Password mode and + plain compression are unaffected; wire format stays v1.6. + * Security: compress -p data-loss guard (was overwriting an + input file); compress -p silent-plaintext guard; heap + OOB read in the AVX2 decoder fast path bounded; overflow-safe bound in the + solid-mode test path; secret-wipe on hybrid-decrypt key-read error. + * GUI reworked for the source-only build: build-aware Hybrid/Full-PQ selector + (no more SDK-mode defaults that fail), PQ-key auto-detect on Extract/Verify, + thread-safety + About fixes. + * Truthful banner/help (real default KDF); cross-platform packaging. + + -- Cristian Cezar Moisés Fri, 10 Jul 2026 18:00:00 +0000 + +vaptvupt (4.2.1-1) UNRELEASED; urgency=medium + + * Fix: `vaptvupt info` mislabelled full post-quantum (--pq-only, enc_type + 0x06) archives as "PQ Hybrid (ML-KEM-768 + X25519)". Full-PQ archives + set the generic ZUPT_FLAG_PQ_HYBRID header flag, but info only checked + that flag. info now reads the real enc_type from the encryption-header + block and reports the actual mode ("ML-KEM-768 only, no classical + layer" for --pq-only; hybrid / SDK-v2 / sealed-box otherwise). + Reader-side only — no wire-format change; existing 4.2.0 archives are + relabelled with no re-encryption. + + -- Cristian Cezar Moisés Fri, 10 Jul 2026 12:00:00 +0000 + +vaptvupt (4.2.0-1) UNRELEASED; urgency=high + + * New native full (pure) post-quantum mode --pq-only: ML-KEM-768 (FIPS + 203) as the sole key-establishment mechanism, no classical X25519 + component (envelope type 0x06; archive key SHA3-512(ml_ss || ml_ct || + "ZUPT-PQ-ONLY-v1")). For compliance postures that require a single + NIST-standardised PQ primitive with no classical KEM in the envelope + (CNSA 2.0-style "PQ-only"). Keys via keygen --pq-only (ZPQK magic; + not interchangeable with hybrid --pq keys). Hybrid --pq remains the + recommended default; --pq-only has no classical fallback, so a break + of ML-KEM-768 alone breaks the archive. In-tree, default build. + * Security (critical): AES-256-CTR keystream reuse under --dedup. Dedup + blocks all use sequence 0, so the previous nonce (base_nonce XOR seq) + collapsed to a single value across blocks, reusing the CTR keystream + (a many-time-pad). Each block now uses a fresh random 128-bit nonce + stored in the block prefix and bound into the block MAC; block_seq is + still bound as MAC AAD. Regression test tests/test_dedup_nonce.sh. + Re-encrypt any --dedup encrypted archives written by <= 4.1.0. + * keygen --sdk / --box on a source-only build now fails with a clear + message pointing to native --pq / --pq-only (or a WITH_SDK=1 build). + * Wire format v1.6 unchanged; the 0x06 envelope is additive. + + -- Cristian Cezar Moisés Thu, 09 Jul 2026 12:00:00 +0000 + vaptvupt (4.1.0-1) UNRELEASED; urgency=high * Source-only build: the prebuilt vendored libraries libzuptsdk.so and @@ -318,12 +463,10 @@ vaptvupt (3.0.2-1) UNRELEASED; urgency=medium vaptvupt (3.0.1-1) UNRELEASED; urgency=medium - * GUI license cleanup: removed MIT-license credit line from the - about panel (the GUI is AGPL-3.0-or-later with commercial dual- - licensing; the MIT reference was a templating mistake). Replaced - gui/LICENSE-GUI (was MIT) with AGPL-3.0-or-later, mirroring the - top-level LICENSE. Top-level LICENSE preamble updated to reflect - the v3.0.0 Zupt → VaptVupt rename. + * GUI license metadata changed to AGPL-3.0-or-later for the then-current + source. The original entry incorrectly called earlier MIT notices a + templating mistake; the 5.2.2 erratum records that historical grants remain + valid for the exact material distributed under them. * GUI version-string parsing bug fix: the v3.0.0 GUI used `replace("zupt ", "")` to peel the product name out of the CLI's version banner, but that substring also appears inside the v3.0.0 diff --git a/packaging/debian/control b/packaging/debian/control index 45e1419..92fe3e2 100644 --- a/packaging/debian/control +++ b/packaging/debian/control @@ -1,41 +1,49 @@ -Source: vaptvupt +Source: zupt Section: utils Priority: optional Maintainer: Cristian Cezar Moisés Build-Depends: + bash, + coreutils, debhelper-compat (= 13), + diffutils, + file, + findutils, gcc, + gawk, + git, + grep, + gzip, + libarchive-tools, + make, libc6-dev, - python3 (>= 3.8) + python3 (>= 3.8), + sed, + tar Standards-Version: 4.6.2 -Homepage: https://git.securityops.co/cristiancmoises/zupt -Vcs-Browser: https://git.securityops.co/cristiancmoises/zupt -Vcs-Git: https://git.securityops.co/cristiancmoises/zupt.git +Homepage: https://github.com/cristiancmoises/zupt +Vcs-Browser: https://github.com/cristiancmoises/zupt +Vcs-Git: https://github.com/cristiancmoises/zupt.git Rules-Requires-Root: no -Package: vaptvupt +Package: zupt Architecture: any -Provides: zupt (= ${binary:Version}) -Replaces: zupt -Conflicts: zupt Depends: ${shlibs:Depends}, ${misc:Depends} -Description: Post-quantum backup compression utility (formerly Zupt) - VaptVupt (renamed from Zupt in v3.0.0 due to INPI Brasil trademark) is - a pure-C11 backup compression utility featuring: +Description: Post-quantum backup compression utility + ZUPT is a pure-C11 backup compression utility featuring: * Post-quantum hybrid encryption (ML-KEM-768 + X25519, FIPS 203) * AES-256-CTR + HMAC-SHA256 authenticated encryption (Encrypt-then-MAC) - * Argon2id password-based key derivation (default since 2.4.1) - * Multi-threaded compression with the VaptVupt LZ + ANS codec 2.48.5 + * PBKDF2-SHA256 password key derivation (Argon2id in WITH_SDK=1 builds) + * Multi-threaded compression with the VaptVupt LZ + ANS codec 2.65.3 * Full-disk backup and restore with sparse-region detection - * End-to-end byte-level tamper detection on encrypted archives - (0 silent-accept positions in the v1.6 exhaustive byte sweep) - * Constant-time cryptographic primitives verified with Jasmin + * Authenticated encrypted-archive metadata and per-block integrity checks + * Portable C implementations with optional source-built assembly paths * NIST/RFC test vectors for SHA-256, SHA-3, ML-KEM-768, AES-256-CTR, - HMAC-SHA256, X25519, PBKDF2, Argon2id + HMAC-SHA256, X25519 and PBKDF2 . The archive extension stays .zupt for format continuity (header magic - unchanged). The binary `zupt` is preserved as a symlink to `vaptvupt`. + unchanged). The package installs only /usr/bin/zupt. . - The archive format includes an integrity trailer that authenticates the - header and footer, per-block HMAC with bound frame-preface AAD, and - optional encrypted comments. + Encrypted archives include an integrity trailer that authenticates the + header and footer, per-block HMAC with bound frame-preface AAD, and optional + encrypted comments. Plain archives use non-cryptographic checksums. diff --git a/packaging/debian/copyright b/packaging/debian/copyright index 12506ba..4c6681e 100644 --- a/packaging/debian/copyright +++ b/packaging/debian/copyright @@ -1,19 +1,35 @@ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ -Upstream-Name: zupt +Upstream-Name: ZUPT Upstream-Contact: Cristian Cezar Moisés -Source: https://git.securityops.co/cristiancmoises/zupt +Source: https://github.com/cristiancmoises/zupt Files: * Copyright: 2025-2026 Cristian Cezar Moisés License: AGPL-3.0-or-later -Files: src/vv_*.c include/vaptvupt*.h include/vv_*.h vendor/zuptsdk/include/vv_*.h vendor/zuptsdk/include/vaptvupt*.h +Files: src/vaptvupt_api.c src/vv_*.c include/vaptvupt*.h include/vv_*.h Copyright: 2025-2026 Cristian Cezar Moisés (VaptVupt codec) License: GPL-3.0-or-later -Files: vendor/zuptsdk/* -Copyright: 2025-2026 Cristian Cezar Moisés (libzuptsdk) -License: GPL-3.0-or-later +Files: src/zupt_xxh.c +Copyright: 2012-2021 Yann Collet + 2025-2026 Cristian Cezar Moisés +License: AGPL-3.0-or-later and BSD-2-Clause + +Files: src/zupt_mlkem.c +Copyright: 2025-2026 Cristian Cezar Moisés + pq-crystals/kyber contributors (adapted portions) +License: AGPL-3.0-or-later and CC0-1.0 + +Files: src/zupt_x25519.c +Copyright: 2008 Google Inc. + 2025-2026 Cristian Cezar Moisés +License: AGPL-3.0-or-later and BSD-3-Clause + +Files: src/vv_xxh64.c +Copyright: 2012-2021 Yann Collet + 2025-2026 Cristian Cezar Moisés (VaptVupt codec adaptation) +License: GPL-3.0-or-later and BSD-2-Clause Files: debian/* Copyright: 2025-2026 Cristian Cezar Moisés @@ -47,3 +63,41 @@ License: GPL-3.0-or-later . On Debian systems, the full text of the GNU General Public License version 3 can be found in the file `/usr/share/common-licenses/GPL-3'. + +License: BSD-2-Clause + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + . + * Redistributions of source code must retain the copyright notice, this list + of conditions and the disclaimer. + * Redistributions in binary form must reproduce the copyright notice, this + list of conditions and the disclaimer in the documentation and/or other + materials provided with the distribution. + . + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + +License: CC0-1.0 + To the extent possible under law, the upstream affirmer has waived all + copyright and related or neighboring rights to the adapted portions. + . + The complete CC0 1.0 Universal legal text is shipped in + `/usr/share/doc/zupt/LICENSE-CC0-1.0`. + +License: BSD-3-Clause + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the copyright notice, conditions, + and disclaimer are retained; neither the name of Google Inc. nor contributor + names may be used to endorse derived products without prior permission. + . + The complete BSD-3-Clause notice and disclaimer are shipped in + `/usr/share/doc/zupt/LICENSE-BSD-3-Clause`. diff --git a/packaging/debian/rules b/packaging/debian/rules index 9663278..14fb383 100755 --- a/packaging/debian/rules +++ b/packaging/debian/rules @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Honour Debian's reproducible-build epoch when set by dpkg-buildpackage. -export SOURCE_DATE_EPOCH ?= 1747699200 +export SOURCE_DATE_EPOCH ?= 1788134400 # Hardening flags — Debian's defaults are already strong, this adds project- # specific ones. @@ -14,24 +14,19 @@ export DEB_LDFLAGS_MAINT_APPEND = -Wl,--as-needed dh $@ override_dh_auto_build: - $(MAKE) -j$$(nproc) + # Source-only build: no vendored libraries, native crypto only. + $(MAKE) WITH_SDK=0 WITH_PQBOX=0 -j$$(nproc) override_dh_auto_test: - # Project's own regression suite covers F-06..F-12. - $(MAKE) test + # Distro-safe quick, traversal, integrity, codec and NIST/RFC checks. + $(MAKE) WITH_SDK=0 WITH_PQBOX=0 check override_dh_auto_install: - $(MAKE) DESTDIR=$(CURDIR)/debian/zupt PREFIX=/usr install - # Vendored libzuptsdk goes alongside the binary at a relative rpath. - install -d $(CURDIR)/debian/zupt/usr/lib/zupt - install -m 0755 vendor/zuptsdk/libzuptsdk.so.2.0.0 \ - $(CURDIR)/debian/zupt/usr/lib/zupt/libzuptsdk.so.2.0.0 - ln -sf libzuptsdk.so.2.0.0 $(CURDIR)/debian/zupt/usr/lib/zupt/libzuptsdk.so.2 - ln -sf libzuptsdk.so.2.0.0 $(CURDIR)/debian/zupt/usr/lib/zupt/libzuptsdk.so + # Binary package is `zupt` -> stage into debian/zupt (dh derives the + # staging dir from the Package: name in debian/control). Source-only: nothing + # to install beyond `make install` (no .so). + $(MAKE) DESTDIR=$(CURDIR)/debian/zupt PREFIX=/usr \ + WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 install override_dh_auto_clean: $(MAKE) clean - -# Skip dh_strip's separate -dbgsym packages for a single-source-package layout. -override_dh_strip: - dh_strip --no-automatic-dbgsym diff --git a/packaging/debian/zupt.docs b/packaging/debian/zupt.docs new file mode 100644 index 0000000..0f4bb5d --- /dev/null +++ b/packaging/debian/zupt.docs @@ -0,0 +1,12 @@ +README.md +CHANGELOG.md +SECURITY.md +THREAT_MODEL.md +NOTICE +THIRD-PARTY-NOTICES.md +LICENSE +LICENSE-AGPL-3.0 +LICENSE-GPL-3.0 +LICENSE-BSD-2-Clause +LICENSE-BSD-3-Clause +LICENSE-CC0-1.0 diff --git a/packaging/guix/zupt.scm b/packaging/guix/zupt.scm new file mode 100644 index 0000000..ec60fa5 --- /dev/null +++ b/packaging/guix/zupt.scm @@ -0,0 +1,219 @@ +;;; SPDX-License-Identifier: AGPL-3.0-or-later +;;; Copyright (c) 2026 Cristian Cezar Moisés +;;; +;;; GNU Guix package definitions for ZUPT (CLI + PySide6 GUI). +;;; Source-only build (no vendored libraries): the CLI links only libc/libm/ +;;; pthread from the store. +;;; +;;; Install into your profile (additive; keeps everything else): +;;; guix package -f packaging/guix/zupt.scm ; installs the GUI +;;; guix package -e '(@ (guix) …)' — or, for the CLI on its own: +;;; guix install -f packaging/guix/zupt.scm ; (last expr = GUI) +;;; The last expression is the GUI, which carries the CLI as an input; to get +;;; the `zupt` command in your profile too, also run: +;;; guix package --install-from-expression='(begin (load "packaging/guix/zupt.scm") zupt)' +;;; +;;; GUI-on-Guix note: PySide6's Qt6 links several leaf libraries (libGL from +;;; mesa, libxkbcommon, the X11/xcb family, libzstd, harfbuzz, icu, ...) that are +;;; NOT in its RUNPATH. The launcher therefore sets LD_LIBRARY_PATH to those +;;; libraries (see %gui-runtime-libs). Without this, `import PySide6.QtWidgets` +;;; fails with "libGL.so.1: cannot open shared object file" and the GUI prints +;;; "requires PySide6 or PyQt6". Qt's OWN libraries are intentionally excluded +;;; from LD_LIBRARY_PATH — they resolve via PySide6's RUNPATH; forcing a second +;;; copy causes Qt private-API symbol clashes. + +(use-modules (guix packages) + (guix download) + (guix gexp) + (guix utils) + (guix build-system gnu) + (guix build-system copy) + ((guix licenses) #:prefix license:) + (gnu packages python) ; python + (gnu packages qt) ; python-pyside-6, python-shiboken-6, qtbase, qtwayland + (gnu packages bash) ; bash-minimal + (gnu packages gl) ; mesa (libGL) + (gnu packages xdisorg) ; libxkbcommon, pixman, mtdev + (gnu packages fontutils) ; fontconfig, freetype, graphite2 + (gnu packages xorg) ; libX11 + xcb family, libxft, libevdev + (gnu packages freedesktop); wayland, libinput-minimal + (gnu packages glib) ; glib, dbus + (gnu packages compression); zlib, zstd, brotli + (gnu packages image) ; libpng, libjpeg-turbo + (gnu packages xml) ; expat, libxml2 + (gnu packages gtk) ; harfbuzz + (gnu packages icu4c) ; icu4c + (gnu packages maths) ; double-conversion + (gnu packages pcre) ; pcre2 + (gnu packages markup) ; md4c + (gnu packages crypto) ; libb2 + (gnu packages linux)) ; eudev (libudev) + +;; Leaf runtime libraries PySide6's Qt6 (Core/Gui/Widgets) needs but that are +;; NOT in its RUNPATH. NEVER add qtbase/qtwayland here (see header note). These +;; already live in PySide6's closure, so referencing them adds no store size. +(define %gui-runtime-libs + (list mesa libxkbcommon fontconfig freetype graphite2 harfbuzz + icu4c double-conversion pcre2 md4c libb2 brotli + libpng libjpeg-turbo zlib expat libxml2 pixman glib dbus wayland + libx11 libxext libxrender libxcb libxrandr libxi libxcursor libxft + libxfixes libxdamage libxcomposite libxtst libxinerama libsm libice + libxau libxdmcp xcb-util xcb-util-image xcb-util-keysyms + xcb-util-renderutil xcb-util-wm xcb-util-cursor + libinput-minimal mtdev libevdev eudev)) + +(define %zupt-version "5.2.8") + +(define %zupt-source + (origin + (method url-fetch) + (uri (string-append + "https://github.com/cristiancmoises/zupt" + "/releases/download/v" %zupt-version + "/zupt-" %zupt-version ".tar.gz")) + (sha256 + (base32 "1xv5vd7bh9pcw2d3fszb6jn1r6sxjp48mlzh9icvji8m4439b2rp")))) + +(define-public zupt + (package + (name "zupt") + (version %zupt-version) + (source %zupt-source) + (build-system gnu-build-system) + (arguments + (list + #:make-flags + #~(list (string-append "PREFIX=" #$output) + "WITH_SDK=0" + "WITH_PQBOX=0" + (string-append "CC=" #$(cc-for-target))) + #:phases + #~(modify-phases %standard-phases + (delete 'configure) ; plain Makefile, no ./configure + (replace 'check + ;; Self-contained NIST/RFC known-answer vectors (FIPS 180-4/202/203, + ;; SP 800-38A, RFC 4231/7748) are the crypto gate. + (lambda* (#:key tests? #:allow-other-keys) + (when tests? + (invoke "make" "WITH_SDK=0" "WITH_PQBOX=0" + (string-append "CC=" #$(cc-for-target)) + "test-vectors") + (invoke "./test_vectors"))))))) + (home-page "https://github.com/cristiancmoises/zupt") + (synopsis "Post-quantum backup compression utility") + (description + "ZUPT is a pure-C11 backup compressor with native +post-quantum encryption. Two in-tree PQ modes: @code{--pq} hybridizes +ML-KEM-768 with X25519 (recommended), and +@code{--pq-only} uses ML-KEM-768 alone for @dfn{PQ-only} compliance postures. +Payload protection is AES-256-CTR + HMAC-SHA256 Encrypt-then-MAC with a fresh +random per-block nonce; AES-NI/SHA-NI dispatch at runtime; the bundled +VaptVupt 2.65.3 LZ+ANS codec has portable fallbacks. Password mode uses +PBKDF2-SHA256. The tool is +AGPL-3.0-or-later; the embedded codec is GPL-3.0-or-later; the two +xxHash-derived XXH64 units additionally carry BSD-2-Clause; and portions of +native ML-KEM adapted from pq-crystals/kyber carry CC0-1.0. Native X25519 +portions adapted from curve25519-donna retain BSD-3-Clause. +The x86 BCJ filter and SHA-NI path also record their public-domain LZMA SDK +and SHA-Intrinsics origins; installed NOTICE and THIRD-PARTY-NOTICES.md carry +the full provenance record.") + (license (list license:agpl3+ license:gpl3+ license:bsd-2 license:bsd-3 license:cc0)))) + +(define-public zupt-gui + (package + (name "zupt-gui") + (version %zupt-version) + (source (package-source zupt)) ; same release tarball + (build-system copy-build-system) + (arguments + (list + #:install-plan + #~'(("gui/src/zupt_gui.py" "lib/zupt-gui/") + ("gui/assets/zupt-icon.png" + "share/icons/hicolor/256x256/apps/zupt-gui.png") + ("gui/README.md" "share/doc/zupt-gui/") + ("LICENSE-AGPL-3.0" + "share/licenses/zupt-gui/LICENSE-AGPL-3.0") + ("gui/LICENSE-GUI" + "share/licenses/zupt-gui/LICENSE-GUI") + ("gui/assets/README.md" + "share/licenses/zupt-gui/ASSET-PROVENANCE.md")) + #:phases + #~(modify-phases %standard-phases + (add-after 'install 'make-launcher + (lambda* (#:key inputs outputs #:allow-other-keys) + (let* ((out (assoc-ref outputs "out")) + (bin (string-append out "/bin")) + (gui (string-append + out "/lib/zupt-gui/zupt_gui.py")) + (sh (search-input-file inputs "/bin/sh")) + (python3 (search-input-file inputs "/bin/python3")) + (cli (search-input-file inputs "/bin/zupt")) + (pyside (assoc-ref inputs "python-pyside-6")) + (site (car (find-files pyside "^site-packages$" + #:directories? #t))) + ;; Shiboken6 is a SEPARATE package PySide6 imports at + ;; runtime; its site-packages must be on GUIX_PYTHONPATH too. + (shiboken (assoc-ref inputs "python-shiboken-6")) + (shsite (car (find-files shiboken "^site-packages$" + #:directories? #t))) + (qtbase (assoc-ref inputs "qtbase")) + (qtwl (assoc-ref inputs "qtwayland")) + ;; zstd ships libzstd.so.1 in its "lib" output (not "out"). + (zstdlib (assoc-ref inputs "zstd")) + (ldpath (string-join + (append + (list #$@(map (lambda (p) (file-append p "/lib")) + %gui-runtime-libs)) + (list (string-append zstdlib "/lib"))) + ":"))) + (mkdir-p bin) + (call-with-output-file (string-append bin "/zupt-gui") + (lambda (port) + (format port "#!~a +export ZUPT_BIN=\"~a\" +export GUIX_PYTHONPATH=\"~a:~a${GUIX_PYTHONPATH:+:}$GUIX_PYTHONPATH\" +export QT_PLUGIN_PATH=\"~a/lib/qt6/plugins:~a/lib/qt6/plugins${QT_PLUGIN_PATH:+:}$QT_PLUGIN_PATH\" +export LD_LIBRARY_PATH=\"~a${LD_LIBRARY_PATH:+:}$LD_LIBRARY_PATH\" +exec \"~a\" \"~a\" \"$@\"\n" + sh cli site shsite qtbase qtwl ldpath python3 gui))) + (chmod (string-append bin "/zupt-gui") #o755)))) + (add-after 'make-launcher 'install-desktop-file + (lambda* (#:key outputs #:allow-other-keys) + (let* ((out (assoc-ref outputs "out")) + (apps (string-append out "/share/applications"))) + (mkdir-p apps) + (call-with-output-file + (string-append apps "/zupt-gui.desktop") + (lambda (port) + (format port "[Desktop Entry] +Type=Application +Name=ZUPT +GenericName=Post-Quantum Backup +Comment=Compress, encrypt and restore .zupt archives +Exec=~a/bin/zupt-gui %F +Icon=zupt-gui +Terminal=false +Categories=Utility;Archiving;Security; +MimeType=application/x-zupt; +Keywords=backup;encryption;post-quantum;compression;zupt;\n" + out))))))))) + (inputs + (append (list bash-minimal python python-pyside-6 python-shiboken-6 + qtbase qtwayland zupt + (list zstd "lib")) ; libzstd.so.1 is in zstd's "lib" output + %gui-runtime-libs)) + (home-page "https://github.com/cristiancmoises/zupt") + (synopsis "Desktop frontend for the ZUPT post-quantum backup tool") + (description + "PySide6 (Qt 6) graphical frontend for ZUPT: create, inspect and +extract @code{.zupt} archives with password or post-quantum recipient +encryption, including the @code{--pq} hybrid and @code{--pq-only} full +post-quantum modes. The launcher pins the matching @code{zupt} CLI from the +store via @env{ZUPT_BIN} and sets @env{LD_LIBRARY_PATH} to the Qt6 leaf +libraries PySide6 needs but does not carry in its RUNPATH.") + (license license:agpl3+))) + +;; `guix package -f' evaluates the file's last expression — the GUI, which +;; carries the CLI as an input. +zupt-gui diff --git a/packaging/homebrew/vaptvupt.rb b/packaging/homebrew/vaptvupt.rb deleted file mode 100644 index 86af2d4..0000000 --- a/packaging/homebrew/vaptvupt.rb +++ /dev/null @@ -1,71 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -# -# Homebrew formula for zupt. -# -# To publish: -# 1. Run `make dist` upstream to produce zupt-VERSION.tar.gz (reproducible). -# 2. Upload to a stable release URL. -# 3. Update `url`, `version`, and `sha256` below. -# 4. Submit to homebrew-core via PR OR host in your own tap -# (e.g. cristiancmoises/homebrew-tap). -# -# Local test: -# brew install --build-from-source ./zupt.rb -# brew test zupt -# brew audit --strict --online zupt -# -# Notes for macOS: -# * Jasmin assembly is disabled at build time on Darwin (no jasminc dep); -# the C fallback for AES-256-CTR / HMAC compare paths is shipped. -# * libzuptsdk is vendored and installed alongside the binary; the binary -# uses @loader_path rpath so users don't have to set DYLD paths. - -class Vaptvupt < Formula - desc "Post-quantum backup compression utility (ML-KEM-768 + AES-256-CTR + HMAC-SHA256)" - homepage "https://git.securityops.co/cristiancmoises/zupt" - url "https://git.securityops.co/cristiancmoises/zupt/releases/download/v4.1.0/vaptvupt-4.1.0.tar.gz" - version "4.1.0" - sha256 "REPLACE_WITH_SHA256_OF_RELEASE_TARBALL" - license "AGPL-3.0-or-later" - - depends_on "python@3.12" => :test # only for test-suite tamper harness - - def install - # macOS build: no Jasmin, C-fallback crypto paths are used. - # The Makefile auto-detects Jasmin availability and falls back cleanly. - ENV["CFLAGS"] = "#{ENV.cflags} -O2 -std=c11 -Wall -Wextra" - - system "make", "-j#{ENV.make_jobs}" - system "make", "DESTDIR=#{prefix}", "PREFIX=", "install" - - # Vendored libzuptsdk goes into lib/zupt/ with @loader_path rpath. - # Note: Linux ships .so.2.0.0; macOS .dylib equivalent must be built - # separately by the vendored makefile. For the initial Homebrew - # submission this assumes the upstream tarball includes a .dylib build; - # if not, build it here. - lib_zupt = lib/"zupt" - lib_zupt.mkpath - if File.exist?("vendor/zuptsdk/libzuptsdk.dylib") - cp "vendor/zuptsdk/libzuptsdk.dylib", lib_zupt - elsif File.exist?("vendor/zuptsdk/libzuptsdk.so.2.0.0") - # Fallback: link Linux-style .so on macOS (works for direct loads but - # not for dlopen-on-Darwin scenarios). Upstream is tracking this. - cp "vendor/zuptsdk/libzuptsdk.so.2.0.0", lib_zupt - end - - # Docs - doc.install "README.md", "SECURITY.md", "CHANGELOG.md", "AUDIT.md" - end - - test do - # End-to-end sanity check: build a real archive, extract it, byte-compare. - (testpath/"input.txt").write("homebrew formula test payload\n") - system bin/"zupt", "c", "-p", "test", "out.zupt", "input.txt" - system bin/"zupt", "info", "out.zupt" - mkdir "extracted" - cd "extracted" do - system bin/"zupt", "x", "-p", "test", "../out.zupt" - end - system "diff", "-q", "input.txt", "extracted/input.txt" - end -end diff --git a/packaging/homebrew/zupt.rb b/packaging/homebrew/zupt.rb new file mode 100644 index 0000000..5b2164e --- /dev/null +++ b/packaging/homebrew/zupt.rb @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Homebrew formula for ZUPT. +# +# To publish: +# 1. Run `make dist` upstream to produce zupt-VERSION.tar.gz. +# 2. Upload to a stable release URL. +# 3. Update `url`, `version`, and `sha256` below. +# 4. Submit to homebrew-core via PR OR host in your own tap +# (e.g. cristiancmoises/homebrew-tap). +# +# Local test: +# brew install --build-from-source ./zupt.rb +# brew test zupt +# brew audit --strict --online zupt +# +# Notes for macOS: +# * Jasmin assembly is disabled at build time on Darwin (no jasminc dep); +# the C fallback for AES-256-CTR / HMAC compare paths is shipped. +# * Source-only build: no vendored libraries; native crypto only. + +class Zupt < Formula + desc "Post-quantum backup compression utility (ML-KEM-768 + AES-256-CTR + HMAC-SHA256)" + homepage "https://github.com/cristiancmoises/zupt" + url "https://github.com/cristiancmoises/zupt/releases/download/v5.2.8/zupt-5.2.8.tar.gz" + version "5.2.8" + sha256 "378b9506211545b9594cf0d38ac8955d9b1cac34eb6b379ae0ec26b84edb65f7" + license all_of: ["AGPL-3.0-or-later", "GPL-3.0-or-later", "BSD-2-Clause", "BSD-3-Clause", "CC0-1.0"] + + depends_on "python@3.12" => :test # only for test-suite tamper harness + + def install + # Source-only build (WITH_SDK=0): native crypto only, no vendored libraries. + # macOS uses the C-fallback crypto paths (no Jasmin); the Makefile detects + # this and falls back cleanly. + ENV["CFLAGS"] = "#{ENV.cflags} -O2 -std=c11 -Wall -Wextra" + + system "make", "WITH_SDK=0", "WITH_PQBOX=0", "-j#{ENV.make_jobs}" + system "make", "PREFIX=#{prefix}", "WITH_SDK=0", "WITH_PQBOX=0", + "INSTALL_LEGACY_ALIAS=0", "install" + + # Docs (no vendored .so/.dylib in the source-only build). `make install` + # also installs the complete project license/notice set. + doc.install "README.md", "SECURITY.md", "CHANGELOG.md" + %w[LICENSE-BSD-3-Clause LICENSE-CC0-1.0].each do |notice| + odie "missing installed license #{notice}" unless \ + (share/"licenses/zupt"/notice).exist? + end + end + + test do + # End-to-end sanity check: build a real archive, extract it, byte-compare. + (testpath/"input.txt").write("homebrew formula test payload\n") + system bin/"zupt", "c", "-p", "test", "out.zupt", "input.txt" + system bin/"zupt", "t", "-p", "test", "out.zupt" + mkdir "extracted" + cd "extracted" do + system bin/"zupt", "x", "-p", "test", "../out.zupt" + end + system "diff", "-q", "input.txt", "extracted/input.txt" + end +end diff --git a/packaging/install-zupt-gui.sh b/packaging/install-zupt-gui.sh index cb25e16..69a2ec5 100755 --- a/packaging/install-zupt-gui.sh +++ b/packaging/install-zupt-gui.sh @@ -1,139 +1,8 @@ -#!/bin/bash +#!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-or-later -# Copyright (c) 2025-2026 Cristian Cezar Moisés -# Zupt + Zupt GUI all-in-one installer for Linux -# Detects your distro, installs all dependencies, then installs -# zupt and zupt-gui. Run as root or with sudo. -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ZUPT_CLI_DEB="$SCRIPT_DIR/zupt_2.2.3_amd64.deb" -ZUPT_GUI_DEB="$SCRIPT_DIR/zupt-gui_1.1.1_all.deb" - -print_step() { echo ""; echo "═══ $* ═══"; } -print_err() { echo "ERROR: $*" >&2; exit 1; } - -# Must be root -if [ "$EUID" -ne 0 ]; then - print_err "Run with sudo: sudo bash $0" -fi - -# Detect distro -if [ -f /etc/os-release ]; then - . /etc/os-release - DISTRO="$ID" - DISTRO_LIKE="${ID_LIKE:-}" -else - print_err "Cannot detect distribution (no /etc/os-release)" -fi - -print_step "Detected: $PRETTY_NAME" - -# 1. Install Python 3 + Qt6 binding -print_step "Step 1/3: Installing Python 3 and Qt6 binding" -case "$DISTRO" in - debian|ubuntu|linuxmint|pop) - apt-get update - apt-get install -y python3 python3-pyqt6 || \ - apt-get install -y python3 python3-pyside6 - ;; - fedora|rhel|centos|rocky|almalinux) - if command -v dnf >/dev/null; then - dnf install -y python3 python3-pyqt6 || dnf install -y python3 python3-pyside6 - else - yum install -y python3 python3-pyqt6 || yum install -y python3 python3-pyside6 - fi - ;; - opensuse*|suse) - zypper install -y python3 python3-pyqt6 || zypper install -y python3 python3-PyQt6 \ - || zypper install -y python3 python3-pyside6 - ;; - arch|manjaro|endeavouros) - pacman -S --noconfirm python python-pyqt6 || pacman -S --noconfirm python python-pyside6 - ;; - alpine) - apk add python3 py3-pyqt6 || apk add python3 py3-pyside6 - ;; - *) - # Fallback: try pip - echo "Unknown distribution '$DISTRO'. Trying pip fallback..." - if command -v pip3 >/dev/null; then - pip3 install --break-system-packages PySide6 || pip3 install PySide6 - else - print_err "No pip3 available. Install python3-pyqt6 manually for your distro." - fi - ;; -esac - -# Verify Qt6 binding works -if ! python3 -c 'import PyQt6.QtWidgets' 2>/dev/null \ - && ! python3 -c 'import PySide6.QtWidgets' 2>/dev/null; then - print_err "Failed to install Qt6 Python binding. Install manually with your package manager." -fi -echo "✓ Python 3 + Qt6 binding installed" - -# 2. Install zupt CLI -print_step "Step 2/3: Installing zupt CLI 2.2.3" -case "$DISTRO" in - debian|ubuntu|linuxmint|pop) - if [ ! -f "$ZUPT_CLI_DEB" ]; then - print_err "Cannot find $ZUPT_CLI_DEB next to this script" - fi - # Force-replace any older zupt - dpkg -i "$ZUPT_CLI_DEB" || apt-get -f install -y - ;; - fedora|rhel|centos|rocky|almalinux|opensuse*|suse) - ZUPT_CLI_RPM="$SCRIPT_DIR/zupt-2.2.3-1.x86_64.rpm" - if [ -f "$ZUPT_CLI_RPM" ]; then - rpm -Uvh --force "$ZUPT_CLI_RPM" - else - print_err "RPM build not provided. Build from source tarball or install via SRPM." - fi - ;; - *) - # Fallback: tarball install - ZUPT_CLI_TAR="$SCRIPT_DIR/zupt-2.2.3-linux-x86_64.tar.gz" - if [ -f "$ZUPT_CLI_TAR" ]; then - tar -xzf "$ZUPT_CLI_TAR" -C /opt/ - ln -sf /opt/zupt-2.2.3-linux-x86_64/zupt /usr/local/bin/zupt - else - print_err "No suitable installer for $DISTRO" - fi - ;; -esac -echo "✓ zupt CLI installed" - -# 3. Install zupt-gui -print_step "Step 3/3: Installing zupt-gui" -case "$DISTRO" in - debian|ubuntu|linuxmint|pop) - dpkg -i "$ZUPT_GUI_DEB" || apt-get -f install -y - ;; - fedora|rhel|centos|rocky|almalinux|opensuse*|suse) - ZUPT_GUI_RPM="$SCRIPT_DIR/zupt-gui-1.1.1-1.noarch.rpm" - if [ -f "$ZUPT_GUI_RPM" ]; then - rpm -Uvh --force "$ZUPT_GUI_RPM" - fi - ;; - *) - # Manual fallback - mkdir -p /opt/zupt-gui /usr/local/bin - cp "$SCRIPT_DIR/zupt_gui.py" /opt/zupt-gui/ 2>/dev/null || true - cat > /usr/local/bin/zupt-gui <<'WRAP' -#!/bin/sh -exec python3 /opt/zupt-gui/zupt_gui.py "$@" -WRAP - chmod +x /usr/local/bin/zupt-gui - ;; -esac -echo "✓ zupt-gui installed" - -print_step "Installation complete" -echo "" -echo "Run:" -echo " zupt help # CLI help" -echo " zupt-gui # Graphical interface" -echo "" -echo "If you encounter issues, check that your zupt version is correct:" -echo " zupt version # should show 2.2.3" +# Stable entry point for the source installer. Dependency installation belongs +# to the operating-system package manager; this script performs no downloads. +set -Eeuo pipefail +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +exec "$repo_root/gui/install.sh" "$@" diff --git a/packaging/nix/flake.nix b/packaging/nix/flake.nix index 2755f17..aba67e8 100644 --- a/packaging/nix/flake.nix +++ b/packaging/nix/flake.nix @@ -1,25 +1,22 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # -# Nix flake for zupt. +# Nix flake for ZUPT. # # Usage (with flakes enabled): # nix build .#zupt # build the package -# nix run .#zupt -- version # run zupt directly +# nix run .#zupt -- --version # run ZUPT directly # nix develop # drop into a dev shell # nix flake check # lint the flake # # To consume from another flake: -# inputs.zupt.url = "git+https://git.securityops.co/cristiancmoises/zupt?ref=v2.4.4"; +# inputs.zupt.url = "github:cristiancmoises/zupt/v5.2.8"; # ...packages.x86_64-linux.default = inputs.zupt.packages.x86_64-linux.zupt; # -# Reproducibility: -# * Nix already pins the source tree by hash. -# * `make dist` is also reproducible (tests/test_dist_reproducible.sh). -# * Together, two independent Nix evaluations of the same flake.lock -# produce byte-identical /nix/store outputs. +# `make dist` has its own reproducibility gate. This development flake has no +# committed lock file and therefore makes no independent locked-output claim. { - description = "Zupt — post-quantum backup compression utility (C11)"; + description = "ZUPT — post-quantum backup compression utility (C11)"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; @@ -27,22 +24,25 @@ }; outputs = { self, nixpkgs, flake-utils }: - flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" ] (system: + flake-utils.lib.eachSystem [ "x86_64-linux" ] (system: let pkgs = import nixpkgs { inherit system; }; zupt = pkgs.stdenv.mkDerivation { - pname = "vaptvupt"; - version = "4.1.0"; + pname = "zupt"; + version = "5.2.8"; # When publishing, replace this with `fetchurl` against the # release tarball. For local development the flake assumes it # lives in the same directory as the source. - src = ./.; + src = builtins.path { path = ../..; name = "zupt-source"; }; nativeBuildInputs = with pkgs; [ gcc + git gnumake + file + gnutar ]; # python3 is only used by the regression-test harness. @@ -52,47 +52,40 @@ # hardening flags. Don't override -O2 from stdenv. NIX_CFLAGS_COMPILE = "-Wall -Wextra -Wpedantic -std=c11"; - # `make` builds the binary using vendored libzuptsdk via rpath. + # Source-only build (WITH_SDK=0): native crypto, no vendored libraries. buildPhase = '' runHook preBuild - make -j$NIX_BUILD_CORES + make WITH_SDK=0 WITH_PQBOX=0 -j$NIX_BUILD_CORES runHook postBuild ''; - # Run the full upstream regression suite. Disable per-package by - # setting doCheck = false; on by default. + # Distro-safe regression subset. Disable with doCheck = false;. doCheck = true; checkPhase = '' runHook preCheck - make test + make WITH_SDK=0 WITH_PQBOX=0 check runHook postCheck ''; installPhase = '' runHook preInstall - make DESTDIR=$out PREFIX= install - - # Move libzuptsdk into $out/lib/zupt/. The binary's rpath is - # $ORIGIN/../lib/zupt after autopatchelf rewrites it during - # the fixup phase. - mkdir -p $out/lib/zupt - install -m 0755 vendor/zuptsdk/libzuptsdk.so.2.0.0 \ - $out/lib/zupt/libzuptsdk.so.2.0.0 - ln -sf libzuptsdk.so.2.0.0 $out/lib/zupt/libzuptsdk.so.2 - ln -sf libzuptsdk.so.2.0.0 $out/lib/zupt/libzuptsdk.so + make PREFIX=$out WITH_SDK=0 WITH_PQBOX=0 \ + INSTALL_LEGACY_ALIAS=0 install # Docs mkdir -p $out/share/doc/zupt - cp README.md SECURITY.md CHANGELOG.md AUDIT.md $out/share/doc/zupt/ + cp README.md SECURITY.md CHANGELOG.md $out/share/doc/zupt/ + test -f $out/share/licenses/zupt/LICENSE-BSD-3-Clause + test -f $out/share/licenses/zupt/LICENSE-CC0-1.0 runHook postInstall ''; meta = with pkgs.lib; { - description = "Post-quantum backup compression utility (ML-KEM-768 + AES-256-CTR + HMAC-SHA256 + Argon2id)"; - homepage = "https://git.securityops.co/cristiancmoises/zupt"; - license = with licenses; [ agpl3Plus gpl3Plus ]; + description = "Post-quantum backup compression utility (ML-KEM-768 + X25519 + AES-256-CTR + HMAC-SHA256)"; + homepage = "https://github.com/cristiancmoises/zupt"; + license = with licenses; [ agpl3Plus gpl3Plus bsd2 bsd3 cc0 ]; maintainers = [ ]; - platforms = [ "x86_64-linux" "aarch64-linux" ]; + platforms = [ "x86_64-linux" ]; mainProgram = "zupt"; }; }; diff --git a/packaging/opensuse/README.md b/packaging/opensuse/README.md index 5bb2e09..cc2a092 100644 --- a/packaging/opensuse/README.md +++ b/packaging/opensuse/README.md @@ -1,88 +1,324 @@ -# openSUSE Build Service update for `home:cabelo:innovators/vaptvupt` +# ZUPT 5.2.8 for openSUSE Build Service -This directory contains the three files needed to build vaptvupt `4.1.0` -in OBS: +This directory is the upstream, source-only OBS recipe for ZUPT. It is a +handoff for the downstream maintainer; its presence does not mean that the +package has been submitted to or accepted by openSUSE Factory. -| File | Purpose | -|---------------|-------------------------------------------------------------------------| -| `_service` | `revision` pinned to `v4.1.0`. Format unchanged (still `tar_scm`). | -| `vaptvupt.spec` | `Version: 4.1.0`. `License: AGPL-3.0-or-later`. `%check` calls `make check`. | -| `vaptvupt.changes`| Changelog for the 4.x series. Older history preserved verbatim. | +Cristian Cezar Moisés, ZUPT's creator and current upstream maintainer, +prepared the current source, build, test, documentation, and upstream packaging +changes in this handoff. Alessandro de Oliveira Faria (Cabelo) is credited only +as the openSUSE collaborator and downstream OBS package maintainer: he reviews +the handoff, commits it through the portal/project he maintains, and may make +the openSUSE-side adjustments he considers necessary. This role does not +attribute upstream code or the +5.2.2/5.2.3/5.2.4/5.2.5/5.2.6/5.2.7/5.2.8 upstream changes to Cabelo. -## Spec notes +## Files and source policy -1. **License** — `AGPL-3.0-or-later` (dual-licensed AGPL-3.0-or-later - + commercial). +| File | Purpose | +|---|---| +| `_service` | Fetch the immutable `v5.2.8` tag and create `Source0` at build time. | +| `zupt.spec` | Build and test the CLI with optional external system integrations disabled. | +| `zupt.changes` | openSUSE-format package history. | +| `source-audit.sh` | Handoff wrapper for the repository scanner; run it from the complete handoff tree. | -2. **No BuildRequires beyond the toolchain** — the default build needs - only `gcc gzip make` (plus `libm`/`pthread` from glibc). There are - **no system library BuildRequires**. The repository is source-only: - the previously vendored `libzuptsdk.so` and `libpqvaptvupt.so` have - been removed from the tree, `%build` and `%install` run with - `WITH_SDK=0`, and `%files` no longer lists any `.so`. The package - installs no shared library. Do not add system crypto BuildRequires. +The source service uses `obs_scm`, with Git submodules and Git LFS explicitly +disabled. Its primary URL is the canonical upstream: - The optional SDK modes (`--pq-sdk`, `--pq-box`) and the Argon2id KDF - require an upstream `make WITH_SDK=1` build linked against the - separately distributed `libzuptsdk`/`libpqvaptvupt` libraries. They - are not part of this package. - -3. **`%check` target** — the s390x branch falls back to `test-vectors`; - other architectures run `make check`. This exercises the HMAC tamper - detection, archive-integrity trailer, byte-level integrity preface - AAD, default-KDF, auth-fail, and encrypted-comment suites, the - NIST/RFC vectors (SHA-256, SHA-3, ML-KEM-768, AES-256-CTR, HMAC, - X25519, PBKDF2), and the path-traversal, argument-order, and - block-swap regressions. - - The default password KDF is **PBKDF2-SHA256** (600k iterations). - Argon2id test vectors run only in a `WITH_SDK=1` build and are not - checked here. - -4. **URLs** — the `URL:` field points at the canonical project URL - `https://git.securityops.co/cristiancmoises/vaptvupt`. The `_service` - file still fetches from GitHub - (`https://github.com/cristiancmoises/vaptvupt`), which is what the - existing `tar_scm` configuration uses in OBS. - -## How to apply - -```sh -# 1. Check out the package -osc checkout home:cabelo:innovators vaptvupt -cd home:cabelo:innovators/vaptvupt - -# 2. Drop the new files in (assuming this README is at -# /path/to/vaptvupt-source/packaging/opensuse/README.md) -cp /path/to/vaptvupt-source/packaging/opensuse/_service . -cp /path/to/vaptvupt-source/packaging/opensuse/vaptvupt.spec . -cp /path/to/vaptvupt-source/packaging/opensuse/vaptvupt.changes . - -# 3. Trigger the service locally to fetch v4.1.0 from GitHub -osc service runall -# Produces vaptvupt-4.1.0.tar.gz in the current directory. - -# 4. (Optional) Local build to verify before committing -osc build openSUSE_Tumbleweed x86_64 - -# 5. Commit upstream -osc status # confirm vaptvupt-4.1.0.tar.gz is staged alongside the - # three text files -osc commit -m "Update to 4.1.0" +```text +https://github.com/cristiancmoises/zupt.git ``` -## Notes for future updates +`obs_scm` stores an `.obscpio` plus `.obsinfo`. The `tar` and `recompress` +services reconstruct `zupt-5.2.8.tar.gz` inside the build environment, which +matches `Source0` in the spec. -* The `_service` `revision` is pinned to `v4.1.0`. To track a new - release, edit that one line and re-run `osc service runall`. -* The spec's `Version:` field is hard-coded — when you bump `_service` - `revision`, also bump `Version:` to match. -* `BuildRequires` is intentionally minimal (`gcc gzip make`). vaptvupt - has no external library dependencies in the default build; do not add - system crypto BuildRequires. +This source policy does not prohibit separately built release-page packages. +The upstream 5.2.8 gates may publish the CLI source tarball, DEB, binary RPM, +SRPM, notice-bearing Linux tar.xz, Windows ZIP, and macOS DMG, together with a +GUI DEB, noarch RPM, GUI SRPM, and source-only portable GUI ZIP after each +format-specific test succeeds. None of those files is an OBS `Source0` input +or belongs in Git. AppImage and bare executables remain excluded: the former +lacks an audited runtime source/relink handoff, while the latter does not carry +the required license and notice payload beside the program. -## Reporting issues +## License and bundled codec -* Upstream bugs: https://git.securityops.co/cristiancmoises/vaptvupt -* openSUSE packaging bugs: https://bugs.opensuse.org/ -* Cabelo's OBS project: https://build.opensuse.org/project/show/home:cabelo:innovators +The resulting executable combines the AGPL-3.0-or-later application with the +GPL-3.0-or-later VaptVupt codec, adapted BSD-2-Clause XXH64 routines, and +CC0-1.0 pq-crystals/kyber-derived ML-KEM portions, plus BSD-3-Clause +curve25519-donna-derived X25519 portions, so the RPM uses: + +```text +AGPL-3.0-or-later AND GPL-3.0-or-later AND BSD-2-Clause AND BSD-3-Clause AND CC0-1.0 +``` + +The bundled codec is VaptVupt codec tag `v2.65.3`. It was integrated into this +repository by commit `59f9ebc59ea13c6edf1d199ca795cdbf00e62226` and is declared +as `bundled(vaptvupt-codec) = 2.65.3`. That integration commit records the local +ANS safe-zone reserve patch applied on top of the upstream tag. The package +retains all license and notice files, including Yann Collet's xxHash notice; +it does not claim that the codec is unbundled. + +## Optional SDK and PQBOX integrations + +The OBS package always builds with: + +```text +WITH_SDK=0 WITH_PQBOX=0 +``` + +The resulting CLI retains the in-tree password, ML-KEM-768, X25519 and hybrid +features. It does not enable the optional libvuptsdk-backed Argon2id/`--pq-sdk` +integration or the separate libpqvaptvupt-backed `--pq-box` integration. Those +options may only be enabled in a future package after their complete source or +system development packages, licenses, ABI and dependencies have been audited. +The build does not download dependencies and never loads a repository-local +`.so`, `.a` or `.o` fallback. + +## Archive integrity and compatibility in 5.2.2 + +New encrypted archives bind every DATA and DEDUP_REF frame to its logical +position. An authenticated reference also carries the authenticated position of +the source DATA frame, and new disk archives use flag-gated index/content-hash +metadata. The on-disk version byte remains 1.6, but an older reader is not +claimed to accept every new 5.2.2 encoding. + +The packaged `extract`, `list`, `test`, and `disk restore` paths require an +archive-integrity trailer by default, without trusting unauthenticated header +flags. `--allow-legacy-no-ait` is accepted only by those commands for recovery +of a known, trusted pre-AIT archive and emits a downgrade warning. `info` merely +reports unauthenticated framing and apparent AIT presence; it does not validate +the trailer or contents. Package documentation must not recommend the override +for untrusted input or present `info` success as an integrity result. + +The separate v5.2.1 compatibility claim is narrow: an actual +password-encrypted, deduplicated DATA/DATA/REF/DATA disk archive created from the +immutable v5.2.1 tag is stored as hexadecimal text with its source and SHA-256 +provenance. The 5.2.2 reader reconstructs the legacy linear block-AAD sequence, +lists, tests, extracts, and restores its input byte-exact through the +fixed-width legacy disk-index parser. This does not cover every historical mode +and passed in the full local Linux gate for commit `ff99770`; the target RPM +`%check` must still exercise it before that package is promoted. + +Disk restore also snapshots the measured archive into a private scratch file +before opening the destination, then validates and restores from that same +stream. An invalid `ZUPT_TMPDIR` override (or the compatibility fallback +`VAPTVUPT_TMPDIR`) and an unknown or insufficient raw-device capacity fail +before the first target write. The package check covers +the unprivileged unknown-capacity path; its loop-device size regression is +reported `SKIP`, not `PASS`, when the builder cannot create a loop device. + +## Migration from the former package name + +The main package is named `zupt` and installs only `/usr/bin/zupt`, its man +page, and its completions. The spec has a versioned `Provides: vaptvupt` and +`Obsoletes: vaptvupt` so an installed package under the former public name can +upgrade cleanly. It intentionally does not claim or install a second +`/usr/bin/vaptvupt` executable. The bundled codec and optional library keep +their established VaptVupt identifiers because those are compatibility-facing +API names, not the application package name. + +## Local validation workflow + +Run these commands in an OBS package checkout, not in the upstream Git tree: + +```sh +xmllint --noout _service +osc service manualrun +rpmspec -P zupt.spec >/dev/null +spec-cleaner --diff zupt.spec +osc build --clean --keep-pkgs="$PWD/.osc-build-results" \ + openSUSE_Tumbleweed x86_64 +rpmlint .osc-build-results/*.rpm +``` + +`osc service manualrun` materializes the service marked `manual` (the pinned +SCM input). The tarball itself is +reconstructed by the build-time services. Neither `%build` nor `%check` may +access the network. + +For a source RPM check outside OBS, place the service-produced +`zupt-5.2.8.tar.gz` next to the spec and use a disposable RPM build tree: + +```sh +rpm_top=$(mktemp -d) +trap 'rm -rf -- "$rpm_top"' EXIT +mkdir -p "$rpm_top"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} +rpmbuild --define "_topdir $rpm_top" --define "_sourcedir $PWD" \ + -bs zupt.spec +``` + +After building, inspect the RPM contents and dependencies, run `rpmlint`, then +install it in a disposable openSUSE environment and execute +`scripts/test-installed-zupt.sh`. Do not claim a repository or architecture +as supported until its build and installed smoke test have actually passed. + +## Prior 5.2.2 committed-candidate local Linux validation + +The immutable 5.2.2 candidate at `ff99770` passed the full local +`make release-check`. Packaging policy +and syntax reported `PASS=49 FAIL=0 SKIP=0`; source-only scanner testing passed +39/39, including GNU thin archives and safe diagnostic cases; strict GCC, +strict Clang, GCC `-fanalyzer`, the 9/9 full tool-enabled static-analysis run, +ASan/UBSan/LSan, and 1,000 mutation-fuzz iterations passed. A reduced +environment completed six available static checks and reported `cppcheck` +unavailable rather than passing it. Earlier off-screen GUI smoke evidence is +supporting evidence, not an exact-commit package result. + +Post-tag CI integration failures prevented 5.2.2 promotion. These historical +local results do not establish 5.2.8, native Windows or macOS success, hosted +GitHub CI/release promotion, authenticated OBS acceptance, or resolution of the +automatic openSUSE `debugsource` rpmlint `no-binary` finding. The immutable +5.2.3 candidate was not promoted because its source-policy test assumed LF for +a Windows `.bat` file checked out as CRLF. + +## Prior 5.2.4 exact-tag source-service evidence + +The immutable v5.2.4 candidate was not promoted. Exact-tag GitHub Actions run +`33431386002` recorded 12 successful jobs and one failed openSUSE job. That job's +standalone `Serviceinfo` harness passed the service directory to the executor +but did not make it the process working directory; dependent native Windows and +macOS jobs were skipped. + +A disposable local openSUSE Tumbleweed reproduction independently resolved +`refs/tags/v5.2.4` to the tagged commit. With `osc` 1.27.3, +`obs-service-obs_scm` 0.12.4, `obs-service-tar` 0.12.4, and +`obs-service-recompress` 0.5.2 installed, the same executor completed +`obs_scm`, `tar`, and `recompress` after `os.chdir(service_dir)`. It produced +exactly one `zupt-5.2.4.tar.gz`; its SHA-256 was +`aa68a58fc2e88ee92296542de1f189e2b8a803154d832fb04d5296b25acaef8f`, and the +source scanner reported `PASS source-only: 204 files, 1 archives`. + +This result establishes that the explicit tag revision works and isolates a +release/test harness defect. It does not change the product, archive format, +cryptography, codec, or SDK ABI; it does not make skipped native jobs pass or +establish authenticated OBS/Factory acceptance. No v5.2.4 evidence transferred +automatically to v5.2.8; the exact candidate later repeated every applicable +upstream gate in run `33456209269`, as recorded below. The automatic openSUSE +`debugsource` rpmlint `no-binary` finding remains unresolved and unsuppressed. + +## Prior 5.2.5 exact-tag native-gate evidence + +The immutable v5.2.5 candidate was not promoted. Exact-tag GitHub Actions run +`33434986357` completed 13 jobs successfully and failed the native Windows and +macOS jobs. Windows exposed a hostile-path fixture that did not preserve its +requested bytes across the command-line boundary; macOS exposed the unsupported +`explicit_bzero` assumption and Bash 3.2 empty-array handling. The 5.2.6 +corrections address those release/test integration defects without an archive, +cryptographic, codec, or SDK ABI change. + +## Prior 5.2.6 exact-tag native-gate evidence + +The immutable v5.2.6 candidate was not promoted. Exact-tag GitHub Actions run +`33442264243` completed 13 jobs successfully and failed two native jobs. The +macOS arm64 SHA-NI test build treated unused x86-only helper declarations as +errors under `-Werror`; Windows argv transcoding aborted the safe printable +UTF-8 fixture before its intended path assertions. The 5.2.7 changes correct +those test-harness boundaries without an archive-format, cryptographic, codec, +or SDK ABI change. They do not establish 5.2.8 hosted, native, OBS, or promotion +evidence. + +## Prior 5.2.7 exact-tag native-gate evidence + +The immutable v5.2.7 candidate was not promoted. Exact-tag GitHub Actions run +`33445470664` concluded `cancelled` at `2026-08-31T23:11:19Z`, with 13 +successful jobs, one failed macOS job, and one cancelled Windows job. macOS +rejected creation of the raw-C1 scanner fixture +with `EILSEQ`; the hosted Windows job stalled in `make check`, and a MinGW/Wine +reproduction isolated the cause to a redirected password prompt entering +`_getch`. Version 5.2.8 makes those test +boundaries fail or skip without hanging, addresses CodeQL High #5/#6/#7 in SDK +key publication, disk restore, and benchmark cleanup, and adds `sdk-test` to +release and hosted Linux gates. None of those changes establishes an exact +5.2.8 OBS, native, hosted-CI, or promotion result. + +## 5.2.8 exact-tag upstream package evidence + +Manually dispatched exact-tag GitHub Actions run `33456209269` passed all 15 +jobs at `ebb9ab3aa1d42c50030ca02883f6162dc4771fe1`. Its openSUSE Tumbleweed +x86_64 job parsed and normalized the spec, executed the pinned standalone OBS +source-service chain, source-scanned the resulting archive, built the binary +RPM and genuine SRPM, ran `rpmlint` without suppressions, and completed the +install/round-trip/uninstall test. The canonical source archive is 798296 bytes +with SHA-256 +`378b9506211545b9594cf0d38ac8955d9b1cac34eb6b379ae0ec26b84edb65f7`. + +Promotion run `33457868306` published the exact tested binary RPM and SRPM with +the other gated assets. The source package is identified by +`%{SOURCEPACKAGE}=1` and an absent `%{SOURCERPM}`; its `%{ARCH}` legitimately +reflects the spec's build architecture and is not the SRPM discriminator. +Repository, Git archive, and upstream source tarball scans remain binary-free. + +This is upstream local-service and package evidence, not a claim that the +package was submitted to or accepted by openSUSE Factory, nor a result for the +full set of automatically generated OBS debug packages or any untested +architecture. + +## Prior openSUSE packaging validation + +The local results below were produced on 2026-08-24 from the 5.2.2 candidate +snapshot captured for the packaging run, in a disposable openSUSE Tumbleweed +20260822 x86_64 container. This matrix was documented afterward, so the results +validate that captured snapshot, not the later documentation edit, a future +commit or a tag. Commit- and tag-dependent checks must be repeated after the +final commit; the validation tarball checksum below is not a release checksum. +`SKIP` is not success. + +| Gate | Result | Evidence | +|---|---|---| +| `_service` XML syntax | PASS | `xmllint --noout`; installed service definitions and parameters also exercised locally. | +| ShellCheck for packaging, export, source-policy, and security regression scripts | PASS | ShellCheck 0.10.0 returned zero for the scripts listed in the CI source-policy job, including the scanner and new archive/disk regressions; repeat after the final commit/tag. | +| Upstream source-only scanner and adversarial scanner tests | PASS | Clean snapshot: 191 files; OBS tar: 191 files/1 archive; SRPM tree: 193 files/1 archive; 29 positive/negative scanner regressions passed. | +| Reproducible source archive (two builds, same SHA-256) | PASS | Two local `obs_scm`/`tar`/`recompress` runs were byte-identical (`39e59f5e...`, validation only; regenerate after the real tag). | +| Upstream build, `make check`, and `make test-all` | SKIP | The real RPM `%check`/`make check` passed; an exact-candidate `make test-all` result was not produced by this packaging run. | +| Positional DATA/DEDUP_REF AAD and mandatory-AIT regressions | PASS | `%check` passed AIT removal, F-09 preface, DATA/REF reorder/replay, little-endian, varint and atomic-output regressions. | +| v5.2.1 encrypted+dedup disk compatibility | PASS | Working-tree candidate decoded the textual 718-byte v5.2.1 DATA/DATA/REF/DATA fixture, then `list`, `test`, generic extraction, and byte-exact disk restore passed; repeat after the final commit/tag. | +| `rpmspec` parse | PASS | Both `rpmspec -P` and `rpmspec --parse` returned zero; Source0 resolved to `zupt-5.2.2.tar.gz`. | +| `spec-cleaner` | PASS | Version 1.2.4+2 returned zero and proposed no diff. | +| `rpmbuild` source and binary RPM | PASS | `rpmbuild -bs` and `-ba` passed from the service-generated Source0 with the openSUSE `.changes` conversion. | +| `rpmlint` main RPM + SRPM | PASS | 0 errors and one `invalid-url Source0` warning for the service-generated local Source0; no `rpmlintrc` or suppression was added. | +| `rpmlint` including automatic debug packages | FAIL | `debugsource: no-binary` error and expected `debuginfo: unstripped-binary-or-object` warning from the complete generated package set; debug packages were not disabled or suppressed. | +| `osc service` | PASS | Installed `obs_scm` 0.12.4, `tar` 0.12.4 and `recompress` 0.5.2 produced the correctly named source tar locally; canonical tag fetch remains tag-dependent. | +| Tumbleweed x86_64 local build/install/round trip/uninstall | PASS | Tumbleweed 20260822 container: RPM `%check`, root and `nobody` installed tests, content/hardening audit and clean uninstall passed. This is not an OBS/Factory result. | +| Official OBS `osc build` invocation | FAIL | The command reached `https://api.opensuse.org` but returned HTTP 401 because no OBS credentials are configured. | +| Factory/Tumbleweed x86_64 OBS validation | SKIP | The failed authenticated `osc build` invocation produced no Factory build result; local Tumbleweed evidence is not promoted to Factory evidence. | +| aarch64, ppc64le, s390x, riscv64 | SKIP | No build evidence yet. | +| Leap and SLE | SKIP | No build evidence yet. | + +`SKIP` is not success. Factory/Tumbleweed x86_64 remains the primary downstream +gate. + +## Handoff procedure for Alessandro/Cabelo + +1. Upstream completes every applicable pre-tag source and local audit gate, + then creates and verifies the annotated `v5.2.8` tag. Exact-tag hosted, + native-platform, package, and promotion gates must pass before release or + downstream handoff; the tag itself is never moved to repair a failure. +2. With Git, `file`, bsdtar, tar, zip, unzip and SHA-256 tools installed, run + `scripts/export-opensuse-package.sh v5.2.8`. Verify the reported ZIP and + SHA-256 outside the Git index. The handoff includes both + `packaging/opensuse/source-audit.sh` and its required + `scripts/check-source-only.sh`; keep that relative layout while auditing. +3. Check out the OBS package: + + ```sh + osc checkout home:cabelo:innovators zupt + cd home:cabelo:innovators/zupt + ``` + +4. From the extracted handoff root, run + `packaging/opensuse/source-audit.sh --archive /path/to/zupt-5.2.8.tar.gz`. + Then copy `_service`, `zupt.spec`, `zupt.changes` and `README.md` + into the flat OBS package checkout. The audit wrapper is not an OBS build + source and must not be copied without its companion `scripts/` directory. +5. Run the local validation workflow above, including the installed round-trip + test. Build every repository and architecture enabled in the OBS project; + record failures or unavailable gates as such. +6. Review `osc diff`, confirm that no RPM or other binary was added as a source, + and commit to OBS only after the required gates pass. + +For future releases, increment the stable patch version, create a new immutable +tag, update the matching revision/version in `_service`, spec and changes, run +the source-only scanner, regenerate the handoff, and repeat every OBS gate. +Never move an existing tag or consume forge release binaries as `Source0`. diff --git a/packaging/opensuse/_service b/packaging/opensuse/_service index 44dc47d..7fc42da 100644 --- a/packaging/opensuse/_service +++ b/packaging/opensuse/_service @@ -1,16 +1,20 @@ + + - - https://github.com/cristiancmoises/zupt - git - v4.1.0 - @PARENT_TAG@ - v(.*) - enable - vaptvupt - - - *.tar - gz - - + + https://github.com/cristiancmoises/zupt.git + git + refs/tags/v5.2.8 + @PARENT_TAG@ + ^v(.*)$ + \1 + zupt + disable + disable + + + + *.tar + gz + diff --git a/packaging/opensuse/source-audit.sh b/packaging/opensuse/source-audit.sh new file mode 100755 index 0000000..77d9771 --- /dev/null +++ b/packaging/opensuse/source-audit.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later + +set -Eeuo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +SCANNER=$SCRIPT_DIR/../../scripts/check-source-only.sh + +if [[ ! -x $SCANNER ]]; then + printf 'ERROR: source-only scanner is missing or not executable: %s\n' "$SCANNER" >&2 + exit 2 +fi + +exec "$SCANNER" "$@" diff --git a/packaging/opensuse/vaptvupt.spec b/packaging/opensuse/vaptvupt.spec deleted file mode 100644 index 6a55236..0000000 --- a/packaging/opensuse/vaptvupt.spec +++ /dev/null @@ -1,103 +0,0 @@ -# -# spec file for package vaptvupt -# -# Copyright (c) 2026 SUSE LLC -# Copyright (c) 2026 Alessandro de Oliveira Faria (A.K.A CABELO) -# Copyright (c) 2025-2026 Cristian Cezar Moisés (upstream) -# -# All modifications and additions to the file contributed by third parties -# remain the property of their copyright owners, unless otherwise agreed -# upon. The license for this file, and modifications and additions to the -# file, is the same license as for the pristine package itself (unless the -# license for the pristine package is not an Open Source License, in which -# case the license is the MIT License). An "Open Source License" is a -# license that conforms to the Open Source Definition (Version 1.9) -# published by the Open Source Initiative. - -# Please submit bugfixes or comments via https://bugs.opensuse.org/ -# - - -Name: vaptvupt -Version: 4.1.0 -Release: 0 -Summary: Post-quantum backup compression with AES-256 + ML-KEM-768 hybrid encryption -License: AGPL-3.0-or-later -Group: Productivity/Archiving/Compression -URL: https://git.securityops.co/cristiancmoises/vaptvupt -Source0: %{name}-%{version}.tar.gz -BuildRequires: gcc -BuildRequires: gzip -BuildRequires: make - -# v3.0.0 renamed the project Zupt -> VaptVupt (prior INPI Brasil -# trademark on "Zupt"). Cleanly supersede any installed zupt package; -# the binary still installs a /usr/bin/zupt compatibility symlink. -Provides: zupt = %{version}-%{release} -Obsoletes: zupt < 3.0.0 - -%description -VaptVupt (formerly Zupt; renamed in v3.0.0 due to a prior INPI Brasil -trademark on the name "Zupt") compresses and encrypts backup archives. -LZ + ANS compression (VaptVupt codec, ~2-3 GB/s decompression on x86_64 -with AVX2 / aarch64 with NEON), AES-256-CTR + HMAC-SHA256 per-block -authenticated encryption, multi-threaded, with ML-KEM-768 + X25519 -post-quantum hybrid key encapsulation (FIPS 203 + RFC 7748) via --pq. - -This package builds entirely from source with no external library -dependency. The password KDF is PBKDF2-SHA256 (600k iterations). The -optional libzuptsdk-backed modes (Argon2id KDF, --pq-sdk, --pq-box) are -not built here; they require an upstream WITH_SDK=1 build against the -separately distributed libzuptsdk/libpqvaptvupt. - -Pure C11, ~5,000 lines of core code. Constant-time cryptographic -primitives are formally verified with Jasmin on x86_64 -(zupt_mac_verify_ct, zupt_ct_select_32); a clean C fallback runs on -aarch64 and other architectures. - -%prep -%autosetup -p1 -chmod +x tests/*.sh - -%build -%make_build V=1 WITH_SDK=0 \ - CFLAGS="%{optflags} -fPIE -Wall -Wextra -std=c11 -Iinclude -Isrc" \ - LDFLAGS="%{?build_ldflags} -pie" \ - LDLIBS="-lm -lpthread" - -%check -# `make check` is the distro-safe subset added in 2.4.8: runs the -# security-critical regressions (F-06 HMAC, F-08 AIT, F-09 byte -# integrity, F-10 KDF, F-11 auth-fail, F-12 comments) plus NIST/RFC -# vectors. Skips threaded and dist-reproducibility tests that are -# sensitive to build-host environment. -# -# On s390x, fall back to just the vector tests (Jasmin assembly is -# x86_64-only; threading harness has been flaky on big-endian). -%ifarch s390x -%make_build V=1 WITH_SDK=0 \ - CFLAGS="%{optflags} -fPIE -Wall -Wextra -std=c11 -Iinclude -Isrc" \ - LDFLAGS="%{?build_ldflags} -pie" \ - LDLIBS="-lm -lpthread" \ - test-vectors -./test_vectors -%else -%make_build V=1 WITH_SDK=0 \ - CFLAGS="%{optflags} -fPIE -Wall -Wextra -std=c11 -Iinclude -Isrc" \ - LDFLAGS="%{?build_ldflags} -pie" \ - LDLIBS="-lm -lpthread" \ - check -%endif - -%install -%make_install WITH_SDK=0 PREFIX=%{_prefix} - -%files -%license LICENSE -%doc README.md SECURITY.md CHANGELOG.md -%{_bindir}/vaptvupt -%{_bindir}/zupt -%{_mandir}/man1/vaptvupt.1%{?ext_man} -%{_mandir}/man1/zupt.1%{?ext_man} - -%changelog diff --git a/packaging/opensuse/vaptvupt.changes b/packaging/opensuse/zupt.changes similarity index 66% rename from packaging/opensuse/vaptvupt.changes rename to packaging/opensuse/zupt.changes index ea2896c..476816b 100644 --- a/packaging/opensuse/vaptvupt.changes +++ b/packaging/opensuse/zupt.changes @@ -1,3 +1,183 @@ +------------------------------------------------------------------- +Mon Aug 31 23:30:00 UTC 2026 - Cristian Cezar Moisés + +- Update to 5.2.8: + * Close CodeQL High path-race findings in SDK key publication, + descriptor-first disk restore, and benchmark workspace cleanup. + * Make the raw-C1 scanner fixture explicitly skip filesystems that reject + the byte with EILSEQ, reject redirected Windows prompts before _getch, and + add sdk-test to release/hosted Linux gates. + * Preserve immutable, unpromoted v5.2.7 run 33445470664: 13 jobs succeeded, + macOS failed the raw-C1 fixture, and Windows was cancelled after the hosted + job stalled; a MinGW/Wine reproduction isolated redirected _getch entry. + * Pin the OBS source service to the immutable v5.2.8 tag and require fresh + exact-candidate evidence before promotion. + +------------------------------------------------------------------- +Mon Aug 31 23:00:00 UTC 2026 - Cristian Cezar Moisés + +- Update to 5.2.7: + * Scope SHA-NI test helpers away from unsupported macOS arm64 builds. + * Preserve safe UTF-8 fixture bytes across the Windows argv boundary. + * Preserve immutable, unpromoted 5.2.6 history and require fresh 5.2.7 gates. + * Pin the OBS source service to the immutable v5.2.7 tag. + +------------------------------------------------------------------- +Mon Aug 31 21:30:00 UTC 2026 - Cristian Cezar Moisés + +- Update to 5.2.6: + * Use the compiler-resistant volatile wipe fallback on macOS and NetBSD. + * Make source-scanner empty-array handling compatible with Bash 3.2. + * Preserve hostile archive-path fixture bytes exactly on Windows. + * Preserve immutable, unpromoted 5.2.5 history and require fresh 5.2.6 gates. + * Pin the OBS source service to the immutable v5.2.6 tag. + +------------------------------------------------------------------- +Mon Aug 31 19:55:00 UTC 2026 - Cristian Cezar Moisés + +- Update to 5.2.5: + * Run the standalone OBS source-service chain from its isolated working + directory so downstream services can find .obsinfo. + * Add a packaging-policy regression for the executor working directory. + * Preserve immutable, unpromoted 5.2.4 history and require fresh 5.2.5 gates. + * Pin the OBS source service to the immutable v5.2.5 tag. + +------------------------------------------------------------------- +Mon Aug 31 18:55:00 UTC 2026 - Cristian Cezar Moisés + +- Update to 5.2.4: + * Make the static Windows GUI package-version check robust to canonical + CRLF checkouts. + * Advance source-only package metadata and prepare final archive hashes. + * Pin the OBS source service to the immutable v5.2.4 tag. + +------------------------------------------------------------------- +Mon Aug 31 18:15:00 UTC 2026 - Cristian Cezar Moisés + +- Update to 5.2.3: + * Derive package checks from the upstream version header and stabilize the + GUI version output consumed by package gates. + * Replace busybox-gawk before installing the native Tumbleweed RPM tooling. + * Pin the OBS source service to the immutable v5.2.3 tag. + +------------------------------------------------------------------- +Mon Aug 31 00:00:00 UTC 2026 - Cristian Cezar Moisés + +- Update to 5.2.2: + * Convert the upstream and OBS inputs to a source-only release: remove + precompiled library and object inputs and reject their reintroduction with + reusable source-archive auditing. + * Build with WITH_SDK=0 and WITH_PQBOX=0. The optional integrations now + require an explicit source or packaged system dependency and never use a + private precompiled fallback. + * Preserve portable compiler and linker flags, architecture-local optimized + translation units, scalar fallbacks, parallel build, and staged DESTDIR + installation. + * Build the packaged executable as PIE with full RELRO/NOW and a + non-executable stack while preserving automatic debuginfo generation and + avoiding manual stripping or RPATH/RUNPATH. + * Update OBS source services to obs_scm pinned to the immutable v5.2.2 tag; + disable submodules and Git LFS and create the compressed tarball at build + time. + * Run the real upstream check target from the RPM check phase without + architecture-specific test suppression. + * Harden archive extraction against traversal, symlink/hardlink and Windows + reparse-point races; publish only fully size/checksum-verified temporary + output and add structurally valid hostile-archive regression fixtures. + * Reject normal, solid, and disk-backup output aliases of an input file, + including alternate spellings, hardlinks, and symlinks, before creating the + output; --force cannot bypass this data-loss guard. + * Snapshot disk-restore input privately before opening its destructive + destination and restore from the same validated stream. Reject raw devices + whose capacity is unknown or smaller than the image before the first write. + * Enforce DATA frame types across serial, threaded, solid, test, and disk + readers, and retain the exact encrypted+dedup AAD sequence used by v5.2.1. + Test an actual v5.2.1 password-encrypted DATA/DATA/REF/DATA disk fixture + through list, test, generic extraction, and disk restore. + * Use random private benchmark scratch directories and remove them without + following links instead of using a predictable process-ID path. + * Package the AGPL-3.0-or-later application together with the bundled + GPL-3.0-or-later VaptVupt codec 2.65.3 and the BSD-2-Clause XXH64-derived + routines; preserve all applicable notices. + * Rename the application and package back to ZUPT/zupt. Install only the + zupt command and add versioned Provides/Obsoletes for migration from the + former vaptvupt package without shipping a duplicate executable. + * Add the source-only openSUSE handoff/export workflow and validation matrix. + * Add explicit password prompt, file, and inherited-descriptor inputs. + * Validate the source audit, rpmbuild -bs/-ba, the complete RPM check phase, + package contents and dependencies, installed round trips, and clean + uninstall in a disposable openSUSE Tumbleweed 20260822 x86_64 container. + OBS/Factory, other architectures, Leap, and SLE remain separate unexecuted + downstream gates and are not claimed by this validation. + * Correct the licensing record without revoking historical MIT grants present + in earlier repository revisions; current files follow current SPDX notices. + * Correct the stale public-domain statement for XXH64-derived code and retain + Yann Collet's BSD-2-Clause copyright, conditions, and disclaimer. + * Record the CC0-1.0 option and provenance for pq-crystals/kyber-derived + ML-KEM portions, the BSD-3-Clause curve25519-donna origin of native X25519 + portions, and the public-domain LZMA SDK origin of the x86 BCJ code. + * Keep AppImage outside the 5.2.2 promoted set until its static runtime has a + complete license/source-relink handoff; publish Windows only as a ZIP with + the executable and notices. + * Gate notice-bearing Linux tar.xz and Windows/macOS CLI bundles plus GUI + DEB, noarch RPM, source RPM, and source-only portable ZIP artifacts; keep + bare executables out of the promoted set. + * Qualify historical formal-verification and constant-time wording: current + source review and runtime regressions are not a proof for every compiler, + CPU, or final package binary. + +------------------------------------------------------------------- +Fri Jul 10 18:00:00 UTC 2026 - Alessandro de Oliveira Faria + +- Update to 5.0.0: + * ML-KEM-768 is now genuinely FIPS 203-conformant (was round-3 + CRYSTALS-Kyber): fixed a transposed matrix-A sampling convention, + the round-3 KDF, and the implicit-rejection domain. Validated + byte-for-byte against OpenSSL 3.5's FIPS 203 ML-KEM-768 + (tests/test_mlkem_fips203.sh, run in %check). + * BREAKING: --pq / --pq-only keys and archives from <= 4.2.1 no + longer decrypt (the KEM math changed). Regenerate keys and + re-encrypt. Password mode and plain compression are unaffected; + wire format stays v1.6. + * Security: compress data-loss and silent-plaintext guards; heap + OOB read in the AVX2 decoder bounded; overflow-safe solid-mode + test path; secret-wipe on hybrid-decrypt key-read error. + * GUI reworked for the source-only build; truthful banner/help. + +------------------------------------------------------------------- +Fri Jul 10 12:00:00 UTC 2026 - Alessandro de Oliveira Faria + +- Update to 4.2.1: + * Fix: "vaptvupt info" mislabelled full post-quantum (--pq-only) + archives as "PQ Hybrid (ML-KEM-768 + X25519)". info now reads the + real enc_type from the encryption-header block and reports the + actual mode ("ML-KEM-768 only, no classical layer" for --pq-only). + Reader-side only; no wire-format change, existing 4.2.0 archives are + relabelled with no re-encryption. + +------------------------------------------------------------------- +Thu Jul 9 12:00:00 UTC 2026 - Alessandro de Oliveira Faria + +- Update to 4.2.0: + * New native full (pure) post-quantum mode --pq-only: ML-KEM-768 + (FIPS 203) as the sole key-establishment mechanism, with no + classical X25519 component (envelope type 0x06; archive key + SHA3-512(ml_ss || ml_ct || "ZUPT-PQ-ONLY-v1")). For compliance + postures that mandate a single NIST-standardised PQ primitive with + no classical KEM in the envelope (CNSA 2.0-style "PQ-only"). Keys + via keygen --pq-only. In-tree crypto, built in the default + source-only package. Hybrid --pq remains the recommended default; + --pq-only has no classical fallback. + * Security (critical): fixed AES-256-CTR keystream reuse under + --dedup. Dedup blocks all use sequence 0, so the previous nonce + (base_nonce XOR seq) collapsed to a single value across blocks, + reusing the CTR keystream. Each block now uses a fresh random + 128-bit nonce. Re-encrypt any --dedup encrypted archives written + by <= 4.1.0. + * keygen --sdk / --box now gives clear guidance toward native --pq / + --pq-only on a source-only build. + * Wire format v1.6 unchanged; the 0x06 envelope is additive. + ------------------------------------------------------------------- Tue Jul 7 12:00:00 UTC 2026 - Alessandro de Oliveira Faria @@ -192,10 +372,10 @@ Tue May 26 02:27:34 UTC 2026 - Alessandro de Oliveira Faria - Update to 3.0.1 - * GUI license cleanup: removed MIT credit line from the about - panel; gui/LICENSE-GUI replaced (was MIT) with AGPL-3.0-or-later - to match the source SPDX header. The GUI was never actually - released under MIT — that was a templating mistake. + * GUI license metadata changed to AGPL-3.0-or-later for the then-current + source. The original entry incorrectly denied earlier MIT grants; the + 5.2.2 erratum records that they remain valid for the exact historical + material distributed under them. * GUI version-string parsing bug fix (the replace("zupt ", ...) substring also matched inside the v3.0.0 parenthetical). Window title, splash header, status bar and about-panel hero number now @@ -244,7 +424,7 @@ Sun May 24 13:08:04 UTC 2026 - Alessandro de Oliveira Faria +# Alessandro's attribution is for downstream openSUSE/OBS packaging only. +# Copyright (c) 2025-2026 Cristian Cezar Moisés (upstream) +# +# All modifications and additions to the file contributed by third parties +# remain the property of their copyright owners, unless otherwise agreed +# upon. The license for this file, and modifications and additions to the +# file, is the same license as for the pristine package itself (unless the +# license for the pristine package is not an Open Source License, in which +# case the license is the MIT License). An "Open Source License" is a +# license that conforms to the Open Source Definition (Version 1.9) +# published by the Open Source Initiative. +# + +Name: zupt +Version: 5.2.8 +Release: 0 +Summary: Backup compression with authenticated and post-quantum encryption +License: AGPL-3.0-or-later AND GPL-3.0-or-later AND BSD-2-Clause AND BSD-3-Clause AND CC0-1.0 +URL: https://github.com/cristiancmoises/zupt +Source0: %{name}-%{version}.tar.gz +BuildRequires: bash +BuildRequires: coreutils +BuildRequires: diffutils +BuildRequires: file +BuildRequires: findutils +BuildRequires: gawk +BuildRequires: gcc +BuildRequires: git-core +BuildRequires: grep +BuildRequires: gzip +BuildRequires: make +BuildRequires: python3-base +BuildRequires: sed +BuildRequires: tar +Provides: bundled(vaptvupt-codec) = 2.65.3 +Provides: vaptvupt = %{version}-%{release} +Obsoletes: vaptvupt < %{version} + +%description +ZUPT creates compressed backup archives with optional authenticated +password encryption or ML-KEM-768 and X25519 hybrid key encapsulation. The +default package is built entirely from the source in the release archive. + +Optional SDK and PQBOX features are disabled because audited development +packages are unavailable. No private compiled library is installed. + +%prep +%autosetup -p1 +bash scripts/check-source-only.sh --tree . + +%build +%make_build WITH_SDK=0 WITH_PQBOX=0 \ + CFLAGS="%{optflags} -fPIE" \ + LDFLAGS="%{?build_ldflags} -Wl,-z,relro,-z,now -pie" + +%check +%make_build WITH_SDK=0 WITH_PQBOX=0 \ + CFLAGS="%{optflags} -fPIE" \ + LDFLAGS="%{?build_ldflags} -Wl,-z,relro,-z,now -pie" \ + check + +%install +%make_install WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 INSTALL_LICENSES=0 \ + PREFIX=%{_prefix} \ + BINDIR=%{_bindir} \ + MANDIR=%{_mandir} + +%files +%license LICENSE LICENSE-AGPL-3.0 LICENSE-GPL-3.0 LICENSE-BSD-2-Clause LICENSE-BSD-3-Clause LICENSE-CC0-1.0 NOTICE THIRD-PARTY-NOTICES.md +%doc README.md CHANGELOG.md SECURITY.md THREAT_MODEL.md +%{_bindir}/zupt +%{_datadir}/bash-completion/completions/zupt +%{_datadir}/zsh/site-functions/_zupt +%{_datadir}/fish/vendor_completions.d/zupt.fish +%{_mandir}/man1/zupt.1%{?ext_man} + +%changelog diff --git a/packaging/portable/README.txt b/packaging/portable/README.txt new file mode 100644 index 0000000..05b06ae --- /dev/null +++ b/packaging/portable/README.txt @@ -0,0 +1,59 @@ +ZUPT GUI — source-only portable launcher template +===================================================== + +This tracked directory contains three launcher templates and this assembly +guide; it is not a complete bundle by itself. A downstream source-only bundle +may add the integrated Python GUI source and artwork listed below, together +with the required license/provenance files. It must not contain Python, Qt, a +precompiled ZUPT command, or a vendored library. Its presence in a release +would not be evidence that every target operating system was tested; consult +that release's validation matrix. + +Contents +-------- + zupt_gui.py GUI source module (the historical module filename is + retained internally for source compatibility). + zupt-gui.bat Windows launcher. + zupt-gui.command macOS Finder launcher. + zupt-gui.sh POSIX shell launcher. + assets/zupt-icon.png PNG application artwork. + assets/zupt.ico Windows application artwork. + LICENSE-AGPL-3.0 Complete current GUI source license text. + LICENSE-GUI GUI licensing and historical-license note. + ASSET-PROVENANCE.md Artwork purpose, provenance, and license record. + CHANGELOG.md Release history and current compatibility notes. + +Requirements +------------ + 1. Python 3.9 or newer. + 2. PySide6 6.5 or newer, or a compatible PyQt6 package. + 3. ZUPT 5.2.8, installed as `zupt` on PATH or placed beside the launcher + (`zupt.exe` on Windows). A local command must have been built + and tested independently; this bundle never downloads one. + +Running +------- + Windows: zupt-gui.bat + macOS: zupt-gui.command + POSIX: ./zupt-gui.sh + +The launchers set ZUPT_BIN when a local command is present. The GUI then +checks `zupt version`, discovers native and optional capabilities, and +exposes SDK or PQ-box modes only when the command reports the corresponding +system-library integration enabled. + +Troubleshooting +--------------- + * "requires PySide6 or PyQt6": install one Qt binding through your operating + system package manager or another trusted, preconfigured Python source. + * "zupt not found": install ZUPT 5.2.8 or place its command beside + the launcher. + * Set ZUPT_DEBUG=1 to print command-discovery diagnostics to stderr. + +The old user-facing command name is not installed by this bundle. The `.zupt` +archive extension remains unchanged for format compatibility. + +Current GUI source license: AGPL-3.0-or-later. Published historical revisions +include MIT grants for the exact material covered by their notices; see +LICENSE-GUI and the 5.2.2 erratum in CHANGELOG.md. +Project: https://github.com/cristiancmoises/zupt diff --git a/packaging/portable/zupt-gui.bat b/packaging/portable/zupt-gui.bat new file mode 100644 index 0000000..ea3b77b --- /dev/null +++ b/packaging/portable/zupt-gui.bat @@ -0,0 +1,30 @@ +@echo off +rem SPDX-License-Identifier: AGPL-3.0-or-later +rem ZUPT GUI launcher for Windows (portable package). +rem +rem Requirements on the target machine: +rem * Python 3.9+ +rem * PySide6 or PyQt6: py -m pip install PySide6 +rem * The ZUPT CLI: zupt.exe next to this file, or on PATH. +rem +rem If zupt.exe sits beside this launcher we pin it via ZUPT_BIN so the +rem GUI drives the bundled CLI rather than any other copy on PATH. +setlocal +set "HERE=%~dp0" +if exist "%HERE%zupt.exe" set "ZUPT_BIN=%HERE%zupt.exe" + +rem Prefer the py launcher, fall back to python on PATH. +where py >nul 2>nul +if %ERRORLEVEL%==0 ( + py -3 "%HERE%zupt_gui.py" %* +) else ( + python "%HERE%zupt_gui.py" %* +) +set "RC=%ERRORLEVEL%" +if not "%RC%"=="0" ( + echo. + echo zupt-gui exited with code %RC%. + echo If you saw an import error, install the Qt binding: py -m pip install PySide6 + echo If the CLI was not found, put zupt.exe next to this launcher or on PATH. +) +endlocal & exit /b %RC% diff --git a/packaging/portable/zupt-gui.command b/packaging/portable/zupt-gui.command new file mode 100755 index 0000000..0c6d738 --- /dev/null +++ b/packaging/portable/zupt-gui.command @@ -0,0 +1,17 @@ +#!/bin/bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# ZUPT GUI launcher for macOS (portable package). +# Double-clickable in Finder (.command). Requirements on the target Mac: +# * Python 3.9+ +# * PySide6 or PyQt6: python3 -m pip install PySide6 +# * The ZUPT CLI: `zupt` next to this file, or on PATH +# (Homebrew: `brew install cristiancmoises/tap/zupt`). +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +[ -x "$HERE/zupt" ] && export ZUPT_BIN="$HERE/zupt" + +PY="$(command -v python3 || command -v python || true)" +if [ -z "$PY" ]; then + osascript -e 'display alert "ZUPT GUI" message "Python 3.9 or newer was not found. Install Python and a trusted PySide6 or PyQt6 package."' 2>/dev/null + echo "Python 3 not found." >&2; exit 1 +fi +exec "$PY" "$HERE/zupt_gui.py" "$@" diff --git a/packaging/portable/zupt-gui.sh b/packaging/portable/zupt-gui.sh new file mode 100755 index 0000000..09185db --- /dev/null +++ b/packaging/portable/zupt-gui.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# SPDX-License-Identifier: AGPL-3.0-or-later +# ZUPT GUI launcher for Linux and the BSDs (portable package). +# Requirements on the target system: +# * Python 3.9+ +# * PySide6 or PyQt6 +# Debian/Ubuntu: sudo apt install python3-pyqt6 +# Fedora/RHEL: sudo dnf install python3-pyqt6 +# FreeBSD: pkg install py311-pyside6 (or py311-qt6-pyqt) +# OpenBSD: pkg_add py3-pyside6 +# any OS via pip: python3 -m pip install PySide6 +# * The ZUPT CLI: `zupt` next to this file, or on PATH. +HERE="$(cd "$(dirname "$0")" && pwd)" +[ -x "$HERE/zupt" ] && export ZUPT_BIN="$HERE/zupt" + +PY="$(command -v python3 || command -v python || true)" +if [ -z "$PY" ]; then + echo "zupt-gui: Python 3 not found on PATH." >&2 + exit 1 +fi +exec "$PY" "$HERE/zupt_gui.py" "$@" diff --git a/packaging/rpm/vaptvupt.spec b/packaging/rpm/vaptvupt.spec deleted file mode 100644 index c4550b7..0000000 --- a/packaging/rpm/vaptvupt.spec +++ /dev/null @@ -1,119 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -# -# Fedora / RHEL / CentOS RPM spec for vaptvupt. -# -# Build with: -# spectool -g vaptvupt.spec # fetches the upstream tarball -# rpmbuild -ba vaptvupt.spec # builds source + binary RPMs -# -# To bring a release into production: -# 1. Run `make dist` upstream → /tmp/vaptvupt-VERSION.tar.gz (reproducible). -# 2. Upload to a stable release URL (git.securityops.co releases). -# 3. Update %{version} below. -# 4. Run `sha256sum /tmp/vaptvupt-VERSION.tar.gz` and update Source0 -# checksum (handled by spectool when configured) or pin via -# sha256sum in a separate manifest if your distro requires it. -# 5. rpmbuild --define '_topdir ~/rpmbuild' -ba zupt.spec -# -# This spec is written for Fedora 38+ and EPEL 9+; it should also work -# on RHEL 8 (with EPEL) by adjusting BuildRequires if Python 3.8+ isn't -# in the base. - -Name: vaptvupt -Version: 4.1.0 -Release: 1%{?dist} -Summary: Post-quantum backup compression utility (AES-256 + ML-KEM-768 + Argon2id, formerly Zupt) - -License: AGPL-3.0-or-later AND GPL-3.0-or-later -URL: https://git.securityops.co/cristiancmoises/vaptvupt -Source0: %{url}/releases/download/v%{version}/%{name}-%{version}.tar.gz - -# v3.0.0: legacy `zupt` package is superseded. Renaming was forced -# by a prior INPI Brasil trademark registration on "Zupt". The -# archive extension (.zupt), wire format, magic bytes, and C ABI -# are unchanged. -Provides: zupt = %{version}-%{release} -Obsoletes: zupt < 3.0.0 -Conflicts: zupt < 3.0.0 - -BuildRequires: gcc -BuildRequires: make -BuildRequires: glibc-devel -BuildRequires: python3 >= 3.8 -# python3 is only needed for the regression-test harness (byte sweeps, -# tamper injection). The shipped binary has no Python dependency. - -Requires: glibc - -%description -Zupt is a pure-C11 backup compression utility featuring: - - * Post-quantum hybrid encryption (ML-KEM-768 + X25519, FIPS 203) - * AES-256-CTR + HMAC-SHA256 authenticated encryption (Encrypt-then-MAC) - * Argon2id password-based key derivation (default since 2.4.1) - * Multi-threaded compression with the VaptVupt LZ codec - * Full-disk backup and restore with sparse-region detection - * End-to-end byte-level tamper detection on encrypted archives - (0 silent-accept positions in the v1.6 exhaustive byte sweep) - * Constant-time cryptographic primitives verified with Jasmin - * NIST/RFC test vectors for SHA-256, SHA-3, ML-KEM-768, AES-256-CTR, - HMAC-SHA256, X25519, PBKDF2, Argon2id - -The archive format includes an integrity trailer that authenticates -the header and footer, per-block HMAC with bound frame-preface AAD, -and optional encrypted comments. - -%global debug_package %{nil} -# Single source RPM, no -debuginfo split for the initial release. - -%prep -%autosetup -n %{name}-%{version} - -%build -# Use Fedora's default optflags but with the project's preferred warning set. -CFLAGS="%{optflags} -Wall -Wextra -Wpedantic -std=c11" \ -LDFLAGS="%{?build_ldflags}" \ -%make_build - -%check -# Run the upstream regression suite. F-06 HMAC trials, F-08 top-MAC sweep, -# F-09 byte sweep (1827 positions), F-10..F-12 regressions, dist -# reproducibility. ~3 minutes on modern hardware. -%make_build test - -%install -%make_install DESTDIR=%{buildroot} PREFIX=/usr - -# Install the vendored libzuptsdk into /usr/lib/zupt/ — the binary is -# linked with -Wl,-rpath,$ORIGIN/vendor/zuptsdk so we preserve the same -# layout under /usr/. -install -d %{buildroot}%{_libdir}/%{name} -install -m 0755 vendor/zuptsdk/libzuptsdk.so.2.0.0 \ - %{buildroot}%{_libdir}/%{name}/libzuptsdk.so.2.0.0 -ln -sf libzuptsdk.so.2.0.0 %{buildroot}%{_libdir}/%{name}/libzuptsdk.so.2 -ln -sf libzuptsdk.so.2.0.0 %{buildroot}%{_libdir}/%{name}/libzuptsdk.so -install -m 0755 vendor/pqvaptvupt/libpqvaptvupt.so.0.6.0 \ - %{buildroot}%{_libdir}/%{name}/libpqvaptvupt.so.0.6.0 -ln -sf libpqvaptvupt.so.0.6.0 %{buildroot}%{_libdir}/%{name}/libpqvaptvupt.so.0 -ln -sf libpqvaptvupt.so.0.6.0 %{buildroot}%{_libdir}/%{name}/libpqvaptvupt.so - -%files -%license LICENSE -%doc README.md SECURITY.md CHANGELOG.md -%{_bindir}/zupt -%{_libdir}/%{name}/libzuptsdk.so.2.0.0 -%{_libdir}/%{name}/libzuptsdk.so.2 -%{_libdir}/%{name}/libzuptsdk.so -%{_libdir}/%{name}/libpqvaptvupt.so.0.6.0 -%{_libdir}/%{name}/libpqvaptvupt.so.0 -%{_libdir}/%{name}/libpqvaptvupt.so -%if 0%{?_mandir:1} -%{_mandir}/man1/zupt.1* -%endif - -%changelog -* Tue May 20 2025 Cristian Cezar Moisés - 2.4.4-1 -- Initial Fedora/EPEL RPM package. -- Tracks upstream v2.4.4: distribution packaging release; archive - format unchanged from v2.4.3 (v1.6, 0/1878 silent-accept byte - tampers). diff --git a/packaging/rpm/zupt.spec b/packaging/rpm/zupt.spec new file mode 100644 index 0000000..cdefc2a --- /dev/null +++ b/packaging/rpm/zupt.spec @@ -0,0 +1,180 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Fedora / RHEL / CentOS RPM spec for zupt. +# +# Build with: +# spectool -g zupt.spec # fetches the upstream tarball +# rpmbuild -ba zupt.spec # builds source + binary RPMs +# +# To bring a release into production: +# 1. Run `make dist` upstream → /tmp/zupt-VERSION.tar.gz (reproducible). +# 2. Upload to the canonical GitHub release. +# 3. Update %{version} below. +# 4. Run `sha256sum /tmp/zupt-VERSION.tar.gz` and update Source0 +# checksum (handled by spectool when configured) or pin via +# sha256sum in a separate manifest if your distro requires it. +# 5. rpmbuild --define '_topdir /path/to/rpmbuild' -ba zupt.spec +# +# This is an upstream Fedora-family recipe. A target is supported only after +# that exact distribution release and architecture have built and passed the +# installed smoke test. + +Name: zupt +Version: 5.2.8 +Release: 1%{?dist} +Summary: Backup compression with authenticated and post-quantum encryption + +License: AGPL-3.0-or-later AND GPL-3.0-or-later AND BSD-2-Clause AND BSD-3-Clause AND CC0-1.0 +URL: https://github.com/cristiancmoises/zupt +Source0: %{url}/releases/download/v%{version}/%{name}-%{version}.tar.gz + +BuildRequires: gcc +BuildRequires: git-core +BuildRequires: make +BuildRequires: glibc-devel +BuildRequires: python3 >= 3.8 +BuildRequires: bash +BuildRequires: coreutils +BuildRequires: diffutils +BuildRequires: file +BuildRequires: findutils +BuildRequires: gawk +BuildRequires: grep +BuildRequires: gzip +BuildRequires: sed +BuildRequires: tar +# python3 is only needed for the regression-test harness (byte sweeps, +# tamper injection). The shipped binary has no Python dependency. + +Provides: bundled(vaptvupt-codec) = 2.65.3 + +%description +ZUPT is a pure-C11 backup compression utility featuring: + + * Post-quantum hybrid encryption (ML-KEM-768 + X25519) and full + ML-KEM-768 mode (--pq-only) + * AES-256-CTR + HMAC-SHA256 authenticated encryption (Encrypt-then-MAC) + * PBKDF2-SHA256 password key derivation (Argon2id in WITH_SDK=1 builds) + * Multi-threaded compression with the VaptVupt LZ + ANS codec + * Full-disk backup and restore with sparse-region detection + * Authenticated encrypted-archive metadata and per-block integrity checks + * Portable C implementations with optional source-built assembly paths + * NIST/RFC test vectors for SHA-256, SHA-3, ML-KEM-768, AES-256-CTR, + HMAC-SHA256, X25519 and PBKDF2 + +Encrypted archives include an integrity trailer that authenticates the header +and footer, per-block HMAC with bound frame-preface AAD, and optional encrypted +comments. Plain archives use non-cryptographic checksums. + +%prep +%autosetup -n %{name}-%{version} + +%build +# Source-only build (WITH_SDK=0): no vendored libraries, no external crypto +# dependency. Fedora's default optflags plus the project's warning set. +%make_build V=1 WITH_SDK=0 WITH_PQBOX=0 \ + CFLAGS="%{optflags}" \ + LDFLAGS="%{?build_ldflags}" + +%check +# Distro-safe quick, path-traversal, integrity, codec, HMAC and NIST/RFC +# checks. Full, optional-integration and dist-reproducibility suites remain +# release gates outside the package build. +%make_build V=1 WITH_SDK=0 WITH_PQBOX=0 \ + CFLAGS="%{optflags}" \ + LDFLAGS="%{?build_ldflags}" \ + check + +%install +%make_install WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 INSTALL_LICENSES=0 \ + PREFIX=%{_prefix} BINDIR=%{_bindir} MANDIR=%{_mandir} + +%files +%license LICENSE LICENSE-AGPL-3.0 LICENSE-GPL-3.0 LICENSE-BSD-2-Clause LICENSE-BSD-3-Clause LICENSE-CC0-1.0 NOTICE THIRD-PARTY-NOTICES.md +%doc README.md SECURITY.md THREAT_MODEL.md CHANGELOG.md +%{_bindir}/%{name} +%{_datadir}/bash-completion/completions/%{name} +%{_datadir}/zsh/site-functions/_%{name} +%{_datadir}/fish/vendor_completions.d/%{name}.fish +%if 0%{?_mandir:1} +%{_mandir}/man1/%{name}.1* +%endif + +%changelog +* Mon Aug 31 2026 Cristian Cezar Moisés - 5.2.8-1 +- Close CodeQL High path-race findings in SDK key save, disk restore, and + benchmark cleanup; add the SDK gate, portable raw-C1 fixture handling, and + redirected Windows password-prompt rejection. +- Preserve immutable, unpromoted v5.2.7 run 33445470664: 13 jobs succeeded, + macOS failed the raw-C1 fixture, and Windows was cancelled after the hosted + job stalled; a MinGW/Wine reproduction isolated redirected _getch entry. +- Require fresh 5.2.8 gates. + +* Mon Aug 31 2026 Cristian Cezar Moisés - 5.2.7-1 +- Correct native test integration: scope SHA-NI helpers away from macOS arm64 + and preserve safe UTF-8 fixture bytes across the Windows argv boundary. +- Preserve immutable, unpromoted 5.2.6 history and require fresh 5.2.7 gates. + +* Mon Aug 31 2026 Cristian Cezar Moisés - 5.2.6-1 +- Correct native release gates: use the secure volatile wipe fallback on + macOS and NetBSD, support Bash 3.2 empty arrays in the source scanner, and + preserve hostile archive-path fixture bytes exactly on Windows. +- Preserve immutable, unpromoted 5.2.5 history and require fresh 5.2.6 gates. + +* Mon Aug 31 2026 Cristian Cezar Moisés - 5.2.5-1 +- Run the standalone OBS source-service chain from its isolated working + directory and add a packaging-policy regression for that contract. +- Preserve immutable, unpromoted 5.2.4 history and require fresh 5.2.5 gates. + +* Mon Aug 31 2026 Cristian Cezar Moisés - 5.2.4-1 +- Make the static Windows GUI package-version check robust to canonical CRLF + checkouts, advance source-only package metadata, and prepare final hashes. + +* Mon Aug 31 2026 Cristian Cezar Moisés - 5.2.3-1 +- Correct the release-package CI version checks and portable GUI version + contract, and make the openSUSE container replace busybox-gawk before + installing the native RPM toolchain. + +* Mon Aug 31 2026 Cristian Cezar Moisés - 5.2.2-1 +- Source-only release; optional SDK/PQBOX integrations use system development + packages only and are disabled for this package. +- Preserve distribution flags and debuginfo, remove RPATH/vendor-library + fallbacks, run the real upstream check target, and restore the zupt command. + +* Sat Jul 11 2026 Cristian Cezar Moisés - 5.1.0-1 +- Codec 2.65.0; large compression-ratio gains (auto format_v2 + level-scaled + block window); --dedup keeps a small block; GUI compress-hang and + job-completion-crash fixes. Wire format unchanged (v1.6). + +* Fri Jul 10 2026 Cristian Cezar Moisés - 5.0.0-1 +- ML-KEM-768 is now genuinely FIPS 203-conformant (was round-3 CRYSTALS-Kyber): + fixed a transposed matrix-A sampling convention, the round-3 KDF, and the + implicit-rejection domain. Validated byte-for-byte against OpenSSL 3.5's + FIPS 203 ML-KEM-768 (tests/test_mlkem_fips203.sh, run in %%check). +- BREAKING: --pq / --pq-only keys and archives from <= 4.2.1 no longer decrypt. + Regenerate keys and re-encrypt. Password mode / plain compression unaffected. +- Security: compress data-loss + silent-plaintext guards; AVX2 decoder heap + OOB-read bound; overflow-safe solid-mode test path; secret-wipe on error. +- GUI reworked for the source-only build; truthful banner/help. + +* Fri Jul 10 2026 Cristian Cezar Moisés - 4.2.1-1 +- Fix: "vaptvupt info" mislabelled full post-quantum (--pq-only) archives as + "PQ Hybrid (ML-KEM-768 + X25519)". info now reads the real enc_type from the + encryption-header block and reports the actual mode ("ML-KEM-768 only, no + classical layer" for --pq-only). Reader-side only; no wire-format change. + +* Thu Jul 09 2026 Cristian Cezar Moisés - 4.2.0-1 +- New native full (pure) post-quantum mode --pq-only: ML-KEM-768 as the + sole KEM, no classical X25519 (envelope 0x06). For "PQ-only" compliance + postures; hybrid --pq remains the recommended default. In-tree crypto. +- Security (critical): fixed AES-256-CTR keystream reuse under --dedup + (every block now uses a fresh random 128-bit nonce). Re-encrypt any + --dedup encrypted archives written by <= 4.1.0. +- Clearer keygen --sdk/--box guidance on source-only builds. +- Wire format v1.6 unchanged. + +* Tue May 20 2025 Cristian Cezar Moisés - 2.4.4-1 +- Initial Fedora/EPEL RPM package. +- Tracks upstream v2.4.4: distribution packaging release; archive + format unchanged from v2.4.3 (v1.6, 0/1878 silent-accept byte + tampers). diff --git a/packaging/windows/zupt-gui.iss b/packaging/windows/zupt-gui.iss new file mode 100644 index 0000000..fae8182 --- /dev/null +++ b/packaging/windows/zupt-gui.iss @@ -0,0 +1,98 @@ +; SPDX-License-Identifier: AGPL-3.0-or-later +; Inno Setup 6 recipe for target-built ZUPT Windows artifacts. +; +; All paths are mandatory command-line definitions. This prevents the recipe +; from silently picking up a stale or placeholder executable from the tree. + +#ifndef AppVersion + #error AppVersion must be defined +#endif +#ifndef GuiExecutable + #error GuiExecutable must name a tested PyInstaller GUI executable +#endif +#ifndef CliExecutable + #error CliExecutable must name a tested source-built zupt.exe +#endif +#ifndef BuildOutputDir + #error BuildOutputDir must be an external output directory +#endif +#ifndef RuntimeNoticesDir + #error RuntimeNoticesDir must contain notices for the exact bundled GUI runtime +#endif + +[Setup] +AppId={{59AD35E4-1860-445D-8E89-4563DB9ED4E2} +AppName=ZUPT +AppVersion={#AppVersion} +AppPublisher=Cristian Cezar Moises +AppPublisherURL=https://github.com/cristiancmoises/zupt +AppSupportURL=https://github.com/cristiancmoises/zupt/issues +DefaultDirName={autopf}\ZUPT +DefaultGroupName=ZUPT +UninstallDisplayIcon={app}\zupt-gui.exe +OutputDir={#BuildOutputDir} +OutputBaseFilename=ZUPT-Setup-{#AppVersion} +Compression=lzma2 +SolidCompression=yes +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +WizardStyle=modern +LicenseFile=..\..\LICENSE +ChangesAssociations=yes + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Files] +Source: "{#GuiExecutable}"; DestDir: "{app}"; DestName: "zupt-gui.exe"; Flags: ignoreversion +Source: "{#CliExecutable}"; DestDir: "{app}"; DestName: "zupt.exe"; Flags: ignoreversion +Source: "..\..\LICENSE"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\..\LICENSE-AGPL-3.0"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\..\LICENSE-GPL-3.0"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\..\LICENSE-BSD-2-Clause"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\..\LICENSE-BSD-3-Clause"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\..\LICENSE-CC0-1.0"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\..\NOTICE"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\..\THIRD-PARTY-NOTICES.md"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\..\gui\LICENSE-GUI"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\..\gui\assets\README.md"; DestDir: "{app}"; DestName: "GUI-ASSET-PROVENANCE.md"; Flags: ignoreversion +Source: "{#RuntimeNoticesDir}\*"; DestDir: "{app}\third-party-runtime-notices"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "..\..\README.md"; DestDir: "{app}"; Flags: ignoreversion isreadme +Source: "..\..\CHANGELOG.md"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\ZUPT GUI"; Filename: "{app}\zupt-gui.exe" +Name: "{group}\ZUPT command prompt"; Filename: "{cmd}"; Parameters: "/K cd /d ""{app}""" +Name: "{group}\Uninstall ZUPT"; Filename: "{uninstallexe}" +Name: "{autodesktop}\ZUPT GUI"; Filename: "{app}\zupt-gui.exe"; Tasks: desktopicon + +[Tasks] +Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional icons:" +Name: "addtopath"; Description: "Add the ZUPT command to PATH for this user"; GroupDescription: "Command line:" + +[Registry] +Root: HKCU; Subkey: "Environment"; ValueType: expandsz; ValueName: "Path"; \ + ValueData: "{olddata};{app}"; Tasks: addtopath; Check: NeedsAddPath('{app}') +Root: HKCU; Subkey: "Software\Classes\.zupt"; ValueType: string; ValueName: ""; \ + ValueData: "ZUPT.Archive"; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\ZUPT.Archive"; ValueType: string; \ + ValueName: ""; ValueData: "ZUPT archive"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\ZUPT.Archive\shell\open\command"; \ + ValueType: string; ValueName: ""; ValueData: """{app}\zupt-gui.exe"" --extract ""%1""" + +[Run] +Filename: "{app}\zupt-gui.exe"; Description: "Launch ZUPT GUI"; \ + Flags: nowait postinstall skipifsilent + +[Code] +function NeedsAddPath(Param: string): Boolean; +var + OrigPath: string; +begin + if not RegQueryStringValue(HKEY_CURRENT_USER, 'Environment', 'Path', OrigPath) then + begin + Result := True; + exit; + end; + Result := Pos(';' + ExpandConstant(Param) + ';', ';' + OrigPath + ';') = 0; +end; diff --git a/packaging/zupt-installer-header.sh b/packaging/zupt-installer-header.sh deleted file mode 100644 index d091c39..0000000 --- a/packaging/zupt-installer-header.sh +++ /dev/null @@ -1,312 +0,0 @@ -#!/bin/bash -# SPDX-License-Identifier: AGPL-3.0-or-later -# Copyright (c) 2025-2026 Cristian Cezar Moisés -# ╔════════════════════════════════════════════════════════════════════╗ -# ║ ZUPT 2.2.3 + ZUPT-GUI 1.1.1 — UNIVERSAL LINUX INSTALLER ║ -# ║ ║ -# ║ One script, all distributions. Self-extracting. No internet ║ -# ║ needed for the package install (only for Qt6 dependency). ║ -# ║ ║ -# ║ Usage: sudo bash zupt-installer.sh ║ -# ║ Or: sudo bash zupt-installer.sh --gui-only ║ -# ║ Or: sudo bash zupt-installer.sh --cli-only ║ -# ║ Or: sudo bash zupt-installer.sh --appimage ║ -# ║ Or: sudo bash zupt-installer.sh --uninstall ║ -# ╚════════════════════════════════════════════════════════════════════╝ -set -e - -VERSION="2.2.3" -GUI_VERSION="1.1.1" -EXTRACT_DIR="" - -cleanup() { - [ -n "$EXTRACT_DIR" ] && [ -d "$EXTRACT_DIR" ] && rm -rf "$EXTRACT_DIR" -} -trap cleanup EXIT - -# ── Color output (if terminal supports) ───────────────────────────── -if [ -t 1 ]; then - BOLD='\033[1m'; CYAN='\033[36m'; GREEN='\033[32m'; YELLOW='\033[33m'; RED='\033[31m'; RESET='\033[0m' -else - BOLD=''; CYAN=''; GREEN=''; YELLOW=''; RED=''; RESET='' -fi - -step() { echo -e "${CYAN}${BOLD}═══ $* ═══${RESET}"; } -ok() { echo -e "${GREEN}✓${RESET} $*"; } -warn() { echo -e "${YELLOW}⚠${RESET} $*"; } -err() { echo -e "${RED}✗${RESET} $*" >&2; } -die() { err "$*"; exit 1; } - -# ── Parse arguments ───────────────────────────────────────────────── -MODE="full" -case "${1:-}" in - --cli-only) MODE="cli" ;; - --gui-only) MODE="gui" ;; - --appimage) MODE="appimage" ;; - --uninstall) MODE="uninstall" ;; - --help|-h) - sed -n '2,15p' "$0" | sed 's/^# //' - exit 0 ;; - "") MODE="full" ;; - *) die "Unknown option: $1. Use --help for options." ;; -esac - -# ── Root check (except for AppImage) ──────────────────────────────── -if [ "$MODE" != "appimage" ] && [ "$EUID" -ne 0 ]; then - die "Run with sudo: sudo bash $0 ${1:-}" -fi - -# ── Distro detection ──────────────────────────────────────────────── -detect_distro() { - if [ -f /etc/os-release ]; then - # Use subshell to prevent /etc/os-release VERSION from clobbering ours - DISTRO=$(. /etc/os-release; echo "${ID:-unknown}") - DISTRO_LIKE=$(. /etc/os-release; echo "${ID_LIKE:-}") - DISTRO_NAME=$(. /etc/os-release; echo "${PRETTY_NAME:-$DISTRO}") - else - DISTRO="unknown"; DISTRO_LIKE=""; DISTRO_NAME="Unknown Linux" - fi -} -detect_distro - -# Categorize -DEB_BASED=0; RPM_BASED=0; ARCH_BASED=0; ALPINE=0 -case "$DISTRO" in - debian|ubuntu|linuxmint|pop|elementary|kali|raspbian|deepin|zorin) DEB_BASED=1 ;; - fedora|rhel|centos|rocky|almalinux|ol) RPM_BASED=1 ;; - opensuse*|suse|sles) RPM_BASED=1 ;; - arch|manjaro|endeavouros|garuda|artix) ARCH_BASED=1 ;; - alpine) ALPINE=1 ;; - *) - case "$DISTRO_LIKE" in - *debian*|*ubuntu*) DEB_BASED=1 ;; - *fedora*|*rhel*|*suse*) RPM_BASED=1 ;; - *arch*) ARCH_BASED=1 ;; - esac ;; -esac - -# ── Self-extract embedded payload ─────────────────────────────────── -extract_payload() { - EXTRACT_DIR=$(mktemp -d -t zupt-installer.XXXXXX) - # Find the line number where the payload starts (marker: __PAYLOAD_BELOW__) - local marker_line - marker_line=$(grep -an '^__PAYLOAD_BELOW__$' "$0" | head -1 | cut -d: -f1) - [ -z "$marker_line" ] && die "Installer is corrupt — no payload marker." - # Skip past marker line, decode base64 → tar - tail -n +$((marker_line + 1)) "$0" | base64 -d | tar -xzC "$EXTRACT_DIR" - [ -f "$EXTRACT_DIR/zupt_${VERSION}_amd64.deb" ] || die "Payload extraction failed." -} - -# ── Install Qt6 binding (needs network) ───────────────────────────── -install_qt6() { - if python3 -c 'import PyQt6.QtWidgets' 2>/dev/null \ - || python3 -c 'import PySide6.QtWidgets' 2>/dev/null; then - ok "Qt6 binding already installed" - return 0 - fi - step "Installing Python 3 + Qt6 binding" - if [ $DEB_BASED -eq 1 ]; then - apt-get update -qq || warn "apt-get update failed (network?); continuing anyway" - apt-get install -y python3 python3-pyqt6 \ - || apt-get install -y python3 python3-pyside6 \ - || warn "Could not install Qt6 binding via apt" - elif [ $RPM_BASED -eq 1 ]; then - case "$DISTRO" in - opensuse*|suse|sles) - zypper --non-interactive install python3 python3-pyqt6 \ - || zypper --non-interactive install python3 python3-PyQt6 \ - || zypper --non-interactive install python3 python3-pyside6 ;; - *) - if command -v dnf >/dev/null; then - dnf install -y python3 python3-pyqt6 \ - || dnf install -y python3 python3-pyside6 - else - yum install -y python3 python3-pyqt6 \ - || yum install -y python3 python3-pyside6 - fi ;; - esac - elif [ $ARCH_BASED -eq 1 ]; then - pacman -Sy --noconfirm python python-pyqt6 \ - || pacman -Sy --noconfirm python python-pyside6 - elif [ $ALPINE -eq 1 ]; then - apk add python3 py3-pyqt6 || apk add python3 py3-pyside6 - else - warn "Unknown distribution. Trying pip fallback..." - if command -v pip3 >/dev/null; then - pip3 install --break-system-packages PySide6 2>/dev/null \ - || pip3 install --user PySide6 - else - warn "No pip3. Install python3-pyqt6 manually." - fi - fi - if python3 -c 'import PyQt6.QtWidgets' 2>/dev/null \ - || python3 -c 'import PySide6.QtWidgets' 2>/dev/null; then - ok "Qt6 binding installed" - else - warn "Qt6 binding install failed. The CLI will still work; the GUI won't." - fi -} - -# ── Install zupt CLI ──────────────────────────────────────────────── -install_cli() { - step "Installing zupt CLI ${VERSION}" - if [ $DEB_BASED -eq 1 ]; then - dpkg -i "$EXTRACT_DIR/zupt_${VERSION}_amd64.deb" 2>&1 \ - | grep -v '^Selecting\|^Preparing\|^Unpacking\|^Setting up\|^Processing' || true - # Resolve any missing libs from apt - apt-get -f install -y 2>/dev/null || true - ok "zupt CLI installed: $(zupt version 2>&1 | head -1)" - elif [ $RPM_BASED -eq 1 ]; then - local rpmtar="$EXTRACT_DIR/zupt-${VERSION}.srpm.tar.gz" - if command -v rpmbuild >/dev/null; then - local rpmroot=$(mktemp -d) - tar -xzC "$rpmroot" -f "$rpmtar" - rpmbuild --define "_topdir $rpmroot" -bb "$rpmroot/SPECS/zupt.spec" - rpm -Uvh --force "$rpmroot"/RPMS/x86_64/zupt-*.rpm - rm -rf "$rpmroot" - else - # rpmbuild not available — fall back to tarball - warn "rpmbuild missing — using portable binary install" - local appdir="$EXTRACT_DIR/zupt-${VERSION}-x86_64.AppDir.tar.gz" - mkdir -p /opt - tar -xzC /opt -f "$appdir" - ln -sf "/opt/zupt-${VERSION}-x86_64.AppDir/AppRun" /usr/local/bin/zupt - ok "zupt CLI installed (portable mode)" - fi - else - # Universal fallback: portable AppDir tarball - warn "No native package format for $DISTRO. Using portable binary." - mkdir -p /opt /usr/local/bin - tar -xzC /opt -f "$EXTRACT_DIR/zupt-${VERSION}-x86_64.AppDir.tar.gz" - ln -sf "/opt/zupt-${VERSION}-x86_64.AppDir/AppRun" /usr/local/bin/zupt - ok "zupt CLI installed (portable mode)" - fi -} - -# ── Install zupt-gui ──────────────────────────────────────────────── -install_gui() { - step "Installing zupt-gui ${GUI_VERSION}" - if [ $DEB_BASED -eq 1 ]; then - dpkg -i "$EXTRACT_DIR/zupt-gui_${GUI_VERSION}_all.deb" 2>&1 \ - | grep -v '^Selecting\|^Preparing\|^Unpacking\|^Setting up\|^Processing' || true - apt-get -f install -y 2>/dev/null || true - ok "zupt-gui installed" - elif [ $RPM_BASED -eq 1 ]; then - local rpmtar="$EXTRACT_DIR/zupt-gui-${GUI_VERSION}.srpm.tar.gz" - if command -v rpmbuild >/dev/null; then - local rpmroot=$(mktemp -d) - tar -xzC "$rpmroot" -f "$rpmtar" - rpmbuild --define "_topdir $rpmroot" -bb "$rpmroot/SPECS/zupt-gui.spec" - rpm -Uvh --force "$rpmroot"/RPMS/noarch/zupt-gui-*.rpm - rm -rf "$rpmroot" - else - warn "rpmbuild missing — using portable mode" - mkdir -p /opt /usr/local/bin - tar -xzC /opt -f "$EXTRACT_DIR/Zupt-GUI-${GUI_VERSION}-x86_64.AppDir.tar.gz" - ln -sf "/opt/zupt-gui.AppDir/AppRun" /usr/local/bin/zupt-gui - ok "zupt-gui installed (portable)" - fi - else - # Portable - mkdir -p /opt /usr/local/bin - tar -xzC /opt -f "$EXTRACT_DIR/Zupt-GUI-${GUI_VERSION}-x86_64.AppDir.tar.gz" - ln -sf "/opt/zupt-gui.AppDir/AppRun" /usr/local/bin/zupt-gui - # Desktop integration if possible - if [ -d /usr/share/applications ]; then - cp /opt/zupt-gui.AppDir/zupt-gui.desktop /usr/share/applications/ 2>/dev/null || true - fi - ok "zupt-gui installed (portable)" - fi -} - -# ── AppImage extract (no install) ─────────────────────────────────── -install_appimage() { - step "Extracting AppImage to current directory" - local target="${PWD}/zupt-portable" - mkdir -p "$target" - tar -xzC "$target" -f "$EXTRACT_DIR/zupt-${VERSION}-x86_64.AppDir.tar.gz" - tar -xzC "$target" -f "$EXTRACT_DIR/Zupt-GUI-${GUI_VERSION}-x86_64.AppDir.tar.gz" - cat > "$target/zupt" < "$target/zupt-gui" </dev/null || true - dpkg -r zupt 2>/dev/null || true - elif [ $RPM_BASED -eq 1 ]; then - rpm -e zupt-gui 2>/dev/null || true - rpm -e zupt 2>/dev/null || true - fi - rm -rf /opt/zupt-2.2.3-x86_64.AppDir /opt/zupt-gui.AppDir 2>/dev/null - rm -f /usr/local/bin/zupt /usr/local/bin/zupt-gui 2>/dev/null - rm -f /usr/share/applications/zupt-gui.desktop 2>/dev/null - ok "Uninstall complete" -} - -# ───────────────────────────────────────────────────────────────────── -# MAIN -# ───────────────────────────────────────────────────────────────────── - -cat <
&2 + exit 2 + } +done +[[ $FORCE_PORTABLE_WATCHDOG == 0 || $FORCE_PORTABLE_WATCHDOG == 1 ]] || { + printf 'ERROR: SOURCE_AUDIT_FORCE_WATCHDOG must be 0 or 1\n' >&2 + exit 2 +} +((ARCHIVE_TIMEOUT_SECONDS > 0)) || { + printf 'ERROR: source-audit archive timeout must be positive\n' >&2 + exit 2 +} +((MAX_ARCHIVE_LIST_KIB <= 2147483647 && MAX_ARCHIVE_KIB <= 2147483647 && + MAX_TOTAL_ARCHIVE_KIB <= 2147483647)) || { + printf 'ERROR: source-audit KiB limits are too large for safe accounting\n' >&2 + exit 2 +} + +AUDIT_TMP=$(mktemp -d "${TMPDIR:-/tmp}/zupt-source-audit.XXXXXXXX") +# shellcheck disable=SC2317 # Invoked indirectly by trap. +cleanup() { + local status=$? + trap - EXIT HUP INT TERM + rm -rf -- "$AUDIT_TMP" + exit "$status" +} +trap cleanup EXIT HUP INT TERM + +for required_tool in file od tr grep find head wc awk; do + if ! command -v "$required_tool" >/dev/null 2>&1; then + printf 'ERROR: source audit requires %s\n' "$required_tool" >&2 + exit 2 + fi +done +unset required_tool + +usage() { + cat <= 2)) || { printf 'ERROR: --root requires a directory\n' >&2; exit 2; } + ROOT=$2 + ROOT_REQUESTED=1 + shift 2 + ;; + --tag) + (($# >= 2)) || { printf 'ERROR: --tag requires a revision\n' >&2; exit 2; } + TAGS+=("$2") + TAG_COUNT=$((TAG_COUNT + 1)) + shift 2 + ;; + --archive) + (($# >= 2)) || { printf 'ERROR: --archive requires a file\n' >&2; exit 2; } + ARCHIVES+=("$2") + ARCHIVE_COUNT=$((ARCHIVE_COUNT + 1)) + HAVE_EXTERNAL_TARGET=1 + shift 2 + ;; + --tree) + (($# >= 2)) || { printf 'ERROR: --tree requires a directory\n' >&2; exit 2; } + TREES+=("$2") + TREE_COUNT=$((TREE_COUNT + 1)) + HAVE_EXTERNAL_TARGET=1 + shift 2 + ;; + --data-manifest) + (($# >= 2)) || { printf 'ERROR: --data-manifest requires a file\n' >&2; exit 2; } + DATA_MANIFEST=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + (($# == 0)) || { printf 'ERROR: unexpected operand\n' >&2; exit 2; } + ;; + *) + printf 'ERROR: unknown option: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if ((HAVE_EXTERNAL_TARGET)) && ((ROOT_REQUESTED == 0)) && ((TAG_COUNT == 0)); then + REPOSITORY_AUDIT=0 +fi + +unicode_format_control() { + local codepoint=$1 + ((codepoint == 0x00ad || + (codepoint >= 0x0600 && codepoint <= 0x0605) || + codepoint == 0x061c || codepoint == 0x06dd || codepoint == 0x070f || + (codepoint >= 0x0890 && codepoint <= 0x0891) || + codepoint == 0x08e2 || codepoint == 0x180e || + (codepoint >= 0x200b && codepoint <= 0x200f) || + (codepoint >= 0x202a && codepoint <= 0x202e) || + (codepoint >= 0x2060 && codepoint <= 0x206f) || + codepoint == 0xfeff || + (codepoint >= 0xfff9 && codepoint <= 0xfffb) || + codepoint == 0x110bd || codepoint == 0x110cd || + (codepoint >= 0x13430 && codepoint <= 0x1343f) || + (codepoint >= 0x1bca0 && codepoint <= 0x1bca3) || + (codepoint >= 0x1d173 && codepoint <= 0x1d17a) || + codepoint == 0xe0001 || + (codepoint >= 0xe0020 && codepoint <= 0xe007f))) +} + +safe_path_for_output() { + local path=$1 + local output='' character='' sequence='' escaped='' + local LC_ALL=C byte byte2 byte3 byte4 codepoint index length + length=${#path} + for ((index = 0; index < length; index++)); do + character=${path:index:1} + printf -v byte '%d' "'$character" + # Bash 3.2 can sign-extend bytes >= 0x80 when converting a character + # with %d. Normalize to an unsigned octet before UTF-8 validation and + # diagnostic escaping. + byte=$((byte & 0xff)) + + if ((byte < 0x20 || byte == 0x7f)); then + printf -v escaped '\\x%02x' "$byte" + output+=$escaped + continue + fi + if ((byte < 0x80)); then + if [[ $character == \\ ]]; then + output+="${character}${character}" + else + output+=$character + fi + continue + fi + + codepoint=0 + sequence= + if ((byte >= 0xc2 && byte <= 0xdf && index + 1 < length)); then + character=${path:index+1:1} + printf -v byte2 '%d' "'$character" + byte2=$((byte2 & 0xff)) + if ((byte2 >= 0x80 && byte2 <= 0xbf)); then + codepoint=$(((byte & 0x1f) << 6 | (byte2 & 0x3f))) + sequence=${path:index:2} + fi + elif ((byte >= 0xe0 && byte <= 0xef && index + 2 < length)); then + character=${path:index+1:1} + printf -v byte2 '%d' "'$character" + byte2=$((byte2 & 0xff)) + character=${path:index+2:1} + printf -v byte3 '%d' "'$character" + byte3=$((byte3 & 0xff)) + if ((byte3 >= 0x80 && byte3 <= 0xbf && + ((byte == 0xe0 && byte2 >= 0xa0 && byte2 <= 0xbf) || + (byte >= 0xe1 && byte <= 0xec && byte2 >= 0x80 && byte2 <= 0xbf) || + (byte == 0xed && byte2 >= 0x80 && byte2 <= 0x9f) || + (byte >= 0xee && byte <= 0xef && byte2 >= 0x80 && byte2 <= 0xbf)))); then + codepoint=$(((byte & 0x0f) << 12 | (byte2 & 0x3f) << 6 | + (byte3 & 0x3f))) + sequence=${path:index:3} + fi + elif ((byte >= 0xf0 && byte <= 0xf4 && index + 3 < length)); then + character=${path:index+1:1} + printf -v byte2 '%d' "'$character" + byte2=$((byte2 & 0xff)) + character=${path:index+2:1} + printf -v byte3 '%d' "'$character" + byte3=$((byte3 & 0xff)) + character=${path:index+3:1} + printf -v byte4 '%d' "'$character" + byte4=$((byte4 & 0xff)) + if ((byte3 >= 0x80 && byte3 <= 0xbf && + byte4 >= 0x80 && byte4 <= 0xbf && + ((byte == 0xf0 && byte2 >= 0x90 && byte2 <= 0xbf) || + (byte >= 0xf1 && byte <= 0xf3 && byte2 >= 0x80 && byte2 <= 0xbf) || + (byte == 0xf4 && byte2 >= 0x80 && byte2 <= 0x8f)))); then + codepoint=$(((byte & 0x07) << 18 | (byte2 & 0x3f) << 12 | + (byte3 & 0x3f) << 6 | (byte4 & 0x3f))) + sequence=${path:index:4} + fi + fi + + if [[ -z $sequence ]]; then + printf -v escaped '\\x%02x' "$byte" + output+=$escaped + elif ((codepoint >= 0x80 && codepoint <= 0x9f)) || + ((codepoint >= 0x2028 && codepoint <= 0x2029)) || + unicode_format_control "$codepoint"; then + if ((codepoint <= 0xffff)); then + printf -v escaped '\\u%04x' "$codepoint" + else + printf -v escaped '\\U%08x' "$codepoint" + fi + output+=$escaped + index=$((index + ${#sequence} - 1)) + else + output+=$sequence + index=$((index + ${#sequence} - 1)) + fi + done + printf '%s' "$output" +} + +canonicalize_allow_missing() { + local path=$1 + if realpath -m -- / >/dev/null 2>&1; then + realpath -m -- "$path" + elif command -v python3 >/dev/null 2>&1; then + python3 - "$path" <<'PY' +import os +import sys +print(os.path.realpath(sys.argv[1])) +PY + else + printf 'ERROR: canonical path checking needs GNU realpath or python3\n' >&2 + return 1 + fi +} + +fail_path() { + local scope=$1 path=$2 reason=$3 + FAILURES=$((FAILURES + 1)) + printf 'FAIL [%s] %s (%s)\n' "$scope" "$(safe_path_for_output "$path")" "$reason" +} + +path_stays_below_root() { + local candidate=${1//\\//} + local component + local depth=0 + local IFS=/ + local -a components=() + + [[ $candidate != /* && $candidate != //* ]] || return 1 + [[ ! $candidate =~ ^[[:alpha:]]: ]] || return 1 + read -r -a components <<< "$candidate" + # Bash 3.2 treats an empty array expansion as unset under `set -u`. + # The + guard expands to no words for an empty path component list. + for component in ${components[@]+"${components[@]}"}; do + case $component in + ''|.) ;; + ..) + ((depth > 0)) || return 1 + depth=$((depth - 1)) + ;; + *) depth=$((depth + 1)) ;; + esac + done +} + +check_link_target() { + local entry=$1 target=$2 scope=$3 display=${4:-$1} + local parent combined + + [[ $target != /* && $target != //* && ! $target =~ ^[[:alpha:]]: ]] || { + fail_path "$scope" "$display" 'absolute symlink target' + return + } + parent=${entry%/*} + [[ $parent != "$entry" ]] || parent=. + combined=$parent/$target + if ! path_stays_below_root "$combined"; then + fail_path "$scope" "$display" 'symlink escapes audit root' + fi +} + +forbidden_extension() { + local path=$1 lower + lower=$(LC_ALL=C printf '%s' "${path##*/}" | tr '[:upper:]' '[:lower:]') + case $lower in + *.o|*.obj|*.so|*.so.*|*.a|*.la|*.dll|*.dylib|*.exe|*.com|\ + *.class|*.jar|*.war|*.wasm|*.pyc|*.pyo|*.rpm|*.deb|*.appimage|\ + *.msi|*.apk|*.ipa|*.dmg|*.elf|*.ko|*.mod|*.lib|*.pdb|*.out) + return 0 + ;; + esac + return 1 +} + +is_declared_binary_data() { + local logical=$1 candidate line path purpose provenance license extra + [[ -n $DATA_MANIFEST && -r $DATA_MANIFEST ]] || return 1 + candidate=${logical##*!} + while IFS= read -r line || [[ -n $line ]]; do + [[ -n $line && ${line:0:1} != '#' ]] || continue + IFS=$'\t' read -r path purpose provenance license extra <<< "$line" + if [[ $path == "$candidate" && -n $purpose && -n $provenance && + -n $license && -z ${extra:-} ]]; then + return 0 + fi + done < "$DATA_MANIFEST" + return 1 +} + +magic_kind() { + local file=$1 hex machine sections flags + hex=$(LC_ALL=C od -An -v -tx1 -N 512 "$file" 2>/dev/null | tr -d '[:space:]') || return 1 + [[ -n $hex ]] || return 1 + + if [[ $hex == 7f454c46* && ${hex:16:6} =~ ^4149(01|02)$ ]]; then + printf 'AppImage executable' + return 0 + fi + case $hex in + 7f454c46*) printf 'ELF executable or object'; return 0 ;; + 4d5a*) printf 'PE/MZ executable'; return 0 ;; + feedface*|cefaedfe*|feedfacf*|cffaedfe*|cafebabe*|bebafeca*|cafebabf*|bfbafeca*) + printf 'Mach-O, universal binary, or Java class'; return 0 ;; + 213c617263683e0a64656269616e2d62696e617279*) printf 'Debian package'; return 0 ;; + 213c617263683e0a*) printf 'ar archive or static library'; return 0 ;; + 213c7468696e3e0a*) printf 'GNU thin archive or static library'; return 0 ;; + edabeedb*) printf 'RPM package'; return 0 ;; + 0061736d*) printf 'WebAssembly bytecode'; return 0 ;; + 6465780a*) printf 'Dalvik bytecode'; return 0 ;; + 1b4c7561*) printf 'Lua bytecode'; return 0 ;; + 4243c0de*) printf 'LLVM bitcode'; return 0 ;; + esac + + # CPython bytecode starts with a version magic ending in CRLF, followed by + # a small flags word. Requiring the complete 16-byte header avoids treating + # ordinary text beginning with CRLF as bytecode. + if ((${#hex} >= 32)) && [[ ${hex:4:4} == 0d0a ]] && + [[ ${hex:8:8} =~ ^(00000000|01000000|02000000|03000000)$ ]]; then + printf 'Python bytecode' + return 0 + fi + + # A COFF object starts with a known machine identifier and a non-zero, + # reasonably bounded section count in its fixed-size 20-byte header. + if ((${#hex} >= 40)); then + machine=${hex:0:4} + sections=${hex:4:4} + flags=${hex:32:8} + case $machine in + 4c01|6486|c001|c201|c401|64aa|6601|f001|f701|bc0e|5001|d301) + if [[ $sections != 0000 && $sections != 00000000 && $flags =~ ^[[:xdigit:]]{8}$ ]]; then + printf 'COFF object' + return 0 + fi + ;; + esac + fi + return 1 +} + +file_utility_kind() { + local file=$1 description mime + command -v file >/dev/null 2>&1 || return 1 + description=$(LC_ALL=C file -b "$file" 2>/dev/null) || return 1 + mime=$(LC_ALL=C file -b --mime-type "$file" 2>/dev/null) || mime= + case $description in + *ELF*) printf 'ELF executable or object'; return 0 ;; + *PE32*|*MS-DOS\ executable*) printf 'PE/MZ executable'; return 0 ;; + *Mach-O*|*COFF*) printf 'Mach-O or COFF compiled code'; return 0 ;; + *RPM*package*|*Debian\ binary\ package*) printf 'binary distribution package'; return 0 ;; + *current\ ar\ archive*|*thin\ archive*) + printf 'ar archive or static library'; return 0 ;; + esac + case $mime in + application/x-executable|application/x-pie-executable|application/x-sharedlib|\ + application/x-object|application/x-archive|application/x-dosexec|\ + application/x-rpm|application/vnd.debian.binary-package|application/wasm|\ + application/java-vm) + printf 'compiled code or binary package' + return 0 + ;; + esac + return 1 +} + +looks_like_archive() { + local file=$1 logical=$2 hex lower + lower=$(LC_ALL=C printf '%s' "$logical" | tr '[:upper:]' '[:lower:]') + case $lower in + *.tar|*.tar.gz|*.tgz|*.tar.xz|*.txz|*.tar.bz2|*.tbz|*.tbz2|\ + *.tar.zst|*.tzst|*.zip|*.jar|*.war|*.deb|*.apk|*.ipa|*.cpio) + return 0 + ;; + *.7z|*.rar) + return 0 + ;; + esac + hex=$(LC_ALL=C od -An -v -tx1 -N 512 "$file" 2>/dev/null | tr -d '[:space:]') || return 1 + case $hex in + 504b0304*|504b0506*|504b0708*|1f8b*|425a68*|fd377a585a00*|\ + 28b52ffd*|213c617263683e0a*|213c7468696e3e0a*|edabeedb*|3037303730*|\ + 377abcaf271c*|526172211a0700*|526172211a070100*) return 0 ;; + esac + [[ ${hex:514:10} == 7573746172 ]] +} + +is_reference_source() { + local logical=$1 base=${1##*/} + case $logical in + *scripts/check-source-only.sh|*tests/test_source_only.sh|\ + *packaging/opensuse/source-audit.sh) + return 1 + ;; + esac + case $base in + Makefile|makefile|GNUmakefile|CMakeLists.txt|*.mk|*.cmake|*.sh|*.bash|\ + *.c|*.h|*.cc|*.hh|*.cpp|*.hpp|*.py|*.pl|*.rb|*.spec|*.service|\ + *.yml|*.yaml|Dockerfile|Containerfile) + return 0 + ;; + esac + return 1 +} + +check_removed_library_reference() { + local file=$1 logical=$2 scope=$3 + is_reference_source "$logical" || return 0 + LC_ALL=C grep -Iq . "$file" 2>/dev/null || return 0 + if LC_ALL=C grep -Eaq -- \ + 'libvuptsdk[.]so|vendor/(vuptsdk|pqvaptvupt)/[^[:space:]"'"'"'`]*[.](so([.][0-9A-Za-z._-]+)?|a|o)([^0-9A-Za-z._-]|$)' \ + "$file" 2>/dev/null; then + fail_path "$scope" "$logical" 'reference to removed vendored library' + fi +} + +archive_tool() { + if command -v bsdtar >/dev/null 2>&1; then + printf 'bsdtar' + elif command -v tar >/dev/null 2>&1; then + printf 'tar' + else + return 1 + fi +} + +run_archive_command() { + local command_pid watchdog_pid status + if [[ $FORCE_PORTABLE_WATCHDOG == 0 ]] && \ + command -v timeout >/dev/null 2>&1 && \ + timeout --help 2>&1 | grep -F -- '--kill-after' >/dev/null; then + timeout --kill-after=2 "${ARCHIVE_TIMEOUT_SECONDS}s" "$@" + else + "$@" & + command_pid=$! + ( + local elapsed=0 + while kill -0 "$command_pid" 2>/dev/null; do + if ((elapsed >= ARCHIVE_TIMEOUT_SECONDS)); then + kill -TERM "$command_pid" 2>/dev/null || exit 0 + sleep 1 + kill -KILL "$command_pid" 2>/dev/null || true + exit 0 + fi + sleep 1 + elapsed=$((elapsed + 1)) + done + ) & + watchdog_pid=$! + if wait "$command_pid"; then status=0; else status=$?; fi + kill "$watchdog_pid" 2>/dev/null || true + wait "$watchdog_pid" 2>/dev/null || true + return "$status" + fi +} + +archive_declared_bytes() { + local tool=$1 verbose=$2 size_field=3 + if "$tool" --version 2>/dev/null | grep -Eqi 'bsdtar|libarchive'; then + size_field=5 + fi + awk -v field="$size_field" -v max_kib="$MAX_ARCHIVE_KIB" ' + BEGIN { total = 0; status = 0; max = max_kib * 1024 } + { + if (NF < field || $field !~ /^[0-9]+$/) { + status = 2 + exit + } + size = $field + 0 + if (size > max - total) { + status = 3 + exit + } + total += size + } + END { + if (status == 0) printf "%.0f\n", total + exit status + } + ' "$verbose" +} + +extracted_regular_bytes() { + local root=$1 file size total=0 max_bytes=$((MAX_ARCHIVE_KIB * 1024)) + while IFS= read -r -d '' file; do + size=$(LC_ALL=C wc -c <"$file" | tr -d '[:space:]') + [[ $size =~ ^[0-9]+$ ]] || return 2 + ((size <= max_bytes - total)) || return 3 + total=$((total + size)) + done < <(find -P "$root" -type f -print0) + printf '%s\n' "$total" +} + +scan_archive() { + local archive=$1 logical=$2 scope=$3 depth=$4 + local tool archive_dir list verbose extract_dir member vline target count validation_start + local list_limit_marker verbose_limit_marker declared_bytes actual_bytes + local file_limit_blocks status limit_reason + local max_total_bytes + + if ((depth > MAX_ARCHIVE_DEPTH)); then + fail_path "$scope" "$logical" 'nested archive depth limit exceeded' + return + fi + if ! tool=$(archive_tool); then + fail_path "$scope" "$logical" 'no supported archive inspection tool' + return + fi + + if ((ARCHIVES_SCANNED >= MAX_ARCHIVES)); then + fail_path "$scope" "$logical" 'global archive count limit exceeded' + return + fi + ARCHIVES_SCANNED=$((ARCHIVES_SCANNED + 1)) + archive_dir=$(mktemp -d "$AUDIT_TMP/archive.XXXXXXXX") + list=$archive_dir/list + verbose=$archive_dir/verbose + extract_dir=$archive_dir/root + list_limit_marker=$archive_dir/member-limit + verbose_limit_marker=$archive_dir/metadata-limit + mkdir -p "$extract_dir" + + if ! run_archive_command "$tool" -tf "$archive" 2>/dev/null | \ + head -c "$((MAX_ARCHIVE_LIST_KIB * 1024 + 1))" | awk \ + -v max="$MAX_ARCHIVE_MEMBERS" \ + -v max_bytes="$((MAX_ARCHIVE_LIST_KIB * 1024))" \ + -v marker="$list_limit_marker" ' + { bytes += length($0) + 1 } + bytes > max_bytes { + print "archive member-name budget exceeded" > marker + exit 43 + } + NR > max { + print "archive member limit exceeded" > marker + exit 42 + } + { print } + ' >"$list"; then + if [[ -s $list_limit_marker ]]; then + limit_reason=$(<"$list_limit_marker") + fail_path "$scope" "$logical" "$limit_reason" + else + fail_path "$scope" "$logical" 'archive cannot be listed safely' + fi + return + fi + count=$(LC_ALL=C wc -l <"$list" | tr -d '[:space:]') + if ((count == 0)); then + fail_path "$scope" "$logical" 'archive has no inspectable members' + return + fi + if ((count > MAX_ARCHIVE_MEMBERS)); then + fail_path "$scope" "$logical" 'archive member limit exceeded' + return + fi + + validation_start=$FAILURES + while IFS= read -r member || [[ -n $member ]]; do + if ! path_stays_below_root "$member"; then + fail_path "$scope" "$logical!$member" 'archive member escapes extraction root' + fi + done <"$list" + + if ! run_archive_command "$tool" -tvf "$archive" 2>/dev/null | \ + head -c "$((MAX_ARCHIVE_LIST_KIB * 2048 + 1))" | awk \ + -v max="$count" -v max_bytes="$((MAX_ARCHIVE_LIST_KIB * 2048))" \ + -v marker="$verbose_limit_marker" ' + { bytes += length($0) + 1 } + bytes > max_bytes || NR > max { + print "archive metadata output limit exceeded" > marker + exit 44 + } + { print } + ' >"$verbose"; then + if [[ -s $verbose_limit_marker ]]; then + limit_reason=$(<"$verbose_limit_marker") + fail_path "$scope" "$logical" "$limit_reason" + else + fail_path "$scope" "$logical" 'archive metadata cannot be inspected safely' + fi + return + fi + if [[ $(wc -l <"$verbose" | tr -d '[:space:]') != "$count" ]]; then + fail_path "$scope" "$logical" 'archive metadata does not match member list' + return + fi + if declared_bytes=$(archive_declared_bytes "$tool" "$verbose"); then + : + else + status=$? + if ((status == 3)); then + fail_path "$scope" "$logical" 'archive declared-size limit exceeded before extraction' + else + fail_path "$scope" "$logical" 'archive member sizes cannot be accounted safely' + fi + return + fi + max_total_bytes=$((MAX_TOTAL_ARCHIVE_KIB * 1024)) + if ((declared_bytes > max_total_bytes - TOTAL_ARCHIVE_BYTES)); then + fail_path "$scope" "$logical" 'global archive declared-size budget exceeded' + return + fi + TOTAL_ARCHIVE_BYTES=$((TOTAL_ARCHIVE_BYTES + declared_bytes)) + exec 3<"$list" 4<"$verbose" + while IFS= read -r member <&3 || [[ -n $member ]]; do + IFS= read -r vline <&4 || vline= + case $vline in + l*' -> '*) + target=${vline##* -> } + check_link_target "$member" "$target" "$scope" "$logical!$member" + ;; + h*' link to '*) + target=${vline##* link to } + if ! path_stays_below_root "$target"; then + fail_path "$scope" "$logical!$member" 'hardlink escapes extraction root' + fi + ;; + b*|c*|p*|s*) + fail_path "$scope" "$logical!$member" 'special archive member is not source data' + ;; + esac + done + exec 3<&- 4<&- + + # Keep validation ahead of mutation when presented with hostile input. + if ((FAILURES > validation_start)); then + return + fi + + # POSIX file-size limits use 512-byte blocks; twice the KiB limit is a + # conservative per-file ceiling. The declared total above remains tighter. + file_limit_blocks=$((MAX_ARCHIVE_KIB * 2 + 2)) + if ! ( + ulimit -f "$file_limit_blocks" 2>/dev/null || true + run_archive_command "$tool" --no-same-owner --no-same-permissions \ + -xf "$archive" \ + -C "$extract_dir" > /dev/null 2>&1 + ); then + fail_path "$scope" "$logical" 'archive cannot be extracted for inspection' + return + fi + if actual_bytes=$(extracted_regular_bytes "$extract_dir"); then + : + else + fail_path "$scope" "$logical" 'archive expanded-size limit exceeded' + return + fi + if ((actual_bytes > declared_bytes)); then + fail_path "$scope" "$logical" 'archive expanded beyond its declared member sizes' + return + fi + scan_tree "$extract_dir" "$scope" "$depth" "$logical" +} + +scan_regular() { + local file=$1 logical=$2 scope=$3 depth=$4 kind lower + SCANNED=$((SCANNED + 1)) + + if [[ ! -r $file ]]; then + fail_path "$scope" "$logical" 'file cannot be read for audit' + return + fi + + if forbidden_extension "$logical"; then + fail_path "$scope" "$logical" 'forbidden compiled/package extension' + fi + lower=$(LC_ALL=C printf '%s' "$logical" | tr '[:upper:]' '[:lower:]') + case $lower in + *.bin) + if ! is_declared_binary_data "$logical"; then + fail_path "$scope" "$logical" \ + 'undeclared .bin data (manifest needs purpose, provenance, and SPDX license)' + fi + ;; + esac + if LC_ALL=C grep -Eaqm1 '^version https://git-lfs[.]github[.]com/spec/v1\r?$' "$file" 2>/dev/null; then + fail_path "$scope" "$logical" 'unresolved Git LFS pointer' + fi + if kind=$(magic_kind "$file"); then + fail_path "$scope" "$logical" "$kind" + elif kind=$(file_utility_kind "$file"); then + fail_path "$scope" "$logical" "$kind" + fi + check_removed_library_reference "$file" "$logical" "$scope" + + if looks_like_archive "$file" "$logical"; then + scan_archive "$file" "$logical" "$scope" "$((depth + 1))" + fi +} + +scan_tree() { + local tree=$1 scope=$2 depth=${3:-0} prefix=${4:-} + local path relative logical target resolved canonical_tree + + if [[ ! -d $tree ]]; then + fail_path "$scope" "$tree" 'tree does not exist' + return + fi + canonical_tree=$(canonicalize_allow_missing "$tree") || { + fail_path "$scope" "$tree" 'cannot canonicalize audit root' + return + } + while IFS= read -r -d '' path; do + relative=${path#"$tree"/} + logical=$relative + [[ -z $prefix ]] || logical=$prefix!$relative + if [[ -L $path ]]; then + target=$(readlink "$path") + check_link_target "$relative" "$target" "$scope" "$logical" + resolved=$(canonicalize_allow_missing "$path") || { + fail_path "$scope" "$logical" 'cannot canonicalize symlink' + continue + } + case $resolved in + "$canonical_tree"|"$canonical_tree"/*) ;; + *) fail_path "$scope" "$logical" 'symlink resolves outside audit root' ;; + esac + elif [[ -f $path ]]; then + scan_regular "$path" "$logical" "$scope" "$depth" + else + fail_path "$scope" "$logical" 'unsupported special filesystem entry' + fi + done < <(find -P "$tree" -path "$tree/.git" -prune -o \ + \( -type f -o -type l -o \( ! -type d \) \) -print0) +} + +scan_index() { + local repo=$1 record metadata logical mode object stage blob target + local serial=0 + + while IFS= read -r -d '' record; do + metadata=${record%%$'\t'*} + logical=${record#*$'\t'} + read -r mode object stage <<< "$metadata" + [[ $stage == 0 ]] || continue + case $mode in + 100*) + serial=$((serial + 1)) + blob=$AUDIT_TMP/index.$serial + if git -C "$repo" cat-file blob "$object" >"$blob" 2>/dev/null; then + scan_regular "$blob" "$logical" tracked 0 + else + fail_path tracked "$logical" 'cannot read indexed blob' + fi + ;; + 120000) + if target=$(git -C "$repo" cat-file blob "$object" 2>/dev/null); then + check_link_target "$logical" "$target" tracked + else + fail_path tracked "$logical" 'cannot read indexed symlink' + fi + ;; + 160000) fail_path tracked "$logical" 'Git submodule entry is not source-only' ;; + *) fail_path tracked "$logical" 'unsupported Git index mode' ;; + esac + done < <(git -C "$repo" ls-files --stage -z) +} + +scan_git_archive() { + local repo=$1 revision=$2 label=$3 tarball + tarball=$(mktemp "$AUDIT_TMP/git-archive.XXXXXXXX") + if ! git -C "$repo" archive --format=tar "$revision" >"$tarball" 2>/dev/null; then + fail_path "$label" "$revision" 'cannot create Git source archive' + return + fi + scan_archive "$tarball" "$revision.tar" "$label" 0 +} + +if ((REPOSITORY_AUDIT)); then + if [[ -z $ROOT ]]; then + if ! ROOT=$(git rev-parse --show-toplevel 2>/dev/null); then + printf 'ERROR: not inside a Git repository; use --tree or --archive\n' >&2 + exit 2 + fi + fi + if ! ROOT=$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null); then + printf 'ERROR: --root is not a Git repository\n' >&2 + exit 2 + fi + + scan_index "$ROOT" + scan_tree "$ROOT" working-tree 0 + if git -C "$ROOT" rev-parse --verify -q 'HEAD^{commit}' >/dev/null; then + scan_git_archive "$ROOT" HEAD git-archive-HEAD + else + fail_path git-archive-HEAD HEAD 'repository has no commit' + fi + if ((TAG_COUNT)); then + for target in "${TAGS[@]}"; do + if git -C "$ROOT" rev-parse --verify -q "$target^{commit}" >/dev/null; then + scan_git_archive "$ROOT" "$target" "git-archive-$target" + else + fail_path git-tag "$target" 'revision does not resolve to a commit' + fi + done + fi +fi + +if ((TREE_COUNT)); then + for target in "${TREES[@]}"; do + if [[ -d $target ]]; then + target=$(canonicalize_allow_missing "$target") || { + fail_path standalone-tree "$target" 'cannot canonicalize tree' + continue + } + scan_tree "$target" standalone-tree 0 + else + fail_path standalone-tree "$target" 'tree does not exist' + fi + done +fi + +if ((ARCHIVE_COUNT)); then + for target in "${ARCHIVES[@]}"; do + if [[ -f $target ]]; then + target=$(canonicalize_allow_missing "$target") || { + fail_path standalone-archive "$target" 'cannot canonicalize archive' + continue + } + scan_archive "$target" "${target##*/}" standalone-archive 0 + else + fail_path standalone-archive "$target" 'archive does not exist' + fi + done +fi + +if ((FAILURES == 0)); then + printf 'PASS source-only: %d files, %d archives\n' "$SCANNED" "$ARCHIVES_SCANNED" + exit 0 +fi +printf 'FAIL source-only: %d finding(s), %d files, %d archives\n' \ + "$FAILURES" "$SCANNED" "$ARCHIVES_SCANNED" +exit 1 diff --git a/scripts/export-opensuse-package.sh b/scripts/export-opensuse-package.sh new file mode 100755 index 0000000..48fee88 --- /dev/null +++ b/scripts/export-opensuse-package.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later + +set -Eeuo pipefail + +umask 077 +export LC_ALL=C +export TZ=UTC + +die() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +need_command() { + command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" +} + +for command_name in awk basename bsdtar cat file find git grep mkdir mktemp mv rm sha256sum sort tar touch unzip xargs zip; do + need_command "$command_name" +done + +repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || + die 'run this script from the ZUPT Git repository' + +cd "$repo_root" + +remote_urls=$(git remote -v | awk '{print $2}' | sort -u) +grep -Eq '(^|[/:])cristiancmoises/zupt(\.git)?$' <<<"$remote_urls" || + die 'no configured remote identifies cristiancmoises/zupt' +grep -Eqi 'vaptvupt-web|zupt-web' <<<"$remote_urls" && + die 'a configured remote points to a web project' + +version=$(awk -F'"' '/^#define ZUPT_VERSION_STRING / { print $2; exit }' include/zupt.h) +[[ -n "$version" ]] || die 'cannot determine version from include/zupt.h' +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || + die "source version is not a stable semantic version: $version" + +release_tag=${1:-v$version} +[[ "$release_tag" == "v$version" ]] || + die "tag $release_tag does not match source version v$version" + +tag_ref="refs/tags/$release_tag" +git show-ref --verify --quiet "$tag_ref" || die "tag does not exist: $release_tag" +[[ $(git cat-file -t "$tag_ref") == tag ]] || die "tag is not annotated: $release_tag" + +head_commit=$(git rev-parse HEAD) +tag_commit=$(git rev-parse "$tag_ref^{commit}") +[[ "$head_commit" == "$tag_commit" ]] || + die "HEAD $head_commit does not match $release_tag commit $tag_commit" + +if ! git diff --quiet || ! git diff --cached --quiet; then + die 'tracked working tree changes must be committed before export' +fi + +scanner="$repo_root/scripts/check-source-only.sh" +[[ -f "$scanner" ]] || die 'missing scripts/check-source-only.sh' +bash "$scanner" --tag "$release_tag" + +git check-ignore -q --no-index dist/ || + die 'dist/ must be ignored before creating the handoff' + +work_dir=$(mktemp -d "${TMPDIR:-/tmp}/zupt-opensuse-export.XXXXXX") +cleanup() { + if [[ -n ${work_dir:-} && -d ${work_dir:-} ]]; then + rm -rf -- "$work_dir" + fi +} +trap cleanup EXIT + +bundle_name="zupt-openSUSE-source-only-$release_tag" +bundle_root="$work_dir/$bundle_name" +mkdir -p "$bundle_root" + +git archive "$release_tag" \ + packaging/opensuse \ + scripts/check-source-only.sh \ + scripts/test-installed-zupt.sh \ + LICENSE LICENSE-AGPL-3.0 LICENSE-GPL-3.0 LICENSE-COMMERCIAL \ + LICENSE-BSD-2-Clause LICENSE-BSD-3-Clause LICENSE-CC0-1.0 \ + NOTICE THIRD-PARTY-NOTICES.md | + tar -xf - -C "$bundle_root" + +handoff_legal_files=( + LICENSE LICENSE-AGPL-3.0 LICENSE-GPL-3.0 LICENSE-COMMERCIAL + LICENSE-BSD-2-Clause LICENSE-BSD-3-Clause LICENSE-CC0-1.0 + NOTICE THIRD-PARTY-NOTICES.md +) +for legal_file in "${handoff_legal_files[@]}"; do + [[ -s $bundle_root/$legal_file ]] || \ + die "handoff legal file is missing or empty: $legal_file" +done + +cat >"$bundle_root/HANDOFF.md" <"$checksum_manifest" +) +mv "$checksum_manifest" "$bundle_root/SHA256SUMS" +( + cd "$bundle_root" + sha256sum -c SHA256SUMS +) + +mkdir -p "$repo_root/dist" +zip_path="$repo_root/dist/$bundle_name.zip" +checksum_path="$zip_path.sha256" +[[ ! -e "$zip_path" && ! -e "$checksum_path" ]] || + die "handoff already exists: $zip_path" + +source_epoch=$(git show -s --format=%ct "$release_tag^{commit}") +[[ "$source_epoch" =~ ^[0-9]+$ ]] || die 'tag commit time is not numeric' +find "$bundle_root" -exec touch -d "@$source_epoch" {} + +( + cd "$work_dir" + find "$bundle_name" -print | LC_ALL=C sort | zip -X -q "$zip_path" -@ +) + +unzip -t "$zip_path" +bash "$scanner" --archive "$zip_path" + +verify_dir="$work_dir/verified" +mkdir -p "$verify_dir" +unzip -q "$zip_path" -d "$verify_dir" +extracted_root="$verify_dir/$bundle_name" +[[ -d "$extracted_root" ]] || die 'validated ZIP did not contain the expected root' +( + cd "$extracted_root" + sha256sum -c SHA256SUMS +) +bash "$scanner" --tree "$extracted_root" + +( + cd "$repo_root/dist" + sha256sum "$(basename "$zip_path")" >"$(basename "$checksum_path")" + sha256sum -c "$(basename "$checksum_path")" +) + +printf 'PASS: source-only openSUSE handoff created\n' +printf 'ZIP: %s\n' "$zip_path" +printf 'SHA-256: %s\n' "$checksum_path" +printf 'Tag: %s\nCommit: %s\n' "$release_tag" "$tag_commit" diff --git a/scripts/test-installed-zupt.sh b/scripts/test-installed-zupt.sh new file mode 100755 index 0000000..3f0e076 --- /dev/null +++ b/scripts/test-installed-zupt.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (c) 2026 Cristian Cezar Moisés + +set -Eeuo pipefail + +umask 077 +export LC_ALL=C + +die() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +pass() { + printf 'PASS: %s\n' "$1" +} + +hash_tree() { + local tree=$1 + ( + cd -- "$tree" + if command -v sha256sum >/dev/null 2>&1; then + find . -type f -exec sha256sum {} \; | LC_ALL=C sort + elif command -v shasum >/dev/null 2>&1; then + find . -type f -exec shasum -a 256 {} \; | LC_ALL=C sort + else + die 'sha256sum or shasum is required' + fi + ) +} + +# ZUPT_BIN is the public override. VAPTVUPT_BIN remains a compatibility +# fallback for existing automation during the package-name transition. +candidate=${1:-${ZUPT_BIN:-${VAPTVUPT_BIN:-zupt}}} +if [[ $candidate == */* ]]; then + [[ -x $candidate ]] || die "executable not found: $candidate" + binary=$(cd -- "$(dirname -- "$candidate")" && pwd -P)/$(basename -- "$candidate") +else + binary=$(command -v -- "$candidate" || true) + [[ -n $binary ]] || die "executable not found on PATH: $candidate" +fi + +test_root=$(mktemp -d "${TMPDIR:-/tmp}/zupt-installed.XXXXXX") +trap 'chmod -R u+rwX "$test_root" 2>/dev/null || true; rm -rf -- "$test_root"' EXIT HUP INT TERM + +input=$test_root/input +plain_out=$test_root/plain-out +password_out=$test_root/password-out +escape_out=$test_root/escape-out +outside=$test_root/outside +mkdir -p "$input/subdir" "$plain_out" "$password_out" "$escape_out" "$outside" + +printf 'ZUPT installed smoke test\nsecond line\n' > "$input/text file.txt" +printf 'conteúdo UTF-8\n' > "$input/subdir/café-安全.txt" +: > "$input/empty file" +dd if=/dev/urandom of="$input/subdir/random.bin" bs=4096 count=8 2>/dev/null +printf 'do-not-overwrite\n' > "$outside/sentinel" + +"$binary" --version > "$test_root/version.log" 2>&1 +grep -q '^zupt ' "$test_root/version.log" || die "--version did not identify zupt" +pass '--version' + +"$binary" --help > "$test_root/help.log" 2>&1 +grep -q '^Usage:' "$test_root/help.log" || die "--help did not contain Usage" +pass '--help' + +if "$binary" --definitely-invalid-option > "$test_root/invalid.log" 2>&1; then + die 'invalid option returned success' +fi +pass 'invalid option returns failure' + +plain_archive=$test_root/plain.zupt +"$binary" compress "$plain_archive" "$input" > "$test_root/plain-compress.log" 2>&1 +"$binary" test "$plain_archive" > "$test_root/plain-test.log" 2>&1 +"$binary" extract -o "$plain_out" "$plain_archive" > "$test_root/plain-extract.log" 2>&1 +extracted_markers=() +while IFS= read -r -d '' marker; do + extracted_markers[${#extracted_markers[@]}]=$marker +done < <(find "$plain_out" -type f -name 'text file.txt' -print0) +[[ ${#extracted_markers[@]} -eq 1 ]] || die 'extracted tree is missing or ambiguous' +extracted_input=$(dirname -- "${extracted_markers[0]}") +[[ -f $extracted_input/subdir/café-安全.txt && -f $extracted_input/empty\ file ]] || \ + die 'extracted tree is incomplete' +diff -r -- "$input" "$extracted_input" > "$test_root/plain-diff.log" || die 'plain round-trip differs' + +hash_tree "$input" > "$test_root/original.sha256" +hash_tree "$extracted_input" > "$test_root/extracted.sha256" +cmp -- "$test_root/original.sha256" "$test_root/extracted.sha256" || die 'round-trip SHA-256 manifests differ' +pass 'text, random, empty, nested, spaces and UTF-8 round-trip' + +password='ZUPT-test-password-2026!' +password_archive=$test_root/password.zupt +"$binary" compress -p "$password" "$password_archive" "$input/text file.txt" > "$test_root/password-compress.log" 2>&1 +"$binary" test -p "$password" "$password_archive" > "$test_root/password-test.log" 2>&1 +"$binary" extract -p "$password" -o "$password_out" "$password_archive" > "$test_root/password-extract.log" 2>&1 +password_markers=() +while IFS= read -r -d '' marker; do + password_markers[${#password_markers[@]}]=$marker +done < <(find "$password_out" -type f -name 'text file.txt' -print0) +[[ ${#password_markers[@]} -eq 1 ]] || die 'password extraction is missing or ambiguous' +cmp -- "$input/text file.txt" "${password_markers[0]}" || die 'password round-trip differs' +if "$binary" extract -p 'incorrect-password' -o "$test_root/wrong-password-out" "$password_archive" > "$test_root/wrong-password.log" 2>&1; then + die 'incorrect password returned success' +fi +pass 'password round-trip and incorrect-password rejection' + +archive_size=$(wc -c < "$plain_archive") +(( archive_size > 32 )) || die 'archive unexpectedly small' +head -c "$((archive_size - 17))" "$plain_archive" > "$test_root/corrupt.zupt" +if "$binary" test "$test_root/corrupt.zupt" > "$test_root/corrupt.log" 2>&1; then + die 'truncated archive returned success' +fi +pass 'corrupt archive rejection' + +archive_input_rel=${extracted_input#"$plain_out"/} +[[ $archive_input_rel != "$extracted_input" && $archive_input_rel != /* ]] || \ + die 'cannot determine the archive extraction path' +mkdir -p -- "$escape_out/$(dirname -- "$archive_input_rel")" +ln -s -- "$outside" "$escape_out/$archive_input_rel" +"$binary" extract -o "$escape_out" "$plain_archive" > "$test_root/escape.log" 2>&1 || true +[[ $(<"$outside/sentinel") == 'do-not-overwrite' ]] || die 'extraction overwrote outside sentinel' +[[ ! -e "$outside/text file.txt" && ! -e "$outside/subdir" ]] || die 'extraction escaped through a destination symlink' +pass 'no write outside extraction destination' + +if [[ $(id -u) -eq 0 ]]; then + printf 'SKIP: unprivileged execution (test process is root)\n' +else + [[ -r $plain_archive && -x $binary ]] || die 'unprivileged process cannot read archive or execute binary' + pass 'execution as an unprivileged user' +fi + +printf 'PASS: installed ZUPT functional test suite\n' diff --git a/sdk/LICENSE b/sdk/LICENSE index eb649ad..efa71b3 100644 --- a/sdk/LICENSE +++ b/sdk/LICENSE @@ -1,56 +1,59 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 +libzuptsdk licensing notice +=========================== - Copyright (C) 2026 Cristian Cezar Moisés +Copyright (C) 2025-2026 Cristian Cezar Moisés - libzuptsdk is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as - published by the Free Software Foundation, either version 3 of the - License, or (at your option) any later version. +The libzuptsdk compatibility wrapper, public header, bindings, tests, and build +integration carry this SPDX expression unless a file states otherwise: - libzuptsdk is distributed in the hope that it will be useful, but - WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Affero General Public License for more details. + AGPL-3.0-or-later - You should have received a copy of the GNU Affero General Public - License along with this program. If not, see: +The shared and static libraries produced by `make sdk` also incorporate the +bundled VaptVupt compression codec sources identified at repository level by: - https://www.gnu.org/licenses/agpl-3.0.txt - https://www.gnu.org/licenses/agpl-3.0.html + GPL-3.0-or-later - SPDX-License-Identifier: AGPL-3.0-or-later +The built library also contains the two xxHash-derived units identified by: - ───────────────────────────────────────────────────────────────────── + BSD-2-Clause - ABOUT THIS LICENSE +It also contains pq-crystals/kyber-derived portions of native ML-KEM under the +upstream option selected by this distribution: - The GNU Affero General Public License v3 (AGPLv3) is a copyleft - license designed for software that may be run as a network service. - It is identical to the GNU General Public License v3, with one - additional requirement (Section 13): if you modify libzuptsdk and - make the modified version available to users over a computer network, - you must offer those users access to the corresponding modified - source code. + CC0-1.0 - This protects libzuptsdk against being adopted by SaaS providers as - a private fork without contributing back, while keeping it freely - usable by individuals, small businesses, and the broader open-source - community. +It contains curve25519-donna-derived portions of native X25519 under: - If you write a separate program that is distributed alongside - libzuptsdk (for example, statically linking it into your own - application), the AGPL requires you to license that combined work - under the AGPL as well — which means you must publish the source. - If this is not acceptable for your use case, please contact the - author for commercial licensing options: + BSD-3-Clause - zupt@riseup.net - https://github.com/cristiancmoises/zupt +The built library therefore contains all five scopes and is described for package +metadata by: - ───────────────────────────────────────────────────────────────────── + AGPL-3.0-or-later AND GPL-3.0-or-later AND BSD-2-Clause AND BSD-3-Clause AND CC0-1.0 - The full text of the GNU Affero General Public License version 3 - should accompany this distribution as a separate file (or you may - download it from the URLs above). It is approximately 35 KB / 619 - lines of plain text. +The complete, unmodified public license texts and applicable notices are in: + + LICENSE-AGPL-3.0 + LICENSE-GPL-3.0 + LICENSE-BSD-2-Clause + LICENSE-BSD-3-Clause + LICENSE-CC0-1.0 + LICENSE + NOTICE + THIRD-PARTY-NOTICES.md + +Preserve per-file SPDX and copyright notices; they are authoritative for files +outside this summary. Published historical revisions may carry different +notices for their exact contents. This current notice does not revoke or +reinterpret a historical grant. + +The in-tree libzuptsdk compatibility library is distinct from the separately +packaged system libvuptsdk used by the ZUPT CLI's optional `WITH_SDK=1` +integration. + +The applicable copyright holder may offer controlled first-party rights under +a separately executed commercial agreement. This notice grants no commercial +permission and cannot relicense rights the licensor does not control. + +Commercial licensing inquiries: sac@securityops.co +Canonical project: https://github.com/cristiancmoises/zupt diff --git a/sdk/Makefile.sdk b/sdk/Makefile.sdk index 9fc8059..c54ae03 100644 --- a/sdk/Makefile.sdk +++ b/sdk/Makefile.sdk @@ -1,89 +1,97 @@ -# ───────────────────────────────────────────────────────────────────── -# libzuptsdk — public C ABI for Zupt -# ───────────────────────────────────────────────────────────────────── +# SPDX-License-Identifier: AGPL-3.0-or-later +# Source-only build rules for the in-tree libzuptsdk compatibility SDK. SDK_VERSION_MAJOR = 1 SDK_VERSION_MINOR = 0 SDK_VERSION_PATCH = 0 -SDK_SOVERSION = $(SDK_VERSION_MAJOR) -SDK_FULLVERSION = $(SDK_VERSION_MAJOR).$(SDK_VERSION_MINOR).$(SDK_VERSION_PATCH) +SDK_SOVERSION = $(SDK_VERSION_MAJOR) +SDK_FULLVERSION = $(SDK_VERSION_MAJOR).$(SDK_VERSION_MINOR).$(SDK_VERSION_PATCH) -SDK_HDR = sdk/include/zuptsdk.h -SDK_SRC = sdk/src/zuptsdk.c -SDK_MAP = sdk/zuptsdk.map -SDK_PREFIX ?= /usr/local +SDK_HDR = sdk/include/zuptsdk.h +SDK_SRC = sdk/src/zuptsdk.c +SDK_MAP = sdk/zuptsdk.map +SDK_BUILD_DIR = sdk/build +SDK_PKGCONFIGDIR ?= $(LIBDIR)/pkgconfig +SDK_LICENSEDIR ?= $(PREFIX)/share/licenses/libzuptsdk +SDK_LICENSE_FILES = LICENSE LICENSE-AGPL-3.0 LICENSE-GPL-3.0 \ + LICENSE-BSD-2-Clause LICENSE-BSD-3-Clause LICENSE-CC0-1.0 \ + NOTICE THIRD-PARTY-NOTICES.md -# All zupt sources except main.c get rebuilt with -fPIC for the SDK. -# Object files go to sdk/build/ to avoid colliding with the CLI build. -SDK_BUILD_DIR = sdk/build -SDK_PIC_OBJS = $(patsubst src/%.c,$(SDK_BUILD_DIR)/%.o,$(filter-out src/zupt_main.c,$(ZUPT_SOURCES))) -SDK_PIC_OBJS += $(patsubst src/%.c,$(SDK_BUILD_DIR)/%.o,$(VV_SOURCES)) -SDK_PIC_OBJS += $(SDK_BUILD_DIR)/zuptsdk.o +# All implementation sources are rebuilt from source as PIC. Objects are kept +# separate from the CLI build so `make -j` may build both safely. +SDK_PIC_OBJS = $(patsubst src/%.c,$(SDK_BUILD_DIR)/%.o,$(filter-out src/zupt_main.c,$(ZUPT_SOURCES))) +SDK_PIC_OBJS += $(patsubst src/%.c,$(SDK_BUILD_DIR)/%.o,$(VV_SOURCES)) +SDK_PIC_OBJS += $(SDK_BUILD_DIR)/zuptsdk.o +SDK_PROJECT_CPPFLAGS = -DZUPT_BUILDING_SDK=1 -Isdk/include +SDK_PROJECT_CFLAGS = -fPIC +SDK_SHARED_LDFLAGS ?= -shared \ + -Wl,-soname,libzuptsdk.so.$(SDK_SOVERSION) \ + -Wl,--version-script,$(SDK_MAP) +SDK_PC_PRIVATE_LIBS ?= $(PROJECT_LDLIBS) -SDK_PIC_FLAGS = -fPIC -DZUPT_BUILDING_SDK=1 - -# VV files need SIMD flags too -SDK_PIC_VV_FLAGS = $(SDK_PIC_FLAGS) $(VV_SIMD_FLAGS) - -SDK_SHARED = sdk/build/libzuptsdk.so.$(SDK_FULLVERSION) -SDK_SHARED_SO = sdk/build/libzuptsdk.so.$(SDK_SOVERSION) -SDK_SHARED_LINK = sdk/build/libzuptsdk.so -SDK_STATIC = sdk/build/libzuptsdk.a - -SDK_PC = sdk/build/zuptsdk.pc - -# Compile rule for SDK PIC objects (vv_* files need SIMD flags) -$(SDK_BUILD_DIR)/vv_%.o: src/vv_%.c | $(SDK_BUILD_DIR) - $(Q)$(CC) $(CFLAGS) $(SDK_PIC_VV_FLAGS) -I include -c $< -o $@ - -$(SDK_BUILD_DIR)/vaptvupt_api.o: src/vaptvupt_api.c | $(SDK_BUILD_DIR) - $(Q)$(CC) $(CFLAGS) $(SDK_PIC_FLAGS) -I include -c $< -o $@ - -$(SDK_BUILD_DIR)/%.o: src/%.c | $(SDK_BUILD_DIR) - $(Q)$(CC) $(CFLAGS) $(SDK_PIC_FLAGS) -I include -c $< -o $@ - -$(SDK_BUILD_DIR)/zuptsdk.o: $(SDK_SRC) $(SDK_HDR) | $(SDK_BUILD_DIR) - $(Q)$(CC) $(CFLAGS) $(SDK_PIC_FLAGS) -I sdk/include -I include -I src -c $< -o $@ +SDK_SHARED = $(SDK_BUILD_DIR)/libzuptsdk.so.$(SDK_FULLVERSION) +SDK_SHARED_SO = $(SDK_BUILD_DIR)/libzuptsdk.so.$(SDK_SOVERSION) +SDK_SHARED_LINK = $(SDK_BUILD_DIR)/libzuptsdk.so +SDK_STATIC = $(SDK_BUILD_DIR)/libzuptsdk.a +SDK_PC = $(SDK_BUILD_DIR)/zuptsdk.pc $(SDK_BUILD_DIR): - $(Q)mkdir -p $(SDK_BUILD_DIR) + $(Q)mkdir -p "$@" + +# Keep ISA flags on the SHA-NI translation unit only. Runtime dispatch in the +# baseline SHA-256 implementation prevents execution on unsupported CPUs. +$(SDK_BUILD_DIR)/zupt_sha256_shani.o: src/zupt_sha256_shani.c $(HEADERS) | $(SDK_BUILD_DIR) + $(Q)$(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(SDK_PROJECT_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) $(SDK_PROJECT_CFLAGS) $(SHANI_FLAGS) -c -o $@ $< + +$(SDK_BUILD_DIR)/vv_%.o: src/vv_%.c $(HEADERS) | $(SDK_BUILD_DIR) + $(Q)$(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(SDK_PROJECT_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) $(SDK_PROJECT_CFLAGS) $(VV_WARNING_FLAGS) \ + $(if $(filter $(SDK_BUILD_DIR)/vv_decoder.o,$@),$(VV_DECODER_WARNING_FLAGS)) -c -o $@ $< + +$(SDK_BUILD_DIR)/vaptvupt_api.o: src/vaptvupt_api.c $(HEADERS) | $(SDK_BUILD_DIR) + $(Q)$(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(SDK_PROJECT_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) $(SDK_PROJECT_CFLAGS) $(VV_WARNING_FLAGS) -c -o $@ $< + +$(SDK_BUILD_DIR)/%.o: src/%.c $(HEADERS) | $(SDK_BUILD_DIR) + $(Q)$(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(SDK_PROJECT_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) $(SDK_PROJECT_CFLAGS) -c -o $@ $< + +$(SDK_BUILD_DIR)/zuptsdk.o: $(SDK_SRC) $(SDK_HDR) $(HEADERS) | $(SDK_BUILD_DIR) + $(Q)$(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(SDK_PROJECT_CPPFLAGS) \ + $(CFLAGS) $(PROJECT_CFLAGS) $(SDK_PROJECT_CFLAGS) -c -o $@ $< -# Shared library $(SDK_SHARED): $(SDK_PIC_OBJS) $(SDK_MAP) $(JAZZ_O) @echo "[sdk-shared] $@" - $(Q)$(CC) -shared -fPIC \ - -Wl,-soname,libzuptsdk.so.$(SDK_SOVERSION) \ - -Wl,--version-script,$(SDK_MAP) \ - $(LDFLAGS) \ - $(SDK_PIC_OBJS) $(JAZZ_O) \ - -o $@ $(LDLIBS) - $(Q)cd $(SDK_BUILD_DIR) && ln -sf $(notdir $(SDK_SHARED)) libzuptsdk.so.$(SDK_SOVERSION) - $(Q)cd $(SDK_BUILD_DIR) && ln -sf libzuptsdk.so.$(SDK_SOVERSION) libzuptsdk.so + $(Q)$(CC) $(CFLAGS) $(PROJECT_CFLAGS) $(SDK_PROJECT_CFLAGS) \ + $(LDFLAGS) $(PROJECT_LDFLAGS) $(SDK_SHARED_LDFLAGS) \ + $(SDK_PIC_OBJS) $(JAZZ_O) -o $@ $(PROJECT_LDLIBS) $(LDLIBS) + $(Q)cd "$(SDK_BUILD_DIR)" && ln -sf "$(notdir $(SDK_SHARED))" "$(notdir $(SDK_SHARED_SO))" + $(Q)cd "$(SDK_BUILD_DIR)" && ln -sf "$(notdir $(SDK_SHARED_SO))" "$(notdir $(SDK_SHARED_LINK))" -# Static library $(SDK_STATIC): $(SDK_PIC_OBJS) $(JAZZ_O) @echo "[sdk-static] $@" - $(Q)$(AR) rcs $@ $(SDK_PIC_OBJS) $(JAZZ_O) + $(Q)$(AR) $(ARFLAGS) $@ $(SDK_PIC_OBJS) $(JAZZ_O) + $(Q)$(RANLIB) $@ -# pkg-config file -$(SDK_PC): $(SDK_HDR) +$(SDK_PC): $(SDK_HDR) | $(SDK_BUILD_DIR) @echo "[sdk-pc] $@" - $(Q)mkdir -p $(SDK_BUILD_DIR) - $(Q)printf 'prefix=$(SDK_PREFIX)\n' > $@ - $(Q)printf 'exec_prefix=$${prefix}\n' >> $@ - $(Q)printf 'libdir=$${exec_prefix}/lib\n' >> $@ - $(Q)printf 'includedir=$${prefix}/include\n\n' >> $@ - $(Q)printf 'Name: zuptsdk\n' >> $@ - $(Q)printf 'Description: Zupt backup compression SDK\n' >> $@ - $(Q)printf 'URL: https://git.securityops.co/cristiancmoises/zupt\n' >> $@ - $(Q)printf 'Version: $(SDK_FULLVERSION)\n' >> $@ - $(Q)printf 'Libs: -L$${libdir} -lzuptsdk\n' >> $@ - $(Q)printf 'Libs.private: -lpthread\n' >> $@ - $(Q)printf 'Cflags: -I$${includedir}\n' >> $@ + $(Q)printf '%s\n' \ + 'prefix=$(PREFIX)' \ + 'exec_prefix=$${prefix}' \ + 'libdir=$(LIBDIR)' \ + 'includedir=$(INCLUDEDIR)' \ + '' \ + 'Name: zuptsdk' \ + 'Description: ZUPT source-built compatibility SDK' \ + 'URL: https://github.com/cristiancmoises/zupt' \ + 'Version: $(SDK_FULLVERSION)' \ + 'Libs: -L$${libdir} -lzuptsdk' \ + 'Libs.private: $(SDK_PC_PRIVATE_LIBS)' \ + 'Cflags: -I$${includedir}' > "$@" -# Convenience targets .PHONY: sdk sdk-shared sdk-static sdk-pkgconfig sdk-clean sdk-install \ - sdk-verify-symbols sdk-test + sdk-uninstall sdk-verify-symbols sdk-test sdk: sdk-shared sdk-static sdk-pkgconfig @@ -94,48 +102,53 @@ sdk-static: $(SDK_STATIC) sdk-pkgconfig: $(SDK_PC) sdk-clean: - $(Q)rm -rf $(SDK_BUILD_DIR) + $(Q)rm -rf "$(SDK_BUILD_DIR)" -# Symbol leakage verification. -# Pass: every exported text symbol starts with `zuptsdk_`. -# Fail: any symbol that doesn't. sdk-verify-symbols: $(SDK_SHARED) @echo "[sdk-verify] checking exported symbols in $(SDK_SHARED)" - $(Q)leaked=$$(nm -D --defined-only $(SDK_SHARED) | grep ' T ' | awk '{print $$3}' | grep -v '^zuptsdk_' || true); \ - if [ -n "$$leaked" ]; then \ - echo "FAIL: non-zuptsdk symbols exported:"; \ - echo "$$leaked"; \ - exit 1; \ + $(Q)set -eu; \ + tmp=$$(mktemp -d "$${TMPDIR:-/tmp}/zupt-sdk-symbols.XXXXXXXX"); \ + trap 'rm -rf -- "$$tmp"' EXIT HUP INT TERM; \ + nm -D --defined-only "$(SDK_SHARED)" | awk '$$2 == "T" { print $$3 }' | \ + sed 's/@.*//' | sort > "$$tmp/exported"; \ + grep '^ zuptsdk_' "$(SDK_MAP)" | tr -d ' ;' | sort > "$$tmp/declared"; \ + if grep -v '^zuptsdk_' "$$tmp/exported"; then \ + echo "FAIL: non-zuptsdk symbols exported" >&2; exit 1; \ fi; \ - expected=$$(grep -c '^ zuptsdk_' $(SDK_MAP)); \ - exported=$$(nm -D --defined-only $(SDK_SHARED) | grep ' T ' | grep -c '^.* T zuptsdk_' || true); \ - echo " $$exported exported / $$expected declared in version script"; \ - if [ "$$exported" -lt "$$expected" ]; then \ - echo "FAIL: $$((expected - exported)) declared symbols are missing from the .so"; \ - nm -D --defined-only $(SDK_SHARED) | grep ' T ' | grep '^.* T zuptsdk_' | awk '{print $$3}' | sort > /tmp/exp; \ - grep '^ zuptsdk_' $(SDK_MAP) | tr -d ' ;' | sort > /tmp/decl; \ - diff /tmp/decl /tmp/exp; \ - exit 1; \ - fi; \ - echo " PASS: no symbol leakage, all declared symbols exported" + diff -u "$$tmp/declared" "$$tmp/exported"; \ + echo " PASS: no symbol leakage and all declared symbols are exported" -# Build & run roundtrip test +# Link and execute without embedding an RPATH. LD_LIBRARY_PATH is scoped to the +# disposable test process and never enters an installed binary. sdk-test: $(SDK_SHARED) @echo "[sdk-test] building and running roundtrip" - $(Q)$(CC) $(CFLAGS) -I sdk/include sdk/tests/test_sdk_roundtrip.c \ - -Lsdk/build -lzuptsdk \ - -Wl,-rpath,'$$ORIGIN/build' \ - -o sdk/build/test_sdk_roundtrip $(LDLIBS) - $(Q)cd sdk && LD_LIBRARY_PATH=build ./build/test_sdk_roundtrip + $(Q)$(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) -Isdk/include \ + $(CFLAGS) $(PROJECT_CFLAGS) $(LDFLAGS) $(PROJECT_LDFLAGS) \ + sdk/tests/test_sdk_roundtrip.c -L"$(SDK_BUILD_DIR)" -lzuptsdk \ + -o "$(SDK_BUILD_DIR)/test_sdk_roundtrip" $(PROJECT_LDLIBS) $(LDLIBS) + $(Q)cd sdk && LD_LIBRARY_PATH=build "$$(pwd)/build/test_sdk_roundtrip" sdk-install: sdk - install -d $(DESTDIR)$(SDK_PREFIX)/lib - install -d $(DESTDIR)$(SDK_PREFIX)/include - install -d $(DESTDIR)$(SDK_PREFIX)/lib/pkgconfig - install -m 0644 $(SDK_HDR) $(DESTDIR)$(SDK_PREFIX)/include/ - install -m 0755 $(SDK_SHARED) $(DESTDIR)$(SDK_PREFIX)/lib/ - cd $(DESTDIR)$(SDK_PREFIX)/lib && \ - ln -sf libzuptsdk.so.$(SDK_FULLVERSION) libzuptsdk.so.$(SDK_SOVERSION) && \ - ln -sf libzuptsdk.so.$(SDK_SOVERSION) libzuptsdk.so - install -m 0644 $(SDK_STATIC) $(DESTDIR)$(SDK_PREFIX)/lib/ - install -m 0644 $(SDK_PC) $(DESTDIR)$(SDK_PREFIX)/lib/pkgconfig/ + $(Q)install -d "$(DESTDIR)$(LIBDIR)" "$(DESTDIR)$(INCLUDEDIR)" \ + "$(DESTDIR)$(SDK_PKGCONFIGDIR)" "$(DESTDIR)$(SDK_LICENSEDIR)" + $(Q)install -m 0644 "$(SDK_HDR)" "$(DESTDIR)$(INCLUDEDIR)/" + $(Q)install -m 0755 "$(SDK_SHARED)" "$(DESTDIR)$(LIBDIR)/" + $(Q)cd "$(DESTDIR)$(LIBDIR)" && \ + ln -sf "libzuptsdk.so.$(SDK_FULLVERSION)" "libzuptsdk.so.$(SDK_SOVERSION)" && \ + ln -sf "libzuptsdk.so.$(SDK_SOVERSION)" libzuptsdk.so + $(Q)install -m 0644 "$(SDK_STATIC)" "$(DESTDIR)$(LIBDIR)/" + $(Q)install -m 0644 "$(SDK_PC)" "$(DESTDIR)$(SDK_PKGCONFIGDIR)/" + $(Q)install -m 0644 $(SDK_LICENSE_FILES) "$(DESTDIR)$(SDK_LICENSEDIR)/" + $(Q)install -m 0644 sdk/LICENSE "$(DESTDIR)$(SDK_LICENSEDIR)/SDK-LICENSE" + +sdk-uninstall: + $(Q)rm -f "$(DESTDIR)$(LIBDIR)/libzuptsdk.so.$(SDK_FULLVERSION)" \ + "$(DESTDIR)$(LIBDIR)/libzuptsdk.so.$(SDK_SOVERSION)" \ + "$(DESTDIR)$(LIBDIR)/libzuptsdk.so" \ + "$(DESTDIR)$(LIBDIR)/libzuptsdk.a" \ + "$(DESTDIR)$(INCLUDEDIR)/$(notdir $(SDK_HDR))" \ + "$(DESTDIR)$(SDK_PKGCONFIGDIR)/$(notdir $(SDK_PC))" + $(Q)set -eu; for license_file in $(SDK_LICENSE_FILES); do \ + rm -f "$(DESTDIR)$(SDK_LICENSEDIR)/$${license_file##*/}"; \ + done + $(Q)rm -f "$(DESTDIR)$(SDK_LICENSEDIR)/SDK-LICENSE" diff --git a/sdk/README.md b/sdk/README.md index 5d4fb9a..5b953d9 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -1,14 +1,19 @@ # libzuptsdk -Public C ABI for the [VaptVupt](https://git.securityops.co/cristiancmoises/vaptvupt) backup compression library. +Public C ABI for the [ZUPT](https://github.com/cristiancmoises/zupt) backup compression library. -Provides post-quantum encrypted compression as a stable, embeddable shared library, independent of the `vaptvupt` CLI and of any external compression library — everything is built from VaptVupt's own implementations. +Provides post-quantum encrypted compression as a stable, embeddable shared library, independent of the `zupt` CLI and of any external compression library — everything is built from ZUPT's own implementations. - **Version:** 1.0.0 -- **License:** AGPL-3.0-or-later +- **License of the built library:** AGPL-3.0-or-later AND GPL-3.0-or-later AND BSD-2-Clause AND BSD-3-Clause AND CC0-1.0 - **ABI:** Stable across 1.x via versioned symbols (`ZUPTSDK_1.0`) - **C standard:** Public header is C99; C11 implementation; works in C++17 +This in-tree compatibility SDK is named **libzuptsdk**. It is not the separately +packaged **libvuptsdk** dependency used by the CLI's optional `WITH_SDK=1` +integration. Running `make sdk` builds `libzuptsdk` from this repository; it does +not enable `--pq-sdk` or the libvuptsdk-backed Argon2id path in `zupt`. + ## Features - **Hybrid post-quantum encryption** — ML-KEM-768 + X25519 KEM @@ -16,7 +21,7 @@ Provides post-quantum encrypted compression as a stable, embeddable shared libra - **Hardware-adaptive compression** — VaptVupt on AVX2/NEON, LZHP elsewhere - **Streaming I/O** — read/write callbacks for sockets, pipes, encrypted volumes - **Secure memory** — mlock-backed buffers for passwords and keys, zeroed on destroy -- **Constant-time crypto** — Jasmin-verified assembly on x86_64 +- **Optional assembly path** — textual Jasmin sources on supported x86_64 builds - **Per-context state** — no globals; safe to use from any thread on distinct contexts - **Custom allocator hooks** — supply your own malloc/free @@ -87,21 +92,35 @@ with zuptsdk.Context() as ctx: ## Build & install ```sh -git clone https://git.securityops.co/cristiancmoises/vaptvupt -cd vaptvupt -make # builds CLI (required: produces jasmin/*.o assembly objects) +git clone https://github.com/cristiancmoises/zupt +cd zupt +make # builds the portable CLI (WITH_JASMIN=0 by default) make sdk # builds libzuptsdk.so.1.0.0 + libzuptsdk.a + zuptsdk.pc make sdk-test # runs C roundtrip suite (15 tests) sudo make sdk-install PREFIX=/usr/local ``` -This SDK is built from source via `make sdk`; the previously vendored prebuilt `vendor/zuptsdk/libzuptsdk.so` has been removed from the tree. +This SDK is built from source via `make sdk`; the previously vendored prebuilt +`vendor/zuptsdk/libzuptsdk.so` has been removed from the tree. Build output is +written below the ignored `sdk/build/` directory and is never part of Git or an +upstream source archive. + +The wrapper and application portions are AGPL-3.0-or-later. The library also +incorporates the bundled VaptVupt codec sources identified as +GPL-3.0-or-later, plus the BSD-2-Clause xxHash-derived routines and CC0-1.0 +pq-crystals/kyber-derived ML-KEM portions, together with BSD-3-Clause +curve25519-donna-derived X25519 portions. Redistribution of the resulting +shared or static library must preserve all five scopes, `LICENSE-AGPL-3.0`, +`LICENSE-GPL-3.0`, `LICENSE-BSD-2-Clause`, `LICENSE-BSD-3-Clause`, +`LICENSE-CC0-1.0`, `NOTICE`, and `THIRD-PARTY-NOTICES.md`; see `sdk/LICENSE` +for the concise scope notice. This installs: - `/usr/local/include/zuptsdk.h` - `/usr/local/lib/libzuptsdk.so.1.0.0` (with versioned `.so.1` and `.so` symlinks) - `/usr/local/lib/libzuptsdk.a` - `/usr/local/lib/pkgconfig/zuptsdk.pc` +- `/usr/local/share/licenses/libzuptsdk/` (all applicable texts and notices) ## Symbol visibility @@ -174,12 +193,17 @@ sdk/ ## License -libzuptsdk is licensed under **AGPL-3.0-or-later** (see `sdk/LICENSE`). +The built libzuptsdk contains AGPL-3.0-or-later wrapper/application code and +GPL-3.0-or-later bundled codec code; its complete SPDX expression is +**AGPL-3.0-or-later AND GPL-3.0-or-later AND BSD-2-Clause AND BSD-3-Clause AND +CC0-1.0** (see `sdk/LICENSE`). -The AGPL allows everyone to use the library freely, but anyone running it as a network service must publish their source code modifications. +Redistributors must comply with the applicable terms and preserve all license +texts and notices. Consult the license texts rather than this summary for the +precise source-correspondence and network-use obligations. ## Contact -- Repository: https://git.securityops.co/cristiancmoises/vaptvupt -- Website: https://zupt.securityops.co -- Email: zupt@riseup.net +- Repository: https://github.com/cristiancmoises/zupt +- Project: https://github.com/cristiancmoises/zupt +- Email: sac@securityops.co diff --git a/sdk/include/zuptsdk.h b/sdk/include/zuptsdk.h index f30ea6c..6ce479f 100644 --- a/sdk/include/zuptsdk.h +++ b/sdk/include/zuptsdk.h @@ -1,12 +1,11 @@ /* - * libzuptsdk — Public C ABI for the Zupt backup compression library + * libzuptsdk — Public C ABI for the ZUPT backup compression library * * Copyright (c) 2026 Cristian Cezar Moisés * SPDX-License-Identifier: AGPL-3.0-or-later * - * Repository: https://git.securityops.co/cristiancmoises/zupt - * Website: https://zupt.securityops.co - * Contact: zupt@riseup.net + * Repository: https://github.com/cristiancmoises/zupt + * Contact: sac@securityops.co * * -------------------------------------------------------------------------- * STABILITY GUARANTEE diff --git a/sdk/src/zuptsdk.c b/sdk/src/zuptsdk.c index d886aa0..f90245e 100644 --- a/sdk/src/zuptsdk.c +++ b/sdk/src/zuptsdk.c @@ -556,20 +556,46 @@ void zuptsdk_keypair_destroy(zuptsdk_keypair_t *kp) { static int zsdk_copy_file(const char *src, const char *dst, mode_t mode) { FILE *fi = fopen(src, "rb"); if (!fi) return ZSDK_FAIL(ZUPTSDK_ERR_IO, "open %s", src); - FILE *fo = fopen(dst, "wb"); - if (!fo) { fclose(fi); return ZSDK_FAIL(ZUPTSDK_ERR_IO, "create %s", dst); } - uint8_t buf[4096]; - size_t n; - int rc = ZUPTSDK_OK; - while ((n = fread(buf, 1, sizeof(buf), fi)) > 0) - if (fwrite(buf, 1, n, fo) != n) { rc = ZSDK_FAIL(ZUPTSDK_ERR_IO, "write %s", dst); break; } - zuptsdk_secure_zero(buf, sizeof(buf)); - fclose(fi); fclose(fo); + FILE *fo = NULL; + zupt_atomic_output_t *output = zupt_atomic_output_open(dst, &fo); + if (!output) { + int saved_errno = errno; + fclose(fi); + errno = saved_errno; + return ZSDK_FAIL(ZUPTSDK_ERR_IO, "create %s", dst); + } + #ifndef _WIN32 - if (rc == ZUPTSDK_OK) chmod(dst, mode); + /* Apply permissions to the private temporary object, never to a + * re-resolved destination path. */ + if (fchmod(fileno(fo), mode) != 0) { + int saved_errno = errno; + fclose(fi); + (void)zupt_atomic_output_finish(output, 0); + errno = saved_errno; + return ZSDK_FAIL(ZUPTSDK_ERR_IO, "set permissions on %s", dst); + } #else (void)mode; #endif + + uint8_t buf[4096]; + size_t n; + int rc = ZUPTSDK_OK; + while ((n = fread(buf, 1, sizeof(buf), fi)) > 0) { + if (fwrite(buf, 1, n, fo) != n) { + rc = ZSDK_FAIL(ZUPTSDK_ERR_IO, "write %s", dst); + break; + } + } + if (rc == ZUPTSDK_OK && ferror(fi)) + rc = ZSDK_FAIL(ZUPTSDK_ERR_IO, "read %s", src); + zuptsdk_secure_zero(buf, sizeof(buf)); + if (fclose(fi) != 0 && rc == ZUPTSDK_OK) + rc = ZSDK_FAIL(ZUPTSDK_ERR_IO, "close %s", src); + if (zupt_atomic_output_finish(output, rc == ZUPTSDK_OK) != 0 && + rc == ZUPTSDK_OK) + rc = ZSDK_FAIL(ZUPTSDK_ERR_IO, "publish %s", dst); return rc; } diff --git a/sdk/tests/test_sdk_roundtrip.c b/sdk/tests/test_sdk_roundtrip.c index 2176a60..f855bde 100644 --- a/sdk/tests/test_sdk_roundtrip.c +++ b/sdk/tests/test_sdk_roundtrip.c @@ -12,6 +12,10 @@ #include #include #include +#ifndef _WIN32 +#include +#include +#endif #include static int g_pass = 0, g_fail = 0; @@ -47,6 +51,98 @@ static const uint8_t TEST_DATA[] = "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod. " "End of test data.\n"; +#ifndef _WIN32 +static int file_matches(const char *path, const void *expected, + size_t expected_size) { + struct stat info; + char observed[128]; + if (expected_size > sizeof(observed)) + return 0; + int fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK); + if (fd < 0) return 0; + int ok = fstat(fd, &info) == 0 && S_ISREG(info.st_mode) && + info.st_size >= 0 && + (uint64_t)info.st_size == (uint64_t)expected_size; + size_t got = 0; + while (ok && got < expected_size) { + ssize_t count = read(fd, observed + got, expected_size - got); + if (count <= 0) { + ok = 0; + break; + } + got += (size_t)count; + } + if (close(fd) != 0) ok = 0; + return ok && got == expected_size && + memcmp(observed, expected, expected_size) == 0; +} + +static int regular_file_info(const char *path, struct stat *info) { + int fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK); + if (fd < 0) return 0; + int ok = fstat(fd, info) == 0 && S_ISREG(info->st_mode); + if (close(fd) != 0) ok = 0; + return ok; +} + +static int private_key_save_avoids_link_targets(const zuptsdk_keypair_t *kp) { + static const char sentinel[] = "do not replace through a symlink\n"; + char workspace[] = "/tmp/zupt-sdk-link-save.XXXXXX"; + char target[192]; + char symlink_path[192]; + char hardlink_path[192]; + FILE *stream; + struct stat target_st; + struct stat output_st; + int ok = 0; + + if (!mkdtemp(workspace)) return 0; + snprintf(target, sizeof(target), "%s/target", workspace); + snprintf(symlink_path, sizeof(symlink_path), "%s/symlink-output", + workspace); + snprintf(hardlink_path, sizeof(hardlink_path), "%s/hardlink-output", + workspace); + + stream = fopen(target, "wb"); + if (!stream) goto cleanup; + size_t written = fwrite(sentinel, 1, sizeof(sentinel) - 1, stream); + int close_rc = fclose(stream); + if (written != sizeof(sentinel) - 1 || close_rc != 0) + goto cleanup; + + if (symlink(target, symlink_path) != 0 || + zuptsdk_keypair_save_private(kp, symlink_path) != ZUPTSDK_OK || + !file_matches(target, sentinel, sizeof(sentinel) - 1) || + !regular_file_info(target, &target_st) || + !regular_file_info(symlink_path, &output_st) || + (target_st.st_dev == output_st.st_dev && + target_st.st_ino == output_st.st_ino) || + output_st.st_size <= 0 || + (output_st.st_mode & 0777) != 0600) + goto cleanup; + + if (link(target, hardlink_path) != 0 || + zuptsdk_keypair_save_private(kp, hardlink_path) != ZUPTSDK_OK || + !file_matches(target, sentinel, sizeof(sentinel) - 1) || + !regular_file_info(target, &target_st) || + !regular_file_info(hardlink_path, &output_st) || + (target_st.st_dev == output_st.st_dev && + target_st.st_ino == output_st.st_ino) || + output_st.st_size <= 0 || + (output_st.st_mode & 0777) != 0600) + goto cleanup; + + ok = 1; + +cleanup: + unlink(symlink_path); + unlink(hardlink_path); + unlink(target); + rmdir(workspace); + return ok; +} +#endif + static void test_version(void) { TEST("version_string returns non-NULL"); const char *v = zuptsdk_version_string(); @@ -250,6 +346,19 @@ cleanup: static void test_keypair_pq(void) { TEST("keypair_generate + compress_pq + extract_pq"); + char saved_priv[160]; + char saved_pub[160]; +#ifdef _WIN32 + snprintf(saved_priv, sizeof(saved_priv), "/tmp/_zsdk_priv_%ld.key", + (long)getpid()); + snprintf(saved_pub, sizeof(saved_pub), "/tmp/_zsdk_pub_%ld.key", + (long)getpid()); + unlink(saved_priv); + unlink(saved_pub); +#else + char saved_workspace[] = "/tmp/zupt-sdk-roundtrip.XXXXXX"; +#endif + zuptsdk_ctx_t *ctx = NULL; CHECK(zuptsdk_ctx_create(&ctx), "ctx"); @@ -257,17 +366,45 @@ static void test_keypair_pq(void) { int rc = zuptsdk_keypair_generate(ctx, &kp); if (rc != ZUPTSDK_OK) { FAIL("keygen"); zuptsdk_ctx_destroy(ctx); return; } +#ifndef _WIN32 + if (!mkdtemp(saved_workspace)) { + FAIL("private temporary workspace"); + zuptsdk_keypair_destroy(kp); + zuptsdk_ctx_destroy(ctx); + return; + } + snprintf(saved_priv, sizeof(saved_priv), "%s/private.key", + saved_workspace); + snprintf(saved_pub, sizeof(saved_pub), "%s/public.key", + saved_workspace); + if (!private_key_save_avoids_link_targets(kp)) { + FAIL("private key save followed a symlink or hardlink target"); + goto err; + } +#endif + /* Save and load to exercise that path too */ - rc = zuptsdk_keypair_save_private(kp, "/tmp/_zsdk_priv.key"); + rc = zuptsdk_keypair_save_private(kp, saved_priv); if (rc != ZUPTSDK_OK) { FAIL("save priv"); goto err; } - rc = zuptsdk_keypair_save_public(kp, "/tmp/_zsdk_pub.key"); + rc = zuptsdk_keypair_save_public(kp, saved_pub); if (rc != ZUPTSDK_OK) { FAIL("save pub"); goto err; } +#ifndef _WIN32 + struct stat private_st; + struct stat public_st; + if (!regular_file_info(saved_priv, &private_st) || + !regular_file_info(saved_pub, &public_st) || + (private_st.st_mode & 0777) != 0600 || + (public_st.st_mode & 0777) != 0644) { + FAIL("saved key permissions do not match the requested modes"); + goto err; + } +#endif zuptsdk_pubkey_t *pub = NULL; zuptsdk_privkey_t *priv = NULL; - rc = zuptsdk_pubkey_load("/tmp/_zsdk_pub.key", &pub); + rc = zuptsdk_pubkey_load(saved_pub, &pub); if (rc != ZUPTSDK_OK) { FAIL("load pub"); goto err; } - rc = zuptsdk_privkey_load("/tmp/_zsdk_priv.key", &priv); + rc = zuptsdk_privkey_load(saved_priv, &priv); if (rc != ZUPTSDK_OK) { FAIL("load priv"); zuptsdk_pubkey_destroy(pub); goto err; } zuptsdk_options_t *opts = NULL; @@ -295,8 +432,10 @@ static void test_keypair_pq(void) { zuptsdk_privkey_destroy(priv); zuptsdk_options_destroy(opts); - unlink("/tmp/_zsdk_priv.key"); - unlink("/tmp/_zsdk_pub.key"); + if (unlink(saved_priv) != 0 || unlink(saved_pub) != 0) ok = 0; +#ifndef _WIN32 + if (rmdir(saved_workspace) != 0) ok = 0; +#endif if (!ok) { FAIL("byte mismatch or rc != OK"); zuptsdk_keypair_destroy(kp); zuptsdk_ctx_destroy(ctx); return; } zuptsdk_keypair_destroy(kp); @@ -305,6 +444,11 @@ static void test_keypair_pq(void) { return; err: + unlink(saved_priv); + unlink(saved_pub); +#ifndef _WIN32 + rmdir(saved_workspace); +#endif zuptsdk_keypair_destroy(kp); zuptsdk_ctx_destroy(ctx); } diff --git a/src/vaptvupt_api.c b/src/vaptvupt_api.c index 928062f..f419883 100644 --- a/src/vaptvupt_api.c +++ b/src/vaptvupt_api.c @@ -1,18 +1,25 @@ /* - * VaptVupt — Zupt Integration API Implementation + * VaptVupt — ZUPT Integration API Implementation * SPDX-License-Identifier: GPL-3.0-or-later * Copyright (c) 2025-2026 Cristian Cezar Moisés * * ZUPT-COMPAT: thin wrapper over vv_compress/vv_decompress with - * backup-optimized defaults for VaptVupt 2.60.4. + * backup-optimized defaults for VaptVupt 2.65.0. * * Defaults applied here (per ZUPT_INTEGRATION.md, Sprint 122): - * - opts.checksum = 0 (Zupt's HMAC-SHA256 / AES-GCM-SIV outer + * - opts.checksum = 0 (ZUPT's HMAC-SHA256 / AES-GCM-SIV outer * already authenticates the compressed * bytes; XXH64 footer is redundant work * and saves ~10% encode time) - * - opts.format_v2 = 1 (4-7% better binary ratio; v2.33.0+ - * decoders read v2 frames transparently) + * - opts.format_v2 = 0 (AUTO). Since codec v2.61.0 the encoder + * auto-enables min_match=3 ('T' blocks) for + * binary-detected input and keeps 'S' blocks + * for text. FORCING format_v2=1 routes text + * through the binary/greedy path and HALVES the + * extreme-mode ratio (text 7.6x -> 3.7x, + * measured on codec 2.65.0); auto keeps the + * optimal parser on text and still wins on + * binary. Never force it here. * - VV_DECOMPRESS_SKIP_CHECKSUM on decode (matched pair to * checksum=0 on encode; saves ~30% on real * fixtures, 2-5x on AEAD-wrapped data) @@ -31,7 +38,7 @@ int64_t vvz_compress(const uint8_t *src, size_t src_len, uint8_t *dst, size_t dst_cap, int level) { vv_options_t opts; vv_default_options(&opts); - opts.checksum = 0; /* outer Zupt MAC authenticates compressed bytes */ + opts.checksum = 0; /* outer ZUPT MAC authenticates compressed bytes */ opts.compat_v246_5_decoder = 0; /* allow 4-stream Huffman literal coding */ if (level <= 2) { @@ -43,14 +50,15 @@ int64_t vvz_compress(const uint8_t *src, size_t src_len, opts.format_v2 = 0; } else if (level <= 7) { opts.mode = VV_MODE_BALANCED; - opts.format_v2 = 1; /* 4-7% better binary ratio (v2.33.0+ decoders) */ + opts.format_v2 = 0; /* AUTO: v2 for binary, optimal 'S' for text. + * Forcing v2 halves text ratio — see header. */ opts.filter_auto = 1; /* BCJ on recognised ELF/PE/Mach-O input * (codec 2.55.0): no-op on everything else. * Blocks where a filter fired need a * v2.54.0+ decoder (tool >= 3.9.0). */ } else { opts.mode = VV_MODE_EXTREME; - opts.format_v2 = 1; /* 4-7% better binary ratio (v2.33.0+ decoders) */ + opts.format_v2 = 0; /* AUTO (see BALANCED / header note) */ opts.filter_auto = 1; /* see BALANCED note above */ } diff --git a/src/vv_ans.c b/src/vv_ans.c index c323372..4718590 100644 --- a/src/vv_ans.c +++ b/src/vv_ans.c @@ -181,6 +181,21 @@ static void build_dec(const uint16_t norm[NSYM], const uint8_t sp[ANS_L], vva_dec_entry_t dec[ANS_L]) { uint16_t occ[NSYM]; memset(occ, 0, sizeof(occ)); + /* SPRINT 125: per-symbol nb_max/low_count were recomputed (including + * an ilog2 while-loop) for every one of the 4096 slots; hoist them + * to one 256-entry precompute pass — identical values, ~16× fewer + * ilog2 evaluations per table build (3-4 builds per block on both + * encode and decode sides). */ + int8_t nbmax_tab[NSYM]; + int16_t lowcnt_tab[NSYM]; + for (int s = 0; s < NSYM; s++) { + uint16_t f = norm[s]; + if (f == 0 || f == (uint16_t)ANS_L) { nbmax_tab[s] = 0; lowcnt_tab[s] = 0; continue; } + int flg = ilog2(f); + int nb = ANS_LOG - flg; + nbmax_tab[s] = (int8_t)nb; + lowcnt_tab[s] = (int16_t)((1 << (flg + 1)) - (int)f); + } for (int x = 0; x < ANS_L; x++) { uint8_t s = sp[x]; uint16_t f = norm[s]; @@ -189,9 +204,8 @@ static void build_dec(const uint16_t norm[NSYM], const uint8_t sp[ANS_L], dec[x].symbol = s; dec[x].nbits = 0; dec[x].baseline = 0; continue; } - int flg = ilog2(f); - int nb_max = ANS_LOG - flg; - int low_count = (1 << (flg + 1)) - (int)f; + int nb_max = nbmax_tab[s]; + int low_count = lowcnt_tab[s]; /* On a VALID normalized table, f ∈ [1, ANS_L) here (f==0 and * f==ANS_L are handled above), so flg ≤ ANS_LOG-1 and nb_max ≥ 1, * and the shifts below are well-defined. A CORRUPT stream can @@ -265,15 +279,34 @@ static inline int enc_sym(const enc_ctx_t *c, uint32_t state, uint8_t sym, int base = c->cum[sym], cnt = c->cum[sym + 1] - base; if (!cnt) return -1; if (cnt == ANS_L) { *bv = 0; *bn = 0; return 0; } - for (int i = base; i < base + cnt; i++) { - uint32_t bl = c->o[i].bl; - int nb = c->o[i].nb; - if (state >= bl && state < bl + (1u << nb)) { - *bv = state - bl; *bn = nb; - return (int)c->o[i].slot; - } + /* SPRINT 124: O(1) slot lookup replacing a linear scan that + * averaged f/2 iterations (up to ~2048 for a dominant symbol — + * 10-15% of encode wall). + * + * The occurrence windows for a symbol with normalized freq f + * tile [0, ANS_L) exactly (see build_dec): occurrences + * k < low_count have nb_max = ANS_LOG - ilog2(f) bits and + * baseline k << nb_max; the rest have nb_max-1 bits. Baselines + * ascend with k and build_enc keeps c->o[] baseline-sorted, so + * c->o[base + k] IS occurrence k — the window containing `state` + * is directly computable. Produces bit-identical output to the + * scan (same slot, same bits). */ + int flg = ilog2((uint32_t)cnt); + int nb_max = ANS_LOG - flg; + uint32_t low_count = (1u << (flg + 1)) - (uint32_t)cnt; + uint32_t threshold = low_count << nb_max; + uint32_t k, nb; + if (state < threshold) { + nb = (uint32_t)nb_max; + k = state >> nb_max; + } else { + nb = (uint32_t)(nb_max - 1); + k = low_count + ((state - threshold) >> nb); } - return -1; + const enc_occ_t *e = &c->o[base + k]; + *bv = state - e->bl; + *bn = (int)nb; + return (int)e->slot; } /* ═══════════════════════════════════════════════════════════════ @@ -1477,6 +1510,11 @@ static size_t parse_sequences(const uint8_t *tokens, size_t tok_len, ll -= LL_MAX; } + /* SPRINT 125: re-check after the split loop — the while() guard + * at the top of the outer loop does not cover seqs consumed by + * splits within this iteration. */ + if (nseq >= seq_cap) return 0; + seqs[nseq].litlen = (uint32_t)ll; seqs[nseq].lit_offset = (uint32_t)nlits; nlits += ll; @@ -1511,10 +1549,89 @@ static size_t parse_sequences(const uint8_t *tokens, size_t tok_len, nseq++; } + /* SPRINT 125 (defense in depth): if the loop stopped because + * seq_cap was reached with tokens still unparsed, the parse is + * TRUNCATED — encoding it would silently drop sequences and emit a + * corrupt block. Unreachable with a correctly-sized seq_cap (see + * the caller's bound derivation), but fail closed regardless. */ + if (tp < tp_end) return 0; + *total_lits = nlits; return nseq; } +/* ═══════════════════════════════════════════════════════════════ + * LITERAL-CODER SIZE ESTIMATION (SPRINT 124) + * + * The literal-format race used to FULLY encode every candidate + * (ANS4 + ANS1 + Huffman + Huffman4) and keep one — measured at + * 6-21% of encode wall, nearly all discarded. One histogram plus + * analytic size estimates picks the winner first; only the winner + * is actually encoded. + * ═══════════════════════════════════════════════════════════════ */ + +/* Unlimited-depth Huffman code lengths, for size estimation only. + * (The real coder limits depth to 15; the difference is a handful of + * bits on pathological distributions — irrelevant for choosing.) */ +static void est_huff_lengths(const uint32_t freq[NSYM], uint8_t len[NSYM]) { + int leaf_sym[NSYM]; + int n = 0; + for (int s = 0; s < NSYM; s++) { + len[s] = 0; + if (freq[s]) leaf_sym[n++] = s; + } + if (n == 0) return; + if (n == 1) { len[leaf_sym[0]] = 1; return; } + + /* Leaves sorted ascending by freq (insertion sort, n ≤ 256). */ + for (int i = 1; i < n; i++) { + int t = leaf_sym[i]; + int j = i - 1; + while (j >= 0 && freq[leaf_sym[j]] > freq[t]) { + leaf_sym[j + 1] = leaf_sym[j]; + j--; + } + leaf_sym[j + 1] = t; + } + + /* Two-queue Huffman: leaves (sorted) + internal nodes (created in + * nondecreasing weight order). Nodes 0..n-1 are leaves; n.. are + * internal. 2n-1 ≤ 511 nodes total. */ + uint64_t w[2 * NSYM]; + int16_t parent[2 * NSYM]; + for (int i = 0; i < n; i++) { w[i] = freq[leaf_sym[i]]; parent[i] = -1; } + int q1 = 0; /* next unconsumed leaf */ + int q2 = n; /* next unconsumed internal node */ + int nn = n; /* next node id to create */ + for (int made = 0; made < n - 1; made++) { + int a, b; + /* pick two smallest among q1-front and q2-front */ + a = (q2 >= nn || (q1 < n && w[q1] <= w[q2])) ? q1++ : q2++; + b = (q2 >= nn || (q1 < n && w[q1] <= w[q2])) ? q1++ : q2++; + w[nn] = w[a] + w[b]; + parent[nn] = -1; + parent[a] = (int16_t)nn; + parent[b] = (int16_t)nn; + nn++; + } + /* Depth of each node = depth(parent) + 1; parents always have + * higher ids, so one reverse pass suffices. */ + uint8_t depth[2 * NSYM]; + memset(depth, 0, sizeof(depth)); + for (int i = nn - 2; i >= 0; i--) + depth[i] = (uint8_t)(depth[parent[i]] + 1); + for (int i = 0; i < n; i++) + len[leaf_sym[i]] = depth[i] ? depth[i] : 1; +} + +/* log2(v) in 1/256 units via ilog2 + linear mantissa interpolation + * (max error ~0.09 bits — fine for candidate selection). */ +static inline uint32_t log2_fp8(uint32_t v) { + int t = ilog2(v); + uint32_t mant = ((v << 8) >> t); /* in [256, 512) */ + return (uint32_t)t * 256u + (mant - 256u); +} + /* ═══════════════════════════════════════════════════════════════ * ENCODE SEQUENCES * @@ -1529,12 +1646,26 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l const uint32_t *ml_base_tab, int disable_huf4) { if (!tok_len) { *dst_len = 0; return VVA_OK; } + /* SPRINT 126: API-misuse guard. Every internal caller passes one + * block's tokens (<= ~1.13 MB), but this entry point is public; + * bound tok_len so the arena size arithmetic below cannot wrap on + * absurd direct-API inputs. 1 GiB is orders of magnitude above any + * legal block token stream. */ + if (tok_len > ((size_t)1 << 30)) return VVA_ERR_PARAM; /* Parse into sequences. * PERF: one combined alloc for seqs + lit_buf. The sizeof(seq_t) * is ≥ 4 bytes so natural alignment for both is satisfied. Saves * 1 malloc/free pair per call. */ - size_t max_seqs = tok_len; /* Upper bound */ + /* SPRINT 125: tight sequence-count bound. Every sequence with a + * match consumes >= 3 token bytes (1 token byte + 2-3 offset bytes); + * zero-match sequences arise only from the final literal-only token + * (<= 1) and from LL_MAX splits (<= total_lits/65535 <= + * tok_len/65535). The old bound (max_seqs = tok_len) allocated + * 16 bytes of seq_t per TOKEN BYTE — ~17 MB of scratch per 1 MB + * block; this bound cuts that ~3x. parse_sequences fails closed if + * the bound were ever wrong (truncation guard). */ + size_t max_seqs = tok_len / 3 + tok_len / 65535 + 8; size_t seqs_sz = max_seqs * sizeof(seq_t); size_t total_scratch = seqs_sz + tok_len; uint8_t *base_scratch = (uint8_t *)malloc(total_scratch); @@ -1550,10 +1681,35 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l size_t nseq = parse_sequences(tokens, tok_len, lit_buf, tok_len, seqs, max_seqs, &total_lits, off_bytes, min_match); if (nseq == 0) { free(base_scratch); return VVA_ERR_CORRUPT; } - /* ─── Encode literals with 4-way ANS ─── */ + /* ─── SPRINT 126: one block-scratch arena ─── + * + * After parse_sequences, nseq and total_lits pin every remaining + * scratch size, so the 6 per-block mallocs that used to follow + * (lit_enc, seq_scratch memoization arrays, LL build tables, ML/OF + * build tables, the bitpair staging array, and the sequence + * bitstream) collapse into ONE allocation with computed offsets — + * one malloc/free pair per block instead of six, and one cleanup + * pointer on every error path. Layout keeps 4/8-byte-aligned + * sections first; sizes are the exact bounds the individual + * allocations used. ML/OF tables are reserved unconditionally + * (40 KB) even when match_count == 0 — a bound, not a leak. */ size_t lit_cap = vva_bound(total_lits); - uint8_t *lit_enc = (uint8_t *)malloc(lit_cap); - if (!lit_enc) { free(base_scratch); return VVA_ERR_NOMEM; } + size_t a_codes_sz = (nseq * sizeof(uint8_t) + 3) & ~(size_t)3; + size_t a_stream_sz = a_codes_sz + nseq * sizeof(uint32_t) + nseq * sizeof(int); + size_t tab_one_sz = ANS_L + ANS_L * sizeof(vva_dec_entry_t); +#define VVA_A8(x) (((x) + 7) & ~(size_t)7) + size_t off_pairs = 0; + size_t off_scratch = off_pairs + VVA_A8(nseq * 6 * sizeof(bitpair_t)); + size_t off_lltab = off_scratch + VVA_A8(3 * a_stream_sz); + size_t off_mloftab = off_lltab + VVA_A8(tab_one_sz); + size_t off_lit = off_mloftab + VVA_A8(2 * tab_one_sz); + size_t off_bs = off_lit + VVA_A8(lit_cap); + size_t arena_sz = off_bs + VVA_A8(nseq * 6 * 4 + 16); + uint8_t *arena = (uint8_t *)malloc(arena_sz); + if (!arena) { free(base_scratch); return VVA_ERR_NOMEM; } + + /* ─── Encode literals with 4-way ANS ─── */ + uint8_t *lit_enc = arena + off_lit; size_t lit_enc_len = 0; uint8_t lit_fmt = 0; /* 0=raw, 1=ANS4, 2=ANS1, 3=Huffman, 4=Huffman4 (Sprint 104) */ @@ -1582,6 +1738,99 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l * unchanged otherwise — existing decoders reject lit_fmt={3,4} * with VVA_ERR_CORRUPT, so this is a decoder-incompatible * format change (requires v2.46.0+ for fmt=3, v2.47+ for fmt=4). */ + if (total_lits >= 4096) { + /* ─── SPRINT 124: estimate-based single-encode selection. + * + * One histogram, then analytic sizes: ANS4 cost is the + * table-quantized Σ f·(ANS_LOG − log2(norm_f)) plus its + * header; Huffman cost is exact given code lengths (built + * without a bitstream pass). Only the winner is encoded, + * directly into lit_enc. ANS1 is dropped here: it can + * undercut ANS4 by at most ~26 header bytes, which is + * noise at ≥4096 literals. The old full race burned + * 6-21% of total encode wall on discarded encodes. */ + uint32_t hist[NSYM]; + memset(hist, 0, sizeof(hist)); + for (size_t i = 0; i < total_lits; i++) hist[lit_buf[i]]++; + + int active = 0, max_sym = 0; + for (int s = 0; s < NSYM; s++) + if (hist[s]) { active++; max_sym = s; } + + uint16_t norm_est[NSYM]; + memset(norm_est, 0, sizeof(norm_est)); + normalize_freq(hist, norm_est); + uint64_t bits256 = 0; + for (int s = 0; s < NSYM; s++) { + if (!hist[s]) continue; + uint32_t nf = norm_est[s] ? norm_est[s] : 1; + bits256 += (uint64_t)hist[s] * + ((uint32_t)ANS_LOG * 256u - log2_fp8(nf)); + } + size_t tbl_hdr = (active <= 64) ? (size_t)(2 + 3 * active) + : (size_t)(2 + 2 * (max_sym + 1)); + size_t ans4_est = (size_t)(bits256 / 2048u) + tbl_hdr + 26; + + uint8_t hlen[NSYM]; + est_huff_lengths(hist, hlen); + uint64_t hbits = 0; + for (int s = 0; s < NSYM; s++) + hbits += (uint64_t)hist[s] * hlen[s]; + size_t huf_est = (size_t)(hbits / 8u) + 130; + size_t huf4_est = huf_est + 12; + + /* Two-finalist race with estimate-gated skips. + * + * The estimates are systematically OPTIMISTIC (linear log2 + * interpolation undershoots; tANS state costs and lane + * overheads are approximated low), so `est >= raw` proves + * the real encode cannot beat raw literals — a safe skip + * that turns incompressible-literal blocks (sensor data) + * into an immediate raw store with zero encode passes. + * When a candidate is plausible it is actually encoded: + * measured sizes decide, exactly like the old 4-way race, + * but with at most 2 encodes (ANS1 dropped — bounded + * ~26 B win; huf-vs-huf4 resolved by their fixed ~12 B + * structural delta instead of dual encodes). */ + uint8_t hb_fmt = disable_huf4 ? 3 : 4; + size_t hb_est = disable_huf4 ? huf_est : huf4_est; + if (!disable_huf4 && huf_est + 32 < huf4_est) { + hb_fmt = 3; hb_est = huf_est; + } + + lit_fmt = 0; + lit_enc_len = 0; + if (ans4_est < total_lits) { + size_t out_len = 0; + if (vva_encode4(lit_buf, total_lits, lit_enc, lit_cap, &out_len) == VVA_OK && + out_len < total_lits) { + lit_enc_len = out_len; + lit_fmt = 1; + } + } + if (hb_est < total_lits && + (lit_fmt == 0 || hb_est < lit_enc_len + lit_enc_len / 8)) { + uint8_t *alt_buf = (uint8_t *)malloc(lit_cap); + if (alt_buf) { + size_t alt_len = 0; + int aok = (hb_fmt == 4) + ? (vvh_encode4(lit_buf, total_lits, alt_buf, lit_cap, &alt_len) == VVH_OK) + : (vvh_encode(lit_buf, total_lits, alt_buf, lit_cap, &alt_len) == VVH_OK); + if (aok && alt_len < total_lits && + (lit_fmt == 0 || alt_len < lit_enc_len)) { + memcpy(lit_enc, alt_buf, alt_len); + lit_enc_len = alt_len; + lit_fmt = hb_fmt; + } + free(alt_buf); + } + } + if (lit_fmt == 0) { + /* Raw literals (lit_cap = vva_bound(total_lits) ≥ total_lits). */ + memcpy(lit_enc, lit_buf, total_lits); + lit_enc_len = total_lits; + } + } else { size_t ans4_len = 0, ans1_len = 0, huf_len = 0, huf4_len = 0; uint8_t *ans4_buf = (uint8_t *)malloc(lit_cap); uint8_t *ans1_buf = (uint8_t *)malloc(lit_cap); @@ -1654,6 +1903,7 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l lit_fmt = 0; } free(ans4_buf); free(ans1_buf); free(huf_buf); free(huf4_buf); + } } /* ─── Count ML, OF, and LL code frequencies ─── */ @@ -1687,16 +1937,11 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l * Net cost: 1 extra malloc region (~14 × nseq bytes), 0 extra * malloc calls. Net saving: the backward pass becomes lookups * instead of re-computation. */ - size_t codes_sz = (nseq * sizeof(uint8_t) + 3) & ~(size_t)3; + size_t codes_sz = a_codes_sz; size_t extra_sz = nseq * sizeof(uint32_t); - size_t nbits_sz = nseq * sizeof(int); - /* 3 streams × (codes + extra + nbits) */ - uint8_t *seq_scratch = (uint8_t *)malloc(3 * (codes_sz + extra_sz + nbits_sz)); - if (!seq_scratch) { - free(base_scratch); free(lit_enc); - return VVA_ERR_NOMEM; - } - size_t stream_sz = codes_sz + extra_sz + nbits_sz; + /* 3 streams × (codes + extra + nbits) — carved from the arena. */ + uint8_t *seq_scratch = arena + off_scratch; + size_t stream_sz = a_stream_sz; uint8_t *seq_of_code = seq_scratch; uint32_t *seq_of_extra = (uint32_t *)(seq_scratch + codes_sz); int *seq_of_nbits = (int *)(seq_scratch + codes_sz + extra_sz); @@ -1799,23 +2044,20 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l ll_hdr_sz = write_hdr_v2(norm_ll, ll_hdr_buf, 600); if (!ll_hdr_sz) goto seq_fail; - /* PERF: one combined alloc for sp_ll + dec_ll. sp_ll lives in - * the first ANS_L bytes, dec_ll follows with alignment (16-byte - * aligned vs 8-byte reads is satisfied since ANS_L=4096 is - * already 4KB-aligned). Saves 1 malloc/free pair. */ + /* sp_ll lives in the first ANS_L bytes of the arena's LL-table + * section, dec_ll follows (ANS_L=4096 keeps dec_ll aligned). */ size_t sp_sz = ANS_L; - size_t dec_sz = ANS_L * sizeof(vva_dec_entry_t); - uint8_t *ll_tables = (uint8_t *)malloc(sp_sz + dec_sz); - if (!ll_tables) goto seq_fail; + uint8_t *ll_tables = arena + off_lltab; uint8_t *sp_ll = ll_tables; vva_dec_entry_t *dec_ll = (vva_dec_entry_t *)(ll_tables + sp_sz); spread_symbols(norm_ll, sp_ll); build_dec(norm_ll, sp_ll, dec_ll); enc_ll_ctx = build_enc(norm_ll, sp_ll, dec_ll); - free(ll_tables); if (!enc_ll_ctx) goto seq_fail; } + enc_ctx_t *enc_ml_ctx = NULL; + enc_ctx_t *enc_of_ctx = NULL; if (match_count > 0) { /* Treat ML codes as a small-alphabet problem */ uint32_t raw_ml[NSYM], raw_of[NSYM]; @@ -1834,14 +2076,10 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l - /* ─── Build encode tables ─── - * PERF: one combined alloc for sp_ml + dec_ml + sp_of + dec_of - * (4 fixed-size ANS_L-based buffers). Saves 3 malloc/free pairs. */ + /* ─── Build encode tables (in the arena's ML/OF section) ─── */ size_t sp_sz = ANS_L; size_t dec_sz = ANS_L * sizeof(vva_dec_entry_t); - size_t combo_sz = (sp_sz + dec_sz) * 2; - uint8_t *ml_of_tables = (uint8_t *)malloc(combo_sz); - if (!ml_of_tables) goto seq_fail; + uint8_t *ml_of_tables = arena + off_mloftab; uint8_t *sp_ml = ml_of_tables; vva_dec_entry_t *dec_ml = (vva_dec_entry_t *)(ml_of_tables + sp_sz); uint8_t *sp_of = ml_of_tables + sp_sz + dec_sz; @@ -1849,23 +2087,36 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l spread_symbols(norm_ml, sp_ml); build_dec(norm_ml, sp_ml, dec_ml); - enc_ctx_t *enc_ml_ctx = build_enc(norm_ml, sp_ml, dec_ml); + enc_ml_ctx = build_enc(norm_ml, sp_ml, dec_ml); spread_symbols(norm_of, sp_of); build_dec(norm_of, sp_of, dec_of); - enc_ctx_t *enc_of_ctx = build_enc(norm_of, sp_of, dec_of); + enc_of_ctx = build_enc(norm_of, sp_of, dec_of); - free(ml_of_tables); if (!enc_ml_ctx || !enc_of_ctx) { free_enc(enc_ml_ctx); free_enc(enc_of_ctx); + enc_ml_ctx = enc_of_ctx = NULL; goto seq_fail; } + } - /* ─── Encode ML/OF codes + extra bits in reverse ─── */ - /* Collect bitpairs for ANS-coded symbols + raw extra bits */ - size_t pair_cap = nseq * 6; /* 3 ANS + 3 extra max per seq */ - bitpair_t *pairs = (bitpair_t *)malloc(pair_cap * sizeof(bitpair_t)); - if (!pairs) { free_enc(enc_ml_ctx); free_enc(enc_of_ctx); goto seq_fail; } + /* ─── Encode ML/OF/LL codes + extra bits in reverse ─── + * + * SPRINT 124 (latent-corruption fix): this section — including the + * LL encoding — used to live INSIDE the match_count > 0 branch. A + * block whose token stream contains no matches at all (pure + * literal run) then wrote the LL table header but NO sequence + * bitstream, while the decoder unconditionally decodes an LL code + * per sequence — it read garbage from an empty stream and failed + * (or worse, produced short output). The case was unreachable + * while emit_block sent every csz >= braw token stream straight + * to RAW storage; the relaxed raw_gate made it reachable. The LL + * bitstream must be written whenever nseq > 0, with ML/OF work + * still gated per-sequence on matchlen > 0. */ + { + /* Collect bitpairs for ANS-coded symbols + raw extra bits + * (arena section; capacity nseq * 6 = 3 ANS + 3 extra per seq). */ + bitpair_t *pairs = (bitpair_t *)(arena + off_pairs); state_ml = 0; state_of = 0; state_ll = 0; size_t npairs = 0; @@ -1904,7 +2155,7 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l uint32_t bv; int bn; int slot = enc_sym(enc_ml_ctx, state_ml, mc, &bv, &bn); if (slot < 0) { - free(pairs); free_enc(enc_ml_ctx); free_enc(enc_of_ctx); + free_enc(enc_ml_ctx); free_enc(enc_of_ctx); goto seq_fail; } pairs[npairs].val = (uint32_t)bv; @@ -1925,7 +2176,7 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l uint32_t bv; int bn; int slot = enc_sym(enc_of_ctx, state_of, oc, &bv, &bn); if (slot < 0) { - free(pairs); free_enc(enc_ml_ctx); free_enc(enc_of_ctx); + free_enc(enc_ml_ctx); free_enc(enc_of_ctx); goto seq_fail; } pairs[npairs].val = (uint32_t)bv; @@ -1950,7 +2201,7 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l uint32_t bv; int bn; int slot = enc_sym(enc_ll_ctx, state_ll, lc, &bv, &bn); if (slot < 0) { - free(pairs); free_enc(enc_ml_ctx); free_enc(enc_of_ctx); + free_enc(enc_ml_ctx); free_enc(enc_of_ctx); goto seq_fail; } pairs[npairs].val = (uint32_t)bv; @@ -1967,15 +2218,13 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l * Each pair is up to 32 bits (ANS slot = 14 bits + extra up to 18). * Allocate 4 bytes per pair + 16-byte safety margin. */ size_t bs_cap = npairs * 4 + 16; - seq_bs = (uint8_t *)malloc(bs_cap); - if (!seq_bs) { free(pairs); goto seq_fail; } + seq_bs = arena + off_bs; /* arena section, sized nseq*6*4 + 16 >= bs_cap */ ans_bw_t w; ans_bw_init(&w, seq_bs, bs_cap); for (size_t i = npairs; i > 0; i--) ans_bw_add(&w, pairs[i - 1].val, pairs[i - 1].nb); seq_bs_len = ans_bw_flush(&w); - free(pairs); } /* Litlens are now ANS-coded in the sequence bitstream — no varints needed */ @@ -2033,17 +2282,15 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l *dst_len = (size_t)(op - dst); } - free(base_scratch); free(lit_enc); - free(seq_scratch); + free(base_scratch); + free(arena); free_enc(enc_ll_ctx); - free(seq_bs); return VVA_OK; seq_fail: - free(base_scratch); free(lit_enc); - free(seq_scratch); + free(base_scratch); + free(arena); free_enc(enc_ll_ctx); - free(seq_bs); return VVA_ERR_OVERFLOW; } @@ -2125,7 +2372,14 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len, if (VV_UNLIKELY(total_lits > dst_cap)) return VVA_ERR_CORRUPT; /* Decode literals based on format byte */ - uint8_t *lit_buf = (uint8_t *)malloc(total_lits + 16); + /* SPRINT 126: one allocation for the literal buffer AND the decode + * tables (previously 2 mallocs; the table section was itself fused + * from 4 in Sprint 125). The table space (52 KB) is reserved + * unconditionally up front so the whole block scratch is a single + * malloc/free — its exact use is decided at table-build below. */ + size_t lit_sec = (total_lits + 16 + 7) & ~(size_t)7; + size_t tab_sec = ANS_L + 3 * (ANS_L * sizeof(vva_dec_entry_t)); + uint8_t *lit_buf = (uint8_t *)malloc(lit_sec + tab_sec); if (!lit_buf) return VVA_ERR_NOMEM; if (total_lits > 0 && lit_enc_len > 0) { @@ -2208,6 +2462,46 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len, if (ll_hdr_sz > 0) read_hdr_v2(p, ll_hdr_sz, norm_ll); p += ll_hdr_sz; + /* SPRINT 125: hoisted table validation. Two invariants are enforced + * once per block so the old per-sequence `code >= VVA_*_CODES` + * branch (one per iteration, on the critical path between the table + * load and the bit read) becomes tautological and is removed from + * the hot loop below: + * + * (1) No out-of-range symbol has nonzero frequency — bounds every + * spread-table entry's symbol. + * (2) Frequencies sum to exactly ANS_L — guarantees spread_symbols + * fills ALL 4096 slots. Without this, a corrupt underfull + * header leaves stale scratch bytes in unfilled slots, whose + * "symbols" bypass check (1) entirely (caught by UBSan as an + * OOB index into ll_extra[36] during validation of this very + * change). normalize_freq guarantees sum == ANS_L on every + * valid stream, so this rejects only corrupt input. + * + * This is STRICTER than the old per-sequence check: malformed + * tables are rejected up front instead of only when a decode path + * lands on a bad entry. Tables that the decode loop never consults + * (ML/OF when match_count == 0; all of them when the loop body + * cannot run) are exempt from (2) for wire compatibility. */ + { + uint32_t sum_ml = 0, sum_of = 0, sum_ll = 0; + for (int s = 0; s < NSYM; s++) { + sum_ml += norm_ml[s]; sum_of += norm_of[s]; sum_ll += norm_ll[s]; + if (s >= VVA_OF_CODES && VV_UNLIKELY(norm_of[s])) { + free(lit_buf); return VVA_ERR_CORRUPT; + } + if (s >= VVA_ML_CODES && VV_UNLIKELY(norm_ml[s] | norm_ll[s])) { + free(lit_buf); return VVA_ERR_CORRUPT; + } + } + if (VV_UNLIKELY(sum_ll != ANS_L && (total_lits > 0 || match_count > 0))) { + free(lit_buf); return VVA_ERR_CORRUPT; + } + if (VV_UNLIKELY(match_count > 0 && (sum_ml != ANS_L || sum_of != ANS_L))) { + free(lit_buf); return VVA_ERR_CORRUPT; + } + } + /* Read initial states */ if (p + 6 > end) { free(lit_buf); return VVA_ERR_CORRUPT; } uint32_t state_ml = (uint32_t)p[0] | ((uint32_t)p[1] << 8); p += 2; @@ -2228,34 +2522,32 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len, * NULL-deref's dec_of and dec_ml. Found by libFuzzer + ASan. * Fix: always allocate all 3 tables. The decode-loop dereferences * are safe because state masks bound the index to ANS_L. */ + /* SPRINT 125: one allocation for the spread scratch + decode tables + * (previously 4 separate mallocs — measurable on small blocks). + * When match_count == 0, the ML/OF tables are never consulted for + * real decode work (the loop `continue`s before the OF/ML reads), + * but the ILP eager-loads at the loop top still index them — alias + * them to the LL table: valid, initialized memory, zero build and + * zero memset cost (replaces two 16 KB sentinel memsets). */ vva_dec_entry_t *dec_ml = NULL, *dec_of = NULL, *dec_ll = NULL; { - uint8_t *sp_tmp = (uint8_t *)malloc(ANS_L); - dec_ml = (vva_dec_entry_t *)malloc(ANS_L * sizeof(vva_dec_entry_t)); - dec_of = (vva_dec_entry_t *)malloc(ANS_L * sizeof(vva_dec_entry_t)); - dec_ll = (vva_dec_entry_t *)malloc(ANS_L * sizeof(vva_dec_entry_t)); - if (!sp_tmp || !dec_ll || !dec_ml || !dec_of) { - free(sp_tmp); free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf); - return VVA_ERR_NOMEM; - } + size_t dec_sz = ANS_L * sizeof(vva_dec_entry_t); + uint8_t *seq_tables = lit_buf + lit_sec; /* reserved above */ + uint8_t *sp_tmp = seq_tables; + dec_ll = (vva_dec_entry_t *)(seq_tables + ANS_L); + spread_symbols(norm_ll, sp_tmp); + build_dec(norm_ll, sp_tmp, dec_ll); if (match_count > 0) { + dec_ml = (vva_dec_entry_t *)(seq_tables + ANS_L + dec_sz); + dec_of = (vva_dec_entry_t *)(seq_tables + ANS_L + 2 * dec_sz); spread_symbols(norm_ml, sp_tmp); build_dec(norm_ml, sp_tmp, dec_ml); spread_symbols(norm_of, sp_tmp); build_dec(norm_of, sp_tmp, dec_of); } else { - /* Initialize ml/of tables to safe sentinel values so any - * unintended read (e.g., the ILP eager-load in the decode - * loop when match_count == 0) returns predictable data - * rather than dereferencing uninitialized memory. The - * loop guard prevents these values from being used in - * actual sequence reconstruction. */ - memset(dec_ml, 0, ANS_L * sizeof(vva_dec_entry_t)); - memset(dec_of, 0, ANS_L * sizeof(vva_dec_entry_t)); + dec_ml = dec_ll; + dec_of = dec_ll; } - spread_symbols(norm_ll, sp_tmp); - build_dec(norm_ll, sp_tmp, dec_ll); - free(sp_tmp); } /* Initialize bitstream reader for sequence data */ @@ -2307,7 +2599,8 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len, * guarantees litlen+matchlen fit without per-iter overflow checking. * (Reserving only ONE run let a crafted final sequence write up to * 65535 bytes past op_end — a heap overflow; the +64 caller slack was - * far too small to absorb it.) + * far too small to absorb it. ZUPT AUDIT FIX, carried across codec + * re-vendors until upstreamed.) * * SPRINT 46: raised from 1<<20 to 1<<24. The 3-byte offset wire * encoding (off_bytes==3 for wlog>16) represents offsets up to @@ -2361,7 +2654,7 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len, size_t iter_count = 0; while (lit_pos < total_lits || matches_decoded < match_count) { if (VV_UNLIKELY(++iter_count > max_iters)) { - free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf); + free(lit_buf); return VVA_ERR_CORRUPT; } /* PERF: issue all 3 ANS table lookups early so CPU can overlap @@ -2391,33 +2684,13 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len, vva_dec_entry_t eof = dec_of[state_of & (ANS_L - 1)]; vva_dec_entry_t eml = dec_ml[state_ml & (ANS_L - 1)]; - /* SPRINT 27 (v2.50.1): combine the 3 per-iteration OOB code - * validators into 1 branch. Previously each of ll_code, of_code, - * ml_code had a separate `if (VV_UNLIKELY(code >= MAX)) return` - * — three predicted-not-taken branches per iteration. ORing - * the three bool comparisons into a single mask lets the compiler - * use one branch and parallel SIMD-style comparisons. - * - * Found via profile-driven analysis on v2.50.0 (Sprint 27). The - * three branches were each individually cheap when not taken, - * but they sit on the critical path between the table-read - * latency (L1/L2 miss on the random-walk index) and the - * subsequent bit-read, where they delay state-update of the - * NEXT iteration. Folding to one branch removes 2 branch slots - * and lets the comparator ALU run in parallel with the load - * latency for ell/eof/eml. - * - * Note: VVA_LL_CODES == VVA_ML_CODES == 36, VVA_OF_CODES == 27. - * Use the strictest bound (27) as a quick-fail mask; codes 27-35 - * are still legal for LL/ML and fall through to the per-code - * tail check below. This catches the most common adversarial - * encoding (high-symbol garbage) at zero cost on the common path. */ - if (VV_UNLIKELY(((unsigned)ell.symbol >= VVA_LL_CODES) | - ((unsigned)eof.symbol >= VVA_OF_CODES) | - ((unsigned)eml.symbol >= VVA_ML_CODES))) { - free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf); - return VVA_ERR_CORRUPT; - } + /* SPRINT 125: the per-iteration OOB code check (Sprint 27's + * combined branch) is gone — table symbols are validated once + * at header-parse time above, so every entry in dec_ll/dec_of/ + * dec_ml carries an in-range symbol by construction. Same + * security property (out-of-range codes on corrupt input are + * rejected, now earlier and unconditionally), one branch less + * on the critical path between the table load and the bit read. */ /* ── Decode LL: state, extra, final litlen ── */ uint32_t ll_bits = ans_br_read(&r, ell.nbits); @@ -2428,11 +2701,11 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len, size_t litlen = ll_decode(ll_code, ll_extra_val); if (VV_UNLIKELY(lit_pos + litlen > total_lits)) { - free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf); + free(lit_buf); return VVA_ERR_CORRUPT; } if (VV_UNLIKELY(!in_safe_zone && op + litlen > op_end)) { - free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf); + free(lit_buf); return VVA_ERR_OVERFLOW; } if (litlen > 0) { @@ -2510,15 +2783,15 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len, * op_safe_end = op_end - SAFEZONE_MAX_MATCH, and matchlen is * always ≤ SAFEZONE_MAX_MATCH by wire format. */ if (VV_UNLIKELY(offset == 0 || offset > SAFEZONE_MAX_OFFSET)) { - free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf); + free(lit_buf); return VVA_ERR_CORRUPT; } if (VV_UNLIKELY(!in_safe_zone && offset > (uint32_t)(op - dst_base))) { - free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf); + free(lit_buf); return VVA_ERR_CORRUPT; } if (VV_UNLIKELY(!in_safe_zone && op + matchlen > op_end)) { - free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf); + free(lit_buf); return VVA_ERR_OVERFLOW; } @@ -2605,7 +2878,7 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len, } *dst_len = (size_t)(op - dst); - free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf); + free(lit_buf); return VVA_OK; } diff --git a/src/vv_bcj.c b/src/vv_bcj.c index 4d203b4..dbb5164 100644 --- a/src/vv_bcj.c +++ b/src/vv_bcj.c @@ -13,13 +13,13 @@ * then compresses well. The inverse runs on decode, before the bytes are * handed back to the caller. * - * This is a clean-room reimplementation of the well-known x86 branch- - * converter algorithm (the same transform used by 7-Zip/xz and described - * in the LZMA SDK). The algorithm is exactly reversible on ARBITRARY input - * — it is a bijection, so applying the filter to non-x86 data and then - * inverting it reproduces the input byte-for-byte. That property is - * fuzz-verified in tests; do not "optimize" the masking logic without - * re-checking inverse(forward(x)) == x on random and adversarial inputs. + * The x86 transform is adapted from Igor Pavlov's public-domain LZMA SDK + * Bra86.c state machine. The exact SDK revision used by the original + * integration was not retained; see THIRD-PARTY-NOTICES.md. The algorithm is + * exactly reversible on arbitrary input: applying the filter and then its + * inverse reproduces the input byte-for-byte. Deterministic randomized and + * adversarial regressions exercise inverse(forward(x)) == x; do not "optimize" + * the masking logic without rerunning them. * * The buffer is transformed in place. `encoding` is non-zero for the * forward (compress-side) transform, zero for the inverse (decode-side). diff --git a/src/vv_decoder.c b/src/vv_decoder.c index 49fea91..a9146b0 100644 --- a/src/vv_decoder.c +++ b/src/vv_decoder.c @@ -149,6 +149,7 @@ decode_block_tokens_impl( * Exit boundary: max is 1 token + 14 lits + 3 offset + 6 match_ext = 24. * Plus match_copy_32 may over-copy 32 bytes past the real end, so * op needs at least 64 bytes of margin. */ +#if VV_INLINE_AVX2 const uint8_t *const ip_safe = (ip_len > 48) ? (ip_end - 48) : ip; uint8_t *const op_safe = (dst_cap > 72) ? (op_end - 72) : op; @@ -162,7 +163,6 @@ decode_block_tokens_impl( * rejected. */ const uint32_t max_valid_off = (off_bytes == 2) ? 0xFFFF : 0xFFFFFF; -#if VV_INLINE_AVX2 /* PERF: two-phase fast path. * Phase 1 (warmup): op hasn't advanced far enough to make any offset * automatically valid. Do full offset validation per sequence. @@ -195,8 +195,18 @@ decode_block_tokens_impl( VV_PREFETCH(op + ll - off_raw); } - if (ll > 0) + /* SPRINT 125: wildcopy for the dominant ll <= 14 case. The loop + * guards reserve 48 bytes of readable input (ip < ip_safe; ip has + * advanced by only the 1 token byte since, as ll <= 14 implies no + * extension bytes) and 72 bytes of writable output (op < op_safe; + * op unchanged since entry), so one unconditional 16-byte copy is + * in-bounds and replaces memcpy's branchy variable-size dispatch. + * The extra bytes past ll are overwritten by the next copy. */ + if (VV_LIKELY(ll <= 14)) { + memcpy(op, ip, 16); + } else { memcpy(op, ip, ll); + } ip += ll; op += ll; @@ -278,8 +288,18 @@ decode_block_tokens_impl( VV_PREFETCH(op + ll - off_raw); } - if (ll > 0) + /* SPRINT 125: wildcopy for the dominant ll <= 14 case. The loop + * guards reserve 48 bytes of readable input (ip < ip_safe; ip has + * advanced by only the 1 token byte since, as ll <= 14 implies no + * extension bytes) and 72 bytes of writable output (op < op_safe; + * op unchanged since entry), so one unconditional 16-byte copy is + * in-bounds and replaces memcpy's branchy variable-size dispatch. + * The extra bytes past ll are overwritten by the next copy. */ + if (VV_LIKELY(ll <= 14)) { + memcpy(op, ip, 16); + } else { memcpy(op, ip, ll); + } ip += ll; op += ll; diff --git a/src/vv_encoder.c b/src/vv_encoder.c index 400078b..358d081 100644 --- a/src/vv_encoder.c +++ b/src/vv_encoder.c @@ -41,14 +41,15 @@ * * Implementation strategy: * - Prefer `explicit_bzero` (BSD/glibc 2.25+, guaranteed-secure) - * - Fall back to `memset_explicit` (C23) - * - Last resort: volatile-pointer memset (compiler cannot + * - Otherwise use a volatile-pointer loop (compiler cannot * prove the writes are dead) * ═══════════════════════════════════════════════════════════════ */ #if defined(__GLIBC__) && (__GLIBC__ > 2 || (__GLIBC__ == 2 && __GLIBC_MINOR__ >= 25)) # define VV_HAS_EXPLICIT_BZERO 1 -#elif defined(__APPLE__) || defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__) +/* Darwin intentionally uses the volatile fallback: current deployment targets + * do not guarantee an explicit_bzero symbol in libSystem. */ +#elif defined(__FreeBSD__) || defined(__OpenBSD__) # define VV_HAS_EXPLICIT_BZERO 1 #else # define VV_HAS_EXPLICIT_BZERO 0 @@ -159,10 +160,28 @@ static inline int32_t extend_match(const uint8_t *a, const uint8_t *b, len += 32; } #endif + /* SPRINT 124: 8-byte xor/ctz stride for the post-8 region. This TU + * is deliberately built without -mavx2 (baseline portability), so + * before this loop existed every match longer than 8 bytes extended + * one byte per iteration — measured at 7-8% of encode wall on + * long-match corpora. Same technique as the fast path above. */ + while (len + 8 <= max_len) { + uint64_t va, vb; + memcpy(&va, a + len, 8); + memcpy(&vb, b + len, 8); + uint64_t x = va ^ vb; + if (x) return len + (__builtin_ctzll(x) >> 3); + len += 8; + } while (len < max_len && a[len] == b[len]) len++; return len; } +/* Branch-free floor(log2(v)); v=0 maps to 0. */ +static inline int enc_ilog2(uint32_t v) { + return 31 - __builtin_clz(v | 1); +} + /* ═══════════════════════════════════════════════════════════════ * MATCHER: hash chain with 5-byte hash + rep-match * ═══════════════════════════════════════════════════════════════ */ @@ -512,8 +531,11 @@ chain_match_ex(const matcher_t *m, const uint8_t *data, /* Pipeline priming: look 4 chain entries ahead. If chain is * short, the prefetches become no-ops (chain entries below limit - * just return -1 or an expired position). */ - if (ref >= limit && ref < pos) { + * just return -1 or an expired position). + * SPRINT 124: only prime for deep walks. At depth 4 (fast mode, + * window trial) the priming loads cost more than the misses they + * hide — measured 5-8% of fast-mode encode wall. */ + if (depth >= 8 && ref >= limit && ref < pos) { __builtin_prefetch(data + ref, 0, 0); int32_t r1 = chain_arr[ref & chain_mask]; if (r1 >= limit && r1 < pos) { @@ -562,6 +584,22 @@ chain_match_ex(const matcher_t *m, const uint8_t *data, if (max > (int32_t)m->max_match) max = (int32_t)m->max_match; int32_t len = 4 + extend_match(data + pos + 4, data + ref + 4, max - 4); if (len > best_len) { + /* SPRINT 124: offset-cost-aware acceptance. The walk + * goes newest→oldest, so a later candidate always has + * a larger offset. SEQ codes offsets as log2 buckets + + * extra bits, so the farther match costs ~dbits more; + * each extra matched byte saves ~6 bits of literals. + * Without this check a barely-longer match at 512 KB + * displaces a same-ish match at 200 B, and the diverse + * offsets also break rep-offset streaks downstream. + * Only affects greedy/lazy paths — the optimal parser + * collects candidates via opt_collect and prices + * offsets itself. */ + if (best_len >= 4) { + int dbits = enc_ilog2((uint32_t)(pos - ref)) + - enc_ilog2((uint32_t)*best_off); + if ((len - best_len) * 6 < dbits) { ref = next_ref; continue; } + } best_len = len; *best_off = pos - ref; if (len >= 256) return best_len; @@ -604,6 +642,12 @@ chain_match_ex(const matcher_t *m, const uint8_t *data, if (max > (int32_t)m->max_match) max = (int32_t)m->max_match; int32_t len = 4 + extend_match(data + pos + 4, data + ref4 + 4, max - 4); if (len > best_len) { + /* Same offset-cost-aware acceptance as the hash5 walk. */ + if (best_len >= 4) { + int dbits = enc_ilog2((uint32_t)(pos - ref4)) + - enc_ilog2((uint32_t)*best_off); + if ((len - best_len) * 6 < dbits) { ref4 = next_ref4; continue; } + } best_len = len; *best_off = pos - ref4; if (len >= 256) return best_len; @@ -825,6 +869,9 @@ static size_t emit_seq(uint8_t *dst, const uint8_t *lits, * ═══════════════════════════════════════════════════════════════ */ #define VV_OPT_MAX_CAND 16 +#ifndef VV_OPT_LONG_MATCH +#define VV_OPT_LONG_MATCH 512 /* take immediately; skip interior DP */ +#endif #define VV_OPT_PRICE_INF 0x3FFFFFFF typedef struct { uint32_t off; int32_t len; } opt_cand_t; @@ -855,22 +902,187 @@ typedef struct { uint32_t off; int32_t len; } opt_cand_t; * captures most of the available win at zero added complexity, so this * sprint ships it and defers the two-pass design until the window-size * lever has been measured (matters more for nci-class fixtures). */ -static inline int32_t opt_lit_price(void) { return 8; } +/* SPRINT 129: per-byte literal prices from the block's byte histogram. + * The flat-8 model (Sprint 44) was chosen as the best single constant, + * but the real literal coder delivers ~4-6 bits/byte on text and 7-8 + * on dense binary — the flat constant over-prices text literals, so + * the parser substitutes marginal matches where literals are cheaper + * in reality. This is the "two-pass repricing" refinement that Sprint + * 44's note deferred, using the raw block histogram as the literal- + * distribution estimate (the true literal stream excludes match- + * covered bytes, but the distributions track closely in practice). + * price[b] = round(log2(N / hist[b])) clamped to [VV_OPT_LIT_MIN, 14]; + * unseen bytes cannot appear as literals and get the ceiling. The + * clamp floor guards degenerate blocks (a byte at ~100% frequency + * would price to 0 and make literal runs look free). Constants swept + * on the 11-file corpus — see CHANGELOG v2.64.0. */ +#ifndef VV_OPT_LIT_MIN +#define VV_OPT_LIT_MIN 2 +#endif +#ifndef VV_OPT_LIT_BLEND +#define VV_OPT_LIT_BLEND 6 +#endif +/* SPRINT 131: OF-code price blend. The old match price decomposes as + * 8 + code_bits + extra_bits with prior code costs {rep: 2, explicit: + * 6}; blend 0/8 therefore reproduces the v2.65.0 model exactly. The + * measured distribution comes from the same greedy prepass that feeds + * literal pricing, classified with the wire's exact rep rules. */ +#ifndef VV_OPT_OF_BLEND +#define VV_OPT_OF_BLEND 0 +#endif +static void opt_build_of_prices(const uint32_t of_hist[27], size_t nseq, + int32_t of_bits[27]) { + for (int x = 0; x < 27; x++) { + int prior = (x < 3) ? 2 : 6; + int bits; + if (!nseq || !of_hist[x]) { + bits = 12; /* unseen code: expensive if the DP tries it */ + } else { + uint32_t ratio8 = (uint32_t)(((uint64_t)nseq << 8) / of_hist[x]); + int t = enc_ilog2(ratio8); + bits = t - 8; + if (t >= 1 && ((ratio8 >> (t - 1)) & 1)) bits++; + if (bits < 1) bits = 1; + if (bits > 12) bits = 12; + } + of_bits[x] = (VV_OPT_OF_BLEND * bits + (8 - VV_OPT_OF_BLEND) * prior) / 8; + } +} +/* fwd decl: the greedy parser (defined below) doubles as the residual- + * literal estimator for the optimal parser's pricing prepass. */ +static size_t compress_block(const uint8_t *src, size_t start_pos, size_t block_len, + uint8_t *dst, size_t dst_cap, + matcher_t *m, vv_mode_t mode, int min_match); -/* match bit price: cost_const(14) + log2(off) + ml_extra; rep ~2 bits */ -static inline int32_t opt_match_price(const matcher_t *m, uint32_t off, int32_t len) { - int is_rep = (off == m->rep[0] || off == m->rep[1] || off == m->rep[2]); - int32_t log2_off = 0; uint32_t o = off; - while (o > 1) { o >>= 1; log2_off++; } - int32_t off_bits = is_rep ? 2 : (14 + log2_off); - int32_t ml_extra = 0, v = len - VV_MIN_MATCH; - if (v >= 15) ml_extra = 8 * (v / 255 + 1); - return off_bits + ml_extra; +/* SPRINT 130: histogram the literal bytes of an LZ token stream (the + * residual literals a parse actually leaves), walking the same wire + * layout extract_literals does but only counting. Returns total + * literal count, or 0 on a malformed stream (caller falls back to the + * raw-block histogram). */ +static size_t tok_lit_hist(const uint8_t *tokens, size_t tok_len, + int off_bytes, uint32_t hist[256], + uint32_t of_hist[27], size_t *nseq_out) { + const uint8_t *tp = tokens, *tp_end = tokens + tok_len; + size_t total = 0, nseq = 0; + uint32_t rep[3] = {0, 0, 0}; /* wire-exact per-block rep tracking */ + while (tp < tp_end) { + uint8_t token = *tp++; + size_t ll = token >> 4; + size_t mc = token & 0x0F; + if (ll == 15) { + do { + if (tp >= tp_end) return 0; + uint8_t b = *tp++; + ll += b; + if (b < 255) break; + } while (tp < tp_end); + } + if ((size_t)(tp_end - tp) < ll) return 0; + for (size_t i = 0; i < ll; i++) hist[tp[i]]++; + total += ll; + tp += ll; + if (tp >= tp_end) break; + if ((size_t)(tp_end - tp) < (size_t)off_bytes) return 0; + uint32_t off = (off_bytes == 3) + ? ((uint32_t)tp[0] | ((uint32_t)tp[1] << 8) | ((uint32_t)tp[2] << 16)) + : ((uint32_t)tp[0] | ((uint32_t)tp[1] << 8)); + tp += off_bytes; + /* SPRINT 131: wire-exact OF code classification (mirrors the SEQ + * encoder's rep detection order and push rule). */ + if (off != 0) { + int x; + if (off == rep[0]) x = 0; + else if (off == rep[1]) x = 1; + else if (off == rep[2]) x = 2; + else x = 3 + enc_ilog2(off); + if (x > 26) x = 26; + of_hist[x]++; + nseq++; + if (off != rep[0]) { rep[2] = rep[1]; rep[1] = rep[0]; rep[0] = off; } + } + if (mc == 15) { + do { + if (tp >= tp_end) return 0; + uint8_t b = *tp++; + if (b < 255) break; + } while (tp < tp_end); + } + } + *nseq_out = nseq; + return total; } -/* Collect match candidates at pos (longest per distinct offset). */ +static void opt_build_lit_prices_from_hist(const uint32_t hist[256], size_t n, + int32_t lit_bits[256]) { + for (int s = 0; s < 256; s++) { + if (!hist[s] || !n) { lit_bits[s] = 14; continue; } + /* ratio8 = (n / hist[s]) in 24.8 fixed point; log2(ratio8) = + * log2(n/hist) + 8. Round via the mantissa bit below the MSB. */ + uint32_t ratio8 = (uint32_t)(((uint64_t)n << 8) / hist[s]); + int t = enc_ilog2(ratio8); + int bits = t - 8; + if (t >= 1 && ((ratio8 >> (t - 1)) & 1)) bits++; /* round half up */ + if (bits < VV_OPT_LIT_MIN) bits = VV_OPT_LIT_MIN; + if (bits > 14) bits = 14; + /* Blend toward the flat-8 prior: a histogram estimate is still + * an approximation of the coder's delivered cost, and pricing + * from it unblended over-buys literals (measured; see the + * v2.64.0 sweep). blend/8 parts per-byte estimate, rest flat. */ + lit_bits[s] = (VV_OPT_LIT_BLEND * bits + (8 - VV_OPT_LIT_BLEND) * 8) / 8; + } +} + +/* match bit price: cost_const(14) + log2(off) + ml_extra; rep ~2 bits. + * + * SPRINT 128: priced against a caller-supplied rep set instead of + * m->rep. The matcher's rep state is a greedy-parser search heuristic + * that nothing updates during an optimal parse (it stayed {0,0,0} for + * every all-extreme frame, so rep pricing here was dead code), and the + * wire's rep state is PER-BLOCK and PATH-DEPENDENT: the SEQ encoder + * and decoder both start each block at {0,0,0} and evolve it per + * emitted sequence. The DP now threads that exact state through + * per-position rep histories (see compress_block_optimal). */ +/* A rep match saves the offset EXTRA bits, not the per-sequence + * overhead: it still spends full LL/OF/ML code symbols (~10 bits). + * The explicit-match constant 14 approximates that overhead plus + * slack, so the rep price must stay close beneath it — pricing reps + * near-free makes the DP shred long matches into chains of short rep + * matches, each paying the un-modeled sequence overhead (measured: + * -15% ratio on logs at rep=2). Constant swept on the 11-file corpus. */ +#ifndef VV_OPT_REP_BITS +#define VV_OPT_REP_BITS 10 +#endif +static inline int32_t opt_match_price(const uint32_t reps[3], uint32_t off, int32_t len, + const int32_t of_bits[27]) { + int32_t log2_off = enc_ilog2(off); + int x; + if (off == reps[0]) x = 0; + else if (off == reps[1]) x = 1; + else if (off == reps[2]) x = 2; + else { x = 3 + log2_off; if (x > 26) x = 26; } + /* 8 = LL+ML sequence overhead; extras only for explicit offsets. */ + int32_t off_cost = 8 + of_bits[x] + ((x >= 3) ? log2_off : 0); + int32_t ml_extra = 0, v = len - VV_MIN_MATCH; + if (v >= 15) ml_extra = 8 * (v / 255 + 1); + return off_cost + ml_extra; +} + +/* Wire rep-history update rule — must mirror vva_encode_sequences' + * enc_rep update (and the decoder's dec_rep) exactly: push only when + * the offset differs from rep[0]. */ +static inline void opt_rep_push(uint32_t dst[3], const uint32_t src3[3], uint32_t off) { + if (off == src3[0]) { + dst[0] = src3[0]; dst[1] = src3[1]; dst[2] = src3[2]; + } else { + dst[0] = off; dst[1] = src3[0]; dst[2] = src3[1]; + } +} + +/* Collect match candidates at pos (longest per distinct offset). + * SPRINT 128: rep candidates come from the DP path's rep history. */ static int opt_collect(const matcher_t *m, const uint8_t *data, - int32_t pos, int32_t end, opt_cand_t *cands) { + int32_t pos, int32_t end, opt_cand_t *cands, + const uint32_t reps[3]) { int n = 0; int32_t max_dist = (int32_t)((1u << m->wlog) - 1); int32_t limit = pos - max_dist; if (limit < 0) limit = 0; @@ -880,11 +1092,14 @@ static int opt_collect(const matcher_t *m, const uint8_t *data, uint32_t pos4; memcpy(&pos4, data + pos, 4); for (int r = 0; r < 3; r++) { - uint32_t roff = m->rep[r]; + uint32_t roff = reps[r]; if (roff == 0 || (int32_t)roff > pos) continue; - const uint8_t *a = data + pos, *b = data + pos - roff; - int32_t l = 0; while (l < max && a[l] == b[l]) l++; + /* SPRINT 132: extend_match (8-byte stride) instead of the + * byte-at-a-time loop — identical result, and this runs three + * times at every DP position. */ + int32_t l = extend_match(data + pos, data + pos - roff, max); if (l >= VV_MIN_MATCH && n < VV_OPT_MAX_CAND) { cands[n].off = roff; cands[n].len = l; n++; } + if (l >= VV_OPT_LONG_MATCH) return n; /* caller short-circuits on it */ } uint32_t h = hash_safe(data + pos, end - pos); int32_t ref = m->table[h]; @@ -898,6 +1113,10 @@ static int opt_collect(const matcher_t *m, const uint8_t *data, int dup = 0; for (int k = 0; k < n; k++) if (cands[k].off == off) { if (cands[k].len < l) cands[k].len = l; dup = 1; break; } if (!dup && l >= VV_MIN_MATCH) { cands[n].off = off; cands[n].len = l; n++; } + /* SPRINT 132: a LONG_MATCH-class hit makes the caller take + * it immediately and ignore other candidates — the rest of + * the walk (up to depth 256 with extends) is wasted work. */ + if (l >= VV_OPT_LONG_MATCH) return n; } ref = chain_arr[ref & chain_mask]; } @@ -907,21 +1126,87 @@ static int opt_collect(const matcher_t *m, const uint8_t *data, static size_t compress_block_optimal(const uint8_t *src, size_t start_pos, size_t block_len, uint8_t *dst, size_t dst_cap, matcher_t *m, int min_match) { + if (min_match < 1 || block_len > (size_t)INT32_MAX || + start_pos > (size_t)INT32_MAX - block_len) + return 0; + uint8_t *op = dst; int32_t base = (int32_t)start_pos; int32_t end = (int32_t)(start_pos + block_len); int off_bytes = (m->wlog > 16) ? 3 : 2; int32_t N = (int32_t)block_len; - /* DP arrays indexed by offset-from-base [0..N]. */ + /* DP arrays indexed by offset-from-base [0..N]. + * SPRINT 128: prep[i] is the wire rep-offset history of the best + * path reaching position i (zstd-btopt-style approximation: paths + * that lose on price but would carry better reps are dropped). + * prep[0] = {0,0,0} because the SEQ encoder and decoder both reset + * their rep state at every block boundary. */ int32_t *price = (int32_t *)malloc(sizeof(int32_t) * (N + 1)); int32_t *plen = (int32_t *)malloc(sizeof(int32_t) * (N + 1)); uint32_t *poff = (uint32_t *)malloc(sizeof(uint32_t) * (N + 1)); + uint32_t (*prep)[3] = (uint32_t (*)[3])malloc(sizeof(uint32_t[3]) * (N + 1)); opt_cand_t *cands = (opt_cand_t *)malloc(sizeof(opt_cand_t) * VV_OPT_MAX_CAND); - if (!price || !plen || !poff || !cands) { free(price); free(plen); free(poff); free(cands); return 0; } + if (!price || !plen || !poff || !prep || !cands) { free(price); free(plen); free(poff); free(prep); free(cands); return 0; } for (int32_t i = 0; i <= N; i++) { price[i] = VV_OPT_PRICE_INF; plen[i] = 0; poff[i] = 0; } price[0] = 0; + prep[0][0] = prep[0][1] = prep[0][2] = 0; + + /* SPRINT 129/130: entropy-aware per-byte literal prices for this + * block. The distribution that matters is the RESIDUAL literal + * stream (bytes a parse leaves uncovered), not the raw block — the + * raw histogram is dominated by exactly the repetitive content + * that matches will remove. A depth-4 greedy prepass on a private + * throwaway matcher (no shared-state pollution, ~1% of the DP's + * runtime) estimates that stream; its token output is histogrammed + * and discarded. Falls back to the raw-block histogram if the + * prepass cannot run. */ + int32_t lit_bits[256]; + int32_t of_bits[27]; + { + uint32_t hist[256]; + uint32_t of_hist[27]; + memset(hist, 0, sizeof(hist)); + memset(of_hist, 0, sizeof(of_hist)); + size_t nlit = 0, nseq_pp = 0; + matcher_t mp; + /* SPRINT 133: the prepass compresses ONE block (<= VV_MAX_BLOCK_SIZE + * = 2^20) with a fresh matcher, so every match it can find is + * intra-block: distance < block_len <= 2^20. A wlog-20 window + * covers that exactly, and its chain index (pos & (2^20-1)) is + * non-aliasing across a <= 2^20-wide position span — so the + * prepass finds the identical match set and emits the identical + * tokens/histogram/prices as it would at the real encode's wlog. + * Capping here avoids allocating and zeroing the full extreme + * window (up to 2 x 2^24 x 4 = 128 MB of chain arrays per block + * at wlog=24) when 2 x 2^20 x 4 = 8 MB suffices. off_bytes is + * unaffected: both >16 wlogs emit 3-byte offsets. Output- + * identical — verified by the ratio gate at +-0. */ + uint32_t pp_wlog = (m->wlog < 20) ? m->wlog : 20; + if (matcher_init(&mp, pp_wlog, 4)) { + mp.accel = 2; + mp.max_match = m->max_match; + size_t pcap = block_len + block_len / 255 + 1024; + uint8_t *ptok = (uint8_t *)malloc(pcap); + if (ptok) { + size_t pcsz = compress_block(src, start_pos, block_len, ptok, + pcap, &mp, VV_MODE_ULTRA_FAST, min_match); + if (pcsz > 0) + nlit = tok_lit_hist(ptok, pcsz, off_bytes, hist, of_hist, &nseq_pp); + free(ptok); + } + matcher_free(&mp); + } + if (nlit == 0) { + /* Prepass unavailable or block fully covered: raw fallback. */ + memset(hist, 0, sizeof(hist)); + for (int32_t i = 0; i < N; i++) hist[src[base + i]]++; + nlit = (size_t)N; + } + opt_build_lit_prices_from_hist(hist, nlit, lit_bits); + opt_build_of_prices(of_hist, nseq_pp, of_bits); + } /* Forward DP. We also must keep the matcher hash chains populated as we * advance, so matches reference earlier positions correctly. We insert @@ -935,7 +1220,7 @@ static size_t compress_block_optimal(const uint8_t *src, size_t start_pos, * via the raw-store path is NOT what we want — instead we cap by * short-circuiting long matches, which both bounds work AND is the * correct optimal choice (a very long match is never beaten). */ - const int32_t LONG_MATCH = 512; /* take immediately, skip interior DP */ + const int32_t LONG_MATCH = VV_OPT_LONG_MATCH; for (int32_t i = 0; i < N; i++) { if (price[i] >= VV_OPT_PRICE_INF) { @@ -944,13 +1229,16 @@ static size_t compress_block_optimal(const uint8_t *src, size_t start_pos, } int32_t ip = base + i; - /* literal edge */ - int32_t lp = price[i] + opt_lit_price(); - if (lp < price[i + 1]) { price[i + 1] = lp; plen[i + 1] = 1; poff[i + 1] = 0; } + /* literal edge (literals leave the rep history unchanged) */ + int32_t lp = price[i] + lit_bits[src[ip]]; + if (lp < price[i + 1]) { + price[i + 1] = lp; plen[i + 1] = 1; poff[i + 1] = 0; + prep[i + 1][0] = prep[i][0]; prep[i + 1][1] = prep[i][1]; prep[i + 1][2] = prep[i][2]; + } /* match edges */ if (ip + min_match <= end) { - int nc = opt_collect(m, src, ip, end, cands); + int nc = opt_collect(m, src, ip, end, cands, prep[i]); /* Find the longest candidate. */ int32_t best_len = 0; uint32_t best_off = 0; for (int c = 0; c < nc; c++) { @@ -964,11 +1252,14 @@ static size_t compress_block_optimal(const uint8_t *src, size_t start_pos, * bounds worst-case work on repetitive data: instead of * O(match_len) work per interior position, we jump over * the whole match. */ - int32_t use = best_len; - if (i + use > N) use = N - i; - int32_t np = price[i] + opt_match_price(m, best_off, use); + int32_t remaining_len = N - i; + int32_t use = best_len > remaining_len ? remaining_len : best_len; + int32_t np = price[i] + opt_match_price(prep[i], best_off, use, of_bits); int32_t j = i + use; - if (np < price[j]) { price[j] = np; plen[j] = use; poff[j] = best_off; } + if (np < price[j]) { + price[j] = np; plen[j] = use; poff[j] = best_off; + opt_rep_push(prep[j], prep[i], best_off); + } /* Insert boundary positions only (match-skip heuristic), * then jump the DP cursor to the match end. */ int32_t end5 = end - 5; @@ -982,12 +1273,18 @@ static size_t compress_block_optimal(const uint8_t *src, size_t start_pos, } for (int c = 0; c < nc; c++) { int32_t mlen = cands[c].len; uint32_t moff = cands[c].off; - if (i + mlen > N) mlen = N - i; + int32_t remaining_len = N - i; + if (mlen > remaining_len) mlen = remaining_len; if (mlen < min_match) continue; for (int32_t L = mlen; L >= min_match; L--) { - int32_t np = price[i] + opt_match_price(m, moff, L); - int32_t j = i + L; - if (np < price[j]) { price[j] = np; plen[j] = L; poff[j] = moff; } + if (L <= 0 || L > remaining_len) continue; + int32_t np = price[i] + opt_match_price(prep[i], moff, L, of_bits); + size_t j = (size_t)i + (size_t)L; + if (j > (size_t)N) continue; + if (np < price[j]) { + price[j] = np; plen[j] = L; poff[j] = moff; + opt_rep_push(prep[j], prep[i], moff); + } if (L > min_match + 8 && L < mlen) L = min_match + 9; } } @@ -1000,7 +1297,7 @@ static size_t compress_block_optimal(const uint8_t *src, size_t start_pos, /* Worst case every position is a literal: N entries. */ int32_t *seq_len = (int32_t *)malloc(sizeof(int32_t) * (N + 1)); uint32_t *seq_off = (uint32_t *)malloc(sizeof(uint32_t) * (N + 1)); - if (!seq_len || !seq_off) { free(price); free(plen); free(poff); free(cands); free(seq_len); free(seq_off); return 0; } + if (!seq_len || !seq_off) { free(price); free(plen); free(poff); free(prep); free(cands); free(seq_len); free(seq_off); return 0; } int32_t ns = 0, cur = N; while (cur > 0) { int32_t L = plen[cur]; @@ -1021,7 +1318,7 @@ static size_t compress_block_optimal(const uint8_t *src, size_t start_pos, size_t ll = (size_t)(src + pos - lit_start); size_t needed = 1 + (ll >= 15 ? ll / 255 + 2 : 0) + ll + 2 + ((size_t)L / 255 + 2); if ((size_t)(op - dst) + needed > dst_cap) { - free(price); free(plen); free(poff); free(cands); free(seq_len); free(seq_off); + free(price); free(plen); free(poff); free(prep); free(cands); free(seq_len); free(seq_off); return 0; } op += emit_seq(op, lit_start, ll, (size_t)L, O, off_bytes, min_match); @@ -1035,13 +1332,13 @@ static size_t compress_block_optimal(const uint8_t *src, size_t start_pos, size_t ll = (size_t)(src + end - lit_start); size_t needed = 1 + (ll >= 15 ? ll / 255 + 2 : 0) + ll; if ((size_t)(op - dst) + needed > dst_cap) { - free(price); free(plen); free(poff); free(cands); free(seq_len); free(seq_off); + free(price); free(plen); free(poff); free(prep); free(cands); free(seq_len); free(seq_off); return 0; } op += emit_seq(op, lit_start, ll, 0, 0, off_bytes, min_match); } - free(price); free(plen); free(poff); free(cands); free(seq_len); free(seq_off); + free(price); free(plen); free(poff); free(prep); free(cands); free(seq_len); free(seq_off); return (size_t)(op - dst); } @@ -1062,10 +1359,12 @@ static size_t compress_block(const uint8_t *src, size_t start_pos, size_t block_ int32_t end = (int32_t)(start_pos + block_len); const uint8_t *lit_start = src + start_pos; int off_bytes = (m->wlog > 16) ? 3 : 2; - uint32_t failures = 0; /* consecutive no-match positions (for --accel skip) */ + uint32_t failures = 0; /* consecutive no-match positions (for accel skip) */ + uint32_t nmatch = 0; /* matches found in this block (early-RAW bail) */ while (pos < end - min_match) { int32_t mlen = 0, moff = 0; + int pos_inserted = 0; /* ─── Step 1: Try rep-match (free, no hash lookup) ─── */ int32_t rep_idx = -1; @@ -1133,6 +1432,7 @@ static size_t compress_block(const uint8_t *src, size_t start_pos, size_t block_ pos + 1 < end - min_match) { /* Check pos+1 */ matcher_insert(m, src, pos, end); + pos_inserted = 1; int32_t noff = 0; int32_t nlen = chain_match(m, src, pos + 1, end, &noff); @@ -1191,6 +1491,7 @@ static size_t compress_block(const uint8_t *src, size_t start_pos, size_t block_ int rhs = moff_bits * (nlen + 1); if (lhs < rhs) { pos++; + pos_inserted = 0; /* the inserted position is now pos-1 */ mlen = nlen; moff = noff; rep_idx = nri; /* may have shifted from explicit→rep or vice versa */ @@ -1237,17 +1538,22 @@ static size_t compress_block(const uint8_t *src, size_t start_pos, size_t block_ * * Saves ~3 instructions per insert. Measured +4% encode * speedup on Silesia fast mode (Sprint 29). */ + /* SPRINT 124: when the lazy probe already inserted pos and + * we did not shift, start at pos+1 — re-inserting pos would + * put a self-duplicate link in the chain, lengthening every + * future walk through that bucket. */ + int32_t ins_first = pos + (pos_inserted ? 1 : 0); if (mlen >= 16) { /* Long match: only insert boundary positions */ int32_t end5 = end - 5; - for (int32_t j = pos; j < pos + 3 && j <= end5; j++) + for (int32_t j = ins_first; j < pos + 3 && j <= end5; j++) matcher_insert_fast(m, src, j); for (int32_t j = pos + mlen - 3; j < pos + mlen && j <= end5; j++) matcher_insert_fast(m, src, j); } else { /* Short match: insert all positions */ int32_t end5 = end - 5; - for (int32_t j = pos; j < pos + mlen && j <= end5; j++) + for (int32_t j = ins_first; j < pos + mlen && j <= end5; j++) matcher_insert_fast(m, src, j); } @@ -1255,14 +1561,28 @@ static size_t compress_block(const uint8_t *src, size_t start_pos, size_t block_ pos += mlen; lit_start = src + pos; failures = 0; /* matched: reset the no-match run */ + nmatch++; } else { - matcher_insert(m, src, pos, end); - /* --accel: skip ahead over unmatchable regions. accel==0 keeps - * the byte-identical default (advance 1). The skipped positions - * are not hashed/inserted and simply become literals. */ + if (!pos_inserted) matcher_insert(m, src, pos, end); + /* Accel: skip ahead over unmatchable regions. accel==0 keeps + * the byte-identical old default (advance 1). The skipped + * positions are not hashed/inserted and simply become + * literals. SPRINT 124: balanced/extreme cap the stride at 8 + * — on sparse-match data (struct-of-floats) an unbounded + * ramp skips over match starts and costs double-digit ratio; + * fast mode keeps the full lz4-style ramp. */ if (m->accel) { - pos += 1 + (int32_t)(((uint32_t)failures * m->accel) >> 6); + uint32_t step = 1 + (((uint32_t)failures * m->accel) >> 6); + if (mode >= VV_MODE_BALANCED && step > 8) step = 8; + pos += (int32_t)step; failures++; + /* Early RAW bail: 128 KB into the block with zero + * matches means this block is going raw anyway (csz + * would exceed braw). Returning 0 makes the caller + * emit a RAW block without paying for the rest of the + * parse or the literal memcpys. */ + if (nmatch == 0 && pos - (int32_t)start_pos >= (1 << 17)) + return 0; } else { pos++; } @@ -1375,25 +1695,55 @@ static size_t extract_literals( * - dst/dst_cap: output buffer * * Returns bytes written to dst on success, or 0 on overflow. */ +/* SPRINT 124: high-watermark tracking for the secure-zero scrub. + * Scrubbing full buffer capacities (~4 MB) per vv_compress call cost + * up to 14% of encode wall on fast inputs; only bytes actually written + * can hold plaintext, so tracking write watermarks preserves the + * Sprint 117 security property at a fraction of the cost. */ +typedef struct { + size_t tmp, lit, stripped, ent_front, ent_back; +} scrub_wm_t; + +static inline void wm_max(size_t *wm, size_t used) { + if (used > *wm) *wm = used; +} + static size_t emit_block(const uint8_t *src, size_t block_start, size_t braw, int last, matcher_t *m, vv_mode_t mode, uint8_t wlog, uint8_t *tmp, size_t tcap, uint8_t *lit_buf, size_t lit_cap, uint8_t *stripped, uint8_t *ent_buf, size_t ent_cap, uint8_t *dst, size_t dst_cap, int min_match, - int compat_v246_5) { + int compat_v246_5, scrub_wm_t *wm) { uint8_t *op = dst; /* SPRINT 42/43 RATIO PROGRAM: extreme mode uses the whole-block optimal * parser; balanced/fast keep greedy/lazy. csz==0 (overflow/alloc) flows - * into the raw-store branch below. */ + * into the raw-store branch below. + * + * SPRINT 124: on format-v2 (binary-detected) input, extreme uses the + * deep greedy/lazy parser instead. The optimal DP prices every match + * at full log2(offset) cost — it has no rep-offset model — so on + * rep-heavy record data (struct-of-floats, sensor logs) it loses + * 15-20% ratio to the rep-aware greedy path, and on incompressible + * binary it pays a full O(N·depth) DP just to store raw (the greedy + * path has skip acceleration and an early-RAW bail). Text-like input + * keeps the optimal parser, where it wins 3-11% over greedy. */ size_t csz; - if (mode >= VV_MODE_EXTREME) + int v2_block = (min_match < (int)VV_MIN_MATCH); + if (mode >= VV_MODE_EXTREME && !v2_block) csz = compress_block_optimal(src, block_start, braw, tmp, tcap, m, min_match); else csz = compress_block(src, block_start, braw, tmp, tcap, m, mode, min_match); + if (wm) wm_max(&wm->tmp, csz); - if (csz == 0 || csz >= braw) { + /* SPRINT 124: in balanced/extreme, a token stream slightly larger + * than raw can still win AFTER entropy coding — on low-match data + * (struct-of-floats, sensor logs) nearly all the compression comes + * from the entropy stage over literals, not from matches. Only the + * entropy-less fast path must reject csz >= braw outright. */ + size_t raw_gate = (mode >= VV_MODE_BALANCED) ? braw + braw / 8 : braw; + if (csz == 0 || csz >= raw_gate) { /* Incompressible: store raw */ if ((size_t)(op - dst) + 4 + braw > dst_cap) return 0; uint32_t bh = vv_bh_pack(VV_BLOCK_RAW, last, (uint32_t)braw); @@ -1423,8 +1773,9 @@ static size_t emit_block(const uint8_t *src, size_t block_start, size_t braw, seq_block_sz = 4 + 3 + 1 + seq_len; seq_valid = 1; } + if (wm) wm_max(&wm->ent_front, seq_len); - /* Path B: literal-only entropy ('I' or 'C') */ + /* Path B: literal-only entropy ('I' or 'A') */ size_t stripped_len = 0; size_t lit_count = 0; uint8_t *ent_buf2 = ent_buf + ent_cap / 2; @@ -1433,97 +1784,49 @@ static size_t emit_block(const uint8_t *src, size_t block_start, size_t braw, uint8_t ent_tag = 0; size_t ent_block_sz = (size_t)-1; - int try_path_b = 1; - /* PERF / dead-code prune (v2.53.3): Path B (literal-only 'I'/'C' - * entropy) has a measured 0% win rate against Path A (SEQ) across - * all real inputs tested (text, binary, logs, CSV) — SEQ always - * codes the same literals at least as small while also coding the - * matches. Path B can only conceivably win on a block where SEQ - * failed to find structure (its compressed size approaches raw). - * So skip Path B's extract_literals + redundant ANS encodes - * whenever SEQ is valid and already beats raw by a clear margin - * (seq_block_sz < braw*7/8). On blocks where SEQ does not compress - * (>= braw*7/8) Path B still runs, preserving the only case it - * could win. Verified byte-identical on all 12 Silesia (balanced + - * extreme) and on binary/log/CSV; the ratio gate guards against any - * regression. This removes redundant per-block work; it is a - * code-cleanliness change, not a measurable speedup (Path B was not - * the encode bottleneck — that is the depth-24 chain walk). */ - if (seq_valid && seq_block_sz < (braw * 7 / 8)) - try_path_b = 0; - if (mode == VV_MODE_BALANCED && seq_valid && seq_block_sz < (braw / 3)) { - /* SPRINT 29 (revised in v2.15): always try Path B in BALANCED - * mode, comparing both costs and picking the smaller. The - * earlier "skip Path B if seq compressed >3:1" heuristic - * (added in Sprint 28 for speed) saved ~30% encode time but - * hurt ratio on text-heavy data — Silesia dickens/reymont - * showed Path B's 'C' tag would have produced 5-10% smaller - * output but never got the chance. - * - * v2.15 trade-off: encoder is ~25% slower in BALANCED mode - * but ratio improves measurably on text. Decode speed is - * unaffected (decoder doesn't care which tag was chosen). - * - * In ULTRA_FAST/FAST modes the original skip remains in - * effect because those modes are throughput-priority. */ - (void)try_path_b; - } + /* Path B gate (v2.53.3, revised SPRINT 124): Path B has a + * measured 0% win rate against Path A (SEQ) on real inputs — + * SEQ codes the same literals at least as small while also + * coding the matches. Run it only when SEQ failed or produced + * weak output (>= 7/8 of raw). Path B is v1-only (its stripped + * tokens carry v1 matchlen bias), so on the v2 path skip the + * work entirely — the result could never be emitted. + * + * SPRINT 124: the CTX (order-1) coder is gone from this path. + * It ran exactly when SEQ was weak — low-redundancy binary — + * where it burned 50% of encode wall (sensors-class inputs) + * and, per the Sprint 53 measurements, never won a block. */ + int try_path_b = !use_v2 && (!seq_valid || + seq_block_sz >= (braw * 7 / 8)); if (try_path_b) { lit_count = extract_literals(tmp, csz, lit_buf, lit_cap, stripped, &stripped_len, off_bytes); + if (wm) { + wm_max(&wm->lit, lit_count); + wm_max(&wm->stripped, stripped_len); + } if (lit_count > 0) { - /* SPRINT 53: skip the expensive CTX (order-1 context) - * path when sequence coding is already winning by a - * big margin. Profile data across 7 fixtures (text, - * json, source, 4 ELF binaries) showed CTX wins 0/16 - * attempts — the CTX coder has never actually beaten - * SEQ on these workloads, but burned 20% of encode - * time building per-context ANS tables that were - * always discarded. - * - * Heuristic: skip CTX when seq_block_sz already does - * better than 2:1 compression (seq_block_sz < braw/2). - * Path A (SEQ) essentially never loses to Path B (CTX) - * when the LZ matcher found strong matches. CTX only - * matters for low-redundancy data where SEQ produces - * close-to-raw output — exactly the case where - * seq_block_sz ≥ braw/2. - * - * Falls back to ANS4 / ANS as literal coders in the - * unchanged code below. These are ~10× cheaper than - * CTX to build. Net encode-time savings measured in - * SPRINT 53 CHANGELOG entry. - * - * Security/correctness: this is purely an encoder - * heuristic. Decoder is unchanged. Output wire format - * still meets spec. Worst case on a pathological - * input where CTX would have won: we produce slightly - * larger output via ANS4 or ANS. Ratio gate guards - * against any real regression. */ - int skip_ctx = seq_valid && seq_block_sz < (braw * 4 / 5); - if (!skip_ctx && mode >= VV_MODE_BALANCED && lit_count >= 4096) { - vva_error_t aerr = vva_encode_ctx(lit_buf, lit_count, - ent_buf2, ent_cap2, &ent_len); - if (aerr == VVA_OK) ent_tag = VV_ENTROPY_CTX; - } + vva_error_t aerr = vva_encode4(lit_buf, lit_count, + ent_buf2, ent_cap2, &ent_len); + if (aerr == VVA_OK) ent_tag = VV_ENTROPY_ANS4; if (!ent_tag) { - vva_error_t aerr = vva_encode4(lit_buf, lit_count, - ent_buf2, ent_cap2, &ent_len); - if (aerr == VVA_OK) ent_tag = VV_ENTROPY_ANS4; - } - if (!ent_tag) { - vva_error_t aerr = vva_encode(lit_buf, lit_count, - ent_buf2, ent_cap2, &ent_len); + aerr = vva_encode(lit_buf, lit_count, + ent_buf2, ent_cap2, &ent_len); if (aerr == VVA_OK) ent_tag = VV_ENTROPY_ANS; } if (ent_tag) { ent_block_sz = 4 + 3 + 1 + 2 + 2 + ent_len + stripped_len; } + if (wm) wm_max(&wm->ent_back, ent_len); } } size_t raw_block_sz = 4 + 3 + csz; + /* Raw-store block size: with the relaxed raw_gate above, csz may + * exceed braw, so every candidate must also beat plain storage. */ + size_t store_sz = 4 + braw; + if (raw_block_sz > store_sz) raw_block_sz = store_sz; if (seq_valid && seq_block_sz <= ent_block_sz && seq_block_sz < raw_block_sz) { if ((size_t)(op - dst) + seq_block_sz > dst_cap) return 0; @@ -1554,21 +1857,20 @@ static size_t emit_block(const uint8_t *src, size_t block_start, size_t braw, op[0] = (uint8_t)(ent_len); op[1] = (uint8_t)(ent_len >> 8); op += 2; memcpy(op, ent_buf2, ent_len); op += ent_len; memcpy(op, stripped, stripped_len); op += stripped_len; - } else if (!use_v2) { + } else if (!use_v2 && csz < braw) { /* Plain VV_BLOCK_COMPRESSED carries raw v1-format tokens. * For v2, we must not emit these — the decoder would - * reconstruct matchlen with +4 instead of +3. Fall to RAW - * block instead (handled below via "else" when raw_block_sz - * is smaller). We reach this branch only when the previous - * conditions all failed AND we're NOT v2. */ - if ((size_t)(op - dst) + raw_block_sz > dst_cap) return 0; + * reconstruct matchlen with +4 instead of +3. Guarded on + * csz < braw because the relaxed raw_gate can let a token + * stream slightly larger than raw reach this point. */ + if ((size_t)(op - dst) + 4 + 3 + csz > dst_cap) return 0; uint32_t bh = vv_bh_pack(VV_BLOCK_COMPRESSED, last, (uint32_t)braw); memcpy(op, &bh, 4); op += 4; op[0] = (uint8_t)(csz); op[1] = (uint8_t)(csz >> 8); op[2] = (uint8_t)(csz >> 16); op += 3; memcpy(op, tmp, csz); op += csz; } else { - /* v2 path, sequence coding didn't fit/help: emit RAW. */ + /* Nothing beat plain storage: emit RAW. */ if ((size_t)(op - dst) + 4 + braw > dst_cap) return 0; uint32_t bh = vv_bh_pack(VV_BLOCK_RAW, last, (uint32_t)braw); memcpy(op, &bh, 4); op += 4; @@ -1696,32 +1998,55 @@ int64_t vv_compress_inner(const uint8_t *src, size_t src_len, size_t sz16 = 0, sz20 = 0; /* SPRINT 93 audit: matcher_init can fail; if it does, skip * the trial (this path is a perf-tuning probe — falling - * back to default wlog is safe). */ + * back to default wlog is safe). + * SPRINT 124: trials run with accel=2 so incompressible + * inputs no longer pay two full 128 KB parses just to + * decide "store raw". Both trials use the same accel, so + * the 16-vs-20 comparison stays apples-to-apples. */ if (matcher_init(&m16, 16, 4)) { + m16.accel = 2; sz16 = compress_block(src, 0, trial_len, trial_buf, trial_cap, &m16, VV_MODE_ULTRA_FAST, VV_MIN_MATCH); matcher_free(&m16); } matcher_t m20; if (matcher_init(&m20, 20, 4)) { + m20.accel = 2; sz20 = compress_block(src, 0, trial_len, trial_buf, trial_cap, &m20, VV_MODE_ULTRA_FAST, VV_MIN_MATCH); matcher_free(&m20); } free(trial_buf); if (sz20 > 0 && sz16 > 0 && sz20 < (sz16 * 97 / 100)) wlog = 20; - /* Binary-like detection: best trial ratio < 2:1 */ + /* Binary-like detection: best trial ratio < 2:1. A zero + * size means the early-RAW bail fired — maximally + * incompressible, so binary-like by definition. */ size_t best_sz = (sz20 > 0 && sz20 < sz16) ? sz20 : sz16; - if (best_sz > 0 && best_sz * 2 > trial_len) enable_hash4 = 1; + if (best_sz == 0 || best_sz * 2 > trial_len) enable_hash4 = 1; } } + /* SPRINT 124: adaptive format v2 (decided here because the window + * overrides below must not fire for v2-routed input). min_match=3 + * ('T' blocks) is a measured 14%+ ratio win on struct-of-floats/ + * record binary and 2-3% on ELF, while slightly HURTING text/JSON + * ratio and decode speed (more, shorter sequences). Auto-enable + * exactly where it wins: binary-detected inputs. Suppressed by + * the compat flag because 'T' blocks require a v2.33.0+ decoder. + * Explicit opts->format_v2 still forces it for any input. */ + int use_v2_fmt = opts->format_v2 || + (enable_hash4 && opts->mode >= VV_MODE_BALANCED && + !opts->compat_v246_5_decoder); + /* SPRINT 67: size-based wlog override. The trial above often * misses wins that only become visible past the 128 KB trial * boundary (long-range refs in multi-MB files). Override to - * wlog=18 for files ≥ 3 MB when the trial left wlog at 16. */ + * wlog=18 for files ≥ 3 MB when the trial left wlog at 16. + * SPRINT 124: not for v2-routed (binary) input — the greedy + * parser regresses badly on rep-heavy data with large windows + * (diverse far offsets break rep streaks and bloat OF codes). */ if (opts->window_log == 0 && opts->mode >= VV_MODE_BALANCED && - wlog == 16 && src_len >= 3145728) { + !use_v2_fmt && wlog == 16 && src_len >= 3145728) { wlog = 18; } @@ -1747,7 +2072,11 @@ int64_t vv_compress_inner(const uint8_t *src, size_t src_len, * Memory at wlog=24: chain[wsz]+hash4_chain[wsz] = 2*4*16M = 128 MB * matcher. Acceptable for extreme ("max ratio, will wait"). */ if (opts->window_log == 0 && opts->mode >= VV_MODE_EXTREME && - src_len > (1u << 20)) { + !use_v2_fmt && src_len > (1u << 20)) { + /* SPRINT 124: v2-routed (binary) extreme input uses the greedy + * parser (no rep model in the optimal DP), and greedy + large + * window is a measured 15-30% ratio LOSS on rep-heavy data — + * keep the trial-chosen window there. */ uint8_t want = 20; uint64_t s = src_len; while ((1ull << want) < s && want < 24) want++; @@ -1781,18 +2110,29 @@ int64_t vv_compress_inner(const uint8_t *src, size_t src_len, * by the balanced/extreme window-selection trial above stay at * single_probe==0 and produce bit-identical trial sizes. */ m.single_probe = (opts->mode == VV_MODE_ULTRA_FAST) ? 1 : 0; - m.accel = opts->accel > 64 ? 64 : opts->accel; + /* SPRINT 124: accel defaults ON. opts->accel == 0 now means "auto": + * fast mode gets the lz4-style ramp (2 → step 1 + failures/32), + * balanced/extreme a gentle one (1 → step 1 + failures/64, capped + * at 8 inside compress_block). This is what turns 1 MB of random + * bytes from a 24 ns/byte full-parse crawl into a near-memcpy RAW + * store. Explicit --accel values are honored unchanged. */ + { + uint32_t eff_accel = opts->accel; + if (eff_accel == 0) + eff_accel = (opts->mode >= VV_MODE_BALANCED) ? 1 : 2; + m.accel = eff_accel > 64 ? 64 : eff_accel; + } m.no_rep = opts->no_rep ? 1 : 0; /* Format v2 cap applies to EVERY match emitted from this matcher, * not just those produced via hash3. Set unconditionally when - * opts.format_v2 is active. */ - if (opts->format_v2) { + * the v2 format is active. */ + if (use_v2_fmt) { matcher_set_format_v2(&m); } /* Hash3 enablement is a separate, adaptive decision. Only fires * on binary-like data (enable_hash4) where length-3 matches * actually help. On text/JSON it stays off to avoid regressions. */ - if (opts->format_v2 && enable_hash4) { + if (use_v2_fmt && enable_hash4) { if (!matcher_enable_hash3(&m)) { matcher_free(&m); return VV_ERR_NOMEM; @@ -1817,7 +2157,14 @@ int64_t vv_compress_inner(const uint8_t *src, size_t src_len, * output. For small one-shot calls this avoids ~3 MB of wasted * allocation and page-faulting every call. */ lit_cap = block_bound; - ent_cap = vva_bound(block_bound); + /* SPRINT 124 (latent-corruption fix): ent_buf is shared by Path A + * (SEQ, writes at ent_buf[0..]) and Path B (literal entropy, + * writes at ent_buf + ent_cap/2). SEQ output on weak blocks can + * reach vva_bound(braw) — with ent_cap == vva_bound the halves + * OVERLAP and Path B silently clobbers SEQ's tail before the + * winner is chosen. Size the buffer so each half holds a full + * vva_bound worth of output. */ + ent_cap = 2 * vva_bound(block_bound); lit_buf = (uint8_t *)malloc(lit_cap); stripped = (uint8_t *)malloc(tcap); ent_buf = (uint8_t *)malloc(ent_cap); @@ -1836,10 +2183,12 @@ int64_t vv_compress_inner(const uint8_t *src, size_t src_len, memcpy(op, &bh, 4); op += 4; } - /* Format v2: when opts->format_v2 is set, encode with min_match=3. + /* Format v2 (explicit or adaptive): encode with min_match=3. * Produces 'T'-tagged ENTROPY blocks which only v2.33.0+ decoders * can read. Closes the real-binary compression gap vs gzip-9. */ - int min_match = opts->format_v2 ? 3 : (int)VV_MIN_MATCH; + int min_match = use_v2_fmt ? 3 : (int)VV_MIN_MATCH; + + scrub_wm_t wm = {0, 0, 0, 0, 0}; while (remaining > 0) { size_t braw = remaining > VV_MAX_BLOCK_SIZE ? VV_MAX_BLOCK_SIZE : remaining; @@ -1850,7 +2199,7 @@ int64_t vv_compress_inner(const uint8_t *src, size_t src_len, tmp, tcap, lit_buf, lit_cap, stripped, ent_buf, ent_cap, op, dst_cap - (size_t)(op - dst), min_match, - opts->compat_v246_5_decoder); + opts->compat_v246_5_decoder, &wm); if (written == 0) { free(lit_buf); free(stripped); free(ent_buf); free(tmp); matcher_free(&m); @@ -1861,11 +2210,19 @@ int64_t vv_compress_inner(const uint8_t *src, size_t src_len, } /* Sprint 117: scrub plaintext-derived working buffers before free - * to prevent heap-residue leak (defense in depth). */ - vv_secure_zero(tmp, tcap); - if (lit_buf) vv_secure_zero(lit_buf, lit_cap); - if (stripped) vv_secure_zero(stripped, tcap); - if (ent_buf) vv_secure_zero(ent_buf, ent_cap); + * to prevent heap-residue leak (defense in depth). + * SPRINT 124: scrub only up to each buffer's write watermark — + * bytes beyond it were never written and cannot hold plaintext. */ + vv_secure_zero(tmp, wm.tmp < tcap ? wm.tmp : tcap); + if (lit_buf) vv_secure_zero(lit_buf, wm.lit < lit_cap ? wm.lit : lit_cap); + if (stripped) vv_secure_zero(stripped, wm.stripped < tcap ? wm.stripped : tcap); + if (ent_buf) { + vv_secure_zero(ent_buf, wm.ent_front < ent_cap ? wm.ent_front : ent_cap); + size_t back_cap = ent_cap - ent_cap / 2; + if (wm.ent_back) + vv_secure_zero(ent_buf + ent_cap / 2, + wm.ent_back < back_cap ? wm.ent_back : back_cap); + } free(lit_buf); free(stripped); free(ent_buf); free(tmp); @@ -1986,8 +2343,13 @@ vv_cstream_t *vv_cstream_create(const vv_options_t *opts) { ctx->tmp = (uint8_t *)malloc(ctx->tcap); ctx->lit_cap = VV_MAX_BLOCK_SIZE; ctx->lit_buf = (uint8_t *)malloc(ctx->lit_cap); - ctx->stripped = (uint8_t *)malloc(ctx->lit_cap); - ctx->ent_cap = vva_bound(VV_MAX_BLOCK_SIZE); + /* SPRINT 124: stripped tokens can slightly exceed the raw block + * size now that emit_block lets csz ∈ [braw, braw*9/8) reach the + * entropy stage — size like tmp, not like lit_buf. */ + ctx->stripped = (uint8_t *)malloc(ctx->tcap); + /* SPRINT 124: 2× so Path A (front half) and Path B (back half) + * can never overlap — see the matching fix in vv_compress_inner. */ + ctx->ent_cap = 2 * vva_bound(VV_MAX_BLOCK_SIZE); ctx->ent_buf = (uint8_t *)malloc(ctx->ent_cap); /* Source window = 2 × window_size so a full block of input can @@ -2014,7 +2376,7 @@ void vv_cstream_destroy(vv_cstream_t *ctx) { * encrypted output. All are scrubbed to prevent heap-residue leak. */ if (ctx->tmp) vv_secure_zero(ctx->tmp, ctx->tcap); if (ctx->lit_buf) vv_secure_zero(ctx->lit_buf, ctx->lit_cap); - if (ctx->stripped) vv_secure_zero(ctx->stripped, ctx->lit_cap); + if (ctx->stripped) vv_secure_zero(ctx->stripped, ctx->tcap); if (ctx->ent_buf) vv_secure_zero(ctx->ent_buf, ctx->ent_cap); if (ctx->src_buf) vv_secure_zero(ctx->src_buf, ctx->src_cap); free(ctx->tmp); free(ctx->lit_buf); free(ctx->stripped); free(ctx->ent_buf); @@ -2168,7 +2530,8 @@ int vv_cstream_compress_chunk(vv_cstream_t *ctx, ctx->lit_buf, ctx->lit_cap, ctx->stripped, ctx->ent_buf, ctx->ent_cap, op, cap_left, stream_min_match, - ctx->opts.compat_v246_5_decoder); + ctx->opts.compat_v246_5_decoder, + NULL /* stream scrubs full caps at destroy */); if (block_sz == 0) return VV_ERR_OVERFLOW; op += block_sz; cap_left -= block_sz; } diff --git a/src/vv_huffman.c b/src/vv_huffman.c index d1440ff..082b708 100644 --- a/src/vv_huffman.c +++ b/src/vv_huffman.c @@ -85,8 +85,23 @@ static inline void br_init(br_t *r, const uint8_t *src, size_t len) { r->bits = 0; r->nbits = 0; r->src = src; r->pos = 0; r->len = len; } -/* Refill: load bytes until accumulator is full (≥56 bits) */ +/* Refill: load bytes until accumulator is full (≥56 bits). + * SPRINT 124: bulk 8-byte fast path. The byte-at-a-time loop was up + * to 7 dependent load-shift-or iterations firing every 3-4 symbols + * per stream — measured as the top cost of Huffman literal decode. + * One unaligned 8-byte load + mask absorbs the same bytes; the tail + * (<8 bytes left) keeps the exact byte loop. */ static inline void br_refill(br_t *r) { + if (r->pos + 8 <= r->len) { + unsigned absorbed = (63u - (unsigned)r->nbits) >> 3; /* 0..7 */ + uint64_t chunk; + memcpy(&chunk, r->src + r->pos, 8); + chunk &= ((uint64_t)1 << (absorbed * 8)) - 1; + r->bits |= chunk << r->nbits; + r->pos += absorbed; + r->nbits += (int)(absorbed * 8); + return; + } while (r->nbits <= 56 && r->pos < r->len) { r->bits |= (uint64_t)r->src[r->pos++] << r->nbits; r->nbits += 8; @@ -767,13 +782,69 @@ vvh_error_t vvh_decode4(const uint8_t *src, size_t src_len, } \ } while (0) + /* Variant without the per-symbol refill check, for rounds where a + * bulk refill has already guaranteed enough bits (see below). */ + #define DEC_ONE_NR(R, OUT) do { \ + uint32_t peek = br_peek(&(R), VVH_DECODE_BITS); \ + uint32_t entry = dec->table[peek]; \ + int sym = (int)(entry & 0xFF); \ + int len = (int)((entry >> 8) & 0xF); \ + if (VV_LIKELY(len > 0)) { \ + br_consume(&(R), len); \ + (OUT) = (uint8_t)sym; \ + } else { \ + int found = 0; \ + for (int s = 0; s < dec->slow_count; s++) { \ + int slen = dec->slow_len[s]; \ + uint32_t mask = (1u << slen) - 1; \ + if ((br_peek(&(R), slen) & mask) == dec->slow_code[s]) { \ + br_consume(&(R), slen); \ + (OUT) = dec->slow_sym[s]; \ + found = 1; \ + break; \ + } \ + } \ + if (!found) { free(dec); return VVH_ERR_CORRUPT; } \ + } \ + } while (0) + /* ─── 7. Hot loop: decode 4 symbols per iteration ─── */ /* Each iteration's 4 decodes are fully independent — different * readers, different table peeks, different output positions. * Modern OoO engines can pipeline 4 independent decode chains - * achieving ~1.8-2.2× speedup over single-stream. */ + * achieving ~1.8-2.2× speedup over single-stream. + * + * SPRINT 127: refill-hoisted fast rounds. One bulk refill per lane + * guarantees >= 56 accumulator bits (its 8-byte fast path applies + * whenever pos + 8 <= len, which the loop guard checks per lane), + * and three symbols consume at most 3 x VVH_MAX_CODE_LEN = 45 bits + * — so each round decodes 3 symbols per lane (12 outputs) with a + * single refill branch per lane instead of one per symbol. Bit + * consumption and decode order are identical to the per-symbol + * loop; corrupt input still bottoms out at the same slow-path + * check, and nbits cannot underflow (56 - 45 >= 0). The tail and + * the last rounds fall back to the checked DEC_ONE loop. */ size_t out_idx = 0; - for (size_t i = 0; i < Q; i++) { + size_t i = 0; + while (i + 3 <= Q && + r0.pos + 8 <= r0.len && r1.pos + 8 <= r1.len && + r2.pos + 8 <= r2.len && r3.pos + 8 <= r3.len) { + br_refill(&r0); br_refill(&r1); br_refill(&r2); br_refill(&r3); + for (int k = 0; k < 3; k++) { + uint8_t y0, y1, y2, y3; + DEC_ONE_NR(r0, y0); + DEC_ONE_NR(r1, y1); + DEC_ONE_NR(r2, y2); + DEC_ONE_NR(r3, y3); + dst[out_idx + 0] = y0; + dst[out_idx + 1] = y1; + dst[out_idx + 2] = y2; + dst[out_idx + 3] = y3; + out_idx += 4; + } + i += 3; + } + for (; i < Q; i++) { uint8_t y0, y1, y2, y3; DEC_ONE(r0, y0); DEC_ONE(r1, y1); @@ -793,6 +864,7 @@ vvh_error_t vvh_decode4(const uint8_t *src, size_t src_len, if (tail >= 3) { uint8_t y; DEC_ONE(r2, y); dst[out_idx++] = y; } #undef DEC_ONE + #undef DEC_ONE_NR /* Total bytes consumed: header + stream-size header + all 4 streams */ *src_consumed = streams_off + s0 + s1 + s2 + s3; diff --git a/src/vv_simd.c b/src/vv_simd.c index 228bf07..ae83d7b 100644 --- a/src/vv_simd.c +++ b/src/vv_simd.c @@ -83,7 +83,9 @@ static void copy_match_scalar(uint8_t *dst, uint32_t offset, size_t length) { #if defined(__x86_64__) || defined(_M_X64) +#ifdef __AVX2__ #include +#include static int vv_has_avx2(void) { unsigned int eax, ebx, ecx, edx; @@ -91,9 +93,6 @@ static int vv_has_avx2(void) { return (ebx & (1 << 5)) != 0; /* AVX2 bit */ } -#ifdef __AVX2__ -#include - static void copy_fast_avx2(uint8_t *dst, const uint8_t *src, size_t n) { while (n >= 32) { __m256i v = _mm256_loadu_si256((const __m256i *)src); diff --git a/src/vv_xxh64.c b/src/vv_xxh64.c index 11d9432..18968ca 100644 --- a/src/vv_xxh64.c +++ b/src/vv_xxh64.c @@ -1,8 +1,9 @@ /* - * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-License-Identifier: GPL-3.0-or-later AND BSD-2-Clause + * Copyright (c) 2012-2021 Yann Collet * * VaptVupt — XXH64 checksum (simplified, standalone) - * Based on xxHash by Yann Collet. Public domain. + * Based on xxHash by Yann Collet. See LICENSE-BSD-2-Clause. */ #include "vaptvupt.h" diff --git a/src/zupt_aes256.c b/src/zupt_aes256.c index 0860de6..8071d39 100644 --- a/src/zupt_aes256.c +++ b/src/zupt_aes256.c @@ -2,7 +2,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later * Copyright (c) 2025-2026 Cristian Cezar Moisés * ZUPT - AES-256 Block Cipher (FIPS 197) - * Pure C, constant-time T-table implementation. + * Pure C, portable table-based implementation. * FRAMA-C: ACSL-annotated (v2.0.0) */ #include "zupt.h" diff --git a/src/zupt_cpuid.c b/src/zupt_cpuid.c index cc723ca..63542fb 100644 --- a/src/zupt_cpuid.c +++ b/src/zupt_cpuid.c @@ -1,7 +1,7 @@ /* * SPDX-License-Identifier: AGPL-3.0-or-later * Copyright (c) 2025-2026 Cristian Cezar Moisés - * Zupt — CPU Feature Detection + * ZUPT — CPU Feature Detection * Copyright (c) 2026 Cristian Cezar Moisés — AGPL-3.0-or-later * * Detects AES-NI, PCLMUL, AVX2, SSE4.1 at runtime. diff --git a/src/zupt_crypto.c b/src/zupt_crypto.c index 9d2a7e4..290a0c2 100644 --- a/src/zupt_crypto.c +++ b/src/zupt_crypto.c @@ -1,5 +1,5 @@ /* - * Zupt — Backup-oriented compression with AES-256 encryption + * ZUPT — Backup-oriented compression with AES-256 encryption * Copyright (c) 2026 Cristian Cezar Moisés * SPDX-License-Identifier: AGPL-3.0-or-later * @@ -13,31 +13,36 @@ #include "zupt.h" #include "zupt_acsl.h" #include "zupt_jasmin.h" -#include "zupt_cpuid.h" /* JASMIN-VERIFIED: AES-NI dispatch */ +#include "zupt_cpuid.h" /* CPU dispatch for the optional Jasmin AES-NI path */ #include #include #include +#include #if defined(__linux__) #include #include #endif +#ifndef _WIN32 + #include +#endif /* ═══════════════════════════════════════════════════════════════════ * CONSTANT-TIME EQUALITY (single audited primitive) * - * Returns 1 if the two buffers are equal, 0 otherwise, in time that - * depends only on `n` — never on the contents or on where the first - * mismatch occurs. This is the one place the MAC-tag comparison is - * implemented; the three former inline byte-OR loops (the v1.6 strict + * Returns 1 if the two buffers are equal and 0 otherwise. Its source-level + * control flow and memory-access pattern are intended to depend only on `n`, + * not the contents or mismatch position. This is the one place the MAC-tag + * comparison is implemented; the three former inline byte-OR loops (the v1.6 strict * decrypt path, the v1.4/v1.5 legacy v2 candidate, and the F-08 archive- * integrity-trailer check) now all call here, so the property is audited * and timing-tested in exactly one location (see tests/test_ct_timing). * * A timing leak in a MAC comparison is a forgery oracle: if "wrong on * byte 0" returned faster than "wrong on byte 31", an attacker could - * recover a valid tag byte-by-byte. The accumulator is therefore folded - * with OR (no early exit) and read through a volatile sink so the - * compiler cannot reintroduce a short-circuit or branch. + * recover a valid tag byte-by-byte. The source therefore uses volatile byte + * loads and an OR accumulator with no explicit early exit. Exact generated + * code remains compiler- and platform-dependent and is covered by a + * dudect-style regression when its positive control is conclusive. * * CT-REQUIRED: no secret-dependent branch or memory access. */ int zupt_ct_memeq(const void *a, const void *b, size_t n) { @@ -79,7 +84,7 @@ void zupt_random_bytes(uint8_t *buf, size_t len) { if (r == (ssize_t)len) return; #endif #endif - FILE *f = fopen("/dev/urandom", "rb"); + FILE *f = zupt_fopen_path("/dev/urandom", "rb"); if (f) { size_t nread = fread(buf, 1, len, f); fclose(f); @@ -242,8 +247,8 @@ void zupt_aes256_ctr(const uint8_t key[32], const uint8_t nonce[16], memcpy(counter, nonce, 16); #ifdef ZUPT_USE_JASMIN - /* JASMIN-VERIFIED: AES-NI path — constant-time, no T-table leakage. - * The Jasmin-generated assembly uses VEX-encoded instructions (vaesenc, + /* OPTIONAL ASSEMBLY PATH: AES-NI implementation uses no table lookups. + * The checked-in assembly uses VEX-encoded instructions (vaesenc, * vmovdqu, vpxor, etc.) which require BOTH AES-NI AND AVX support. * Checking only has_aesni would SIGILL on CPUs with AES-NI but no AVX, * or where the OS hasn't enabled XSAVE for YMM state. */ @@ -391,11 +396,23 @@ uint8_t *zupt_encrypt_buffer_aad(const zupt_keyring_t *kr, uint8_t *pkg = (uint8_t *)malloc(*olen); if (!pkg) return NULL; - /* Derive per-block nonce */ + /* Per-block nonce: a fresh random 128-bit value for every block. + * + * SECURITY FIX (v4.2.0): the previous scheme derived the nonce as + * base_nonce XOR block_seq, but dedup mode hard-codes block_seq == 0 for + * every data block (the sentinel needed so cross-file dedup references MAC + * the same way). That collapsed every dedup block's nonce to the single + * per-archive base_nonce, reusing the AES-256-CTR keystream across distinct + * plaintext blocks — a many-time-pad that leaks plaintext to a + * ciphertext-only attacker, in every encryption mode (password, hybrid PQ, + * full PQ). A random 128-bit nonce is unique with overwhelming probability + * regardless of dedup or thread scheduling. The nonce is stored in the + * package prefix and bound by the HMAC, and decrypt reads it back directly, + * so this is an encrypt-side change only — the on-disk format, the MAC + * transcript (which still uses block_seq as aad_seq), and the decrypt path + * are all unchanged, and pre-4.2 archives still extract byte-exact. */ uint8_t nonce[16]; - memcpy(nonce, kr->base_nonce, 16); - for (int i = 0; i < 8; i++) - nonce[i] ^= (uint8_t)(block_seq >> (i * 8)); + zupt_random_bytes(nonce, 16); /* Store nonce */ memcpy(pkg, nonce, 16); @@ -559,6 +576,288 @@ uint8_t *zupt_decrypt_buffer(const zupt_keyring_t *kr, return zupt_decrypt_buffer_aad(kr, pkg, pkglen, block_seq, NULL, 0, olen); } +/* Write a key file without ever opening an existing directory entry. Private + * material is created mode 0600 on POSIX independently of the caller's umask. + * Windows uses CREATE_NEW and, for private material, a protected DACL granting + * access only to the current token's user SID. A failed write, flush, or close + * leaves the exclusively created incomplete or durability-uncertain file in + * place for the user to review and remove. This deliberately avoids a + * pathname-based cleanup after close: + * another process with write access to the parent directory could otherwise + * replace the entry and trick cleanup into deleting an unrelated file. + * + * This is intentionally shared with the optional pq-box module. Public-key + * output is exclusive too: besides avoiding symlink truncation, that prevents + * `keygen --pub -o private.key -k private.key` from destroying the only copy of + * a private key. */ +int zupt_keyfile_write_new(const char *path, const uint8_t *data, size_t length, + int private_material) { + if (!path || path[0] == '\0' || (!data && length != 0) || + (private_material != 0 && private_material != 1)) { + errno = EINVAL; + return -1; + } + +#ifdef _WIN32 + if (length > (size_t)MAXDWORD) { + errno = EFBIG; + return -1; + } + wchar_t *wide_path = zupt_win_utf8_to_wide_alloc(path); + if (!wide_path) { + errno = EINVAL; + return -1; + } + + SECURITY_ATTRIBUTES attributes; + SECURITY_DESCRIPTOR descriptor; + SECURITY_ATTRIBUTES *attributes_ptr = NULL; + HMODULE advapi = NULL; + HANDLE token = NULL; + TOKEN_USER *token_user = NULL; + ACL *acl = NULL; + + if (private_material) { + typedef BOOL (WINAPI *open_process_token_fn)(HANDLE, DWORD, PHANDLE); + typedef BOOL (WINAPI *get_token_information_fn)( + HANDLE, TOKEN_INFORMATION_CLASS, LPVOID, DWORD, PDWORD); + typedef DWORD (WINAPI *get_length_sid_fn)(PSID); + typedef BOOL (WINAPI *initialize_acl_fn)(PACL, DWORD, DWORD); + typedef BOOL (WINAPI *add_access_allowed_ace_fn)( + PACL, DWORD, DWORD, PSID); + typedef BOOL (WINAPI *initialize_security_descriptor_fn)( + PSECURITY_DESCRIPTOR, DWORD); + typedef BOOL (WINAPI *set_security_descriptor_dacl_fn)( + PSECURITY_DESCRIPTOR, BOOL, PACL, BOOL); + typedef BOOL (WINAPI *set_security_descriptor_control_fn)( + PSECURITY_DESCRIPTOR, SECURITY_DESCRIPTOR_CONTROL, + SECURITY_DESCRIPTOR_CONTROL); + + advapi = LoadLibraryW(L"advapi32.dll"); + if (!advapi) goto windows_security_error; + open_process_token_fn open_process_token = + (open_process_token_fn)(void (*)(void)) + GetProcAddress(advapi, "OpenProcessToken"); + get_token_information_fn get_token_information = + (get_token_information_fn)(void (*)(void)) + GetProcAddress(advapi, "GetTokenInformation"); + get_length_sid_fn get_length_sid = + (get_length_sid_fn)(void (*)(void)) + GetProcAddress(advapi, "GetLengthSid"); + initialize_acl_fn initialize_acl = + (initialize_acl_fn)(void (*)(void)) + GetProcAddress(advapi, "InitializeAcl"); + add_access_allowed_ace_fn add_access_allowed_ace = + (add_access_allowed_ace_fn)(void (*)(void)) + GetProcAddress(advapi, "AddAccessAllowedAce"); + initialize_security_descriptor_fn initialize_security_descriptor = + (initialize_security_descriptor_fn)(void (*)(void)) + GetProcAddress(advapi, "InitializeSecurityDescriptor"); + set_security_descriptor_dacl_fn set_security_descriptor_dacl = + (set_security_descriptor_dacl_fn)(void (*)(void)) + GetProcAddress(advapi, "SetSecurityDescriptorDacl"); + set_security_descriptor_control_fn set_security_descriptor_control = + (set_security_descriptor_control_fn)(void (*)(void)) + GetProcAddress(advapi, "SetSecurityDescriptorControl"); + if (!open_process_token || !get_token_information || !get_length_sid || + !initialize_acl || !add_access_allowed_ace || + !initialize_security_descriptor || !set_security_descriptor_dacl || + !set_security_descriptor_control) + goto windows_security_error; + + if (!open_process_token(GetCurrentProcess(), TOKEN_QUERY, &token)) + goto windows_security_error; + DWORD token_size = 0; + (void)get_token_information(token, TokenUser, NULL, 0, &token_size); + if (token_size == 0) goto windows_security_error; + token_user = (TOKEN_USER *)malloc(token_size); + if (!token_user) { + errno = ENOMEM; + goto windows_security_cleanup; + } + if (!get_token_information(token, TokenUser, token_user, token_size, + &token_size)) + goto windows_security_error; + + DWORD sid_size = get_length_sid(token_user->User.Sid); + if (sid_size == 0 || + sid_size > MAXDWORD - (DWORD)sizeof(ACL) - + (DWORD)sizeof(ACCESS_ALLOWED_ACE)) + goto windows_security_error; + DWORD acl_size = (DWORD)sizeof(ACL) + + (DWORD)sizeof(ACCESS_ALLOWED_ACE) - + (DWORD)sizeof(DWORD) + sid_size; + acl = (ACL *)malloc(acl_size); + if (!acl) { + errno = ENOMEM; + goto windows_security_cleanup; + } + if (!initialize_acl(acl, acl_size, ACL_REVISION) || + !add_access_allowed_ace(acl, ACL_REVISION, GENERIC_ALL, + token_user->User.Sid) || + !initialize_security_descriptor(&descriptor, + SECURITY_DESCRIPTOR_REVISION) || + !set_security_descriptor_dacl(&descriptor, TRUE, acl, FALSE) || + !set_security_descriptor_control(&descriptor, SE_DACL_PROTECTED, + SE_DACL_PROTECTED)) + goto windows_security_error; + + attributes.nLength = sizeof(attributes); + attributes.lpSecurityDescriptor = &descriptor; + attributes.bInheritHandle = FALSE; + attributes_ptr = &attributes; + } + + HANDLE handle = CreateFileW( + wide_path, GENERIC_WRITE | DELETE, 0, attributes_ptr, CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_WRITE_THROUGH, + NULL); + { + DWORD create_error = handle == INVALID_HANDLE_VALUE ? GetLastError() : 0; + free(acl); + free(token_user); + if (token) CloseHandle(token); + if (advapi) FreeLibrary(advapi); + acl = NULL; + token_user = NULL; + token = NULL; + advapi = NULL; + if (handle == INVALID_HANDLE_VALUE) { + free(wide_path); + errno = (create_error == ERROR_FILE_EXISTS || + create_error == ERROR_ALREADY_EXISTS) + ? EEXIST : EACCES; + return -1; + } + } + + DWORD written = 0; + int failed = !WriteFile(handle, data, (DWORD)length, &written, NULL) || + written != (DWORD)length || !FlushFileBuffers(handle); + if (!CloseHandle(handle)) failed = 1; + if (failed) { + free(wide_path); + errno = EIO; + return -1; + } + free(wide_path); + return 0; + +windows_security_error: + errno = EACCES; +windows_security_cleanup: + free(acl); + free(token_user); + if (token) CloseHandle(token); + if (advapi) FreeLibrary(advapi); + free(wide_path); + return -1; +#else + int flags = O_WRONLY | O_CREAT | O_EXCL; +#ifdef O_NOFOLLOW + flags |= O_NOFOLLOW; +#endif +#ifdef O_CLOEXEC + flags |= O_CLOEXEC; +#endif + int descriptor = open(path, flags, private_material ? 0600 : 0666); + if (descriptor < 0) return -1; + + int failed = 0; + int saved_errno = 0; + if (private_material && fchmod(descriptor, 0600) != 0) { + failed = 1; + saved_errno = errno; + } +#ifndef O_CLOEXEC + if (!failed) { + int descriptor_flags = fcntl(descriptor, F_GETFD); + if (descriptor_flags < 0 || + fcntl(descriptor, F_SETFD, descriptor_flags | FD_CLOEXEC) != 0) { + failed = 1; + saved_errno = errno; + } + } +#endif + size_t offset = 0; + while (!failed && offset < length) { + ssize_t amount = write(descriptor, data + offset, length - offset); + if (amount < 0 && errno == EINTR) continue; + if (amount <= 0) { + failed = 1; + saved_errno = amount < 0 ? errno : EIO; + break; + } + offset += (size_t)amount; + } + if (!failed && fsync(descriptor) != 0) { + failed = 1; + saved_errno = errno; + } + if (close(descriptor) != 0 && !failed) { + failed = 1; + saved_errno = errno; + } + if (failed) { + errno = saved_errno ? saved_errno : EIO; + return -1; + } + return 0; +#endif +} + +/* Load and structurally validate one native key blob before exposing any key + * bytes to a caller. The historical writers have always serialized the XXH64 + * trailer with zupt_le64_put(), so readers deliberately interpret it as + * little-endian on every host. This newly enforces the existing format rather + * than changing it; valid v1 files remain byte-for-byte compatible. */ +static int load_native_key_blob(const char *path, const char magic[4], + uint8_t version, uint8_t private_flag, + size_t public_file_size, + size_t private_file_size, + int require_private, + uint8_t *buffer, size_t buffer_capacity, + size_t *file_size) { + if (!path || !buffer || !file_size || public_file_size < 16 || + private_file_size <= public_file_size || + private_file_size > buffer_capacity) { + errno = EINVAL; + return -1; + } + *file_size = 0; + FILE *stream = zupt_fopen_path(path, "rb"); + if (!stream) return -1; + + size_t length = fread(buffer, 1, private_file_size, stream); + int trailing = fgetc(stream); + int failed = ferror(stream) != 0; + if (fclose(stream) != 0) failed = 1; + + int has_private = length >= 6 && buffer[5] == private_flag; + size_t expected_size = has_private ? private_file_size : public_file_size; + if (failed || trailing != EOF || + (length != public_file_size && length != private_file_size) || + memcmp(buffer, magic, 4) != 0 || buffer[4] != version || + (buffer[5] != 0 && buffer[5] != private_flag) || + buffer[6] != 0 || buffer[7] != 0 || + length != expected_size || (require_private && !has_private)) { + zupt_secure_wipe(buffer, buffer_capacity); + errno = EINVAL; + return -1; + } + + uint64_t stored_checksum = zupt_le64_get(buffer + length - 8); + uint64_t computed_checksum = zupt_xxh64(buffer, length - 8, 0); + if (stored_checksum != computed_checksum) { + zupt_secure_wipe(buffer, buffer_capacity); + errno = EINVAL; + return -1; + } + *file_size = length; + return 0; +} + /* ═══════════════════════════════════════════════════════════════════ * HYBRID POST-QUANTUM KEM: ML-KEM-768 + X25519 (v0.7.0) * @@ -586,26 +885,25 @@ uint8_t *zupt_decrypt_buffer(const zupt_keyring_t *kr, #define ZKEY_FLAG_PRIVATE 0x01 #define ZKEY_PUB_SIZE (8 + 1184 + 32) /* header + ml_kem_pk + x25519_pk */ #define ZKEY_PRIV_SIZE (8 + 1184 + 32 + 2400 + 32) /* + ml_kem_sk + x25519_sk */ +#define ZKEY_CHECKSUM_SIZE 8 +#define ZKEY_PUB_FILE_SIZE (ZKEY_PUB_SIZE + ZKEY_CHECKSUM_SIZE) +#define ZKEY_PRIV_FILE_SIZE (ZKEY_PRIV_SIZE + ZKEY_CHECKSUM_SIZE) int zupt_hybrid_keygen(const char *keyfile) { - uint8_t ml_pk[MLKEM_PUBLICKEYBYTES], ml_sk[MLKEM_SECRETKEYBYTES]; - uint8_t x_sk[32], x_pk[32]; + uint8_t ml_pk[MLKEM_PUBLICKEYBYTES] = {0}; + uint8_t ml_sk[MLKEM_SECRETKEYBYTES] = {0}; + uint8_t x_sk[32] = {0}, x_pk[32] = {0}; + uint8_t buf[ZKEY_PRIV_FILE_SIZE] = {0}; + const size_t total = ZKEY_PRIV_SIZE; + int result = -1; /* Generate ML-KEM-768 keypair */ - if (zupt_mlkem768_keygen(ml_pk, ml_sk) != 0) return -1; + if (zupt_mlkem768_keygen(ml_pk, ml_sk) != 0) goto out; /* Generate X25519 keypair */ zupt_random_bytes(x_sk, 32); zupt_x25519_base(x_pk, x_sk); - /* Write private key file */ - FILE *f = fopen(keyfile, "wb"); - if (!f) return -1; - - size_t total = ZKEY_PRIV_SIZE; - uint8_t *buf = (uint8_t *)calloc(total + 8, 1); /* +8 for checksum */ - if (!buf) { fclose(f); return -1; } - memcpy(buf, ZKEY_MAGIC, 4); buf[4] = ZKEY_VERSION; buf[5] = ZKEY_FLAG_PRIVATE; @@ -616,85 +914,77 @@ int zupt_hybrid_keygen(const char *keyfile) { memcpy(buf + 8 + 1184 + 32 + 2400, x_sk, 32); /* Checksum */ - uint64_t ck = zupt_xxh64(buf, total, 0); - zupt_le64_put(buf + total, ck); - - size_t written = fwrite(buf, 1, total + 8, f); - fclose(f); + zupt_le64_put(buf + total, zupt_xxh64(buf, total, 0)); + result = zupt_keyfile_write_new(keyfile, buf, sizeof(buf), 1); +out: zupt_secure_wipe(ml_sk, sizeof(ml_sk)); - zupt_secure_wipe(x_sk, 32); - zupt_secure_wipe(buf, total + 8); - free(buf); - - return (written == total + 8) ? 0 : -1; + zupt_secure_wipe(x_sk, sizeof(x_sk)); + zupt_secure_wipe(buf, sizeof(buf)); + return result; } int zupt_hybrid_export_pubkey(const char *privfile, const char *pubfile) { - FILE *f = fopen(privfile, "rb"); - if (!f) return -1; + uint8_t private_blob[ZKEY_PRIV_FILE_SIZE] = {0}; + uint8_t public_blob[ZKEY_PUB_FILE_SIZE] = {0}; + size_t private_size = 0; + const size_t total = ZKEY_PUB_SIZE; + int result = -1; + if (load_native_key_blob(privfile, ZKEY_MAGIC, ZKEY_VERSION, + ZKEY_FLAG_PRIVATE, ZKEY_PUB_FILE_SIZE, + ZKEY_PRIV_FILE_SIZE, 1, private_blob, + sizeof(private_blob), &private_size) != 0) + goto out; - uint8_t hdr[8]; - if (fread(hdr, 1, 8, f) != 8 || memcmp(hdr, ZKEY_MAGIC, 4) != 0 || - !(hdr[5] & ZKEY_FLAG_PRIVATE)) { - fclose(f); return -1; - } + memcpy(public_blob, ZKEY_MAGIC, 4); + public_blob[4] = ZKEY_VERSION; + public_blob[5] = 0; /* no private key */ + public_blob[6] = public_blob[7] = 0; + memcpy(public_blob + 8, private_blob + 8, 1184 + 32); - uint8_t pk_data[1184 + 32]; - if (fread(pk_data, 1, 1216, f) != 1216) { fclose(f); return -1; } - fclose(f); + zupt_le64_put(public_blob + total, + zupt_xxh64(public_blob, total, 0)); - /* Write public key file */ - FILE *out = fopen(pubfile, "wb"); - if (!out) return -1; - - size_t total = ZKEY_PUB_SIZE; - uint8_t buf[ZKEY_PUB_SIZE + 8]; - memcpy(buf, ZKEY_MAGIC, 4); - buf[4] = ZKEY_VERSION; - buf[5] = 0; /* no private key */ - buf[6] = buf[7] = 0; - memcpy(buf + 8, pk_data, 1216); - - uint64_t ck = zupt_xxh64(buf, total, 0); - zupt_le64_put(buf + total, ck); - - size_t written = fwrite(buf, 1, total + 8, out); - fclose(out); - return (written == total + 8) ? 0 : -1; + result = zupt_keyfile_write_new(pubfile, public_blob, + sizeof(public_blob), 0); +out: + zupt_secure_wipe(private_blob, sizeof(private_blob)); + zupt_secure_wipe(public_blob, sizeof(public_blob)); + return result; } /* Read public key from a .zupt-key file (works for both pub and priv files) */ static int read_pubkey(const char *path, uint8_t ml_pk[1184], uint8_t x_pk[32]) { - FILE *f = fopen(path, "rb"); - if (!f) return -1; - - uint8_t hdr[8]; - if (fread(hdr, 1, 8, f) != 8 || memcmp(hdr, ZKEY_MAGIC, 4) != 0) { - fclose(f); return -1; - } - if (fread(ml_pk, 1, 1184, f) != 1184) { fclose(f); return -1; } - if (fread(x_pk, 1, 32, f) != 32) { fclose(f); return -1; } - fclose(f); + uint8_t blob[ZKEY_PRIV_FILE_SIZE] = {0}; + size_t file_size = 0; + /* Accept a structurally valid private file here for compatibility: older + * releases explicitly allowed encryption directly with either ZKEY role. */ + if (load_native_key_blob(path, ZKEY_MAGIC, ZKEY_VERSION, + ZKEY_FLAG_PRIVATE, ZKEY_PUB_FILE_SIZE, + ZKEY_PRIV_FILE_SIZE, 0, blob, sizeof(blob), + &file_size) != 0) + return -1; + memcpy(ml_pk, blob + 8, 1184); + memcpy(x_pk, blob + 8 + 1184, 32); + zupt_secure_wipe(blob, sizeof(blob)); return 0; } /* Read private key from a .zupt-key file */ static int read_privkey(const char *path, uint8_t ml_pk[1184], uint8_t x_pk[32], uint8_t ml_sk[2400], uint8_t x_sk[32]) { - FILE *f = fopen(path, "rb"); - if (!f) return -1; - - uint8_t hdr[8]; - if (fread(hdr, 1, 8, f) != 8 || memcmp(hdr, ZKEY_MAGIC, 4) != 0 || - !(hdr[5] & ZKEY_FLAG_PRIVATE)) { - fclose(f); return -1; - } - if (fread(ml_pk, 1, 1184, f) != 1184) { fclose(f); return -1; } - if (fread(x_pk, 1, 32, f) != 32) { fclose(f); return -1; } - if (fread(ml_sk, 1, 2400, f) != 2400) { fclose(f); return -1; } - if (fread(x_sk, 1, 32, f) != 32) { fclose(f); return -1; } - fclose(f); + uint8_t blob[ZKEY_PRIV_FILE_SIZE] = {0}; + size_t file_size = 0; + if (load_native_key_blob(path, ZKEY_MAGIC, ZKEY_VERSION, + ZKEY_FLAG_PRIVATE, ZKEY_PUB_FILE_SIZE, + ZKEY_PRIV_FILE_SIZE, 1, blob, sizeof(blob), + &file_size) != 0) + return -1; + memcpy(ml_pk, blob + 8, 1184); + memcpy(x_pk, blob + 8 + 1184, 32); + memcpy(ml_sk, blob + 8 + 1184 + 32, 2400); + memcpy(x_sk, blob + 8 + 1184 + 32 + 2400, 32); + zupt_secure_wipe(blob, sizeof(blob)); return 0; } @@ -805,7 +1095,14 @@ int zupt_hybrid_decrypt_init(zupt_keyring_t *kr, const char *privkeyfile, const uint8_t *nonce = enc_hdr + 1 + 1088 + 32; uint8_t ml_pk[1184], x_pk[32], ml_sk[2400], x_sk[32]; - if (read_privkey(privkeyfile, ml_pk, x_pk, ml_sk, x_sk) != 0) return -1; + if (read_privkey(privkeyfile, ml_pk, x_pk, ml_sk, x_sk) != 0) { + /* Wipe any partially-read secret-key material on error, matching the + * pq-only decrypt path (a bad/truncated key file must not leave secret + * bytes on the stack). */ + zupt_secure_wipe(ml_sk, sizeof(ml_sk)); + zupt_secure_wipe(x_sk, sizeof(x_sk)); + return -1; + } /* ML-KEM-768 decapsulation */ uint8_t ml_ss[32]; @@ -850,3 +1147,210 @@ int zupt_hybrid_decrypt_init(zupt_keyring_t *kr, const char *privkeyfile, return 0; } + +/* ═══════════════════════════════════════════════════════════════════ + * FULL POST-QUANTUM KEM: ML-KEM-768 only (v4.2.0) + * + * Unlike the hybrid --pq mode (ML-KEM-768 + X25519), this mode uses + * ML-KEM-768 ALONE — no classical X25519 component. It is "fully + * post-quantum": confidentiality of the archive key rests solely on + * ML-KEM (FIPS 203, IND-CCA2 with the Fujisaki-Okamoto transform / + * implicit rejection that zupt_mlkem768_decaps implements). + * + * SECURITY NOTE: the hybrid --pq mode remains the recommended default. + * A pure-PQ scheme has NO classical fallback, so a future break of + * ML-KEM-768 leaves no second layer. Use --pq-only only when a strictly + * post-quantum construction is a hard requirement (e.g. a policy that + * forbids classical primitives entirely). + * + * Key file (ZPQK): + * [4B] "ZPQK" + * [1B] version 0x01 + * [1B] flags: bit0 = has_private + * [2B] reserved + * [1184B] ml_kem_pk + * [2400B] ml_kem_sk (only if has_private) + * [8B] xxh64 of everything above + * + * enc_hdr (ZUPT_ENC_PQ_ONLY = 0x06), 1105 bytes: + * [1B] 0x06 + * [1088B] ml_kem_ciphertext + * [16B] base_nonce + * + * archive_key[64] = SHA3-512(ml_ss ‖ ml_ct ‖ "ZUPT-PQ-ONLY-v1") + * enc_key = archive_key[0:32], mac_key = archive_key[32:64] + * The ML-KEM ciphertext is bound into the KDF transcript (defense in + * depth) alongside the domain separator, which also prevents cross-mode + * key reuse with the hybrid path (different label). + * ═══════════════════════════════════════════════════════════════════ */ + +#define ZPQK_MAGIC "ZPQK" +#define ZPQK_VERSION 0x01 +#define ZPQK_FLAG_PRIVATE 0x01 +#define ZPQK_HDR 8 +#define ZPQK_PUB_SIZE (ZPQK_HDR + 1184) +#define ZPQK_PRIV_SIZE (ZPQK_HDR + 1184 + 2400) +#define ZPQK_CHECKSUM_SIZE 8 +#define ZPQK_PUB_FILE_SIZE (ZPQK_PUB_SIZE + ZPQK_CHECKSUM_SIZE) +#define ZPQK_PRIV_FILE_SIZE (ZPQK_PRIV_SIZE + ZPQK_CHECKSUM_SIZE) +#define ZUPT_PQ_ONLY_LABEL "ZUPT-PQ-ONLY-v1" /* 15 bytes */ + +int zupt_pq_keygen(const char *keyfile) { + uint8_t ml_pk[MLKEM_PUBLICKEYBYTES] = {0}; + uint8_t ml_sk[MLKEM_SECRETKEYBYTES] = {0}; + uint8_t buf[ZPQK_PRIV_FILE_SIZE] = {0}; + const size_t total = ZPQK_PRIV_SIZE; + int result = -1; + if (zupt_mlkem768_keygen(ml_pk, ml_sk) != 0) goto out; + + memcpy(buf, ZPQK_MAGIC, 4); + buf[4] = ZPQK_VERSION; + buf[5] = ZPQK_FLAG_PRIVATE; + buf[6] = buf[7] = 0; + memcpy(buf + ZPQK_HDR, ml_pk, 1184); + memcpy(buf + ZPQK_HDR + 1184, ml_sk, 2400); + + zupt_le64_put(buf + total, zupt_xxh64(buf, total, 0)); + + result = zupt_keyfile_write_new(keyfile, buf, sizeof(buf), 1); +out: + zupt_secure_wipe(ml_sk, sizeof(ml_sk)); + zupt_secure_wipe(buf, sizeof(buf)); + return result; +} + +int zupt_pq_export_pubkey(const char *privfile, const char *pubfile) { + uint8_t private_blob[ZPQK_PRIV_FILE_SIZE] = {0}; + uint8_t public_blob[ZPQK_PUB_FILE_SIZE] = {0}; + size_t private_size = 0; + const size_t total = ZPQK_PUB_SIZE; + int result = -1; + if (load_native_key_blob(privfile, ZPQK_MAGIC, ZPQK_VERSION, + ZPQK_FLAG_PRIVATE, ZPQK_PUB_FILE_SIZE, + ZPQK_PRIV_FILE_SIZE, 1, private_blob, + sizeof(private_blob), &private_size) != 0) + goto out; + + memcpy(public_blob, ZPQK_MAGIC, 4); + public_blob[4] = ZPQK_VERSION; + public_blob[5] = 0; + public_blob[6] = public_blob[7] = 0; + memcpy(public_blob + ZPQK_HDR, private_blob + ZPQK_HDR, 1184); + zupt_le64_put(public_blob + total, + zupt_xxh64(public_blob, total, 0)); + result = zupt_keyfile_write_new(pubfile, public_blob, + sizeof(public_blob), 0); +out: + zupt_secure_wipe(private_blob, sizeof(private_blob)); + zupt_secure_wipe(public_blob, sizeof(public_blob)); + return result; +} + +static int read_pq_pubkey(const char *path, uint8_t ml_pk[1184]) { + uint8_t blob[ZPQK_PRIV_FILE_SIZE] = {0}; + size_t file_size = 0; + /* Preserve the historical convenience of encrypting with a valid private + * ZPQK file while still validating its private role, size, and checksum. */ + if (load_native_key_blob(path, ZPQK_MAGIC, ZPQK_VERSION, + ZPQK_FLAG_PRIVATE, ZPQK_PUB_FILE_SIZE, + ZPQK_PRIV_FILE_SIZE, 0, blob, sizeof(blob), + &file_size) != 0) + return -1; + memcpy(ml_pk, blob + ZPQK_HDR, 1184); + zupt_secure_wipe(blob, sizeof(blob)); + return 0; +} + +static int read_pq_privkey(const char *path, uint8_t ml_pk[1184], uint8_t ml_sk[2400]) { + uint8_t blob[ZPQK_PRIV_FILE_SIZE] = {0}; + size_t file_size = 0; + if (load_native_key_blob(path, ZPQK_MAGIC, ZPQK_VERSION, + ZPQK_FLAG_PRIVATE, ZPQK_PUB_FILE_SIZE, + ZPQK_PRIV_FILE_SIZE, 1, blob, sizeof(blob), + &file_size) != 0) + return -1; + memcpy(ml_pk, blob + ZPQK_HDR, 1184); + memcpy(ml_sk, blob + ZPQK_HDR + 1184, 2400); + zupt_secure_wipe(blob, sizeof(blob)); + return 0; +} + +/* archive_key = SHA3-512(ml_ss ‖ ml_ct ‖ label). Shared by encrypt/decrypt. */ +static void pq_only_derive(const uint8_t ml_ss[32], const uint8_t ml_ct[1088], + uint8_t archive_key[64]) { + uint8_t kdf_input[32 + 1088 + 15]; + memcpy(kdf_input, ml_ss, 32); + memcpy(kdf_input + 32, ml_ct, 1088); + memcpy(kdf_input + 32 + 1088, ZUPT_PQ_ONLY_LABEL, 15); + zupt_sha3_512(kdf_input, sizeof(kdf_input), archive_key); + zupt_secure_wipe(kdf_input, sizeof(kdf_input)); +} + +int zupt_pq_encrypt_init(zupt_keyring_t *kr, const char *pubkeyfile, + uint8_t *enc_hdr, size_t *enc_hdr_len) { + uint8_t ml_pk[1184]; + if (read_pq_pubkey(pubkeyfile, ml_pk) != 0) return -1; + + uint8_t ml_ct[1088], ml_ss[32]; + if (zupt_mlkem768_encaps(ml_ct, ml_ss, ml_pk) != 0) return -1; + + uint8_t archive_key[64]; + pq_only_derive(ml_ss, ml_ct, archive_key); + + kr->canary_head = ZUPT_CANARY; + memcpy(kr->enc_key, archive_key, 32); + memcpy(kr->mac_key, archive_key + 32, 32); + zupt_random_bytes(kr->base_nonce, ZUPT_NONCE_SIZE); + kr->iterations = 0; + kr->active = 1; + kr->canary_tail = ZUPT_CANARY; + zupt_mlock_keys(kr->enc_key, ZUPT_AES_KEY_SIZE); + zupt_mlock_keys(kr->mac_key, ZUPT_HMAC_SIZE); + + enc_hdr[0] = ZUPT_ENC_PQ_ONLY; + memcpy(enc_hdr + 1, ml_ct, 1088); + memcpy(enc_hdr + 1 + 1088, kr->base_nonce, 16); + *enc_hdr_len = 1 + 1088 + 16; /* 1105 bytes */ + + zupt_secure_wipe(ml_ss, 32); + zupt_secure_wipe(archive_key, 64); + return 0; +} + +int zupt_pq_decrypt_init(zupt_keyring_t *kr, const char *privkeyfile, + const uint8_t *enc_hdr, size_t enc_hdr_len) { + if (enc_hdr_len < 1 + 1088 + 16) return -1; + if (enc_hdr[0] != ZUPT_ENC_PQ_ONLY) return -1; + const uint8_t *ml_ct = enc_hdr + 1; + const uint8_t *nonce = enc_hdr + 1 + 1088; + + uint8_t ml_pk[1184], ml_sk[2400]; + if (read_pq_privkey(privkeyfile, ml_pk, ml_sk) != 0) { + zupt_secure_wipe(ml_sk, sizeof(ml_sk)); /* wipe any partial secret from a truncated key file */ + return -1; + } + + /* ML-KEM-768 decapsulation (FO implicit rejection: an invalid ciphertext + * yields a pseudorandom shared secret, so a wrong/tampered ct produces a + * wrong archive key and the per-block HMAC fails-closed at extract time). */ + uint8_t ml_ss[32]; + zupt_mlkem768_decaps(ml_ss, ml_ct, ml_sk); + + uint8_t archive_key[64]; + pq_only_derive(ml_ss, ml_ct, archive_key); + + kr->canary_head = ZUPT_CANARY; + memcpy(kr->enc_key, archive_key, 32); + memcpy(kr->mac_key, archive_key + 32, 32); + memcpy(kr->base_nonce, nonce, ZUPT_NONCE_SIZE); + kr->iterations = 0; + kr->active = 1; + kr->canary_tail = ZUPT_CANARY; + zupt_mlock_keys(kr->enc_key, ZUPT_AES_KEY_SIZE); + zupt_mlock_keys(kr->mac_key, ZUPT_HMAC_SIZE); + + zupt_secure_wipe(ml_sk, sizeof(ml_sk)); + zupt_secure_wipe(ml_ss, 32); + zupt_secure_wipe(archive_key, 64); + return 0; +} diff --git a/src/zupt_crypto_pqbox.c b/src/zupt_crypto_pqbox.c index c865782..bdf56a1 100644 --- a/src/zupt_crypto_pqbox.c +++ b/src/zupt_crypto_pqbox.c @@ -3,12 +3,12 @@ * Copyright (c) 2026 Cristian Cezar Moisés * * zupt_crypto_pqbox.c — ZUPT_ENC_PQ_BOX_V1 (0x05): hybrid PQ sealed-box - * recipient encryption backed by vendored libpqvaptvupt (v0.6.0). + * recipient encryption backed by the optional system libpqvaptvupt. * * Why a third PQ mode: * - legacy --pq (0x02) combines the ML-KEM and X25519 shared secrets * with XOR+SHA3 — functional, but not the modern recommendation; - * - --pq-sdk (0x03) is libzuptsdk's v2 envelope (kept for back-compat); + * - --pq-sdk (0x03) is libvuptsdk's v2 envelope (kept for back-compat); * - --pq-box (0x05) uses libpqvaptvupt's sealed box, which combines the * two KEM secrets through HKDF-SHA256 Extract/Expand with a * domain-separating info string ("pqvv-seal-v1") — the construction @@ -33,7 +33,7 @@ */ #include "zupt.h" -#ifdef ZUPT_WITH_SDK +#ifdef ZUPT_WITH_PQBOX #include "zupt_keccak.h" #include "pqvaptvupt.h" #include @@ -47,19 +47,23 @@ static int pqbox_write_keyfile(const char *path, char role, const uint8_t *key, size_t klen) { - FILE *f = fopen(path, "wb"); - if (!f) return -1; - int ok = fwrite(PQBOX_MAGIC, 1, PQBOX_MAGIC_LEN, f) == PQBOX_MAGIC_LEN - && fputc(role, f) != EOF - && fwrite(key, 1, klen, f) == klen; - if (fclose(f) != 0) ok = 0; - return ok ? 0 : -1; + if (klen > SIZE_MAX - PQBOX_HDR_LEN) return -1; + size_t length = PQBOX_HDR_LEN + klen; + uint8_t *blob = (uint8_t *)malloc(length); + if (!blob) return -1; + memcpy(blob, PQBOX_MAGIC, PQBOX_MAGIC_LEN); + blob[PQBOX_MAGIC_LEN] = (uint8_t)role; + memcpy(blob + PQBOX_HDR_LEN, key, klen); + int result = zupt_keyfile_write_new(path, blob, length, role == 'S'); + if (role == 'S') zupt_secure_wipe(blob, length); + free(blob); + return result; } /* Reads and validates a key file. Returns 0 and fills `key` on success. */ static int pqbox_read_keyfile(const char *path, char role, uint8_t *key, size_t klen) { - FILE *f = fopen(path, "rb"); + FILE *f = zupt_fopen_path(path, "rb"); if (!f) return -1; uint8_t hdr[PQBOX_HDR_LEN]; int ok = fread(hdr, 1, PQBOX_HDR_LEN, f) == PQBOX_HDR_LEN @@ -67,14 +71,18 @@ static int pqbox_read_keyfile(const char *path, char role, && hdr[PQBOX_MAGIC_LEN] == (uint8_t)role && fread(key, 1, klen, f) == klen && fgetc(f) == EOF; /* exact size — no trailing bytes */ - fclose(f); + if (fclose(f) != 0) ok = 0; + if (!ok && role == 'S') zupt_secure_wipe(key, klen); return ok ? 0 : -1; } int zupt_pqbox_keygen(const char *privkeyfile, const char *pubkeyfile) { - uint8_t pk[PQVV_PUBLICKEYBYTES]; - uint8_t sk[PQVV_SECRETKEYBYTES]; - if (pqvv_keygen(pk, sk) != PQVV_OK) return -1; + uint8_t pk[PQVV_PUBLICKEYBYTES] = {0}; + uint8_t sk[PQVV_SECRETKEYBYTES] = {0}; + if (pqvv_keygen(pk, sk) != PQVV_OK) { + zupt_secure_wipe(sk, sizeof(sk)); + return -1; + } int rc = 0; if (pqbox_write_keyfile(privkeyfile, 'S', sk, sizeof(sk)) != 0) rc = -1; @@ -146,7 +154,7 @@ int zupt_pqbox_decrypt_init(zupt_keyring_t *kr, const char *privkeyfile, if (sealed_len != PQBOX_SEALED_SESSION || payload_len < 5 + (size_t)sealed_len) return -1; - uint8_t sk[PQVV_SECRETKEYBYTES]; + uint8_t sk[PQVV_SECRETKEYBYTES] = {0}; if (pqbox_read_keyfile(privkeyfile, 'S', sk, sizeof(sk)) != 0) { fprintf(stderr, "Error: '%s' is not a pq-box SECRET key file.\n", privkeyfile); return -1; @@ -182,18 +190,18 @@ int zupt_pqbox_decrypt_init(zupt_keyring_t *kr, const char *privkeyfile, return 0; } -#else /* !ZUPT_WITH_SDK */ +#else /* !ZUPT_WITH_PQBOX */ -/* Source-only build (no vendored libpqvaptvupt binary). The --pq-box sealed-box - * mode is unavailable; use native --pq (ML-KEM-768 + X25519) instead, or rebuild - * with `make WITH_SDK=1` (requires the vendored libpqvaptvupt). */ +/* Baseline build without the optional system libpqvaptvupt. The --pq-box + * sealed-box mode is unavailable; use native --pq (ML-KEM-768 + X25519) + * instead, or rebuild with WITH_PQBOX=1 and the system development package. */ #include static int pqbox_unavailable(const char *what) { fprintf(stderr, "Error: this build has no libpqvaptvupt support, so %s is unavailable.\n" " Use native --pq (ML-KEM-768 + X25519) instead, or rebuild with " - "'make WITH_SDK=1'.\n", what); + "'make WITH_PQBOX=1' and the system development package.\n", what); return -1; } @@ -212,4 +220,4 @@ int zupt_pqbox_decrypt_init(zupt_keyring_t *kr, const char *privkeyfile, return pqbox_unavailable("--pq-box decryption (this archive needs it)"); } -#endif /* ZUPT_WITH_SDK */ +#endif /* ZUPT_WITH_PQBOX */ diff --git a/src/zupt_crypto_sdk.c b/src/zupt_crypto_sdk.c index fd3d5c9..3093905 100644 --- a/src/zupt_crypto_sdk.c +++ b/src/zupt_crypto_sdk.c @@ -1,7 +1,7 @@ /* zupt_crypto_sdk.c — SDK-backed crypto for zupt v2.2+ archives. * * Replaces the legacy zupt_crypto.c hybrid path (XOR+SHA3-512 combiner) with - * libzuptsdk's HKDF-SHA3-256 combiner + key commitment + HPKE binding + + * libvuptsdk's HKDF-SHA3-256 combiner + key commitment + HPKE binding + * anti-fault decap. Per-block AEAD switches from AES-256-CTR + HMAC-SHA256 * to XChaCha20-Poly1305 (default) or AES-256-SIV (nonce-misuse-resistant). * @@ -210,7 +210,7 @@ int zupt_sdk_password_decrypt_init(zupt_keyring_t *kr, const char *password, /* v3.4.0 self-describing KDF profile. Absent (33-byte header) means * the implicit legacy profile; present (>=34 bytes) names it - * explicitly. Both currently map to the same libzuptsdk MODERATE + * explicitly. Both currently map to the same libvuptsdk MODERATE * Argon2id derivation, so the key is identical and old archives keep * decrypting. An unrecognised profile is refused rather than guessed * — better a clear failure than a wrong key derivation. */ @@ -249,20 +249,20 @@ int zupt_sdk_password_decrypt_init(zupt_keyring_t *kr, const char *password, #else /* !ZUPT_WITH_SDK */ -/* Source-only build (no vendored libzuptsdk binary). The SDK-backed modes +/* Baseline build without the optional system libvuptsdk. The SDK-backed modes * — --pq-sdk and the Argon2id default password KDF — are unavailable. These * stubs let the project build and link from source with no prebuilt library; * callers fall back to native crypto (PBKDF2-SHA256 password KDF, native * ML-KEM-768 + X25519 via --pq) or report the requested mode as unsupported. - * Rebuild with `make WITH_SDK=1` (requires the vendored libzuptsdk) to enable. */ + * Rebuild with WITH_SDK=1 and the system development package to enable. */ #include static int sdk_unavailable(const char *what) { fprintf(stderr, - "Error: this build has no libzuptsdk support, so %s is unavailable.\n" + "Error: this build has no libvuptsdk support, so %s is unavailable.\n" " Use native crypto instead (password mode uses PBKDF2-SHA256; " "--pq uses ML-KEM-768 + X25519),\n" - " or rebuild with 'make WITH_SDK=1'.\n", what); + " or rebuild with 'make WITH_SDK=1' and the system development package.\n", what); return -1; } diff --git a/src/zupt_dedup.c b/src/zupt_dedup.c index a7f4587..db3cc4f 100644 --- a/src/zupt_dedup.c +++ b/src/zupt_dedup.c @@ -1,11 +1,11 @@ /* * SPDX-License-Identifier: AGPL-3.0-or-later * Copyright (c) 2025-2026 Cristian Cezar Moisés - * Zupt v2.1.5 — Block-Level Deduplication + * ZUPT v2.1.5 — Block-Level Deduplication * Copyright (c) 2026 Cristian Cezar Moises — AGPL-3.0-or-later * * Eliminates redundant data blocks before compression using XXH64 - * fingerprinting with full content verification on match. + * fingerprinting with an independent SHA-256/128 verification on match. * * Architecture: * Source → XXH64 fingerprint → Hash table lookup → Match? @@ -16,13 +16,14 @@ * capped at ZUPT_DEDUP_MAX_ENTRIES (2M entries = ~48MB RAM). * * Security: - * - XXH64 is not collision-resistant, so we verify full content - * on hash match before emitting a reference. + * - XXH64 is not collision-resistant, so a reference also requires an + * independent 128-bit prefix of SHA-256 to match. * - Hash table memory is securely wiped on free. * - Dedup operates on plaintext before encryption. * - References are intra-archive offsets only. */ #include "zupt.h" +#include "zupt_internal.h" #include #include #include @@ -31,8 +32,10 @@ typedef struct { uint64_t fingerprint; /* XXH64 of the block content */ uint64_t block_offset; /* File offset where the block was written */ + uint8_t digest[ZUPT_DEDUP_DIGEST_SIZE]; /* independent SHA-256 prefix */ uint32_t block_size; /* Uncompressed size of the block */ uint32_t occupied; /* 0 = empty, 1 = occupied */ + uint64_t aad_seq; /* Logical position used to authenticate DATA */ } zupt_dedup_entry_t; /* Dedup context */ @@ -75,23 +78,25 @@ void zupt_dedup_free(zupt_dedup_ctx_t *ctx) { * Look up a block in the dedup index. * Returns 1 if a match is found (sets *ref_offset), 0 if not found. * - * The caller must verify content equality before trusting the match - * (XXH64 is fast but not collision-resistant). The content verification - * is done by the caller who has access to the archive FILE* to seek - * and re-read the original block. + * XXH64 selects the probe chain; the independent SHA-256 prefix must also + * match before the stored offset is returned. */ -int zupt_dedup_lookup(zupt_dedup_ctx_t *ctx, uint64_t fingerprint, - uint64_t *ref_offset, uint32_t *ref_size) { - if (!ctx || !ctx->table) return 0; +int zupt_dedup_lookup_secure(zupt_dedup_ctx_t *ctx, uint64_t fingerprint, + const uint8_t digest[ZUPT_DEDUP_DIGEST_SIZE], + uint64_t *ref_offset, uint32_t *ref_size, + uint64_t *ref_aad_seq) { + if (!ctx || !ctx->table || !digest) return 0; uint32_t idx = (uint32_t)(fingerprint % ctx->capacity); for (uint32_t i = 0; i < 64; i++) { /* Max 64 probes */ uint32_t slot = (idx + i) % ctx->capacity; zupt_dedup_entry_t *e = &ctx->table[slot]; if (!e->occupied) return 0; /* Empty slot = not found */ - if (e->fingerprint == fingerprint) { + if (e->fingerprint == fingerprint && + memcmp(e->digest, digest, ZUPT_DEDUP_DIGEST_SIZE) == 0) { if (ref_offset) *ref_offset = e->block_offset; if (ref_size) *ref_size = e->block_size; + if (ref_aad_seq) *ref_aad_seq = e->aad_seq; return 1; } } @@ -102,9 +107,11 @@ int zupt_dedup_lookup(zupt_dedup_ctx_t *ctx, uint64_t fingerprint, * Insert a block into the dedup index. * Returns 1 on success, 0 if table is full. */ -int zupt_dedup_insert(zupt_dedup_ctx_t *ctx, uint64_t fingerprint, - uint64_t block_offset, uint32_t block_size) { - if (!ctx || !ctx->table) return 0; +int zupt_dedup_insert_secure(zupt_dedup_ctx_t *ctx, uint64_t fingerprint, + const uint8_t digest[ZUPT_DEDUP_DIGEST_SIZE], + uint64_t block_offset, uint32_t block_size, + uint64_t block_aad_seq) { + if (!ctx || !ctx->table || !digest) return 0; if (ctx->count >= ctx->capacity * 3 / 4) return 0; /* 75% load factor limit */ uint32_t idx = (uint32_t)(fingerprint % ctx->capacity); @@ -114,7 +121,9 @@ int zupt_dedup_insert(zupt_dedup_ctx_t *ctx, uint64_t fingerprint, if (!e->occupied) { e->fingerprint = fingerprint; e->block_offset = block_offset; + memcpy(e->digest, digest, ZUPT_DEDUP_DIGEST_SIZE); e->block_size = block_size; + e->aad_seq = block_aad_seq; e->occupied = 1; ctx->count++; return 1; @@ -123,6 +132,22 @@ int zupt_dedup_insert(zupt_dedup_ctx_t *ctx, uint64_t fingerprint, return 0; /* Probe limit */ } +/* Preserve the published 5.2.1 symbols and signatures. First-party archive + * writers use the secure variants above with an independent digest. */ +int zupt_dedup_lookup(zupt_dedup_ctx_t *ctx, uint64_t fingerprint, + uint64_t *ref_offset, uint32_t *ref_size) { + static const uint8_t legacy_digest[ZUPT_DEDUP_DIGEST_SIZE] = {0}; + return zupt_dedup_lookup_secure(ctx, fingerprint, legacy_digest, + ref_offset, ref_size, NULL); +} + +int zupt_dedup_insert(zupt_dedup_ctx_t *ctx, uint64_t fingerprint, + uint64_t block_offset, uint32_t block_size) { + static const uint8_t legacy_digest[ZUPT_DEDUP_DIGEST_SIZE] = {0}; + return zupt_dedup_insert_secure(ctx, fingerprint, legacy_digest, + block_offset, block_size, 0); +} + void zupt_dedup_record_hit(zupt_dedup_ctx_t *ctx, uint64_t saved_bytes) { if (!ctx) return; ctx->blocks_deduped++; @@ -176,3 +201,121 @@ int zupt_dedup_write_ref(FILE *out, uint64_t ref_offset, if (fwrite(payload, 1, 8, out) != 8) return -1; return 0; } + +/* New encrypted archives authenticate the otherwise mutable reference offset. + * The logical size/checksum remain in the frame preface and are included in + * v1.6 preface AAD. The encrypted payload binds both the intra-archive offset + * and the logical AAD sequence used by the referenced DATA frame; the + * reference frame itself uses its own logical position as AAD. */ +int zupt_dedup_write_ref_secure(FILE *out, uint64_t ref_offset, + uint32_t orig_size, uint64_t orig_checksum, + uint64_t current_aad_seq, + uint64_t referenced_aad_seq, + const zupt_keyring_t *keyring) { + uint8_t reference[16]; + zupt_le64_put(reference, ref_offset); + zupt_le64_put(reference + 8, referenced_aad_seq); + const uint8_t *payload = reference; + size_t payload_size = keyring && keyring->active ? sizeof(reference) : 8u; + uint16_t block_flags = 0; + uint8_t *encrypted = NULL; + + if (keyring && keyring->active) { + size_t encrypted_size = 0; + if (keyring->use_preface_aad) { + uint8_t preface[ZUPT_PREFACE_AAD_LEN]; + uint64_t predicted_size = 16u + sizeof(reference) + 32u; + zupt_serialize_preface_aad_scalars( + ZUPT_BLOCK_DEDUP_REF, ZUPT_CODEC_STORE, + ZUPT_BFLAG_ENCRYPTED, orig_size, predicted_size, + orig_checksum, preface); + encrypted = zupt_encrypt_buffer_aad( + keyring, reference, sizeof(reference), current_aad_seq, + preface, sizeof(preface), &encrypted_size); + zupt_secure_wipe(preface, sizeof(preface)); + } else { + encrypted = zupt_encrypt_buffer(keyring, reference, + sizeof(reference), current_aad_seq, + &encrypted_size); + } + if (!encrypted) return -1; + payload = encrypted; + payload_size = encrypted_size; + block_flags = ZUPT_BFLAG_ENCRYPTED; + } + + zupt_w8(out, ZUPT_BLOCK_MAGIC_0); + zupt_w8(out, ZUPT_BLOCK_MAGIC_1); + zupt_w8(out, ZUPT_BLOCK_DEDUP_REF); + zupt_w16le(out, ZUPT_CODEC_STORE); + zupt_w16le(out, block_flags); + zupt_write_varint(out, (uint64_t)orig_size); + zupt_write_varint(out, payload_size); + zupt_w64le(out, orig_checksum); + int result = fwrite(payload, 1, payload_size, out) == payload_size && + !ferror(out) ? 0 : -1; + free(encrypted); + return result; +} + +zupt_error_t zupt_dedup_read_ref(const zupt_block_t *block, + const zupt_keyring_t *keyring, + int require_authentication, + uint64_t current_aad_seq, + uint64_t *ref_offset, + uint64_t *referenced_aad_seq) { + if (!block || !ref_offset || !referenced_aad_seq || + block->block_type != ZUPT_BLOCK_DEDUP_REF || + block->codec_id != ZUPT_CODEC_STORE || !block->payload) + return ZUPT_ERR_CORRUPT; + + const uint8_t *payload = block->payload; + size_t payload_size = (size_t)block->compressed_size; + uint8_t *plain = NULL; + + if (require_authentication) { + if (!(block->block_flags & ZUPT_BFLAG_ENCRYPTED) || + !keyring || !keyring->active) + return ZUPT_ERR_AUTH_FAIL; + size_t plain_size = 0; + if (keyring->use_preface_aad) { + uint8_t preface[ZUPT_PREFACE_AAD_LEN]; + zupt_serialize_preface_aad_scalars( + block->block_type, block->codec_id, block->block_flags, + block->uncompressed_size, block->compressed_size, + block->checksum, preface); + plain = zupt_decrypt_buffer_aad( + keyring, payload, payload_size, current_aad_seq, + preface, sizeof(preface), &plain_size); + zupt_secure_wipe(preface, sizeof(preface)); + } else { + plain = zupt_decrypt_buffer(keyring, payload, payload_size, + current_aad_seq, + &plain_size); + } + if (!plain) return ZUPT_ERR_AUTH_FAIL; + if (plain_size != 16) { + zupt_secure_wipe(plain, plain_size); + free(plain); + return ZUPT_ERR_CORRUPT; + } + payload = plain; + payload_size = plain_size; + } else if (block->block_flags != 0 || payload_size != 8) { + return ZUPT_ERR_CORRUPT; + } + + if ((!require_authentication && payload_size != 8) || + (require_authentication && payload_size != 16)) { + free(plain); + return ZUPT_ERR_CORRUPT; + } + *ref_offset = zupt_le64_get(payload); + *referenced_aad_seq = require_authentication + ? zupt_le64_get(payload + 8) : 0; + if (plain) { + zupt_secure_wipe(plain, payload_size); + free(plain); + } + return ZUPT_OK; +} diff --git a/src/zupt_disk.c b/src/zupt_disk.c index ef3d760..d6bc090 100644 --- a/src/zupt_disk.c +++ b/src/zupt_disk.c @@ -1,7 +1,7 @@ /* * SPDX-License-Identifier: AGPL-3.0-or-later * Copyright (c) 2025-2026 Cristian Cezar Moisés - * Zupt v2.1.4 — Full-Disk Backup/Restore + * ZUPT v2.1.4 — Full-Disk Backup/Restore * Copyright (c) 2026 Cristian Cezar Moisés — AGPL-3.0-or-later * * Reads a raw block device or file, compresses in streaming chunks, @@ -18,7 +18,7 @@ * On Android/Termux: requires root for block devices * * Archive format: standard .zupt with ZUPT_FLAG_DISK_IMAGE set. - * - Single index entry with path = source device/file path + * - Single index entry with a safe basename label for the source * - Content = raw byte-for-byte disk image (decompressed) * - Sparse blocks encoded as codec=STORE with all-zero payload * @@ -31,6 +31,7 @@ */ #define _GNU_SOURCE #include "zupt.h" +#include "zupt_internal.h" #include "zupt_cpuid.h" #include "vaptvupt_api.h" #include @@ -40,9 +41,14 @@ #include #ifdef _WIN32 + #include #include - #define fseeko _fseeki64 - #define ftello _ftelli64 + #ifndef fseeko + #define fseeko _fseeki64 + #endif + #ifndef ftello + #define ftello _ftelli64 + #endif #else #include #include @@ -54,51 +60,83 @@ #ifdef __APPLE__ #include /* DKIOCGETBLOCKCOUNT, DKIOCGETBLOCKSIZE */ #endif + #ifdef __FreeBSD__ + #include /* DIOCGMEDIASIZE */ + #endif #endif +static int disk_label_reserved(const char *label, size_t length) { + size_t base = 0; + while (base < length && label[base] != '.') base++; + char upper[5] = {0}; + if (base > 4) return 0; + for (size_t i = 0; i < base; i++) { + unsigned char c = (unsigned char)label[i]; + upper[i] = (char)(c >= 'a' && c <= 'z' ? c - ('a' - 'A') : c); + } + if (strcmp(upper, "CON") == 0 || strcmp(upper, "PRN") == 0 || + strcmp(upper, "AUX") == 0 || strcmp(upper, "NUL") == 0) + return 1; + return base == 4 && + ((memcmp(upper, "COM", 3) == 0 || + memcmp(upper, "LPT", 3) == 0) && + upper[3] >= '1' && upper[3] <= '9'); +} + +static const char *disk_archive_label(const char *source, + char label[ZUPT_MAX_PATH]) { + const char *leaf = source ? source : ""; + for (const char *p = leaf; *p; p++) + if (*p == '/' || *p == '\\') leaf = p + 1; + size_t length = strlen(leaf); + int safe = length > 0 && length < ZUPT_MAX_PATH && + strcmp(leaf, ".") != 0 && strcmp(leaf, "..") != 0 && + leaf[length - 1] != '.' && leaf[length - 1] != ' ' && + !disk_label_reserved(leaf, length); + for (size_t i = 0; safe && i < length; i++) { + unsigned char c = (unsigned char)leaf[i]; + if (c < 0x20 || c == 0x7f || c == ':') safe = 0; + } + if (!safe) leaf = "disk-image.raw"; + length = strlen(leaf); + memcpy(label, leaf, length + 1); + return label; +} + /* ═══════════════════════════════════════════════════════════════════ * DEVICE SIZE DETECTION * ═══════════════════════════════════════════════════════════════════ */ -static int64_t get_device_size(const char *path) { +/* Measure the already-open source so size discovery and subsequent reads use + * the same kernel object. The caller owns the stream and its file position. */ +static int64_t get_device_size(FILE *stream) { #ifdef _WIN32 - /* Windows: use GetFileSizeEx for files, IOCTL_DISK_GET_LENGTH_INFO for devices */ - HANDLE h = CreateFileA(path, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, - NULL, OPEN_EXISTING, 0, NULL); - if (h == INVALID_HANDLE_VALUE) return -1; + /* Windows: use the CRT stream's handle for files and raw devices. */ + intptr_t raw_handle = _get_osfhandle(_fileno(stream)); + if (raw_handle == -1) return -1; + HANDLE h = (HANDLE)raw_handle; LARGE_INTEGER sz; - if (GetFileSizeEx(h, &sz)) { CloseHandle(h); return (int64_t)sz.QuadPart; } + if (GetFileSizeEx(h, &sz)) return (int64_t)sz.QuadPart; /* Try disk IOCTL */ GET_LENGTH_INFORMATION gli; DWORD ret; - if (DeviceIoControl(h, IOCTL_DISK_GET_LENGTH_INFO, NULL, 0, &gli, sizeof(gli), &ret, NULL)) { - CloseHandle(h); return (int64_t)gli.Length.QuadPart; - } - CloseHandle(h); + if (DeviceIoControl(h, IOCTL_DISK_GET_LENGTH_INFO, NULL, 0, &gli, + sizeof(gli), &ret, NULL)) + return (int64_t)gli.Length.QuadPart; return -1; #else - /* Open first, then fstat on the fd — eliminates TOCTOU race between - * stat() and open() where the path could change between the two calls. */ - int fd = open(path, O_RDONLY); + int fd = fileno(stream); if (fd < 0) return -1; struct stat st; - if (fstat(fd, &st) != 0) { close(fd); return -1; } + if (fstat(fd, &st) != 0) return -1; - if (S_ISREG(st.st_mode)) { - int64_t sz = (int64_t)st.st_size; - close(fd); - return sz; - } + if (S_ISREG(st.st_mode)) return (int64_t)st.st_size; #ifdef __linux__ if (S_ISBLK(st.st_mode)) { uint64_t sz = 0; - if (ioctl(fd, BLKGETSIZE64, &sz) == 0) { - close(fd); - return (int64_t)sz; - } - close(fd); + if (ioctl(fd, BLKGETSIZE64, &sz) == 0) return (int64_t)sz; return -1; } #endif @@ -107,22 +145,393 @@ static int64_t get_device_size(const char *path) { if (S_ISBLK(st.st_mode) || S_ISCHR(st.st_mode)) { uint64_t bc = 0, bs = 0; if (ioctl(fd, DKIOCGETBLOCKCOUNT, &bc) == 0 && - ioctl(fd, DKIOCGETBLOCKSIZE, &bs) == 0) { - close(fd); + ioctl(fd, DKIOCGETBLOCKSIZE, &bs) == 0) return (int64_t)(bc * bs); - } - close(fd); return -1; } #endif /* FreeBSD/generic: try seeking to end */ off_t end = lseek(fd, 0, SEEK_END); - close(fd); + if (end >= 0 && lseek(fd, 0, SEEK_SET) < 0) return -1; return (end >= 0) ? (int64_t)end : -1; #endif } +typedef struct { +#ifdef _WIN32 + DWORD volume_serial; + DWORD file_index_high; + DWORD file_index_low; +#else + dev_t device; + ino_t inode; +#endif +} disk_file_identity_t; + +static int disk_stream_identity(FILE *stream, disk_file_identity_t *identity) { + if (!stream || !identity) { + errno = EINVAL; + return 0; + } +#ifdef _WIN32 + intptr_t raw_handle = _get_osfhandle(_fileno(stream)); + BY_HANDLE_FILE_INFORMATION info; + if (raw_handle == -1 || + !GetFileInformationByHandle((HANDLE)raw_handle, &info)) { + errno = EIO; + return 0; + } + identity->volume_serial = info.dwVolumeSerialNumber; + identity->file_index_high = info.nFileIndexHigh; + identity->file_index_low = info.nFileIndexLow; +#else + struct stat info; + if (fstat(fileno(stream), &info) != 0) return 0; + identity->device = info.st_dev; + identity->inode = info.st_ino; +#endif + return 1; +} + +static int disk_identity_equal(const disk_file_identity_t *left, + const disk_file_identity_t *right) { +#ifdef _WIN32 + return left->volume_serial == right->volume_serial && + left->file_index_high == right->file_index_high && + left->file_index_low == right->file_index_low; +#else + return left->device == right->device && left->inode == right->inode; +#endif +} + +/* Return 1 for the same kernel object, 0 for a different/missing output, and + * -1 when an existing output cannot be inspected safely. Path lookup follows + * the final symlink deliberately: an output symlink to the source itself is + * just as destructive as spelling the source path directly. */ +static int disk_source_matches_output(FILE *source, const char *output_path) { + disk_file_identity_t source_identity; + disk_file_identity_t output_identity; + if (!disk_stream_identity(source, &source_identity)) return -1; +#ifdef _WIN32 + wchar_t *wide_output = zupt_win_utf8_to_wide_alloc(output_path); + if (!wide_output) { + errno = EINVAL; + return -1; + } + HANDLE output_handle = CreateFileW( + wide_output, FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL); + free(wide_output); + if (output_handle == INVALID_HANDLE_VALUE) { + DWORD error = GetLastError(); + if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND) + return 0; + errno = EACCES; + return -1; + } + BY_HANDLE_FILE_INFORMATION info; + int inspected = GetFileInformationByHandle(output_handle, &info) != 0; + if (!CloseHandle(output_handle)) inspected = 0; + if (!inspected) { + errno = EIO; + return -1; + } + output_identity.volume_serial = info.dwVolumeSerialNumber; + output_identity.file_index_high = info.nFileIndexHigh; + output_identity.file_index_low = info.nFileIndexLow; +#else + struct stat info; + if (stat(output_path, &info) != 0) { + if (errno == ENOENT || errno == ENOTDIR) return 0; + return -1; + } + output_identity.device = info.st_dev; + output_identity.inode = info.st_ino; +#endif + return disk_identity_equal(&source_identity, &output_identity); +} + +/* Disk restore cannot roll a block device back after a late validation error. + * Copy the already-open archive into a private, automatically removed file; + * both the complete preflight and the restore then read this stable snapshot. */ +static FILE *open_private_restore_snapshot(void) { +#ifdef _WIN32 + wchar_t default_directory[MAX_PATH + 1]; + wchar_t *override_directory = NULL; + const wchar_t *directory = NULL; + const char *override_utf8 = getenv("ZUPT_TMPDIR"); + if (override_utf8 && override_utf8[0] != '\0') { + override_directory = zupt_win_utf8_to_wide_alloc(override_utf8); + directory = override_directory; + } else { + DWORD length = GetTempPathW(MAX_PATH + 1, default_directory); + if (length == 0 || length > MAX_PATH) { + errno = EIO; + return NULL; + } + directory = default_directory; + } + if (!directory) { + errno = EINVAL; + return NULL; + } + + size_t directory_length = wcslen(directory); + size_t path_capacity = directory_length + 64; + wchar_t *path = (wchar_t *)calloc(path_capacity, sizeof(*path)); + if (!path) { + free(override_directory); + errno = ENOMEM; + return NULL; + } + + FILE *stream = NULL; + static const wchar_t hex[] = L"0123456789abcdef"; + for (int attempt = 0; attempt < 64 && !stream; attempt++) { + uint8_t nonce[16]; + zupt_random_bytes(nonce, sizeof(nonce)); + size_t position = 0; + memcpy(path, directory, directory_length * sizeof(*path)); + position = directory_length; + if (position > 0 && path[position - 1] != L'\\' && + path[position - 1] != L'/') + path[position++] = L'\\'; + const wchar_t prefix[] = L"zupt-restore-"; + memcpy(path + position, prefix, (wcslen(prefix)) * sizeof(*path)); + position += wcslen(prefix); + for (size_t i = 0; i < sizeof(nonce); i++) { + path[position++] = hex[nonce[i] >> 4]; + path[position++] = hex[nonce[i] & 0x0f]; + } + const wchar_t suffix[] = L".tmp"; + memcpy(path + position, suffix, sizeof(suffix)); + + HANDLE handle = CreateFileW( + path, GENERIC_READ | GENERIC_WRITE, 0, NULL, CREATE_NEW, + FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE | + FILE_FLAG_SEQUENTIAL_SCAN, + NULL); + if (handle == INVALID_HANDLE_VALUE) { + DWORD error = GetLastError(); + if (error == ERROR_FILE_EXISTS || error == ERROR_ALREADY_EXISTS) + continue; + errno = EACCES; + break; + } + int descriptor = _open_osfhandle((intptr_t)handle, + _O_BINARY | _O_RDWR); + if (descriptor < 0) { + CloseHandle(handle); + break; + } + stream = _fdopen(descriptor, "w+b"); + if (!stream) _close(descriptor); + } + free(path); + free(override_directory); + if (!stream && errno == 0) errno = EIO; + return stream; +#else + const char *directory = getenv("ZUPT_TMPDIR"); + if (!directory || directory[0] == '\0') directory = getenv("TMPDIR"); + if (!directory || directory[0] == '\0') directory = "/tmp"; + static const char suffix[] = "/zupt-restore-XXXXXX"; + size_t directory_length = strlen(directory); + if (directory_length > SIZE_MAX - sizeof(suffix)) { + errno = ENAMETOOLONG; + return NULL; + } + char *path = (char *)malloc(directory_length + sizeof(suffix)); + if (!path) { + errno = ENOMEM; + return NULL; + } + memcpy(path, directory, directory_length); + memcpy(path + directory_length, suffix, sizeof(suffix)); + + int descriptor = mkstemp(path); + if (descriptor < 0) { + free(path); + return NULL; + } + int descriptor_flags = fcntl(descriptor, F_GETFD); + if (fchmod(descriptor, 0600) != 0 || descriptor_flags < 0 || + fcntl(descriptor, F_SETFD, descriptor_flags | FD_CLOEXEC) != 0 || + unlink(path) != 0) { + int saved_errno = errno; + close(descriptor); + unlink(path); + free(path); + errno = saved_errno; + return NULL; + } + free(path); + FILE *stream = fdopen(descriptor, "w+b"); + if (!stream) { + int saved_errno = errno; + close(descriptor); + errno = saved_errno; + } + return stream; +#endif +} + +static FILE *copy_private_restore_snapshot(FILE *source, + uint64_t archive_size) { + FILE *snapshot = open_private_restore_snapshot(); + if (!snapshot) return NULL; + uint8_t *buffer = (uint8_t *)malloc(1024u * 1024u); + if (!buffer) { + fclose(snapshot); + errno = ENOMEM; + return NULL; + } + uint64_t remaining = archive_size; + if (fseeko(source, 0, SEEK_SET) != 0) goto fail; + + while (remaining > 0) { + size_t wanted = remaining > 1024u * 1024u + ? 1024u * 1024u + : (size_t)remaining; + size_t received = fread(buffer, 1, wanted, source); + if (received == 0) { + if (errno == 0) errno = EIO; + goto fail; + } + if (fwrite(buffer, 1, received, snapshot) != received) goto fail; + remaining -= received; + } + free(buffer); + if (fflush(snapshot) != 0 || fseeko(snapshot, 0, SEEK_SET) != 0) { + int saved_errno = errno ? errno : EIO; + fclose(snapshot); + errno = saved_errno; + return NULL; + } + return snapshot; + +fail: + { + int saved_errno = errno ? errno : EIO; + free(buffer); + fclose(snapshot); + errno = saved_errno; + return NULL; + } +} + +#if !defined(_WIN32) && \ + (defined(__linux__) || defined(__APPLE__) || defined(__FreeBSD__)) +/* Query a raw restore target through the already-open descriptor. Unknown + * device kinds fail closed: an irreversible restore must know the complete + * target capacity before its first write. */ +static int disk_restore_target_capacity(int descriptor, + const struct stat *info, + uint64_t *capacity) { + if (!info || !capacity) { + errno = EINVAL; + return 0; + } +#ifdef __linux__ + if (S_ISBLK(info->st_mode)) { + uint64_t bytes = 0; + if (ioctl(descriptor, BLKGETSIZE64, &bytes) == 0 && bytes > 0) { + *capacity = bytes; + return 1; + } + } +#elif defined(__APPLE__) + if (S_ISBLK(info->st_mode) || S_ISCHR(info->st_mode)) { + uint64_t block_count = 0; + uint32_t block_size = 0; + if (ioctl(descriptor, DKIOCGETBLOCKCOUNT, &block_count) == 0 && + ioctl(descriptor, DKIOCGETBLOCKSIZE, &block_size) == 0 && + block_count > 0 && block_size > 0 && + block_count <= UINT64_MAX / block_size) { + *capacity = block_count * block_size; + return 1; + } + } +#elif defined(__FreeBSD__) + if (S_ISCHR(info->st_mode)) { + off_t media_size = 0; + if (ioctl(descriptor, DIOCGMEDIASIZE, &media_size) == 0 && + media_size > 0) { + *capacity = (uint64_t)media_size; + return 1; + } + } +#endif + errno = ENOTSUP; + return 0; +} +#endif + +#ifdef _WIN32 +/* Inspect an existing restore target by handle. The subsequent publication + * is a handle-relative atomic rename, so a name exchange after this check can + * only replace that directory entry; it can never make ZUPT follow and + * truncate an attacker-selected object. */ +static int validate_windows_restore_target( + const char *target_path, const disk_file_identity_t *archive_identity) { + wchar_t *wide_target = zupt_win_utf8_to_wide_alloc(target_path); + if (!wide_target) { + errno = EINVAL; + return 0; + } + + HANDLE target_handle = CreateFileW( + wide_target, FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, NULL); + free(wide_target); + if (target_handle == INVALID_HANDLE_VALUE) { + DWORD error = GetLastError(); + if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND) + return 1; + errno = EACCES; + return 0; + } + + BY_HANDLE_FILE_INFORMATION target_info; + int reported = 0; + int valid = GetFileInformationByHandle(target_handle, &target_info) != 0; + if (valid && + (target_info.dwFileAttributes & (FILE_ATTRIBUTE_REPARSE_POINT | + FILE_ATTRIBUTE_DIRECTORY)) != 0) { + fprintf(stderr, + "Error: refusing a reparse-point or directory restore target.\n"); + reported = 1; + valid = 0; + } + if (valid && target_info.nNumberOfLinks != 1) { + fprintf(stderr, + "Error: refusing a multiply-linked restore target.\n"); + reported = 1; + valid = 0; + } + if (valid && + target_info.dwVolumeSerialNumber == archive_identity->volume_serial && + target_info.nFileIndexHigh == archive_identity->file_index_high && + target_info.nFileIndexLow == archive_identity->file_index_low) { + fprintf(stderr, + "Error: archive and restore target are the same file.\n"); + reported = 1; + valid = 0; + } + if (!CloseHandle(target_handle)) valid = 0; + if (!valid) { + if (!reported) + fprintf(stderr, "Error: cannot inspect restore target safely.\n"); + errno = EACCES; + } + return valid; +} +#endif + /* ═══════════════════════════════════════════════════════════════════ * SPARSE DETECTION * ═══════════════════════════════════════════════════════════════════ */ @@ -176,11 +585,34 @@ extern int zupt_write_varint(FILE *f, uint64_t v); zupt_error_t zupt_disk_backup(const char *output_path, const char *source_path, zupt_options_t *opts) { - /* Detect source size */ - int64_t source_size = get_device_size(source_path); + /* Open exactly once: size measurement and reads stay bound to the same + * file/device even if the source path is exchanged concurrently. */ + FILE *src_f = zupt_fopen_path(source_path, "rb"); + if (!src_f) { + fprintf(stderr, "Error: Cannot open '%s': %s\n", source_path, strerror(errno)); + return ZUPT_ERR_IO; + } + int source_matches_output = + strcmp(source_path, output_path) == 0 + ? 1 + : disk_source_matches_output(src_f, output_path); + if (source_matches_output != 0) { + if (source_matches_output > 0) { + fprintf(stderr, + "Error: disk source and archive output are the same file.\n"); + } else { + fprintf(stderr, + "Error: Cannot inspect disk archive output safely: %s\n", + strerror(errno)); + } + fclose(src_f); + return source_matches_output > 0 ? ZUPT_ERR_INVALID : ZUPT_ERR_IO; + } + int64_t source_size = get_device_size(src_f); if (source_size <= 0) { fprintf(stderr, "Error: Cannot determine size of '%s': %s\n", source_path, strerror(errno)); + fclose(src_f); return ZUPT_ERR_IO; } @@ -189,6 +621,17 @@ zupt_error_t zupt_disk_backup(const char *output_path, const char *source_path, opts->block_size = 4 * 1024 * 1024; if (opts->block_size < ZUPT_MIN_BLOCK_SZ) opts->block_size = ZUPT_MIN_BLOCK_SZ; + { + uint64_t source_bytes = (uint64_t)source_size; + uint64_t required_blocks = source_bytes / opts->block_size; + if (source_bytes % opts->block_size != 0) required_blocks++; + if (required_blocks > UINT32_MAX) { + fprintf(stderr, + "Error: disk image needs more blocks than the format index can represent.\n"); + fclose(src_f); + return ZUPT_ERR_OVERFLOW; + } + } /* Resolve AUTO codec */ if (opts->codec_id == ZUPT_CODEC_AUTO) @@ -202,16 +645,12 @@ zupt_error_t zupt_disk_backup(const char *output_path, const char *source_path, if (opts->encrypt) fprintf(stderr, " Encryption: ENABLED\n"); fprintf(stderr, "\n"); - /* Open source */ - FILE *src_f = fopen(source_path, "rb"); - if (!src_f) { - fprintf(stderr, "Error: Cannot open '%s': %s\n", source_path, strerror(errno)); - return ZUPT_ERR_IO; - } - - /* Open output */ - FILE *out = fopen(output_path, "wb"); - if (!out) { + /* Build beside the final archive and publish by directory entry only. + * This prevents a symlink/reparse-point output from being followed. */ + FILE *out = NULL; + zupt_atomic_output_t *atomic_output = + zupt_atomic_output_open(output_path, &out); + if (!atomic_output) { fprintf(stderr, "Error: Cannot create '%s': %s\n", output_path, strerror(errno)); fclose(src_f); return ZUPT_ERR_IO; @@ -227,22 +666,30 @@ zupt_error_t zupt_disk_backup(const char *output_path, const char *source_path, hdr.magic[4] = ZUPT_MAGIC_4; hdr.magic[5] = ZUPT_MAGIC_5; hdr.version_major = ZUPT_FORMAT_MAJOR; hdr.version_minor = ZUPT_FORMAT_MINOR; - hdr.global_flags = ZUPT_FLAG_CKSUM_XXH64 | ZUPT_FLAG_DISK_IMAGE; - if (opts->encrypt) hdr.global_flags |= ZUPT_FLAG_ENCRYPTED; + hdr.global_flags = ZUPT_FLAG_CKSUM_XXH64 | ZUPT_FLAG_DISK_IMAGE | + ZUPT_FLAG_DISK_CONTENT_HASH; + if (opts->encrypt) { + hdr.global_flags |= ZUPT_FLAG_ENCRYPTED | ZUPT_FLAG_AAD_SEQ | + ZUPT_FLAG_AAD_PREFACE; + opts->keyring.use_preface_aad = 1; + } if (opts->threads > 1) hdr.global_flags |= ZUPT_FLAG_MULTITHREADED; if (opts->dedup) hdr.global_flags |= ZUPT_FLAG_DEDUP; + if (opts->dedup && opts->encrypt) + hdr.global_flags |= ZUPT_FLAG_AUTH_DEDUP_REFS; hdr.creation_time = (uint64_t)time(NULL) * 1000000000ULL; zupt_random_bytes(hdr.archive_id, 16); hdr.archive_id[6] = (hdr.archive_id[6] & 0x0F) | 0x40; hdr.archive_id[8] = (hdr.archive_id[8] & 0x3F) | 0x80; - if (fwrite(&hdr, sizeof(hdr), 1, out) != 1) write_err = 1; + if (zupt_write_archive_header(out, &hdr) != 0) write_err = 1; /* ─── Encryption header ─── */ /* ─── Encryption header (uses same code as zupt compress) ─── */ if (opts->encrypt) { zupt_error_t enc_err = write_enc_header(out, &hdr, opts); if (enc_err != ZUPT_OK) { - fclose(src_f); fclose(out); + fclose(src_f); + zupt_atomic_output_finish(atomic_output, 0); return enc_err; } } @@ -256,11 +703,13 @@ zupt_error_t zupt_disk_backup(const char *output_path, const char *source_path, if (!rbuf || !cbuf) { free(rbuf); free(cbuf); - fclose(src_f); fclose(out); + fclose(src_f); + zupt_atomic_output_finish(atomic_output, 0); return ZUPT_ERR_NOMEM; } uint64_t total_read = 0, total_written = 0; + uint64_t content_hash = 0; uint64_t block_seq = 0; uint64_t sparse_blocks = 0, data_blocks = 0; uint64_t first_block_off = (uint64_t)ftello(out); @@ -268,6 +717,12 @@ zupt_error_t zupt_disk_backup(const char *output_path, const char *source_path, /* Dedup context (NULL if --dedup not set) */ zupt_dedup_ctx_t *dedup = opts->dedup ? zupt_dedup_init() : NULL; + if (opts->dedup && !dedup) { + free(rbuf); free(cbuf); + fclose(src_f); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_NOMEM; + } while (total_read < (uint64_t)source_size) { size_t to_read = opts->block_size; @@ -275,24 +730,39 @@ zupt_error_t zupt_disk_backup(const char *output_path, const char *source_path, to_read = (size_t)((uint64_t)source_size - total_read); size_t nread = fread(rbuf, 1, to_read, src_f); - if (nread == 0) break; - - /* Pad partial last block with zeros */ - if (nread < to_read) - memset(rbuf + nread, 0, to_read - nread); + if (nread != to_read) { + fprintf(stderr, "Error: disk source changed or could not be read completely\n"); + write_err = 1; + break; + } uint64_t checksum = zupt_xxh64(rbuf, nread, 0); + uint8_t dedup_digest[32]; + if (dedup) zupt_sha256(rbuf, nread, dedup_digest); + uint64_t logical_aad_seq = block_seq; + content_hash = zupt_xxh64(rbuf, nread, content_hash); /* ─── Dedup check: skip compression if block already written ─── */ if (dedup) { zupt_dedup_record_block(dedup); - uint64_t ref_off = 0; uint32_t ref_sz = 0; - if (zupt_dedup_lookup(dedup, checksum, &ref_off, &ref_sz) && + uint64_t ref_off = 0, referenced_aad_seq = 0; + uint32_t ref_sz = 0; + if (zupt_dedup_lookup_secure(dedup, checksum, dedup_digest, + &ref_off, &ref_sz, + &referenced_aad_seq) && ref_sz == (uint32_t)nread) { - zupt_dedup_write_ref(out, ref_off, (uint32_t)nread, checksum); + const zupt_keyring_t *ref_keyring = opts->encrypt + ? &opts->keyring : NULL; + if (zupt_dedup_write_ref_secure( + out, ref_off, (uint32_t)nread, checksum, + logical_aad_seq, referenced_aad_seq, + ref_keyring) != 0) { + write_err = 1; + break; + } zupt_dedup_record_hit(dedup, nread); total_read += nread; - total_written += 8; + total_written += opts->encrypt ? 64u : 8u; block_seq++; if (!opts->quiet) disk_progress("Backup", total_read, (uint64_t)source_size, start_time); @@ -374,11 +844,26 @@ zupt_error_t zupt_disk_backup(const char *output_path, const char *source_path, uint16_t bflags = 0; if (opts->encrypt && opts->keyring.active) { size_t enc_len; - enc_payload = zupt_encrypt_buffer(&opts->keyring, payload, payload_size, - block_seq, &enc_len); + uint64_t aad_seq = logical_aad_seq; + if (opts->keyring.use_preface_aad) { + uint8_t preface[ZUPT_PREFACE_AAD_LEN]; + uint64_t predicted_size = 16u + payload_size + 32u; + zupt_serialize_preface_aad_scalars( + ZUPT_BLOCK_DATA, codec, ZUPT_BFLAG_ENCRYPTED, + nread, predicted_size, checksum, preface); + enc_payload = zupt_encrypt_buffer_aad( + &opts->keyring, payload, payload_size, aad_seq, + preface, sizeof(preface), &enc_len); + zupt_secure_wipe(preface, sizeof(preface)); + } else { + enc_payload = zupt_encrypt_buffer( + &opts->keyring, payload, payload_size, aad_seq, &enc_len); + } if (!enc_payload) { free(rbuf); free(cbuf); - fclose(src_f); fclose(out); + zupt_dedup_free(dedup); + fclose(src_f); + zupt_atomic_output_finish(atomic_output, 0); return ZUPT_ERR_NOMEM; } payload = enc_payload; @@ -408,7 +893,9 @@ zupt_error_t zupt_disk_backup(const char *output_path, const char *source_path, /* Insert into dedup index */ if (dedup) - zupt_dedup_insert(dedup, checksum, this_block_off, (uint32_t)nread); + zupt_dedup_insert_secure(dedup, checksum, dedup_digest, + this_block_off, (uint32_t)nread, + logical_aad_seq); free(enc_payload); total_read += nread; @@ -428,15 +915,16 @@ zupt_error_t zupt_disk_backup(const char *output_path, const char *source_path, uint8_t idx_buf[ZUPT_MAX_PATH + 128]; size_t idx_pos = 0; - /* File count (4B LE) */ - idx_buf[idx_pos++] = 1; idx_buf[idx_pos++] = 0; - idx_buf[idx_pos++] = 0; idx_buf[idx_pos++] = 0; + /* File count uses the same canonical varint representation as regular + * archives so list/test can parse disk-image archives too. */ + idx_pos += (size_t)zupt_encode_varint(idx_buf + idx_pos, 1); /* Path (varint length + bytes) */ - size_t path_len = strlen(source_path); - if (path_len > ZUPT_MAX_PATH - 1) path_len = ZUPT_MAX_PATH - 1; + char archive_label[ZUPT_MAX_PATH]; + disk_archive_label(source_path, archive_label); + size_t path_len = strlen(archive_label); idx_pos += (size_t)zupt_encode_varint(idx_buf + idx_pos, path_len); - memcpy(idx_buf + idx_pos, source_path, path_len); + memcpy(idx_buf + idx_pos, archive_label, path_len); idx_pos += path_len; /* Uncompressed size (8B LE) */ @@ -447,12 +935,11 @@ zupt_error_t zupt_disk_backup(const char *output_path, const char *source_path, uint64_t mtime = (uint64_t)time(NULL) * 1000000000ULL; for (int i = 0; i < 8; i++) idx_buf[idx_pos++] = (uint8_t)(mtime >> (i*8)); /* Content hash (8B LE) */ - uint64_t content_hash = zupt_xxh64(source_path, path_len, (uint64_t)source_size); for (int i = 0; i < 8; i++) idx_buf[idx_pos++] = (uint8_t)(content_hash >> (i*8)); /* First block offset (8B LE) */ for (int i = 0; i < 8; i++) idx_buf[idx_pos++] = (uint8_t)(first_block_off >> (i*8)); - /* Block count (4B LE) */ - for (int i = 0; i < 4; i++) idx_buf[idx_pos++] = (uint8_t)(block_seq >> (i*8)); + /* Block count (canonical varint) */ + idx_pos += (size_t)zupt_encode_varint(idx_buf + idx_pos, block_seq); /* Attributes (4B LE) */ idx_buf[idx_pos++] = 0; idx_buf[idx_pos++] = 0; idx_buf[idx_pos++] = 0; idx_buf[idx_pos++] = 0; @@ -460,29 +947,68 @@ zupt_error_t zupt_disk_backup(const char *output_path, const char *source_path, /* Write index block */ uint64_t index_offset = (uint64_t)ftello(out); uint64_t idx_ck = zupt_xxh64(idx_buf, idx_pos, 0); + const uint8_t *idx_payload = idx_buf; + size_t idx_payload_size = idx_pos; + uint16_t idx_flags = 0; + uint8_t *encrypted_index = NULL; + if (opts->encrypt && opts->keyring.active) { + size_t encrypted_size = 0; + if (opts->keyring.use_preface_aad) { + uint8_t preface[ZUPT_PREFACE_AAD_LEN]; + uint64_t predicted_size = 16u + idx_pos + 32u; + zupt_serialize_preface_aad_scalars( + ZUPT_BLOCK_INDEX, ZUPT_CODEC_STORE, ZUPT_BFLAG_ENCRYPTED, + idx_pos, predicted_size, idx_ck, preface); + encrypted_index = zupt_encrypt_buffer_aad( + &opts->keyring, idx_buf, idx_pos, UINT64_MAX, + preface, sizeof(preface), &encrypted_size); + zupt_secure_wipe(preface, sizeof(preface)); + } else { + encrypted_index = zupt_encrypt_buffer( + &opts->keyring, idx_buf, idx_pos, UINT64_MAX, + &encrypted_size); + } + if (!encrypted_index) { + free(rbuf); free(cbuf); + zupt_dedup_free(dedup); + fclose(src_f); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_NOMEM; + } + idx_payload = encrypted_index; + idx_payload_size = encrypted_size; + idx_flags = ZUPT_BFLAG_ENCRYPTED; + } { uint8_t bm[2] = {ZUPT_BLOCK_MAGIC_0, ZUPT_BLOCK_MAGIC_1}; fwrite(bm, 1, 2, out); uint8_t bt = ZUPT_BLOCK_INDEX; fwrite(&bt, 1, 1, out); uint8_t c16[2] = {0, 0}; fwrite(c16, 1, 2, out); - uint8_t f16[2] = {0, 0}; fwrite(f16, 1, 2, out); - zupt_write_varint(out, idx_pos); + uint8_t f16[2] = {(uint8_t)(idx_flags & 0xff), + (uint8_t)(idx_flags >> 8)}; + fwrite(f16, 1, 2, out); zupt_write_varint(out, idx_pos); + zupt_write_varint(out, idx_payload_size); uint8_t ck8[8]; for (int i = 0; i < 8; i++) ck8[i] = (uint8_t)(idx_ck >> (i*8)); fwrite(ck8, 1, 8, out); - fwrite(idx_buf, 1, idx_pos, out); + if (fwrite(idx_payload, 1, idx_payload_size, out) != idx_payload_size) + write_err = 1; } + free(encrypted_index); /* ─── Write footer ─── */ zupt_footer_t ft; + uint8_t serialized_header[ZUPT_ARCHIVE_HEADER_SIZE]; ft.index_offset = index_offset; ft.total_blocks = block_seq; - ft.archive_checksum = zupt_xxh64(&hdr, sizeof(hdr), block_seq); + zupt_serialize_archive_header(&hdr, serialized_header); + ft.archive_checksum = zupt_xxh64(serialized_header, + sizeof(serialized_header), block_seq); ft.footer_magic[0] = 'Z'; ft.footer_magic[1] = 'E'; ft.footer_magic[2] = 'N'; ft.footer_magic[3] = 'D'; ft.footer_version = 1; - fwrite(&ft, sizeof(ft), 1, out); + if (zupt_write_footer(out, &ft) != 0) write_err = 1; /* F-08 of v2.3.0: archive-integrity-trailer. * @@ -497,14 +1023,31 @@ zupt_error_t zupt_disk_backup(const char *output_path, const char *source_path, const zupt_keyring_t *kr = opts->encrypt ? &opts->keyring : NULL; if (zupt_format_ait_write(out, &hdr, &ft, kr) != 0) { fprintf(stderr, "Error: failed to write archive-integrity-trailer\n"); + write_err = 1; } } - /* Get final archive size before closing */ - uint64_t out_bytes = (uint64_t)ftello(out); + /* Get final archive size before the atomic close/publication. */ + int64_t final_offset = ftello(out); + if (final_offset < 0 || ferror(out)) write_err = 1; + uint64_t out_bytes = final_offset < 0 ? 0 : (uint64_t)final_offset; free(rbuf); free(cbuf); - fclose(src_f); fclose(out); + int64_t final_source_size = get_device_size(src_f); + if (final_source_size != source_size) { + fprintf(stderr, "Error: disk source size changed during backup\n"); + write_err = 1; + } + if (fclose(src_f) != 0) write_err = 1; + if (total_read != (uint64_t)source_size) write_err = 1; + if (zupt_atomic_output_finish(atomic_output, !write_err) != 0) + write_err = 1; + + if (write_err) { + fprintf(stderr, "Error: Disk backup failed; the previous archive was preserved.\n"); + zupt_dedup_free(dedup); + return ZUPT_ERR_IO; + } /* Summary */ time_t elapsed = time(NULL) - start_time; @@ -513,15 +1056,6 @@ zupt_error_t zupt_disk_backup(const char *output_path, const char *source_path, zupt_format_size((uint64_t)source_size, in_sz, sizeof(in_sz)); - /* Re-open to get actual file size */ - { - FILE *check = fopen(output_path, "rb"); - if (check) { - fseeko(check, 0, SEEK_END); - out_bytes = (uint64_t)ftello(check); - fclose(check); - } - } zupt_format_size(out_bytes, out_sz, sizeof(out_sz)); fprintf(stderr, "\n Disk backup complete:\n"); @@ -549,7 +1083,7 @@ zupt_error_t zupt_disk_backup(const char *output_path, const char *source_path, fprintf(stderr, "\n"); zupt_dedup_free(dedup); - return write_err ? ZUPT_ERR_IO : ZUPT_OK; + return ZUPT_OK; } /* ═══════════════════════════════════════════════════════════════════ @@ -572,119 +1106,94 @@ zupt_error_t zupt_disk_backup(const char *output_path, const char *source_path, zupt_error_t zupt_disk_restore(const char *archive_path, const char *target_path, zupt_options_t *opts) { - FILE *f = fopen(archive_path, "rb"); - if (!f) { + FILE *archive_source = zupt_fopen_path(archive_path, "rb"); + if (!archive_source) { fprintf(stderr, "Error: Cannot open '%s': %s\n", archive_path, strerror(errno)); return ZUPT_ERR_IO; } - - /* ─── Read archive header ─── */ - zupt_archive_header_t hdr; - if (fread(&hdr, sizeof(hdr), 1, f) != 1) { - fclose(f); - fprintf(stderr, "Error: Cannot read archive header\n"); + disk_file_identity_t archive_identity; + int64_t signed_archive_size = get_device_size(archive_source); + if (signed_archive_size <= 0 || + !disk_stream_identity(archive_source, &archive_identity)) { + fprintf(stderr, "Error: Cannot inspect archive '%s': %s\n", + archive_path, strerror(errno)); + fclose(archive_source); + return ZUPT_ERR_IO; + } + if (!opts->quiet) { + char archive_size_text[32]; + zupt_format_size((uint64_t)signed_archive_size, archive_size_text, + sizeof(archive_size_text)); + fprintf(stderr, + " Securing private restore snapshot (%s scratch space)...\n", + archive_size_text); + } + FILE *f = copy_private_restore_snapshot( + archive_source, (uint64_t)signed_archive_size); + int snapshot_errno = errno; + fclose(archive_source); + if (!f) { + fprintf(stderr, + "Error: Cannot create private restore snapshot: %s\n" + " Set ZUPT_TMPDIR to a private filesystem with at least " + "%llu free bytes.\n", + strerror(snapshot_errno), + (unsigned long long)signed_archive_size); return ZUPT_ERR_IO; } - if (hdr.magic[0] != ZUPT_MAGIC_0 || hdr.magic[1] != ZUPT_MAGIC_1 || - hdr.magic[2] != ZUPT_MAGIC_2 || hdr.magic[3] != ZUPT_MAGIC_3) { - fclose(f); - fprintf(stderr, "Error: Not a .zupt archive\n"); - return ZUPT_ERR_BAD_MAGIC; - } - - if (!(hdr.global_flags & ZUPT_FLAG_DISK_IMAGE)) { - fclose(f); - fprintf(stderr, "Error: Archive is not a disk image. Use 'zupt extract' instead.\n"); - return ZUPT_ERR_INVALID; - } - - /* ─── Read encryption header (uses same code as zupt extract) ─── */ - if (hdr.global_flags & ZUPT_FLAG_ENCRYPTED) { - if (!opts->encrypt && opts->password[0] == '\0' && !opts->pq_mode) { - fclose(f); - fprintf(stderr, "Error: Archive is encrypted. Use -p or --pq to provide key.\n"); - return ZUPT_ERR_AUTH_FAIL; - } - opts->encrypt = 1; - - zupt_error_t enc_err = read_enc_header(f, &hdr, opts); - if (enc_err != ZUPT_OK) { - fclose(f); - fprintf(stderr, "Error: Encryption header read failed (%s)\n", - zupt_strerror(enc_err)); - return enc_err; - } - } - - /* ─── Read footer to get total block count ─── */ - int64_t after_enc_pos = ftello(f); /* Save position after enc header */ - - /* F-08 of v2.3.0: footer may be at EOF-32 (v1.4) or EOF-64 (v1.5, with - * a 32-byte AIT trailing). Try v1.5 first; fall back to v1.4. */ + zupt_archive_header_t hdr; zupt_footer_t ft; - uint8_t ait_buf[ZUPT_AIT_SIZE]; - int has_ait = 0; - fseeko(f, 0, SEEK_END); - int64_t restore_file_size = ftello(f); - if (restore_file_size >= (int64_t)(sizeof(ft) + ZUPT_AIT_SIZE)) { - fseeko(f, -(int64_t)(sizeof(ft) + ZUPT_AIT_SIZE), SEEK_END); - zupt_footer_t cand; - if (fread(&cand, sizeof(cand), 1, f) == 1 && - cand.footer_magic[0] == 'Z' && cand.footer_magic[1] == 'E' && - cand.footer_magic[2] == 'N' && cand.footer_magic[3] == 'D' && - fread(ait_buf, sizeof(ait_buf), 1, f) == 1) { - ft = cand; - has_ait = 1; - } + /* Parse and authenticate the central index before opening the restore + * target. This supplies the protected byte count/content hash and keeps + * disk restore aligned with list/test validation. */ + zupt_index_entry_t *disk_entries = NULL; + int disk_entry_count = 0; + if (fseeko(f, 0, SEEK_SET) != 0) { + fclose(f); + return ZUPT_ERR_IO; } - if (!has_ait) { - fseeko(f, -(int64_t)sizeof(zupt_footer_t), SEEK_END); - if (fread(&ft, sizeof(ft), 1, f) != 1) { - fclose(f); - return ZUPT_ERR_CORRUPT; - } - if (ft.footer_magic[0] != 'Z' || ft.footer_magic[1] != 'E' || - ft.footer_magic[2] != 'N' || ft.footer_magic[3] != 'D') { - fclose(f); - fprintf(stderr, "Error: Invalid footer magic\n"); - return ZUPT_ERR_BAD_MAGIC; - } + zupt_error_t index_err = zupt_open_archive_internal( + f, opts, &hdr, &ft, &disk_entries, &disk_entry_count); + if (index_err != ZUPT_OK || disk_entry_count != 1 || !disk_entries || + !(hdr.global_flags & ZUPT_FLAG_DISK_IMAGE)) { + free(disk_entries); + fclose(f); + fprintf(stderr, "Error: Invalid disk-image index\n"); + return index_err == ZUPT_OK ? ZUPT_ERR_CORRUPT : index_err; + } + uint64_t expected_size = disk_entries[0].uncompressed_size; + uint64_t expected_hash = disk_entries[0].content_hash; + uint64_t first_data_offset = disk_entries[0].first_block_offset; + uint32_t expected_blocks = disk_entries[0].block_count; + free(disk_entries); + if (expected_blocks != ft.total_blocks || first_data_offset >= ft.index_offset) { + fclose(f); + fprintf(stderr, "Error: Invalid disk-image block range\n"); + return ZUPT_ERR_CORRUPT; } - /* F-08: verify the archive-integrity-trailer if present. For v1.4 disk - * archives we emit the same downgrade warning as zupt extract does. */ - if (has_ait) { - extern zupt_error_t zupt_format_ait_verify_extern( - const zupt_archive_header_t *hdr, const zupt_footer_t *ft, - const uint8_t ait[ZUPT_AIT_SIZE], const zupt_keyring_t *kr_or_null); - int is_encrypted = (hdr.global_flags & ZUPT_FLAG_ENCRYPTED) != 0; - const zupt_keyring_t *kr = is_encrypted ? &opts->keyring : NULL; - zupt_error_t aerr = zupt_format_ait_verify_extern(&hdr, &ft, ait_buf, kr); - if (aerr != ZUPT_OK) { + /* A device cannot be rolled back after a late authentication/checksum + * failure. Perform the complete read-only archive test on the same + * private snapshot that restore will consume before opening any target. + * Regular files also use atomic publication below. */ + { + int saved_quiet = opts->quiet; + opts->quiet = 1; + zupt_error_t preflight = zupt_test_archive_stream(f, opts); + opts->quiet = saved_quiet; + if (preflight != ZUPT_OK) { fclose(f); - /* F-11 of v2.4.2: same collapse-wrong-key-with-tamper logic as - * open_archive in src/zupt_format.c. */ - if (is_encrypted) { - if (opts->verbose) { - fprintf(stderr, "Error: archive-integrity-trailer (top-MAC) verification failed.\n" - " This means EITHER wrong password/key OR a tampered\n" - " header or footer.\n"); - } - fprintf(stderr, "Error: Authentication failed (wrong key, wrong password, or tampered archive).\n"); - } else { - fprintf(stderr, "Error: archive-integrity-trailer (XXH64) verification failed.\n" - " The disk image header or footer has been corrupted or tampered with.\n"); - } - return aerr; + fprintf(stderr, + "Error: disk archive preflight failed; target was not opened.\n"); + return preflight; } - } else if (hdr.global_flags & ZUPT_FLAG_ENCRYPTED) { - fprintf(stderr, "Warning: legacy v1.4 disk image without top-MAC (F-08).\n"); } - - /* ─── Seek back to first data block ─── */ - fseeko(f, after_enc_pos, SEEK_SET); + if (fseeko(f, (int64_t)first_data_offset, SEEK_SET) != 0) { + fclose(f); + return ZUPT_ERR_IO; + } /* ─── Open target for writing ─── * Block devices require raw POSIX I/O (open/write) because stdio @@ -694,58 +1203,151 @@ zupt_error_t zupt_disk_restore(const char *archive_path, const char *target_path * * To avoid TOCTOU races (stat then open on a path that could change), * we open the fd first, then fstat on the fd to classify it. */ + FILE *target_stream = NULL; + zupt_atomic_output_t *target_atomic = NULL; #ifdef _WIN32 - FILE *tgt = fopen(target_path, "wb"); - if (!tgt) { - fprintf(stderr, "Error: Cannot open target '%s': %s\n", + if (!validate_windows_restore_target(target_path, &archive_identity)) { + fclose(f); + return ZUPT_ERR_INVALID; + } + target_atomic = zupt_atomic_output_open(target_path, &target_stream); + if (!target_atomic) { + fprintf(stderr, "Error: Cannot create target '%s': %s\n", target_path, strerror(errno)); fclose(f); return ZUPT_ERR_IO; } #else - int tgt_fd; + int tgt_fd = -1; int is_block_dev = 0; - - /* Open the target — try without O_CREAT first (for existing devices/files), - * fall back to O_CREAT | O_TRUNC for new files. */ - tgt_fd = open(target_path, O_WRONLY); - if (tgt_fd < 0) { - /* SECURITY: 0600, not 0644 — a restored disk image holds decrypted - * backup contents and must not be world-/group-readable. Matches the - * file-extraction convention in zupt_safe_fopen_output(). (When the - * target is an existing block device the mode is ignored.) */ - tgt_fd = open(target_path, O_WRONLY | O_CREAT | O_TRUNC, 0600); - } - if (tgt_fd < 0) { + /* Resolve the target exactly once before making any type or identity + * decision. The open is non-truncating, O_NOFOLLOW rejects a final + * symlink, and fstat classifies the kernel object that was actually + * opened. Device restores retain this same descriptor through the final + * write, so a concurrent pathname exchange cannot redirect the restore. */ + tgt_fd = open(target_path, O_WRONLY | O_NOFOLLOW | O_CLOEXEC | + O_NONBLOCK | O_SYNC); + if (tgt_fd >= 0) { + struct stat opened_st; + if (fstat(tgt_fd, &opened_st) != 0) { + int saved_errno = errno; + close(tgt_fd); + tgt_fd = -1; + errno = saved_errno; + } else if (S_ISREG(opened_st.st_mode)) { + int close_result = close(tgt_fd); + tgt_fd = -1; + if (close_result != 0) { + fclose(f); + return ZUPT_ERR_IO; + } + if (opened_st.st_dev == archive_identity.device && + opened_st.st_ino == archive_identity.inode) { + fprintf(stderr, + "Error: archive and restore target are the same file.\n"); + fclose(f); + return ZUPT_ERR_INVALID; + } + if (opened_st.st_nlink != 1) { + fprintf(stderr, + "Error: refusing a multiply-linked restore target.\n"); + fclose(f); + return ZUPT_ERR_INVALID; + } + target_atomic = + zupt_atomic_output_open(target_path, &target_stream); + } else if (S_ISBLK(opened_st.st_mode) || + S_ISCHR(opened_st.st_mode)) { + int flags = fcntl(tgt_fd, F_GETFL); + if (flags < 0 || + fcntl(tgt_fd, F_SETFL, flags & ~O_NONBLOCK) != 0) { + close(tgt_fd); + tgt_fd = -1; + } else { +#if defined(__linux__) || defined(__APPLE__) || defined(__FreeBSD__) + uint64_t target_capacity = 0; + if (!disk_restore_target_capacity( + tgt_fd, &opened_st, &target_capacity)) { + fprintf(stderr, + "Error: cannot determine restore device " + "capacity safely.\n"); + close(tgt_fd); + tgt_fd = -1; + } else if (expected_size > target_capacity) { + fprintf(stderr, + "Error: disk image (%llu bytes) exceeds " + "restore device capacity (%llu bytes).\n", + (unsigned long long)expected_size, + (unsigned long long)target_capacity); + close(tgt_fd); + tgt_fd = -1; + errno = EFBIG; + } else { + is_block_dev = 1; + } +#else + fprintf(stderr, + "Error: restore-device capacity queries are " + "not supported on this platform.\n"); + close(tgt_fd); + tgt_fd = -1; +#endif + } + } else { + close(tgt_fd); + tgt_fd = -1; + fprintf(stderr, + "Error: restore target is not a regular file or device.\n"); + fclose(f); + return ZUPT_ERR_INVALID; + } + } else if (errno == ENOENT) { + target_atomic = zupt_atomic_output_open(target_path, &target_stream); + } else if (errno == ELOOP) { + fprintf(stderr, "Error: refusing a symbolic-link restore target.\n"); + fclose(f); + return ZUPT_ERR_INVALID; + } else { fprintf(stderr, "Error: Cannot open target '%s': %s\n", target_path, strerror(errno)); fclose(f); return ZUPT_ERR_IO; } - /* Classify the fd (not the path) to avoid TOCTOU */ - { - struct stat tgt_st; - if (fstat(tgt_fd, &tgt_st) == 0 && - (S_ISBLK(tgt_st.st_mode) || S_ISCHR(tgt_st.st_mode))) { - is_block_dev = 1; - /* Enable synchronous I/O for block devices */ - int fl = fcntl(tgt_fd, F_GETFL); - if (fl >= 0) fcntl(tgt_fd, F_SETFL, fl | O_SYNC); - } else if (fstat(tgt_fd, &tgt_st) == 0 && S_ISREG(tgt_st.st_mode)) { - /* Regular file — truncate if we opened without O_TRUNC */ - if (ftruncate(tgt_fd, 0) != 0) { - /* Non-fatal: file may already be empty */ - } - } + if ((!target_atomic || !target_stream) && tgt_fd < 0) { + fprintf(stderr, "Error: Cannot open target '%s': %s\n", + target_path, strerror(errno)); + fclose(f); + return ZUPT_ERR_IO; } #endif + int legacy_encrypted_dedup = + (hdr.global_flags & ZUPT_FLAG_ENCRYPTED) != 0 && + (hdr.global_flags & ZUPT_FLAG_DEDUP) != 0 && + (hdr.global_flags & ZUPT_FLAG_AUTH_DEDUP_REFS) == 0; + zupt_legacy_disk_aad_map_t legacy_aad_map = {0}; + if (legacy_encrypted_dedup) { + zupt_error_t map_error = zupt_legacy_disk_aad_map_build( + f, first_data_offset, expected_blocks, &legacy_aad_map); + if (map_error != ZUPT_OK) { + fprintf(stderr, + "Error: cannot map legacy disk dedup authentication positions.\n"); + if (target_atomic) zupt_atomic_output_finish(target_atomic, 0); +#if !defined(_WIN32) + if (tgt_fd >= 0) close(tgt_fd); +#endif + fclose(f); + return map_error; + } + } + fprintf(stderr, " Restoring disk image to: %s\n", target_path); fprintf(stderr, " Blocks: %llu\n\n", (unsigned long long)ft.total_blocks); time_t start_time = time(NULL); uint64_t total_written = 0; + uint64_t restored_hash = 0; uint64_t block_seq = 0; int errors = 0; @@ -767,36 +1369,78 @@ zupt_error_t zupt_disk_restore(const char *archive_path, const char *target_path break; /* Reached index — all data blocks done */ } - /* Handle dedup reference blocks — seek to original, decompress it */ - if (blk.block_type == ZUPT_BLOCK_DEDUP_REF && blk.compressed_size == 8 && blk.payload) { - uint64_t ref_off = zupt_le64_get(blk.payload); - free(blk.payload); + /* Resolve a dedup reference only after authenticating its offset in + * new archives and proving that it points backward to the expected + * DATA frame. */ + if (blk.block_type == ZUPT_BLOCK_DEDUP_REF) { + uint64_t ref_off = 0, referenced_aad_seq = 0; int64_t cur = ftello(f); - fseeko(f, (int64_t)ref_off, SEEK_SET); - zupt_block_t ref_blk; - zupt_error_t rr = read_block(f, &ref_blk); - fseeko(f, cur, SEEK_SET); - if (rr != ZUPT_OK) { - fprintf(stderr, " Block %llu: dedup ref read error\n", (unsigned long long)bi); - errors++; break; + int require_authentication = + (hdr.global_flags & ZUPT_FLAG_AUTH_DEDUP_REFS) != 0; + zupt_error_t rr = zupt_dedup_read_ref( + &blk, &opts->keyring, require_authentication, + require_authentication ? block_seq : 0, + &ref_off, &referenced_aad_seq); + if (rr == ZUPT_OK && legacy_encrypted_dedup && + !zupt_legacy_disk_aad_map_lookup( + &legacy_aad_map, ref_off, &referenced_aad_seq)) + rr = ZUPT_ERR_CORRUPT; + if (rr != ZUPT_OK || cur < 0 || ref_off >= (uint64_t)cur || + fseeko(f, (int64_t)ref_off, SEEK_SET) != 0) { + free(blk.payload); + fprintf(stderr, " Block %llu: invalid dedup reference\n", + (unsigned long long)bi); + errors++; + break; } + zupt_block_t ref_blk; + rr = read_block(f, &ref_blk); + if (fseeko(f, cur, SEEK_SET) != 0 && rr == ZUPT_OK) + rr = ZUPT_ERR_IO; + if (rr != ZUPT_OK || ref_blk.block_type != ZUPT_BLOCK_DATA || + ref_blk.uncompressed_size != blk.uncompressed_size || + ref_blk.checksum != blk.checksum) { + free(blk.payload); + free(ref_blk.payload); + fprintf(stderr, " Block %llu: dedup ref read error\n", + (unsigned long long)bi); + errors++; + break; + } + free(blk.payload); uint8_t *dbuf = NULL; size_t dlen = 0; - zupt_error_t dr = decompress_block(&ref_blk, &opts->keyring, block_seq, &dbuf, &dlen); + zupt_error_t dr = decompress_block(&ref_blk, &opts->keyring, + referenced_aad_seq, + &dbuf, &dlen); free(ref_blk.payload); - if (dr != ZUPT_OK) { - fprintf(stderr, " Block %llu: dedup ref decompress failed\n", (unsigned long long)bi); - errors++; break; + if (dr != ZUPT_OK || total_written > expected_size || + (uint64_t)dlen > expected_size - total_written) { + free(dbuf); + fprintf(stderr, " Block %llu: dedup ref decompress failed\n", + (unsigned long long)bi); + errors++; + break; } /* Write dedup-resolved data to target */ int dok = 0; #ifdef _WIN32 - dok = (fwrite(dbuf, 1, dlen, tgt) == dlen); + dok = (fwrite(dbuf, 1, dlen, target_stream) == dlen); #else - { size_t dw = 0; - while (dw < dlen) { ssize_t w = write(tgt_fd, dbuf + dw, dlen - dw); if (w<=0) break; dw += (size_t)w; } - dok = (dw == dlen); } + if (target_stream) { + dok = (fwrite(dbuf, 1, dlen, target_stream) == dlen); + } else if (tgt_fd >= 0) { + size_t dw = 0; + while (dw < dlen) { + ssize_t w = write(tgt_fd, dbuf + dw, dlen - dw); + if (w < 0 && errno == EINTR) continue; + if (w <= 0) break; + dw += (size_t)w; + } + dok = (dw == dlen); + } #endif if (!dok) { fprintf(stderr, " Block %llu: write error\n", (unsigned long long)bi); free(dbuf); errors++; break; } + restored_hash = zupt_xxh64(dbuf, dlen, restored_hash); total_written += dlen; block_seq++; free(dbuf); @@ -807,20 +1451,29 @@ zupt_error_t zupt_disk_restore(const char *archive_path, const char *target_path if (blk.block_type != ZUPT_BLOCK_DATA) { free(blk.payload); - continue; /* Skip unknown block types */ + fprintf(stderr, " Block %llu: unexpected block type\n", + (unsigned long long)bi); + errors++; + break; } /* Decompress + decrypt + verify checksum */ { uint8_t *out_buf = NULL; size_t out_len = 0; + uint64_t aad_seq = block_seq; zupt_error_t derr = decompress_block(&blk, &opts->keyring, - block_seq, &out_buf, &out_len); + aad_seq, &out_buf, &out_len); free(blk.payload); + if (derr == ZUPT_OK && + (total_written > expected_size || + (uint64_t)out_len > expected_size - total_written)) + derr = ZUPT_ERR_OVERFLOW; if (derr != ZUPT_OK) { fprintf(stderr, " Block %llu: decompression/checksum failed (%s)\n", (unsigned long long)bi, zupt_strerror(derr)); + free(out_buf); errors++; break; } @@ -828,12 +1481,17 @@ zupt_error_t zupt_disk_restore(const char *archive_path, const char *target_path /* Write to target */ int write_ok = 0; #ifdef _WIN32 - write_ok = (fwrite(out_buf, 1, out_len, tgt) == out_len); + write_ok = + (fwrite(out_buf, 1, out_len, target_stream) == out_len); #else - { + if (target_stream) { + write_ok = + (fwrite(out_buf, 1, out_len, target_stream) == out_len); + } else if (tgt_fd >= 0) { size_t written = 0; while (written < out_len) { ssize_t w = write(tgt_fd, out_buf + written, out_len - written); + if (w < 0 && errno == EINTR) continue; if (w <= 0) break; written += (size_t)w; } @@ -848,6 +1506,7 @@ zupt_error_t zupt_disk_restore(const char *archive_path, const char *target_path break; } + restored_hash = zupt_xxh64(out_buf, out_len, restored_hash); total_written += out_len; block_seq++; free(out_buf); @@ -858,20 +1517,35 @@ zupt_error_t zupt_disk_restore(const char *archive_path, const char *target_path } /* end decompress scope */ } - fclose(f); -#ifdef _WIN32 - fclose(tgt); -#else + if (block_seq != expected_blocks || total_written != expected_size) { + fprintf(stderr, "Error: restored disk size/block count does not match index\n"); + errors++; + } + if (hdr.global_flags & ZUPT_FLAG_DISK_CONTENT_HASH) { + if (restored_hash != expected_hash) { + fprintf(stderr, "Error: restored disk content hash does not match index\n"); + errors++; + } + } else { + fprintf(stderr, "Warning: legacy disk archive has no full-image content hash.\n"); + } + + if (fclose(f) != 0) errors++; + if (target_atomic) { + if (zupt_atomic_output_finish(target_atomic, errors == 0) != 0) + errors++; + target_atomic = NULL; + target_stream = NULL; + } +#if !defined(_WIN32) if (tgt_fd >= 0) { - fsync(tgt_fd); /* Flush file descriptor buffers */ - close(tgt_fd); - } - if (is_block_dev) { - sync(); /* Force kernel to flush ALL dirty pages to disk. - * Critical for loop devices: fsync on the loop fd - * may not flush the backing file's page cache. */ + if (fsync(tgt_fd) != 0) errors++; + if (close(tgt_fd) != 0) errors++; + tgt_fd = -1; } + (void)is_block_dev; #endif + zupt_legacy_disk_aad_map_free(&legacy_aad_map); if (errors > 0) { fprintf(stderr, "\n Restore FAILED: %d error(s)\n", errors); diff --git a/src/zupt_filetype.c b/src/zupt_filetype.c index 48dde1b..3916bd1 100644 --- a/src/zupt_filetype.c +++ b/src/zupt_filetype.c @@ -1,7 +1,7 @@ /* * SPDX-License-Identifier: AGPL-3.0-or-later * Copyright (c) 2025-2026 Cristian Cezar Moisés - * Zupt v2.0.0 — Adaptive Compression: File Type Detection + * ZUPT v2.0.0 — Adaptive Compression: File Type Detection * Copyright (c) 2026 Cristian Cezar Moisés — AGPL-3.0-or-later * * Detects file type by magic bytes (not just extension) and returns diff --git a/src/zupt_format.c b/src/zupt_format.c index 8017830..f127d98 100644 --- a/src/zupt_format.c +++ b/src/zupt_format.c @@ -11,10 +11,11 @@ */ #define _GNU_SOURCE #include "zupt.h" +#include "zupt_internal.h" #include "zupt_cpuid.h" /* zupt_cpu for AUTO codec detection */ #include "zupt_parallel.h" #include "vaptvupt.h" /* VAPTVUPT: VaptVupt codec integration */ -#include "vaptvupt_api.h" /* VAPTVUPT: simplified Zupt integration API */ +#include "vaptvupt_api.h" /* VAPTVUPT: simplified ZUPT integration API */ #include #include #include @@ -23,6 +24,7 @@ #ifndef _WIN32 #include #include +#endif /* F-08 of v2.3.0: forward decls — definitions are below read_block(), but the * compress write paths at zupt_compress_files()/_solid() and disk-restore at @@ -35,12 +37,18 @@ zupt_error_t zupt_format_ait_verify_extern(const zupt_archive_header_t *hdr, const zupt_footer_t *ft, const uint8_t ait[ZUPT_AIT_SIZE], const zupt_keyring_t *kr_or_null); -#endif #ifdef _WIN32 #include - #define fseeko _fseeki64 - #define ftello _ftelli64 + #include + #include + #include + #ifndef fseeko + #define fseeko _fseeki64 + #endif + #ifndef ftello + #define ftello _ftelli64 + #endif #endif /* ═══════════════════════════════════════════════════════════════════ @@ -70,14 +78,90 @@ const char *zupt_strerror(zupt_error_t e) { const char *zupt_codec_name(uint16_t id) { switch (id) { case ZUPT_CODEC_STORE: return "Store"; - case ZUPT_CODEC_ZUPT_LZ: return "Zupt-LZ"; - case ZUPT_CODEC_ZUPT_LZH: return "Zupt-LZH"; - case ZUPT_CODEC_ZUPT_LZHP: return "Zupt-LZHP"; + case ZUPT_CODEC_ZUPT_LZ: return "ZUPT-LZ"; + case ZUPT_CODEC_ZUPT_LZH: return "ZUPT-LZH"; + case ZUPT_CODEC_ZUPT_LZHP: return "ZUPT-LZHP"; case ZUPT_CODEC_VAPTVUPT: return "VaptVupt"; /* VAPTVUPT */ case ZUPT_CODEC_AUTO: return "Auto"; default: return "Unknown"; } } + +/* Decode one shortest-form UTF-8 scalar without reading past the terminating + * NUL. A zero return means invalid UTF-8. */ +static size_t zupt_decode_utf8_scalar(const unsigned char *text, + uint32_t *codepoint) { + unsigned char a = text[0]; + if (a < 0x80u) { + *codepoint = a; + return 1; + } + if (text[1] == 0) return 0; + unsigned char b = text[1]; + if (a >= 0xC2u && a <= 0xDFu && (b & 0xC0u) == 0x80u) { + *codepoint = ((uint32_t)(a & 0x1Fu) << 6) | (uint32_t)(b & 0x3Fu); + return 2; + } + if (text[2] == 0) return 0; + unsigned char c = text[2]; + if ((c & 0xC0u) != 0x80u) return 0; + if (((a == 0xE0u && b >= 0xA0u && b <= 0xBFu) || + ((a >= 0xE1u && a <= 0xECu) && (b & 0xC0u) == 0x80u) || + (a == 0xEDu && b >= 0x80u && b <= 0x9Fu) || + ((a >= 0xEEu && a <= 0xEFu) && (b & 0xC0u) == 0x80u))) { + *codepoint = ((uint32_t)(a & 0x0Fu) << 12) | + ((uint32_t)(b & 0x3Fu) << 6) | + (uint32_t)(c & 0x3Fu); + return 3; + } + if (text[3] == 0) return 0; + unsigned char d = text[3]; + if ((d & 0xC0u) != 0x80u) return 0; + if (!((a == 0xF0u && b >= 0x90u && b <= 0xBFu) || + ((a >= 0xF1u && a <= 0xF3u) && (b & 0xC0u) == 0x80u) || + (a == 0xF4u && b >= 0x80u && b <= 0x8Fu))) + return 0; + *codepoint = ((uint32_t)(a & 0x07u) << 18) | + ((uint32_t)(b & 0x3Fu) << 12) | + ((uint32_t)(c & 0x3Fu) << 6) | + (uint32_t)(d & 0x3Fu); + return 4; +} + +static int zupt_codepoint_is_display_control(uint32_t codepoint) { + return codepoint < 0x20u || + (codepoint >= 0x7Fu && codepoint <= 0x9Fu) || + codepoint == 0x061Cu || + (codepoint >= 0x200Bu && codepoint <= 0x200Fu) || + (codepoint >= 0x2028u && codepoint <= 0x202Eu) || + (codepoint >= 0x2060u && codepoint <= 0x206Fu) || + codepoint == 0xFEFFu || + (codepoint >= 0xFFF9u && codepoint <= 0xFFFBu); +} + +/* Archive comments are authenticated data, but authentication says nothing + * about whether their author is trusted. Escape invalid UTF-8 and actual + * Unicode control/format scalars before display so an untrusted archive cannot + * inject forged lines, ANSI/OSC commands, clipboard sequences, or bidi-spoofed + * diagnostics. Printable UTF-8 is preserved byte-for-byte. */ +static void zupt_print_terminal_safe_text(FILE *stream, const char *text) { + const unsigned char *cursor = (const unsigned char *)text; + while (*cursor != '\0') { + uint32_t codepoint = 0; + size_t length = zupt_decode_utf8_scalar(cursor, &codepoint); + if (length == 0) { + fprintf(stream, "\\x%02X", (unsigned int)*cursor++); + } else if (zupt_codepoint_is_display_control(codepoint)) { + for (size_t i = 0; i < length; i++) + fprintf(stream, "\\x%02X", (unsigned int)cursor[i]); + cursor += length; + } else { + fwrite(cursor, 1, length, stream); + cursor += length; + } + } +} + void zupt_default_options(zupt_options_t *o) { memset(o, 0, sizeof(*o)); o->level = 7; @@ -91,14 +175,14 @@ void zupt_default_options(zupt_options_t *o) { /* Resolve ZUPT_CODEC_AUTO to a concrete codec. * VaptVupt decode works on ALL architectures (scalar fallback), but the * AVX2 SIMD decode path gives ~3× throughput. On non-AVX2 hardware, - * Zupt-LZHP is a better default since its simpler decoder doesn't + * ZUPT-LZHP is a better default since its simpler decoder doesn't * benefit from SIMD as much. * * Detection order: * 1. Compile-time: __x86_64__ + __AVX2__ → VaptVupt (compiled with -mavx2) * 2. Runtime: zupt_cpu.has_avx2 → VaptVupt (for x86_64 without -mavx2) * 3. Compile-time: __aarch64__ + __ARM_NEON → VaptVupt (NEON decode) - * 4. Fallback: Zupt-LZHP (works everywhere) + * 4. Fallback: ZUPT-LZHP (works everywhere) */ uint16_t zupt_resolve_auto_codec(void) { #if defined(__x86_64__) || defined(_M_X64) @@ -118,11 +202,33 @@ uint16_t zupt_resolve_auto_codec(void) { } static uint32_t auto_block_size(int level) { - if (level <= 2) return 131072; - if (level <= 4) return 131072; - if (level <= 6) return 262144; - if (level <= 7) return 262144; - return 524288; + /* The block IS the codec's LZ window: matches never cross a block + * boundary, so a small block throttles the "large-window extreme" + * parser (512 KiB gave text 3.75x where a whole-file window gives + * 7.6x — measured on codec 2.65.0). Higher levels therefore get a + * larger block. Trade-offs held in mind: (a) block size also sets + * --dedup granularity, so the speed-first low levels (where dedup is + * most used) stay small; and (b) extreme's optimal DP is ~O(block), + * so the extreme block is bounded at 8 MiB — 16 MiB bought only a few + * more percent of ratio for ~2.5x the encode time, not worth it as a + * default (raise it explicitly with -b for archival runs). Decode + * speed and memory are unaffected by block size. */ + if (level <= 2) return 131072; /* fast: speed + MT + dedup granularity */ + if (level <= 4) return 1u << 20; /* 1 MiB */ + if (level <= 6) return 2u << 20; /* 2 MiB */ + if (level <= 7) return 4u << 20; /* 4 MiB balanced: ~free, big ratio win */ + return 8u << 20; /* 8 MiB extreme: large usable window */ +} + +/* Block size when --dedup is active. Dedup detects duplicate BLOCKS, so a + * large block almost never finds a duplicate (an 8 MiB block rarely repeats + * byte-exactly), collapsing the dedup ratio to 1.0x — directly opposed to the + * large-window compression goal, which they share the one block_size knob for. + * With --dedup the user has chosen block-level dup detection, so pick a small + * block that actually finds repeats (256 KiB is the classic dedup granularity; + * finer than that costs index memory for little gain on real backups). */ +static uint32_t auto_block_size_dedup(int level) { + return level <= 2 ? 131072u : 262144u; } void zupt_format_size(uint64_t b, char *buf, size_t cap) { if (b < 1024) snprintf(buf, cap, "%llu B", (unsigned long long)b); @@ -157,18 +263,23 @@ int zupt_encode_varint(uint8_t *b, uint64_t v) { int n=0; do { uint8_t x=(uint8_t)(v&0x7F); v>>=7; if(v)x|=0x80; b[n++]=x; } while(v); return n; } int zupt_decode_varint(const uint8_t *b, size_t blen, uint64_t *v) { - *v=0; int s=0,n=0; - while(n<(int)blen&&n<10){ - uint64_t x=b[n]; - *v|=(x&0x7F)<= 64 means an 11th byte - * would be needed, which we refuse. */ - if(s>=64) return -1; + if (!b || !v) return -1; + *v = 0; + unsigned int shift = 0; + for (size_t n = 0; n < blen && n < 10; n++) { + uint8_t byte = b[n]; + uint8_t payload = (uint8_t)(byte & 0x7fu); + /* A uint64_t varint has only one payload bit in byte ten. Check it + * before shifting so malformed values cannot wrap modulo 2^64. */ + if (n == 9 && (byte & 0xfeu) != 0) return -1; + *v |= (uint64_t)payload << shift; + if ((byte & 0x80u) == 0) { + /* Writers always use the shortest representation. Reject an + * overlong final zero so a scalar has exactly one wire encoding. */ + if (n > 0 && payload == 0) return -1; + return (int)n + 1; + } + shift += 7; } return -1; } @@ -176,14 +287,21 @@ int zupt_write_varint(FILE *f, uint64_t v) { uint8_t b[10]; int n=zupt_encode_varint(b,v); return fwrite(b,1,(size_t)n,f)==(size_t)n?n:-1; } int zupt_read_varint(FILE *f, uint64_t *v) { - *v=0; int s=0; - for(int i=0;i<10;i++){ - int c=fgetc(f); if(c==EOF) return -1; - *v|=(uint64_t)(c&0x7F)<=64) return -1; + if (!f || !v) return -1; + *v = 0; + unsigned int shift = 0; + for (int i = 0; i < 10; i++) { + int raw = fgetc(f); + if (raw == EOF) return -1; + uint8_t byte = (uint8_t)raw; + uint8_t payload = (uint8_t)(byte & 0x7fu); + if (i == 9 && (byte & 0xfeu) != 0) return -1; + *v |= (uint64_t)payload << shift; + if ((byte & 0x80u) == 0) { + if (i > 0 && payload == 0) return -1; + return i + 1; + } + shift += 7; } return -1; } @@ -193,16 +311,51 @@ int zupt_read_varint(FILE *f, uint64_t *v) { * ═══════════════════════════════════════════════════════════════════ */ void zupt_filelist_init(zupt_filelist_t *fl) { - fl->paths = NULL; fl->arc_paths = NULL; fl->count = 0; fl->capacity = 0; + fl->paths = NULL; fl->arc_paths = NULL; + fl->count = 0; fl->capacity = 0; } void zupt_filelist_free(zupt_filelist_t *fl) { for (int i = 0; i < fl->count; i++) { free(fl->paths[i]); free(fl->arc_paths[i]); } free(fl->paths); free(fl->arc_paths); - fl->paths = fl->arc_paths = NULL; fl->count = fl->capacity = 0; + fl->paths = fl->arc_paths = NULL; + fl->count = fl->capacity = 0; } + +static char *zupt_normalize_archive_path(const char *path, int fold_ascii) { + if (!path) return NULL; + size_t length = strlen(path); + char *normalized = (char *)malloc(length + 1); + if (!normalized) return NULL; + size_t out = 0; + int previous_separator = 0; + for (size_t i = 0; i < length; i++) { + unsigned char c = (unsigned char)path[i]; + if (c == '/' || c == '\\') { + if (previous_separator) continue; + normalized[out++] = '/'; + previous_separator = 1; + continue; + } + previous_separator = 0; + if (fold_ascii && c >= 'A' && c <= 'Z') + c = (unsigned char)(c + ('a' - 'A')); + normalized[out++] = (char)c; + } + normalized[out] = '\0'; + return normalized; +} + void zupt_filelist_add(zupt_filelist_t *fl, const char *disk, const char *arc) { + if (!fl || !disk || !arc || fl->count < 0 || fl->capacity < 0 || + fl->capacity > ZUPT_MAX_FILES || + fl->count > fl->capacity || fl->count >= ZUPT_MAX_FILES) { + zupt_internal_filelist_mark_failed(fl); + return; + } if (fl->count >= fl->capacity) { int new_cap = fl->capacity ? fl->capacity * 2 : 256; + if (new_cap < fl->capacity || new_cap > ZUPT_MAX_FILES) + new_cap = ZUPT_MAX_FILES; /* Allocate both buffers atomically: if either fails, both are * discarded and the existing fl state is untouched. The previous * implementation could leak or corrupt fl->paths when the second @@ -215,6 +368,7 @@ void zupt_filelist_add(zupt_filelist_t *fl, const char *disk, const char *arc) { free(new_paths); /* free(NULL) is well-defined */ free(new_arcs); fprintf(stderr, " Warning: out of memory adding '%s'\n", disk); + zupt_internal_filelist_mark_failed(fl); return; } if (fl->paths) memcpy(new_paths, fl->paths, (size_t)fl->count * sizeof(char*)); @@ -225,69 +379,166 @@ void zupt_filelist_add(zupt_filelist_t *fl, const char *disk, const char *arc) { fl->arc_paths = new_arcs; fl->capacity = new_cap; } - fl->paths[fl->count] = strdup(disk); - fl->arc_paths[fl->count] = strdup(arc); - if (!fl->paths[fl->count] || !fl->arc_paths[fl->count]) { - free(fl->paths[fl->count]); - free(fl->arc_paths[fl->count]); + char *new_path = strdup(disk); + char *new_arc = zupt_normalize_archive_path(arc, 0); + if (!new_path || !new_arc) { + free(new_path); + free(new_arc); fprintf(stderr, " Warning: out of memory adding '%s'\n", disk); + zupt_internal_filelist_mark_failed(fl); return; } + fl->paths[fl->count] = new_path; + fl->arc_paths[fl->count] = new_arc; fl->count++; } static int is_dir(const char *path) { #ifdef _WIN32 - DWORD attr = GetFileAttributesA(path); - return (attr != INVALID_FILE_ATTRIBUTES && (attr & FILE_ATTRIBUTE_DIRECTORY)); + DWORD attr = zupt_win_get_attributes_utf8(path); + return attr != INVALID_FILE_ATTRIBUTES && + (attr & FILE_ATTRIBUTE_DIRECTORY) && + !(attr & FILE_ATTRIBUTE_REPARSE_POINT); #else struct stat st; - return (stat(path, &st) == 0 && S_ISDIR(st.st_mode)); + return lstat(path, &st) == 0 && S_ISDIR(st.st_mode); #endif } +#ifdef _WIN32 +static int zupt_win_has_extended_or_device_prefix(const char *path) { + return path && + (path[0] == '\\' || path[0] == '/') && + (path[1] == '\\' || path[1] == '/') && + (path[2] == '?' || path[2] == '.') && + (path[3] == '\\' || path[3] == '/'); +} +#endif + +static int zupt_path_is_safe(const char *path); +static int zupt_path_has_unsafe_text(const char *path); + void zupt_collect_files(zupt_filelist_t *fl, const char *path, const char *base) { +#ifdef _WIN32 + /* Extended/device namespaces need separate canonicalisation rules. Until + * that support exists, reject them before creating an archive; otherwise + * a name such as \\?\C:\\file would be stored with a colon and the archive + * would correctly refuse to extract its own unsafe entry. */ + if (zupt_win_has_extended_or_device_prefix(path) || + zupt_win_has_extended_or_device_prefix(base)) { + fprintf(stderr, + " Error: Windows extended/device namespace inputs are unsupported: %s\n", + path ? path : "(null)"); + zupt_internal_filelist_mark_failed(fl); + return; + } +#endif if (!is_dir(path)) { /* Skip non-regular files (symlinks, devices, FIFOs, sockets) */ if (!zupt_is_regular_file(path)) { - fprintf(stderr, " Skipping non-regular file: %s\n", path); + fprintf(stderr, " Error: input is unreadable or not a regular file: %s\n", path); + zupt_internal_filelist_mark_failed(fl); return; } const char *arc = base; +#ifdef _WIN32 + /* A drive designator is a disk namespace prefix, never archive data. */ + if (((arc[0] >= 'A' && arc[0] <= 'Z') || + (arc[0] >= 'a' && arc[0] <= 'z')) && arc[1] == ':') + arc += 2; +#endif while (arc[0]=='.' && (arc[1]=='/'||arc[1]=='\\')) arc+=2; while (*arc=='/'||*arc=='\\') arc++; if (*arc == '\0') arc = path; while (*arc=='/'||*arc=='\\') arc++; +#ifndef _WIN32 + if (strchr(arc, '\\') != NULL) { + fprintf(stderr, + " Error: POSIX input name contains a non-portable backslash.\n"); + zupt_internal_filelist_mark_failed(fl); + return; + } +#endif + if (!zupt_path_is_safe(arc)) { + fprintf(stderr, + " Error: input would create an unsafe archive path.\n"); + zupt_internal_filelist_mark_failed(fl); + return; + } zupt_filelist_add(fl, path, arc); return; } #ifdef _WIN32 - char pattern[ZUPT_MAX_PATH]; - snprintf(pattern, sizeof(pattern), "%s\\*", path); - WIN32_FIND_DATAA fd; - HANDLE h = FindFirstFileA(pattern, &fd); - if (h == INVALID_HANDLE_VALUE) return; + wchar_t *wide_path = zupt_win_utf8_to_wide_alloc(path); + if (!wide_path) { zupt_internal_filelist_mark_failed(fl); return; } + size_t path_length = wcslen(wide_path); + if (path_length > ZUPT_MAX_PATH - 3) { + free(wide_path); zupt_internal_filelist_mark_failed(fl); return; + } + wchar_t pattern[ZUPT_MAX_PATH]; + memcpy(pattern, wide_path, (path_length + 1) * sizeof(wchar_t)); + free(wide_path); + if (path_length > 0 && pattern[path_length - 1] != L'/' && + pattern[path_length - 1] != L'\\') + pattern[path_length++] = L'\\'; + pattern[path_length++] = L'*'; + pattern[path_length] = L'\0'; + + WIN32_FIND_DATAW fd; + HANDLE h = FindFirstFileW(pattern, &fd); + if (h == INVALID_HANDLE_VALUE) { + zupt_internal_filelist_mark_failed(fl); + return; + } do { - if (fd.cFileName[0]=='.' && (fd.cFileName[1]=='\0' || - (fd.cFileName[1]=='.' && fd.cFileName[2]=='\0'))) continue; + if (fd.cFileName[0]==L'.' && (fd.cFileName[1]==L'\0' || + (fd.cFileName[1]==L'.' && fd.cFileName[2]==L'\0'))) continue; + char *name = zupt_win_wide_to_utf8_alloc(fd.cFileName); + if (!name) { zupt_internal_filelist_mark_failed(fl); break; } char child_disk[ZUPT_MAX_PATH], child_arc[ZUPT_MAX_PATH]; - snprintf(child_disk, sizeof(child_disk), "%s\\%s", path, fd.cFileName); - snprintf(child_arc, sizeof(child_arc), "%s/%s", base, fd.cFileName); + int disk_length = snprintf(child_disk, sizeof(child_disk), + "%s\\%s", path, name); + int arc_length = snprintf(child_arc, sizeof(child_arc), + "%s/%s", base, name); + free(name); + if (disk_length < 0 || (size_t)disk_length >= sizeof(child_disk) || + arc_length < 0 || (size_t)arc_length >= sizeof(child_arc)) { + zupt_internal_filelist_mark_failed(fl); + break; + } zupt_collect_files(fl, child_disk, child_arc); - } while (FindNextFileA(h, &fd)); + if (zupt_internal_filelist_failed(fl)) break; + } while (FindNextFileW(h, &fd)); + if (!zupt_internal_filelist_failed(fl) && + GetLastError() != ERROR_NO_MORE_FILES) + zupt_internal_filelist_mark_failed(fl); FindClose(h); #else DIR *d = opendir(path); - if (!d) return; + if (!d) { zupt_internal_filelist_mark_failed(fl); return; } struct dirent *ent; - while ((ent = readdir(d)) != NULL) { + for (;;) { + errno = 0; + ent = readdir(d); + if (!ent) { + if (errno != 0) zupt_internal_filelist_mark_failed(fl); + break; + } if (ent->d_name[0]=='.' && (ent->d_name[1]=='\0' || (ent->d_name[1]=='.' && ent->d_name[2]=='\0'))) continue; char child_disk[ZUPT_MAX_PATH], child_arc[ZUPT_MAX_PATH]; - snprintf(child_disk, sizeof(child_disk), "%s/%s", path, ent->d_name); - snprintf(child_arc, sizeof(child_arc), "%s/%s", base, ent->d_name); + int disk_length = snprintf(child_disk, sizeof(child_disk), + "%s/%s", path, ent->d_name); + int arc_length = snprintf(child_arc, sizeof(child_arc), + "%s/%s", base, ent->d_name); + if (disk_length < 0 || (size_t)disk_length >= sizeof(child_disk) || + arc_length < 0 || (size_t)arc_length >= sizeof(child_arc)) { + zupt_internal_filelist_mark_failed(fl); + break; + } zupt_collect_files(fl, child_disk, child_arc); + if (zupt_internal_filelist_failed(fl)) break; } closedir(d); #endif @@ -303,6 +554,69 @@ int zupt_w64le(FILE*f,uint64_t v){uint8_t b[8];zupt_le64_put(b,v);return fwrite( static int r16le(FILE*f,uint16_t*v){uint8_t b[2];if(fread(b,1,2,f)!=2)return -1;*v=zupt_le16_get(b);return 0;} static int r64le(FILE*f,uint64_t*v){uint8_t b[8];if(fread(b,1,8,f)!=8)return -1;*v=zupt_le64_get(b);return 0;} +void zupt_serialize_archive_header(const zupt_archive_header_t *header, + uint8_t out[ZUPT_ARCHIVE_HEADER_SIZE]) { + memset(out, 0, ZUPT_ARCHIVE_HEADER_SIZE); + memcpy(out, header->magic, sizeof(header->magic)); + out[6] = header->version_major; + out[7] = header->version_minor; + zupt_le32_put(out + 8, header->global_flags); + zupt_le64_put(out + 12, header->creation_time); + memcpy(out + 20, header->archive_id, sizeof(header->archive_id)); + zupt_le64_put(out + 36, header->encryption_header_off); + zupt_le64_put(out + 44, header->comment_offset); + memcpy(out + 52, header->reserved, sizeof(header->reserved)); +} + +void zupt_serialize_footer(const zupt_footer_t *footer, + uint8_t out[ZUPT_FOOTER_SIZE]) { + memset(out, 0, ZUPT_FOOTER_SIZE); + zupt_le64_put(out, footer->index_offset); + zupt_le64_put(out + 8, footer->total_blocks); + zupt_le64_put(out + 16, footer->archive_checksum); + memcpy(out + 24, footer->footer_magic, sizeof(footer->footer_magic)); + zupt_le32_put(out + 28, footer->footer_version); +} + +static void deserialize_archive_header( + const uint8_t in[ZUPT_ARCHIVE_HEADER_SIZE], zupt_archive_header_t *header) { + memset(header, 0, sizeof(*header)); + memcpy(header->magic, in, sizeof(header->magic)); + header->version_major = in[6]; + header->version_minor = in[7]; + header->global_flags = zupt_le32_get(in + 8); + header->creation_time = zupt_le64_get(in + 12); + memcpy(header->archive_id, in + 20, sizeof(header->archive_id)); + header->encryption_header_off = zupt_le64_get(in + 36); + header->comment_offset = zupt_le64_get(in + 44); + memcpy(header->reserved, in + 52, sizeof(header->reserved)); +} + +static void deserialize_footer(const uint8_t in[ZUPT_FOOTER_SIZE], + zupt_footer_t *footer) { + memset(footer, 0, sizeof(*footer)); + footer->index_offset = zupt_le64_get(in); + footer->total_blocks = zupt_le64_get(in + 8); + footer->archive_checksum = zupt_le64_get(in + 16); + memcpy(footer->footer_magic, in + 24, sizeof(footer->footer_magic)); + footer->footer_version = zupt_le32_get(in + 28); +} + +int zupt_write_archive_header(FILE *stream, + const zupt_archive_header_t *header) { + uint8_t serialized[ZUPT_ARCHIVE_HEADER_SIZE]; + zupt_serialize_archive_header(header, serialized); + return fwrite(serialized, 1, sizeof(serialized), stream) == + sizeof(serialized) ? 0 : -1; +} + +int zupt_write_footer(FILE *stream, const zupt_footer_t *footer) { + uint8_t serialized[ZUPT_FOOTER_SIZE]; + zupt_serialize_footer(footer, serialized); + return fwrite(serialized, 1, sizeof(serialized), stream) == + sizeof(serialized) ? 0 : -1; +} + /* Aliases for internal use (backward compat with existing code) */ #define w8 zupt_w8 #define w16le zupt_w16le @@ -343,19 +657,19 @@ zupt_error_t write_enc_header(FILE *out, zupt_archive_header_t *hdr, return ZUPT_ERR_IO; fseeko(out, 0, SEEK_SET); - if (fwrite(hdr, sizeof(*hdr), 1, out) != 1) return ZUPT_ERR_IO; + if (zupt_write_archive_header(out, hdr) != 0) return ZUPT_ERR_IO; fseeko(out, 0, SEEK_END); if (!opts->quiet) fprintf(stderr, " Encryption: AES-256-CTR + HMAC-SHA256 (Encrypt-then-MAC)\n\n"); } else if (opts->sdk_mode && opts->pq_mode) { - /* ─── SDK V2 PQ MODE (libzuptsdk: HKDF combiner + commitment + HPKE) ─── */ + /* ─── SDK V2 PQ MODE (libvuptsdk: HKDF combiner + commitment + HPKE) ─── */ hdr->global_flags |= ZUPT_FLAG_PQ_HYBRID; uint8_t enc_hdr_buf[1500]; size_t enc_hdr_len = 0; if (!opts->quiet) - fprintf(stderr, " PQ key encapsulation via libzuptsdk (HKDF-SHA3 + commitment + HPKE)...\n"); + fprintf(stderr, " PQ key encapsulation via libvuptsdk (HKDF-SHA3 + commitment + HPKE)...\n"); if (zupt_sdk_hybrid_encrypt_init(&opts->keyring, opts->keyfile, enc_hdr_buf, &enc_hdr_len) != 0) { fprintf(stderr, "Error: SDK PQ key encapsulation failed.\n"); @@ -372,11 +686,40 @@ zupt_error_t write_enc_header(FILE *out, zupt_archive_header_t *hdr, return ZUPT_ERR_IO; fseeko(out, 0, SEEK_SET); - if (fwrite(hdr, sizeof(*hdr), 1, out) != 1) return ZUPT_ERR_IO; + if (zupt_write_archive_header(out, hdr) != 0) return ZUPT_ERR_IO; fseeko(out, 0, SEEK_END); if (!opts->quiet) fprintf(stderr, " Encryption: SDK-v2 PQ Hybrid + XChaCha20-Poly1305 (commitment + HPKE)\n\n"); + } else if (opts->pqonly_mode) { + /* ─── FULL POST-QUANTUM MODE (ML-KEM-768 only, no X25519) ─── */ + hdr->global_flags |= ZUPT_FLAG_PQ_HYBRID; /* generic PQ indicator; enc_type distinguishes */ + + uint8_t enc_hdr_buf[1200]; + size_t enc_hdr_len = 0; + if (!opts->quiet) + fprintf(stderr, " Full post-quantum key encapsulation (ML-KEM-768, no classical layer)...\n"); + if (zupt_pq_encrypt_init(&opts->keyring, opts->keyfile, + enc_hdr_buf, &enc_hdr_len) != 0) { + fprintf(stderr, "Error: full-PQ key encapsulation failed (wrong key file?).\n"); + return ZUPT_ERR_AUTH_FAIL; + } + + zupt_w8(out, ZUPT_BLOCK_MAGIC_0); zupt_w8(out, ZUPT_BLOCK_MAGIC_1); + zupt_w8(out, ZUPT_BLOCK_ENC_HEADER); + zupt_w16le(out, ZUPT_CODEC_STORE); zupt_w16le(out, 0); + zupt_write_varint(out, enc_hdr_len); + zupt_write_varint(out, enc_hdr_len); + zupt_w64le(out, zupt_xxh64(enc_hdr_buf, enc_hdr_len, 0)); + if (fwrite(enc_hdr_buf, 1, enc_hdr_len, out) != enc_hdr_len) + return ZUPT_ERR_IO; + + fseeko(out, 0, SEEK_SET); + if (zupt_write_archive_header(out, hdr) != 0) return ZUPT_ERR_IO; + fseeko(out, 0, SEEK_END); + + if (!opts->quiet) + fprintf(stderr, " Encryption: Full PQ (ML-KEM-768) + AES-256-CTR + HMAC-SHA256\n\n"); } else if (opts->pq_mode) { /* ─── PQ HYBRID MODE ─── */ hdr->global_flags |= ZUPT_FLAG_PQ_HYBRID; @@ -402,7 +745,7 @@ zupt_error_t write_enc_header(FILE *out, zupt_archive_header_t *hdr, /* Re-write header with PQ flag */ fseeko(out, 0, SEEK_SET); - if (fwrite(hdr, sizeof(*hdr), 1, out) != 1) return ZUPT_ERR_IO; + if (zupt_write_archive_header(out, hdr) != 0) return ZUPT_ERR_IO; fseeko(out, 0, SEEK_END); if (!opts->quiet) @@ -410,7 +753,7 @@ zupt_error_t write_enc_header(FILE *out, zupt_archive_header_t *hdr, } else { /* ─── PASSWORD MODE ─── * - * v2.4.1+: default to Argon2id (libzuptsdk path, enc_type=0x04). + * v2.4.1+: default to Argon2id (libvuptsdk path, enc_type=0x04). * PBKDF2-SHA256 (enc_type=0x01) is available via --kdf pbkdf2 for * compatibility with v2.4.0 and older readers. Argon2id is the * OWASP recommendation for password KDFs; PBKDF2 with 600k @@ -423,20 +766,19 @@ zupt_error_t write_enc_header(FILE *out, zupt_archive_header_t *hdr, * enc-header bytes differ. Read-path dispatch on enc_type byte * at offset 0 of the enc-header block already handles both. */ #ifdef ZUPT_WITH_SDK - int use_pbkdf2 = opts->kdf_legacy_pbkdf2; + if (opts->kdf_legacy_pbkdf2) { #else - /* No libzuptsdk in this build: Argon2id is unavailable, so the password + /* No libvuptsdk in this build: Argon2id is unavailable, so the password * KDF is always native PBKDF2-SHA256 (600k iters, AES-256-CTR + HMAC- * SHA256). Archives written this way are readable by any build. */ - int use_pbkdf2 = 1; + { #endif - if (use_pbkdf2) { uint8_t salt[ZUPT_SALT_SIZE], nonce[ZUPT_NONCE_SIZE]; zupt_random_bytes(salt, ZUPT_SALT_SIZE); zupt_random_bytes(nonce, ZUPT_NONCE_SIZE); if (!opts->quiet) - fprintf(stderr, " Deriving encryption key (PBKDF2-SHA256, %d iterations, --kdf pbkdf2 legacy)...\n", + fprintf(stderr, " Deriving encryption key (PBKDF2-SHA256, %d iterations)...\n", ZUPT_KDF_ITERATIONS); zupt_derive_keys(&opts->keyring, opts->password, salt, nonce, ZUPT_KDF_ITERATIONS); @@ -444,8 +786,7 @@ zupt_error_t write_enc_header(FILE *out, zupt_archive_header_t *hdr, enc_hdr[0] = ZUPT_ENC_PBKDF2; memcpy(enc_hdr + 1, salt, 32); memcpy(enc_hdr + 33, nonce, 16); - uint32_t iter = ZUPT_KDF_ITERATIONS; - memcpy(enc_hdr + 49, &iter, 4); + zupt_le32_put(enc_hdr + 49, ZUPT_KDF_ITERATIONS); zupt_w8(out, ZUPT_BLOCK_MAGIC_0); zupt_w8(out, ZUPT_BLOCK_MAGIC_1); zupt_w8(out, ZUPT_BLOCK_ENC_HEADER); @@ -453,12 +794,13 @@ zupt_error_t write_enc_header(FILE *out, zupt_archive_header_t *hdr, zupt_write_varint(out, 53); zupt_write_varint(out, 53); zupt_w64le(out, zupt_xxh64(enc_hdr, 53, 0)); if (fwrite(enc_hdr, 1, 53, out) != 53) return ZUPT_ERR_IO; +#ifdef ZUPT_WITH_SDK } else { /* Argon2id default (v2.4.1+) */ - uint8_t enc_hdr[33]; + uint8_t enc_hdr[ZUPT_ARGON2_HDR_LEN_V2]; size_t enc_hdr_len = 0; if (!opts->quiet) - fprintf(stderr, " Deriving encryption key (Argon2id, libzuptsdk)...\n"); + fprintf(stderr, " Deriving encryption key (Argon2id, libvuptsdk)...\n"); if (zupt_sdk_password_encrypt_init(&opts->keyring, opts->password, enc_hdr, &enc_hdr_len) != 0) { fprintf(stderr, "Error: Argon2id key derivation failed.\n" @@ -473,9 +815,12 @@ zupt_error_t write_enc_header(FILE *out, zupt_archive_header_t *hdr, zupt_w64le(out, zupt_xxh64(enc_hdr, enc_hdr_len, 0)); if (fwrite(enc_hdr, 1, enc_hdr_len, out) != enc_hdr_len) return ZUPT_ERR_IO; } +#else + } +#endif fseeko(out, 0, SEEK_SET); - if (fwrite(hdr, sizeof(*hdr), 1, out) != 1) return ZUPT_ERR_IO; + if (zupt_write_archive_header(out, hdr) != 0) return ZUPT_ERR_IO; fseeko(out, 0, SEEK_END); if (!opts->quiet) @@ -485,12 +830,6 @@ zupt_error_t write_enc_header(FILE *out, zupt_archive_header_t *hdr, return ZUPT_OK; } -static void ensure_dirs(const char *path) { - char tmp[ZUPT_MAX_PATH]; strncpy(tmp, path, sizeof(tmp)-1); tmp[sizeof(tmp)-1]='\0'; - for (char *p=tmp+1;*p;p++) - if (*p=='/'||*p=='\\') { *p='\0'; zupt_mkdir(tmp); *p=ZUPT_PATH_SEP; } -} - /* SECURITY: Validate an archive entry's path is safe to extract. * * Blocks classic Zip-Slip / path-traversal attacks (Snyk 2018) where a @@ -500,90 +839,1066 @@ static void ensure_dirs(const char *path) { * Rules enforced: * 1. Reject NULL/empty paths. * 2. Reject absolute paths (Unix: starts with '/'; Windows: 'X:' or '\\'). - * 3. Reject any component equal to ".." (after splitting on / and \). - * 4. Reject embedded NUL bytes (defense in depth). - * 5. Reject leading/embedded "~" expansions and "$" variable references - * that some shell-aware tooling might expand later. + * 3. Reject empty, ".", and ".." components (splitting on / and \). + * 4. Reject Windows ADS syntax, control characters, trailing dots/spaces, + * and reserved DOS device names on every platform. This keeps an + * archive made on POSIX safe when it is later extracted on Windows. + * 5. Embedded NUL bytes are rejected while parsing the length-delimited + * index entry, before it reaches this C-string interface. * * Returns 1 if path is safe, 0 if it should be rejected. */ +static int zupt_ascii_equal_ci(const char *value, size_t value_len, + const char *literal) { + size_t literal_len = strlen(literal); + if (value_len != literal_len) return 0; + for (size_t i = 0; i < value_len; i++) { + unsigned char a = (unsigned char)value[i]; + unsigned char b = (unsigned char)literal[i]; + if (a >= 'a' && a <= 'z') a = (unsigned char)(a - ('a' - 'A')); + if (b >= 'a' && b <= 'z') b = (unsigned char)(b - ('a' - 'A')); + if (a != b) return 0; + } + return 1; +} + +static int zupt_is_reserved_dos_name(const char *component, size_t len) { + size_t base_len = 0; + while (base_len < len && component[base_len] != '.') base_len++; + if (zupt_ascii_equal_ci(component, base_len, "CON") || + zupt_ascii_equal_ci(component, base_len, "PRN") || + zupt_ascii_equal_ci(component, base_len, "AUX") || + zupt_ascii_equal_ci(component, base_len, "NUL")) + return 1; + if (base_len == 4 && + ((component[0] == 'C' || component[0] == 'c') && + (component[1] == 'O' || component[1] == 'o') && + (component[2] == 'M' || component[2] == 'm') && + component[3] >= '1' && component[3] <= '9')) + return 1; + if (base_len == 4 && + ((component[0] == 'L' || component[0] == 'l') && + (component[1] == 'P' || component[1] == 'p') && + (component[2] == 'T' || component[2] == 't') && + component[3] >= '1' && component[3] <= '9')) + return 1; + return 0; +} + static int zupt_path_is_safe(const char *path) { if (!path || !*path) return 0; size_t len = strlen(path); if (len >= ZUPT_MAX_PATH) return 0; + if (zupt_path_has_unsafe_text(path)) return 0; /* Absolute paths */ if (path[0] == '/' || path[0] == '\\') return 0; - /* Windows drive letters: "C:..." or UNC "\\server" */ - if (len >= 2 && path[1] == ':') return 0; + /* A colon is a drive designator or NTFS alternate-data-stream marker. */ + if (memchr(path, ':', len) != NULL) return 0; /* Component scan: split on '/' and '\\' */ const char *start = path; for (size_t i = 0; i <= len; i++) { if (path[i] == '/' || path[i] == '\\' || path[i] == '\0') { size_t complen = (size_t)(path + i - start); - /* Reject ".." as a complete component */ - if (complen == 2 && start[0] == '.' && start[1] == '.') return 0; - /* Reject embedded NUL within string (string strlen would have - * stopped, but defense in depth in case caller passes a buffer - * with a NUL in middle) */ + /* Repeated separators are normalized by the descriptor walk; + * a trailing separator cannot name a regular-file entry. */ + if (complen == 0) { + if (i == len) return 0; + start = path + i + 1; + continue; + } + if ((complen == 1 && start[0] == '.') || + (complen == 2 && start[0] == '.' && start[1] == '.')) + return 0; + if (start[complen - 1] == '.' || start[complen - 1] == ' ' || + zupt_is_reserved_dos_name(start, complen)) + return 0; + for (size_t j = 0; j < complen; j++) { + unsigned char c = (unsigned char)start[j]; + if (c < 0x20 || c == 0x7f) return 0; + } start = path + i + 1; } } - /* Defense in depth: reject NUL bytes within declared length */ - for (size_t i = 0; i < len; i++) { - if (path[i] == '\0') return 0; - } - return 1; } -/* SECURITY: Open an output file for writing, refusing to follow symlinks. - * - * Defends against the case where an attacker has placed a symlink in the - * output directory before extraction, e.g. ~/Downloads/innocent.txt → /etc/passwd. - * On Linux/BSD/macOS we use O_NOFOLLOW + O_EXCL semantics: if the path - * exists and is a symlink, open() returns ELOOP. If the path doesn't - * exist, the symlink check is moot. - * - * Windows behavior: defaults to fopen "wb" (does not follow reparse points - * unless explicitly enabled). The most common Windows attack vector here - * is via reparse points, but we leave it to the user's directory ACLs for - * now since CreateFileW with FILE_FLAG_OPEN_REPARSE_POINT is non-trivial - * to wire portably. - */ -static FILE *zupt_safe_fopen_output(const char *path) { +static int zupt_path_has_unsafe_text(const char *path) { + if (!path) return 1; + const unsigned char *cursor = (const unsigned char *)path; + while (*cursor != '\0') { + uint32_t codepoint = 0; + size_t length = zupt_decode_utf8_scalar(cursor, &codepoint); + if (length == 0 || zupt_codepoint_is_display_control(codepoint)) + return 1; + cursor += length; + } + return 0; +} + +struct zupt_atomic_output { + FILE *stream; #if defined(_WIN32) - /* No portable O_NOFOLLOW on Windows; rely on directory permissions. */ - return fopen(path, "wb"); + HANDLE parent_handle; + HANDLE temp_handle; + WCHAR final_name[ZUPT_MAX_PATH]; #else - /* Open with O_NOFOLLOW so that if the leaf is a symlink, open fails. - * O_TRUNC zeros existing file (matches "wb" semantics). */ - int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, 0600); - if (fd < 0) return NULL; - FILE *f = fdopen(fd, "wb"); - if (!f) { close(fd); return NULL; } - return f; + int parent_fd; + char final_name[ZUPT_MAX_PATH]; + char temp_name[96]; +#endif +}; + +typedef struct zupt_atomic_output zupt_output_file_t; + +static void zupt_output_init(zupt_output_file_t *output) { + memset(output, 0, sizeof(*output)); +#if defined(_WIN32) + output->parent_handle = INVALID_HANDLE_VALUE; + output->temp_handle = INVALID_HANDLE_VALUE; +#else + output->parent_fd = -1; #endif } -static uint64_t get_mtime(const char *path) { -#ifdef _WIN32 - (void)path; return now_ns(); +#if defined(_WIN32) +static void zupt_win_set_nt_errno(NTSTATUS status) { + if (status == (NTSTATUS)0xC0000034L || /* STATUS_OBJECT_NAME_NOT_FOUND */ + status == (NTSTATUS)0xC000003AL) { /* STATUS_OBJECT_PATH_NOT_FOUND */ + errno = ENOENT; + } else if (status == (NTSTATUS)0xC0000035L) { /* NAME_COLLISION */ + errno = EEXIST; + } else { + errno = EACCES; + } +} + +static int zupt_win_is_plain_directory(HANDLE handle) { + BY_HANDLE_FILE_INFORMATION info; + return GetFileInformationByHandle(handle, &info) && + (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) && + !(info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT); +} + +static int zupt_win_component_to_wide(const char *component, UINT code_page, + WCHAR wide[ZUPT_MAX_PATH]) { + DWORD flags = code_page == CP_UTF8 ? MB_ERR_INVALID_CHARS : 0; + int length = MultiByteToWideChar(code_page, flags, component, -1, + wide, ZUPT_MAX_PATH); + return length > 1 && length <= ZUPT_MAX_PATH; +} + +/* New archives use UTF-8. Fall back to ACP only for legacy Windows archives + * written before archive names were normalized at collection time. */ +static int zupt_win_archive_component_to_wide( + const char *component, WCHAR wide[ZUPT_MAX_PATH]) { + return zupt_win_component_to_wide(component, CP_UTF8, wide) || + zupt_win_component_to_wide(component, CP_ACP, wide); +} + +/* Open one component relative to an already resolved directory handle. This + * deliberately avoids a second string-path resolution between checking a + * directory and using it, which would permit a junction/reparse-point race. */ +static HANDLE zupt_win_open_relative_dir_wide(HANDLE parent, const WCHAR *wide, + int create) { + UNICODE_STRING name; + name.Buffer = (PWSTR)wide; + name.Length = (USHORT)(wcslen(wide) * sizeof(WCHAR)); + name.MaximumLength = name.Length + sizeof(WCHAR); + OBJECT_ATTRIBUTES attributes; + InitializeObjectAttributes(&attributes, &name, OBJ_CASE_INSENSITIVE, + parent, NULL); + IO_STATUS_BLOCK status_block; + HANDLE handle = INVALID_HANDLE_VALUE; + NTSTATUS status = NtCreateFile( + &handle, + FILE_LIST_DIRECTORY | FILE_TRAVERSE | FILE_READ_ATTRIBUTES | SYNCHRONIZE, + &attributes, &status_block, NULL, FILE_ATTRIBUTE_NORMAL, + FILE_SHARE_READ | FILE_SHARE_WRITE, + create ? FILE_OPEN_IF : FILE_OPEN, + FILE_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT | + FILE_SYNCHRONOUS_IO_NONALERT, + NULL, 0); + if (status < 0 || handle == INVALID_HANDLE_VALUE) { + zupt_win_set_nt_errno(status); + return INVALID_HANDLE_VALUE; + } + if (!zupt_win_is_plain_directory(handle)) { + CloseHandle(handle); + errno = EACCES; + return INVALID_HANDLE_VALUE; + } + return handle; +} + +static HANDLE zupt_win_open_drive_root(const WCHAR *full) { + WCHAR root[4] = {full[0], L':', L'\\', L'\0'}; + HANDLE handle = CreateFileW( + root, FILE_LIST_DIRECTORY | FILE_TRAVERSE | FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + if (handle != INVALID_HANDLE_VALUE && !zupt_win_is_plain_directory(handle)) { + CloseHandle(handle); + handle = INVALID_HANDLE_VALUE; + } + if (handle == INVALID_HANDLE_VALUE) errno = EACCES; + return handle; +} + +static HANDLE zupt_win_create_temp(HANDLE parent, const WCHAR *name) { + UNICODE_STRING object_name; + object_name.Buffer = (PWSTR)name; + object_name.Length = (USHORT)(wcslen(name) * sizeof(WCHAR)); + object_name.MaximumLength = object_name.Length + sizeof(WCHAR); + OBJECT_ATTRIBUTES attributes; + InitializeObjectAttributes(&attributes, &object_name, OBJ_CASE_INSENSITIVE, + parent, NULL); + IO_STATUS_BLOCK status_block; + HANDLE handle = INVALID_HANDLE_VALUE; + NTSTATUS status = NtCreateFile( + &handle, FILE_READ_DATA | FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES | + DELETE | SYNCHRONIZE, + &attributes, &status_block, NULL, FILE_ATTRIBUTE_NORMAL, 0, FILE_CREATE, + FILE_NON_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT | + FILE_SYNCHRONOUS_IO_NONALERT, + NULL, 0); + if (status < 0) { + zupt_win_set_nt_errno(status); + SetLastError(errno == EEXIST ? ERROR_FILE_EXISTS : ERROR_ACCESS_DENIED); + return INVALID_HANDLE_VALUE; + } + return handle; +} + +static int zupt_win_delete_by_handle(HANDLE handle) { + FILE_DISPOSITION_INFO disposition; + disposition.DeleteFile = TRUE; + int deleted = SetFileInformationByHandle(handle, FileDispositionInfo, + &disposition, + sizeof(disposition)) != 0; + if (!deleted) errno = EACCES; + return deleted; +} + +static int zupt_win_publish_by_handle(HANDLE handle, HANDLE parent, + const WCHAR *final_name, int replace) { + size_t name_bytes = wcslen(final_name) * sizeof(WCHAR); + if (name_bytes == 0 || + name_bytes > MAXDWORD - sizeof(FILE_RENAME_INFORMATION)) + return 0; + size_t info_size = sizeof(FILE_RENAME_INFORMATION) + name_bytes; + FILE_RENAME_INFORMATION *info = + (FILE_RENAME_INFORMATION *)calloc(1, info_size); + if (!info) return 0; + info->ReplaceIfExists = replace ? TRUE : FALSE; + info->RootDirectory = parent; + info->FileNameLength = (ULONG)name_bytes; + memcpy(info->FileName, final_name, name_bytes); + IO_STATUS_BLOCK status_block; + NTSTATUS status = NtSetInformationFile( + handle, &status_block, info, (ULONG)info_size, FileRenameInformation); + free(info); + if (status < 0) zupt_win_set_nt_errno(status); + return status >= 0; +} + +/* Resolve an UTF-8 directory path one component at a time while holding a + * handle to each parent. Reparse-point ancestors are rejected. UNC and + * extended-length paths remain intentionally unsupported until they can be + * given the same handle-relative guarantees. */ +static HANDLE zupt_win_open_output_root_utf8(const char *root_path, int create) { + WCHAR *wide_root = zupt_win_utf8_to_wide_alloc( + (root_path && *root_path) ? root_path : "."); + WCHAR full[ZUPT_MAX_PATH + 256]; + if (!wide_root || + !_wfullpath(full, wide_root, sizeof(full) / sizeof(full[0]))) { + free(wide_root); + errno = EINVAL; + return INVALID_HANDLE_VALUE; + } + free(wide_root); + + if (full[0] == L'\\' && full[1] == L'\\') { + errno = EINVAL; + return INVALID_HANDLE_VALUE; + } + for (WCHAR *p = full; *p; p++) if (*p == L'/') *p = L'\\'; + if (!(full[0] && full[1] == L':' && full[2] == L'\\')) { + errno = EINVAL; + return INVALID_HANDLE_VALUE; + } + + HANDLE current = zupt_win_open_drive_root(full); + if (current == INVALID_HANDLE_VALUE) return INVALID_HANDLE_VALUE; + WCHAR *scan = full + 3; + while (*scan) { + WCHAR *separator = wcschr(scan, L'\\'); + if (separator) *separator = L'\0'; + HANDLE next = zupt_win_open_relative_dir_wide(current, scan, create); + if (next == INVALID_HANDLE_VALUE) { + if (separator) *separator = L'\\'; + CloseHandle(current); + return INVALID_HANDLE_VALUE; + } + CloseHandle(current); + current = next; + if (!separator) break; + *separator = L'\\'; + scan = separator + 1; + } + return current; +} + #else - struct stat st; - if (stat(path, &st) == 0) return (uint64_t)st.st_mtime * 1000000000ULL; - return now_ns(); +/* Resolve symlinks in the user-selected portion of an output root once, then + * use only the resulting physical path. This keeps normal macOS paths such + * as /tmp -> private/tmp usable without following a symlink after traversal + * has begun. A suffix that does not exist yet is accepted only when it has + * no unresolved ".." component. */ +static int zupt_canonical_output_root(const char *path, int create, + char resolved[ZUPT_MAX_PATH]) { + const char *root = (path && *path) ? path : "."; + size_t root_len = strlen(root); + if (root_len >= ZUPT_MAX_PATH) { errno = ENAMETOOLONG; return 0; } + + if (realpath(root, resolved)) return 1; + if (!create || errno != ENOENT) return 0; + + char probe[ZUPT_MAX_PATH]; + char suffix[ZUPT_MAX_PATH] = {0}; + memcpy(probe, root, root_len + 1); + + for (;;) { + size_t probe_len = strlen(probe); + while (probe_len > 1 && probe[probe_len - 1] == '/') + probe[--probe_len] = '\0'; + + if (realpath(probe, resolved)) break; + if (errno != ENOENT) return 0; + + char *separator = strrchr(probe, '/'); + char *leaf = separator ? separator + 1 : probe; + if (*leaf == '\0') { errno = EINVAL; return 0; } + if (strcmp(leaf, "..") == 0) { errno = EINVAL; return 0; } + + if (strcmp(leaf, ".") != 0) { + size_t leaf_len = strlen(leaf); + size_t suffix_len = strlen(suffix); + size_t separator_len = suffix_len ? 1u : 0u; + if (leaf_len + separator_len + suffix_len >= sizeof(suffix)) { + errno = ENAMETOOLONG; + return 0; + } + memmove(suffix + leaf_len + separator_len, suffix, + suffix_len + 1); + memcpy(suffix, leaf, leaf_len); + if (separator_len) suffix[leaf_len] = '/'; + } + + if (!separator) { + memcpy(probe, ".", 2); + } else if (separator == probe) { + probe[1] = '\0'; + } else { + *separator = '\0'; + } + } + + if (*suffix) { + size_t resolved_len = strlen(resolved); + size_t suffix_len = strlen(suffix); + int needs_separator = resolved_len > 0 && resolved[resolved_len - 1] != '/'; + if (resolved_len + (size_t)needs_separator + suffix_len >= ZUPT_MAX_PATH) { + errno = ENAMETOOLONG; + return 0; + } + if (needs_separator) resolved[resolved_len++] = '/'; + memcpy(resolved + resolved_len, suffix, suffix_len + 1); + } + return 1; +} + +/* Open every component of the canonical output root relative to a pinned + * descriptor. Symlinks created or exchanged after canonicalization are + * rejected, and a trailing slash is treated like the same path without it. */ +static int zupt_open_output_root(const char *path, int create) { + char resolved[ZUPT_MAX_PATH]; + if (!zupt_canonical_output_root(path, create, resolved)) return -1; + const char *root = resolved; + size_t root_len = strlen(root); + if (root_len >= ZUPT_MAX_PATH) { errno = ENAMETOOLONG; return -1; } + + int current_fd = open(root[0] == '/' ? "/" : ".", + O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (current_fd < 0) return -1; + + char copy[ZUPT_MAX_PATH]; + memcpy(copy, root, root_len + 1); + char *component = copy; + if (*component == '/') while (*component == '/') component++; + + while (*component != '\0') { + char *separator = strchr(component, '/'); + if (separator) *separator = '\0'; + if (*component != '\0' && strcmp(component, ".") != 0) { + /* The extraction root is chosen by the user, so a lexical ".." + * here is legitimate (for example, -o ../restore). It is still + * resolved relative to the pinned descriptor. Only archive entry + * components are forbidden from containing "..". */ + if (create && strcmp(component, "..") != 0 && + mkdirat(current_fd, component, 0755) != 0 && errno != EEXIST) { + close(current_fd); return -1; + } + int next_fd = openat(current_fd, component, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (next_fd < 0) { close(current_fd); return -1; } + close(current_fd); + current_fd = next_fd; + } + if (!separator) break; + component = separator + 1; + while (*component == '/') component++; + } + return current_fd; +} #endif + +typedef struct { + uint64_t device; + uint64_t file; + uint64_t size; + uint64_t mtime_marker; + uint64_t archive_mtime; +} zupt_input_identity_t; + +static int zupt_input_identity_equal(const zupt_input_identity_t *left, + const zupt_input_identity_t *right) { + return left && right && left->device == right->device && + left->file == right->file && left->size == right->size && + left->mtime_marker == right->mtime_marker; +} + +static int zupt_input_identity_from_stream(FILE *stream, + zupt_input_identity_t *identity) { + if (!stream || !identity) { errno = EINVAL; return 0; } +#ifdef _WIN32 + intptr_t os_handle = _get_osfhandle(_fileno(stream)); + if (os_handle == -1) { errno = EBADF; return 0; } + BY_HANDLE_FILE_INFORMATION info; + LARGE_INTEGER size; + HANDLE handle = (HANDLE)os_handle; + if (!GetFileInformationByHandle(handle, &info) || + !GetFileSizeEx(handle, &size) || size.QuadPart < 0 || + GetFileType(handle) != FILE_TYPE_DISK || + (info.dwFileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT))) { + errno = EACCES; + return 0; + } + identity->device = (uint64_t)info.dwVolumeSerialNumber; + identity->file = ((uint64_t)info.nFileIndexHigh << 32) | + (uint64_t)info.nFileIndexLow; + identity->size = (uint64_t)size.QuadPart; + uint64_t filetime_ticks = + ((uint64_t)info.ftLastWriteTime.dwHighDateTime << 32) | + (uint64_t)info.ftLastWriteTime.dwLowDateTime; + const uint64_t windows_unix_epoch = 116444736000000000ULL; + if (filetime_ticks < windows_unix_epoch || + filetime_ticks - windows_unix_epoch > UINT64_MAX / 100ULL) { + errno = EOVERFLOW; + return 0; + } + identity->mtime_marker = filetime_ticks; + identity->archive_mtime = + (filetime_ticks - windows_unix_epoch) * 100ULL; +#else + struct stat info; + if (fstat(fileno(stream), &info) != 0 || !S_ISREG(info.st_mode) || + info.st_size < 0) { + if (errno == 0) errno = EINVAL; + return 0; + } + identity->device = (uint64_t)info.st_dev; + identity->file = (uint64_t)info.st_ino; + identity->size = (uint64_t)info.st_size; +#if defined(__APPLE__) + identity->mtime_marker = + (uint64_t)info.st_mtimespec.tv_sec * 1000000000ULL + + (uint64_t)info.st_mtimespec.tv_nsec; +#else + identity->mtime_marker = + (uint64_t)info.st_mtim.tv_sec * 1000000000ULL + + (uint64_t)info.st_mtim.tv_nsec; +#endif + identity->archive_mtime = identity->mtime_marker; +#endif + return 1; +} + +/* Resolve and pin the parent, then open the leaf without following a + * symlink/reparse point. Validation is performed on the descriptor actually + * consumed by compression, closing the collection-to-read race. */ +static FILE *zupt_open_regular_input(const char *path, + zupt_input_identity_t *identity) { + if (!path || !*path || !identity || strlen(path) >= ZUPT_MAX_PATH) { + errno = EINVAL; + return NULL; + } + char split[ZUPT_MAX_PATH]; + memcpy(split, path, strlen(path) + 1); +#ifdef _WIN32 + if ((split[0] == '/' || split[0] == '\\') || + (split[0] != '\0' && split[1] == ':' && + split[2] != '/' && split[2] != '\\')) { + errno = EINVAL; + return NULL; + } + char *slash = strrchr(split, '/'); + char *backslash = strrchr(split, '\\'); + char *separator = slash; + if (backslash && (!separator || backslash > separator)) + separator = backslash; +#else + char *separator = strrchr(split, '/'); +#endif + const char *leaf = split; + const char *parent = "."; +#ifdef _WIN32 + char drive_root[4] = {0}; +#endif + if (separator) { + leaf = separator + 1; +#ifdef _WIN32 + if (separator == split + 2 && split[1] == ':') { + drive_root[0] = split[0]; + drive_root[1] = ':'; + drive_root[2] = '\\'; + parent = drive_root; + } else { +#endif + *separator = '\0'; + if (split[0] != '\0') parent = split; +#ifndef _WIN32 + else parent = "/"; +#endif +#ifdef _WIN32 + } +#endif + } + if (*leaf == '\0' || strcmp(leaf, ".") == 0 || + strcmp(leaf, "..") == 0) { + errno = EINVAL; + return NULL; + } + +#ifdef _WIN32 + HANDLE parent_handle = zupt_win_open_output_root_utf8(parent, 0); + if (parent_handle == INVALID_HANDLE_VALUE) return NULL; + WCHAR *wide_leaf = zupt_win_utf8_to_wide_alloc(leaf); + if (!wide_leaf) { + CloseHandle(parent_handle); + errno = EINVAL; + return NULL; + } + UNICODE_STRING name; + name.Buffer = wide_leaf; + name.Length = (USHORT)(wcslen(wide_leaf) * sizeof(WCHAR)); + name.MaximumLength = name.Length + sizeof(WCHAR); + OBJECT_ATTRIBUTES attributes; + InitializeObjectAttributes(&attributes, &name, OBJ_CASE_INSENSITIVE, + parent_handle, NULL); + IO_STATUS_BLOCK status_block; + HANDLE handle = INVALID_HANDLE_VALUE; + NTSTATUS status = NtCreateFile( + &handle, FILE_READ_DATA | FILE_READ_ATTRIBUTES | SYNCHRONIZE, + &attributes, &status_block, NULL, FILE_ATTRIBUTE_NORMAL, + FILE_SHARE_READ, FILE_OPEN, + FILE_NON_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT | + FILE_SYNCHRONOUS_IO_NONALERT, + NULL, 0); + free(wide_leaf); + CloseHandle(parent_handle); + if (status < 0 || handle == INVALID_HANDLE_VALUE) { + zupt_win_set_nt_errno(status); + return NULL; + } + int descriptor = _open_osfhandle((intptr_t)handle, + _O_RDONLY | _O_BINARY); + if (descriptor < 0) { + CloseHandle(handle); + return NULL; + } + FILE *stream = _fdopen(descriptor, "rb"); + if (!stream) { + _close(descriptor); + return NULL; + } +#else + int parent_fd = zupt_open_output_root(parent, 0); + if (parent_fd < 0) return NULL; + int descriptor = openat(parent_fd, leaf, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); + int open_errno = errno; + close(parent_fd); + errno = open_errno; + if (descriptor < 0) return NULL; + int descriptor_flags = fcntl(descriptor, F_GETFL); + if (descriptor_flags < 0 || + fcntl(descriptor, F_SETFL, descriptor_flags & ~O_NONBLOCK) != 0) { + int saved_errno = errno; + close(descriptor); + errno = saved_errno; + return NULL; + } + FILE *stream = fdopen(descriptor, "rb"); + if (!stream) { + int saved_errno = errno; + close(descriptor); + errno = saved_errno; + return NULL; + } +#endif + if (!zupt_input_identity_from_stream(stream, identity)) { + int saved_errno = errno; + fclose(stream); + errno = saved_errno; + return NULL; + } + return stream; +} + +/* SECURITY: create a private temporary output relative to a pinned extraction + * root. Every parent is opened without following symlinks. The final name + * is published only after validation and a successful close, and an existing + * regular file, symlink, or hardlink is never overwritten. + * + * Defends against the case where an attacker has placed a symlink in the + * output directory before extraction, e.g. ~/Downloads/innocent.txt → /etc/passwd. + * On Linux/BSD/macOS, every directory is opened with openat(), O_DIRECTORY + * and O_NOFOLLOW. The leaf uses O_NOFOLLOW + O_EXCL, so extraction never + * truncates a pre-existing symlink, hardlink, or regular file. Refusing an + * existing leaf also closes the hardlink variant of the same attack. + * + * Windows rejects reparse-point parents and uses CREATE_NEW with + * FILE_FLAG_OPEN_REPARSE_POINT for the leaf. + */ +static int zupt_safe_fopen_output(const char *dir, const char *entry, + char *display, size_t display_size, + zupt_output_file_t *output) { + zupt_output_init(output); + if (!entry || !*entry || !display || display_size == 0) { + errno = EINVAL; + return 0; + } + int written; + if (dir) written = snprintf(display, display_size, "%s%c%s", dir, ZUPT_PATH_SEP, entry); + else written = snprintf(display, display_size, "%s", entry); + if (written < 0 || (size_t)written >= display_size) { + errno = ENAMETOOLONG; + return 0; + } + +#if defined(_WIN32) + HANDLE current = zupt_win_open_output_root_utf8(dir, 1); + if (current == INVALID_HANDLE_VALUE) return 0; + + char relative[ZUPT_MAX_PATH]; + size_t entry_len = strlen(entry); + if (entry_len == 0 || entry_len >= sizeof(relative)) { + CloseHandle(current); + errno = ENAMETOOLONG; + return 0; + } + memcpy(relative, entry, entry_len + 1); + for (char *p = relative; *p; p++) if (*p == '\\') *p = '/'; + + char *component = relative; + for (char *p = relative; ; p++) { + if (*p != '/' && *p != '\0') continue; + char saved = *p; + *p = '\0'; + if (*component == '\0') { + if (saved == '\0') { CloseHandle(current); errno = EINVAL; return 0; } + } else if (saved == '\0') { + if (!zupt_win_archive_component_to_wide(component, + output->final_name)) { + CloseHandle(current); + errno = EINVAL; + return 0; + } + break; + } else { + WCHAR archive_component[ZUPT_MAX_PATH]; + if (!zupt_win_archive_component_to_wide(component, + archive_component)) { + CloseHandle(current); + errno = EINVAL; + return 0; + } + HANDLE next = zupt_win_open_relative_dir_wide( + current, archive_component, 1); + if (next == INVALID_HANDLE_VALUE) { + CloseHandle(current); + return 0; + } + CloseHandle(current); + current = next; + } + component = p + 1; + } + + uint8_t nonce[12]; + zupt_random_bytes(nonce, sizeof(nonce)); + WCHAR nonce_hex[25]; + for (size_t i = 0; i < sizeof(nonce); i++) + swprintf(nonce_hex + i * 2, 3, L"%02x", nonce[i]); + WCHAR temp_name[48]; + if (swprintf(temp_name, sizeof(temp_name) / sizeof(temp_name[0]), + L".zupt-tmp-%ls", nonce_hex) < 0) { + CloseHandle(current); + return 0; + } + + HANDLE handle = zupt_win_create_temp(current, temp_name); + if (handle == INVALID_HANDLE_VALUE) { CloseHandle(current); return 0; } + if (!DuplicateHandle(GetCurrentProcess(), handle, GetCurrentProcess(), + &output->temp_handle, 0, FALSE, + DUPLICATE_SAME_ACCESS)) { + zupt_win_delete_by_handle(handle); + CloseHandle(handle); + CloseHandle(current); + return 0; + } + output->parent_handle = current; + int fd = _open_osfhandle((intptr_t)handle, _O_WRONLY | _O_BINARY); + if (fd < 0) { + CloseHandle(handle); + zupt_win_delete_by_handle(output->temp_handle); + CloseHandle(output->temp_handle); + CloseHandle(output->parent_handle); + zupt_output_init(output); + return 0; + } + output->stream = _fdopen(fd, "wb"); + if (!output->stream) { + _close(fd); + zupt_win_delete_by_handle(output->temp_handle); + CloseHandle(output->temp_handle); + CloseHandle(output->parent_handle); + zupt_output_init(output); + return 0; + } + return 1; +#else + char relative[ZUPT_MAX_PATH]; + size_t entry_len = strlen(entry); + if (entry_len == 0 || entry_len >= sizeof(relative)) { + errno = ENAMETOOLONG; + return 0; + } + memcpy(relative, entry, entry_len + 1); + for (char *p = relative; *p; p++) if (*p == '\\') *p = '/'; + + int parent_fd = zupt_open_output_root(dir, 1); + if (parent_fd < 0) return 0; + + char *component = relative; + for (char *p = relative; ; p++) { + if (*p != '/' && *p != '\\' && *p != '\0') continue; + char saved = *p; + *p = '\0'; + if (*component == '\0' || strcmp(component, ".") == 0) { + if (saved == '\0') { close(parent_fd); errno = EINVAL; return 0; } + } else if (saved == '\0') { + if (strlen(component) >= sizeof(output->final_name)) { + close(parent_fd); errno = ENAMETOOLONG; return 0; + } + memcpy(output->final_name, component, strlen(component) + 1); + + uint8_t nonce[12]; + zupt_random_bytes(nonce, sizeof(nonce)); + char nonce_hex[25]; + for (size_t i = 0; i < sizeof(nonce); i++) + snprintf(nonce_hex + i * 2, 3, "%02x", nonce[i]); + snprintf(output->temp_name, sizeof(output->temp_name), + ".zupt-tmp-%s", nonce_hex); + int fd = openat(parent_fd, output->temp_name, + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + 0600); + if (fd < 0) { close(parent_fd); return 0; } + output->stream = fdopen(fd, "wb"); + if (!output->stream) { + close(fd); unlinkat(parent_fd, output->temp_name, 0); + close(parent_fd); return 0; + } + output->parent_fd = parent_fd; + return 1; + } else { + if (mkdirat(parent_fd, component, 0755) != 0 && errno != EEXIST) { + close(parent_fd); + return 0; + } + int next_fd = openat(parent_fd, component, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (next_fd < 0) { close(parent_fd); return 0; } + close(parent_fd); + parent_fd = next_fd; + } + if (saved == '\0') break; + component = p + 1; + } + close(parent_fd); + errno = EINVAL; + return 0; +#endif +} + +/* Open a private, seekable stream in the destination archive's directory. + * The POSIX parent is canonicalized once and then pinned component-by-component; + * Windows rejects reparse-point parents. The + * caller may overwrite an existing archive at publication time, but only by + * replacing that directory entry: a symlink or hardlink target is never + * opened or truncated. */ +static int zupt_safe_fopen_archive(const char *path, + zupt_output_file_t *output) { + zupt_output_init(output); + if (!path || !*path) { errno = EINVAL; return 0; } + + size_t path_len = strlen(path); + if (path_len >= ZUPT_MAX_PATH) { errno = ENAMETOOLONG; return 0; } + + const char *separator = strrchr(path, '/'); +#if defined(_WIN32) + const char *backslash = strrchr(path, '\\'); + if (!separator || (backslash && backslash > separator)) separator = backslash; +#endif + const char *leaf = separator ? separator + 1 : path; + size_t leaf_len = strlen(leaf); + if (leaf_len == 0 || leaf_len >= ZUPT_MAX_PATH || + (leaf_len == 1 && leaf[0] == '.') || + (leaf_len == 2 && leaf[0] == '.' && leaf[1] == '.')) { + errno = EINVAL; + return 0; + } + + char parent[ZUPT_MAX_PATH]; + if (!separator) { + memcpy(parent, ".", 2); + } else { + size_t parent_len = (size_t)(separator - path); + if (parent_len == 0) { + parent[0] = *separator; + parent[1] = '\0'; +#if defined(_WIN32) + } else if (parent_len == 2 && path[1] == ':') { + memcpy(parent, path, 3); + parent[3] = '\0'; +#endif + } else { + if (parent_len >= sizeof(parent)) { errno = ENAMETOOLONG; return 0; } + memcpy(parent, path, parent_len); + parent[parent_len] = '\0'; + } + } + +#if defined(_WIN32) + if (!zupt_path_is_safe(leaf) || + !zupt_win_component_to_wide(leaf, CP_UTF8, output->final_name)) { + errno = EINVAL; + return 0; + } + HANDLE current = zupt_win_open_output_root_utf8(parent, 0); + if (current == INVALID_HANDLE_VALUE) return 0; + + HANDLE handle = INVALID_HANDLE_VALUE; + for (unsigned int attempt = 0; attempt < 16; attempt++) { + uint8_t nonce[12]; + zupt_random_bytes(nonce, sizeof(nonce)); + WCHAR nonce_hex[25]; + for (size_t i = 0; i < sizeof(nonce); i++) + swprintf(nonce_hex + i * 2, 3, L"%02x", nonce[i]); + WCHAR temp_name[64]; + if (swprintf(temp_name, + sizeof(temp_name) / sizeof(temp_name[0]), + L".zupt-archive-%ls", nonce_hex) < 0) { + CloseHandle(current); + return 0; + } + handle = zupt_win_create_temp(current, temp_name); + if (handle != INVALID_HANDLE_VALUE) break; + if (GetLastError() != ERROR_FILE_EXISTS && + GetLastError() != ERROR_ALREADY_EXISTS) + break; + } + if (handle == INVALID_HANDLE_VALUE) { CloseHandle(current); return 0; } + if (!DuplicateHandle(GetCurrentProcess(), handle, GetCurrentProcess(), + &output->temp_handle, 0, FALSE, + DUPLICATE_SAME_ACCESS)) { + zupt_win_delete_by_handle(handle); + CloseHandle(handle); + CloseHandle(current); + return 0; + } + output->parent_handle = current; + int fd = _open_osfhandle((intptr_t)handle, _O_RDWR | _O_BINARY); + if (fd < 0) { + CloseHandle(handle); + zupt_win_delete_by_handle(output->temp_handle); + CloseHandle(output->temp_handle); + CloseHandle(output->parent_handle); + zupt_output_init(output); + return 0; + } + output->stream = _fdopen(fd, "w+b"); + if (!output->stream) { + _close(fd); + zupt_win_delete_by_handle(output->temp_handle); + CloseHandle(output->temp_handle); + CloseHandle(output->parent_handle); + zupt_output_init(output); + return 0; + } + return 1; +#else + if (leaf_len >= sizeof(output->final_name)) { + errno = ENAMETOOLONG; + return 0; + } + memcpy(output->final_name, leaf, leaf_len + 1); + int parent_fd = zupt_open_output_root(parent, 0); + if (parent_fd < 0) return 0; + + int fd = -1; + for (unsigned int attempt = 0; attempt < 16; attempt++) { + uint8_t nonce[12]; + zupt_random_bytes(nonce, sizeof(nonce)); + char nonce_hex[25]; + for (size_t i = 0; i < sizeof(nonce); i++) + snprintf(nonce_hex + i * 2, 3, "%02x", nonce[i]); + int n = snprintf(output->temp_name, sizeof(output->temp_name), + ".zupt-archive-%s", nonce_hex); + if (n < 0 || (size_t)n >= sizeof(output->temp_name)) { + close(parent_fd); + errno = ENAMETOOLONG; + return 0; + } + fd = openat(parent_fd, output->temp_name, + O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + 0600); + if (fd >= 0 || errno != EEXIST) break; + } + if (fd < 0) { close(parent_fd); return 0; } + output->stream = fdopen(fd, "w+b"); + if (!output->stream) { + close(fd); + unlinkat(parent_fd, output->temp_name, 0); + close(parent_fd); + return 0; + } + output->parent_fd = parent_fd; + return 1; +#endif +} + +/* Close and either atomically publish or remove the private temporary file. + * Returns zero only when the requested outcome completed successfully. */ +static int zupt_finish_output(zupt_output_file_t *output, int publish, + int replace) { + int failed = 0; + if (!output || !output->stream) return -1; + if (ferror(output->stream)) failed = 1; + if (fflush(output->stream) != 0) failed = 1; +#if !defined(_WIN32) + if (publish && !failed && fsync(fileno(output->stream)) != 0) failed = 1; +#endif + if (fclose(output->stream) != 0) failed = 1; + output->stream = NULL; + if (failed) publish = 0; + +#if defined(_WIN32) + if (publish && !failed && !FlushFileBuffers(output->temp_handle)) + failed = 1; + if (publish && !failed && + !zupt_win_publish_by_handle(output->temp_handle, + output->parent_handle, + output->final_name, replace)) + failed = 1; + if (!publish || failed) { + if (!zupt_win_delete_by_handle(output->temp_handle)) failed = 1; + } + if (!CloseHandle(output->temp_handle)) failed = 1; + if (!CloseHandle(output->parent_handle)) failed = 1; + output->temp_handle = INVALID_HANDLE_VALUE; + output->parent_handle = INVALID_HANDLE_VALUE; +#else + int namespace_changed = 0; + if (publish) { + int publish_result = replace + ? renameat(output->parent_fd, output->temp_name, + output->parent_fd, output->final_name) + : linkat(output->parent_fd, output->temp_name, + output->parent_fd, output->final_name, 0); + if (publish_result != 0) failed = 1; + else namespace_changed = 1; + } + if (unlinkat(output->parent_fd, output->temp_name, 0) == 0) { + namespace_changed = 1; + } else if (errno != ENOENT) { + failed = 1; + } + /* Directory fsync is unsupported on some otherwise valid filesystems. + * Attempt it for crash durability without weakening runtime atomicity. */ + if (namespace_changed) (void)fsync(output->parent_fd); + close(output->parent_fd); + output->parent_fd = -1; +#endif + return failed ? -1 : 0; +} + +zupt_atomic_output_t *zupt_atomic_output_open(const char *output_path, + FILE **stream_out) { + if (!stream_out) { errno = EINVAL; return NULL; } + *stream_out = NULL; + zupt_atomic_output_t *output = + (zupt_atomic_output_t *)calloc(1, sizeof(*output)); + if (!output) return NULL; + if (!zupt_safe_fopen_archive(output_path, output)) { + free(output); + return NULL; + } + *stream_out = output->stream; + return output; +} + +int zupt_atomic_output_finish(zupt_atomic_output_t *output, int publish) { + if (!output) { errno = EINVAL; return -1; } + int result = zupt_finish_output(output, publish, 1); + free(output); + return result; +} + +static int zupt_write_verified_chunk(FILE *stream, const uint8_t *data, + size_t length, uint64_t expected_size, + uint64_t *written, uint64_t *hash) { + if (!stream || !written || !hash || (length > 0 && !data) || + *written > expected_size || (uint64_t)length > expected_size - *written) + return 0; + if (length > 0) { + if (fwrite(data, 1, length, stream) != length) return 0; + *hash = zupt_xxh64(data, length, *hash); + } + *written += (uint64_t)length; + return 1; } /* Safe ftello wrapper: returns 0 on error (caller should check context) */ /* F-09 of v2.3.1: serialize the canonical per-block frame preface for use as * extended-AAD input to the per-block MAC. Format is fixed-width little-endian - * (NOT the on-disk varint encoding — varints are non-canonical, two encodings - * of the same logical value would produce different MACs and either break - * roundtrip or open a malleability window). + * rather than the variable-width on-disk representation, so the authenticated + * input is independent of parser storage and stays stable across platforms. * * Layout: block_type (1B) || codec_id (2B LE) || block_flags (2B LE) * || uncompressed_size (8B LE) || compressed_size (8B LE) @@ -652,6 +1967,187 @@ static uint32_t index_get_u32(const uint8_t *buf) { * COMPRESSION * ═══════════════════════════════════════════════════════════════════ */ +/* Compare existing paths by kernel identity, following the final symlink. + * Return 1 when equal, 0 when the output does not exist or differs, and -1 on + * an inspection error. An alias of an input must never be replaced at final + * archive publication, even when --force was requested by the CLI. */ +static int zupt_compress_paths_same_file(const char *output_path, + const char *input_path) { +#ifdef _WIN32 + wchar_t *wide_output = zupt_win_utf8_to_wide_alloc(output_path); + wchar_t *wide_input = zupt_win_utf8_to_wide_alloc(input_path); + if (!wide_output || !wide_input) { + free(wide_output); + free(wide_input); + errno = EINVAL; + return -1; + } + HANDLE output = CreateFileW( + wide_output, FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL); + free(wide_output); + if (output == INVALID_HANDLE_VALUE) { + DWORD error = GetLastError(); + free(wide_input); + if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND) + return 0; + errno = EACCES; + return -1; + } + HANDLE input = CreateFileW( + wide_input, FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL); + free(wide_input); + if (input == INVALID_HANDLE_VALUE) { + CloseHandle(output); + errno = EACCES; + return -1; + } + BY_HANDLE_FILE_INFORMATION output_info; + BY_HANDLE_FILE_INFORMATION input_info; + int inspected = GetFileInformationByHandle(output, &output_info) != 0 && + GetFileInformationByHandle(input, &input_info) != 0; + if (!CloseHandle(input)) inspected = 0; + if (!CloseHandle(output)) inspected = 0; + if (!inspected) { + errno = EIO; + return -1; + } + return output_info.dwVolumeSerialNumber == input_info.dwVolumeSerialNumber && + output_info.nFileIndexHigh == input_info.nFileIndexHigh && + output_info.nFileIndexLow == input_info.nFileIndexLow; +#else + struct stat output_info; + struct stat input_info; + if (stat(output_path, &output_info) != 0) { + if (errno == ENOENT || errno == ENOTDIR) return 0; + return -1; + } + if (stat(input_path, &input_info) != 0) return -1; + return output_info.st_dev == input_info.st_dev && + output_info.st_ino == input_info.st_ino; +#endif +} + +static int zupt_compare_path_keys(const void *left, const void *right) { + const char *const *a = (const char *const *)left; + const char *const *b = (const char *const *)right; + return strcmp(*a, *b); +} + +#ifdef _WIN32 +static int zupt_compare_windows_path_keys(const void *left, + const void *right) { + const WCHAR *const *a = (const WCHAR *const *)left; + const WCHAR *const *b = (const WCHAR *const *)right; + int relation = CompareStringOrdinal(*a, -1, *b, -1, TRUE); + if (relation == CSTR_LESS_THAN) return -1; + if (relation == CSTR_GREATER_THAN) return 1; + if (relation == CSTR_EQUAL) return 0; + return wcscmp(*a, *b); +} +#endif + +/* Every archive entry must map to one portable extraction destination. + * Separators are canonicalized and ASCII case is folded for collision + * detection because Windows extraction is case-insensitive. */ +static zupt_error_t zupt_validate_archive_destinations( + const char **archive_paths, int count, int require_safe_paths) { + if (count < 0 || (count > 0 && !archive_paths)) + return ZUPT_ERR_INVALID; + if (count < 2) { + if (count == 1 && require_safe_paths && + !zupt_path_is_safe(archive_paths[0])) { + fprintf(stderr, "Error: unsafe archive path.\n"); + return ZUPT_ERR_INVALID; + } + return ZUPT_OK; + } + char **keys = (char **)calloc((size_t)count, sizeof(*keys)); + if (!keys) return ZUPT_ERR_NOMEM; + zupt_error_t result = ZUPT_OK; + for (int i = 0; i < count; i++) { + if (require_safe_paths && !zupt_path_is_safe(archive_paths[i])) { + fprintf(stderr, "Error: unsafe archive path.\n"); + result = ZUPT_ERR_INVALID; + break; + } + keys[i] = zupt_normalize_archive_path(archive_paths[i], 1); + if (!keys[i]) { + result = ZUPT_ERR_NOMEM; + break; + } + } + if (result == ZUPT_OK) { + qsort(keys, (size_t)count, sizeof(*keys), zupt_compare_path_keys); + for (int i = 1; i < count; i++) { + size_t previous_length = strlen(keys[i - 1]); + if (strcmp(keys[i - 1], keys[i]) == 0 || + (strncmp(keys[i - 1], keys[i], previous_length) == 0 && + keys[i][previous_length] == '/')) { + fprintf(stderr, + "Error: archive paths collide after portable normalization: %s\n", + keys[i]); + result = ZUPT_ERR_INVALID; + break; + } + } + } +#ifdef _WIN32 + /* CompareStringOrdinal models the case-insensitive Unicode namespace used + * by normal Windows extraction roots; ASCII folding alone misses pairs + * such as non-ASCII upper/lower-case spellings. */ + WCHAR **windows_keys = NULL; + if (result == ZUPT_OK) { + windows_keys = (WCHAR **)calloc((size_t)count, sizeof(*windows_keys)); + if (!windows_keys) result = ZUPT_ERR_NOMEM; + } + if (result == ZUPT_OK) { + for (int i = 0; i < count; i++) { + windows_keys[i] = zupt_win_utf8_to_wide_alloc(keys[i]); + if (!windows_keys[i]) { + fprintf(stderr, "Error: archive path is not valid UTF-8.\n"); + result = ZUPT_ERR_INVALID; + break; + } + } + } + if (result == ZUPT_OK) { + qsort(windows_keys, (size_t)count, sizeof(*windows_keys), + zupt_compare_windows_path_keys); + for (int i = 1; i < count; i++) { + size_t previous_length = wcslen(windows_keys[i - 1]); + size_t current_length = wcslen(windows_keys[i]); + int equal = CompareStringOrdinal(windows_keys[i - 1], -1, + windows_keys[i], -1, + TRUE) == CSTR_EQUAL; + int prefix = current_length > previous_length && + windows_keys[i][previous_length] == L'/' && + CompareStringOrdinal(windows_keys[i - 1], + (int)previous_length, + windows_keys[i], + (int)previous_length, + TRUE) == CSTR_EQUAL; + if (equal || prefix) { + fprintf(stderr, + "Error: archive paths collide in the Windows namespace.\n"); + result = ZUPT_ERR_INVALID; + break; + } + } + } + if (windows_keys) { + for (int i = 0; i < count; i++) free(windows_keys[i]); + free(windows_keys); + } +#endif + for (int i = 0; i < count; i++) free(keys[i]); + free(keys); + return result; +} + /* F-12 of v2.4.3: write an optional comment block to the archive between * the data blocks and the central index. Stores plaintext UTF-8 text * (max ZUPT_MAX_COMMENT_LEN bytes) under a new block type @@ -739,14 +2235,46 @@ zupt_error_t zupt_compress_files(const char *output_path, const char **disk_paths, int num_files, zupt_options_t *opts) { - if (opts->block_size == 0) opts->block_size = auto_block_size(opts->level); + if (num_files < 0 || + (uint64_t)num_files > + (uint64_t)(ZUPT_MAX_INDEX_ALLOC_BYTES / + sizeof(zupt_index_entry_t))) { + fprintf(stderr, "Error: file count exceeds the safe index-memory limit.\n"); + return ZUPT_ERR_OVERFLOW; + } + zupt_error_t path_error = + zupt_validate_archive_destinations(arc_paths, num_files, 1); + if (path_error != ZUPT_OK) return path_error; + for (int file_index = 0; file_index < num_files; file_index++) { + int same_file = zupt_compress_paths_same_file( + output_path, disk_paths[file_index]); + if (same_file > 0) { + fprintf(stderr, + "Error: archive output and input '%s' are the same file.\n", + disk_paths[file_index]); + return ZUPT_ERR_INVALID; + } + if (same_file < 0) { + fprintf(stderr, + "Error: cannot inspect archive output/input identity safely: %s\n", + strerror(errno)); + return ZUPT_ERR_IO; + } + } + if (opts->block_size == 0) opts->block_size = opts->dedup ? auto_block_size_dedup(opts->level) : auto_block_size(opts->level); /* Resolve AUTO codec before compression */ if (opts->codec_id == ZUPT_CODEC_AUTO) opts->codec_id = zupt_resolve_auto_codec(); - FILE *out = fopen(output_path, "wb"); - if (!out) { fprintf(stderr, "Error: Cannot create '%s': %s\n", output_path, strerror(errno)); return ZUPT_ERR_IO; } + FILE *out = NULL; + zupt_atomic_output_t *atomic_output = + zupt_atomic_output_open(output_path, &out); + if (!atomic_output) { + fprintf(stderr, "Error: Cannot create '%s': %s\n", + output_path, strerror(errno)); + return ZUPT_ERR_IO; + } int write_err = 0; /* Accumulate write errors */ @@ -763,17 +2291,17 @@ zupt_error_t zupt_compress_files(const char *output_path, hdr.global_flags |= ZUPT_FLAG_AAD_PREFACE; opts->keyring.use_preface_aad = 1; } - if (opts->threads > 1) hdr.global_flags |= ZUPT_FLAG_MULTITHREADED; if (opts->dedup) hdr.global_flags |= ZUPT_FLAG_DEDUP; + if (opts->dedup && opts->encrypt) + hdr.global_flags |= ZUPT_FLAG_AUTH_DEDUP_REFS; hdr.creation_time = now_ns(); gen_uuid(hdr.archive_id); - if (fwrite(&hdr, sizeof(hdr), 1, out) != 1) write_err = 1; + if (zupt_write_archive_header(out, &hdr) != 0) write_err = 1; if (opts->encrypt) { zupt_error_t enc_err = write_enc_header(out, &hdr, opts); if (enc_err != ZUPT_OK) { - fclose(out); - unlink(output_path); + zupt_atomic_output_finish(atomic_output, 0); return enc_err; } } @@ -781,7 +2309,11 @@ zupt_error_t zupt_compress_files(const char *output_path, zupt_index_entry_t *index = (zupt_index_entry_t*)calloc((size_t)num_files, sizeof(zupt_index_entry_t)); uint8_t *rbuf = (uint8_t*)malloc(opts->block_size); uint8_t *cbuf = (uint8_t*)malloc(zupt_lzh_bound(opts->block_size) + 512); - if (!index || !rbuf || !cbuf) { free(index); free(rbuf); free(cbuf); fclose(out); return ZUPT_ERR_NOMEM; } + if (!index || !rbuf || !cbuf) { + free(index); free(rbuf); free(cbuf); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_NOMEM; + } uint64_t total_blocks = 0, total_in = 0, total_out = 0; /* block_seq is now PER-FILE: resets at the start of each file's compress. @@ -792,6 +2324,11 @@ zupt_error_t zupt_compress_files(const char *output_path, /* Dedup context (NULL if --dedup not set) */ zupt_dedup_ctx_t *dedup = opts->dedup ? zupt_dedup_init() : NULL; + if (opts->dedup && !dedup) { + free(index); free(rbuf); free(cbuf); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_NOMEM; + } /* Create parallel context if multi-threaded. * Dedup requires sequential block ordering, so force single-threaded. */ @@ -812,35 +2349,61 @@ zupt_error_t zupt_compress_files(const char *output_path, if (!opts->quiet) fprintf(stderr, " Thread creation failed, using single thread\n"); } } + /* Record multithreading only after worker creation succeeds. Dedup and a + * worker-start failure both use the single-threaded encoder, so marking + * either archive as multithreaded would make its metadata inaccurate. */ + if (effective_threads > 1) { + int64_t output_position = ftello(out); + hdr.global_flags |= ZUPT_FLAG_MULTITHREADED; + if (output_position < 0 || fseeko(out, 0, SEEK_SET) != 0 || + zupt_write_archive_header(out, &hdr) != 0 || + fseeko(out, output_position, SEEK_SET) != 0) { + zpar_destroy(pctx); + zupt_dedup_free(dedup); + free(index); free(rbuf); free(cbuf); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_IO; + } + } for (int fi = 0; fi < num_files; fi++) { /* Per-file block_seq counter (resets to 0 for each file) — used as * AAD in encrypt/decrypt. Extract recomputes the same counter from * the same per-file zero baseline, ensuring MAC consistency. */ uint64_t block_seq = 0; - FILE *inf = fopen(disk_paths[fi], "rb"); - if (!inf) { fprintf(stderr, " Skipping: %s (%s)\n", disk_paths[fi], strerror(errno)); continue; } + zupt_input_identity_t input_identity; + FILE *inf = zupt_open_regular_input(disk_paths[fi], &input_identity); + if (!inf) { + fprintf(stderr, "Error: Cannot read input '%s': %s\n", + disk_paths[fi], strerror(errno)); + write_err = 1; + break; + } - fseeko(inf, 0, SEEK_END); - int64_t file_size = ftello(inf); - if (file_size < 0) { fclose(inf); continue; } - fseeko(inf, 0, SEEK_SET); + if (input_identity.size > INT64_MAX) { + fprintf(stderr, "Error: Cannot determine input size '%s'\n", + disk_paths[fi]); + fclose(inf); + write_err = 1; + break; + } + int64_t file_size = (int64_t)input_identity.size; strncpy(index[fi].path, arc_paths[fi], ZUPT_MAX_PATH-1); index[fi].uncompressed_size = (uint64_t)file_size; index[fi].first_block_offset = safe_ftello(out); - index[fi].modification_time = get_mtime(disk_paths[fi]); + index[fi].modification_time = input_identity.archive_mtime; index[fi].attributes = 0644; index[fi].block_count = 0; char sz_buf[32]; zupt_format_size((uint64_t)file_size, sz_buf, sizeof(sz_buf)); - if (opts->verbose) + if (zupt_internal_verbose(opts)) fprintf(stderr, " %s (%s)\n", arc_paths[fi], sz_buf); /* Chained hash: xxh64 over concatenated file content */ uint64_t file_hash_state = 0; uint64_t file_comp = 0; - size_t remaining = (size_t)file_size; + uint64_t remaining = (uint64_t)file_size; uint64_t file_done = 0; if (pctx && effective_threads > 1) { @@ -850,7 +2413,8 @@ zupt_error_t zupt_compress_files(const char *output_path, uint64_t *pending_seqs = (uint64_t *)malloc((size_t)effective_threads * sizeof(uint64_t)); if (!pending_slots || !pending_seqs) { free(pending_slots); free(pending_seqs); fclose(inf); - write_err = 1; continue; + write_err = 1; + break; } while (remaining > 0) { @@ -858,9 +2422,14 @@ zupt_error_t zupt_compress_files(const char *output_path, /* Fill batch: read and submit up to N blocks */ while (remaining > 0 && npending < effective_threads) { - size_t chunk = remaining < opts->block_size ? remaining : opts->block_size; + size_t chunk = remaining < opts->block_size + ? (size_t)remaining : opts->block_size; size_t nread = fread(rbuf, 1, chunk, inf); - if (nread == 0) break; + if (nread != chunk) { + fprintf(stderr, " Read failed or input changed: %s\n", disk_paths[fi]); + write_err = 1; + break; + } /* Chained hash computed in main thread (sequential, fast) */ file_hash_state = zupt_xxh64(rbuf, nread, file_hash_state); @@ -914,7 +2483,7 @@ zupt_error_t zupt_compress_files(const char *output_path, if (write_err) break; - if (!opts->verbose && !opts->quiet && file_size > (int64_t)opts->block_size) + if (!zupt_internal_verbose(opts) && !opts->quiet && file_size > (int64_t)opts->block_size) show_progress(arc_paths[fi], file_done, (uint64_t)file_size); } @@ -923,30 +2492,50 @@ zupt_error_t zupt_compress_files(const char *output_path, } else { /* ─── SINGLE-THREADED COMPRESSION PATH (bit-for-bit v0.5.1) ─── */ while (remaining > 0) { - size_t chunk = remaining < opts->block_size ? remaining : opts->block_size; + size_t chunk = remaining < opts->block_size + ? (size_t)remaining : opts->block_size; size_t nread = fread(rbuf, 1, chunk, inf); - if (nread == 0) break; + if (nread != chunk) { + fprintf(stderr, " Read failed or input changed: %s\n", disk_paths[fi]); + write_err = 1; + break; + } uint64_t checksum = zupt_xxh64(rbuf, nread, 0); + uint8_t dedup_digest[32]; + if (dedup) zupt_sha256(rbuf, nread, dedup_digest); + uint64_t logical_aad_seq = + (((uint64_t)(fi + 1)) << 32) | block_seq; /* Chained hash: feed previous hash as seed for next block */ file_hash_state = zupt_xxh64(rbuf, nread, file_hash_state); /* ─── Dedup check: skip compression if block already written ─── */ if (dedup) { zupt_dedup_record_block(dedup); - uint64_t ref_off = 0; uint32_t ref_sz = 0; - if (zupt_dedup_lookup(dedup, checksum, &ref_off, &ref_sz) && + uint64_t ref_off = 0, referenced_aad_seq = 0; + uint32_t ref_sz = 0; + if (zupt_dedup_lookup_secure(dedup, checksum, dedup_digest, + &ref_off, &ref_sz, + &referenced_aad_seq) && ref_sz == (uint32_t)nread) { /* Fingerprint match + same size — write reference block */ - zupt_dedup_write_ref(out, ref_off, (uint32_t)nread, checksum); + const zupt_keyring_t *ref_keyring = opts->encrypt + ? &opts->keyring : NULL; + if (zupt_dedup_write_ref_secure( + out, ref_off, (uint32_t)nread, checksum, + logical_aad_seq, referenced_aad_seq, + ref_keyring) != 0) { + write_err = 1; + break; + } zupt_dedup_record_hit(dedup, nread); - file_comp += 8; /* ref block payload is 8 bytes */ + file_comp += opts->encrypt ? 64u : 8u; index[fi].block_count++; total_blocks++; block_seq++; remaining -= nread; file_done += nread; - if (!opts->verbose && !opts->quiet && file_size > (int64_t)opts->block_size) + if (!zupt_internal_verbose(opts) && !opts->quiet && file_size > (int64_t)opts->block_size) show_progress(arc_paths[fi], file_done, (uint64_t)file_size); continue; } @@ -1025,22 +2614,11 @@ zupt_error_t zupt_compress_files(const char *output_path, uint16_t bflags = 0; if (opts->encrypt && opts->keyring.active) { size_t enc_len; - /* AAD = ((file_index+1) << 32) | per_file_block_seq. - * Combines file identity with block position to prevent - * cross-file block-swap attacks. - * - * Exception: in dedup mode, blocks may be referenced from - * other files via offset-only refs. The decrypt-side ref - * lookup has no way to know the original source file, so - * dedup blocks use sentinel seq=0 (legacy MAC, no AAD). - * Dedup mode still has block-level integrity via the - * stored XXH64 plaintext checksum. */ - uint64_t aad_seq; - if (opts->dedup) { - aad_seq = 0; /* sentinel; dedup decrypt path uses 0 too */ - } else { - aad_seq = (((uint64_t)(fi + 1)) << 32) | block_seq; - } + /* Bind every new DATA frame to its file and logical block. + * An authenticated DEDUP_REF carries this source sequence so + * a later reference can verify the original frame without + * weakening all dedup DATA frames to sequence zero. */ + uint64_t aad_seq = logical_aad_seq; /* F-09 of v2.3.1: bind frame preface into MAC for v1.6 archives. * Predicted compressed_size = nonce(16) + payload + hmac(32). */ if (opts->keyring.use_preface_aad) { @@ -1057,7 +2635,14 @@ zupt_error_t zupt_compress_files(const char *output_path, } else { enc_payload = zupt_encrypt_buffer(&opts->keyring, payload, payload_size, aad_seq, &enc_len); } - if (!enc_payload) { fclose(inf); free(index); free(rbuf); free(cbuf); fclose(out); return ZUPT_ERR_NOMEM; } + if (!enc_payload) { + fclose(inf); + if (pctx) zpar_destroy(pctx); + zupt_dedup_free(dedup); + free(index); free(rbuf); free(cbuf); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_NOMEM; + } payload = enc_payload; payload_size = enc_len; bflags |= ZUPT_BFLAG_ENCRYPTED; @@ -1076,7 +2661,9 @@ zupt_error_t zupt_compress_files(const char *output_path, /* Insert into dedup index so future blocks can reference this one */ if (dedup) - zupt_dedup_insert(dedup, checksum, this_block_off, (uint32_t)nread); + zupt_dedup_insert_secure(dedup, checksum, dedup_digest, + this_block_off, (uint32_t)nread, + logical_aad_seq); free(enc_payload); file_comp += payload_size; @@ -1086,18 +2673,33 @@ zupt_error_t zupt_compress_files(const char *output_path, remaining -= nread; file_done += nread; - if (!opts->verbose && !opts->quiet && file_size > (int64_t)opts->block_size) + if (!zupt_internal_verbose(opts) && !opts->quiet && file_size > (int64_t)opts->block_size) show_progress(arc_paths[fi], file_done, (uint64_t)file_size); } /* end while (remaining > 0) */ } /* end else (single-threaded) */ + if (write_err) { + fclose(inf); + break; + } + + zupt_input_identity_t final_identity; + if (!zupt_input_identity_from_stream(inf, &final_identity) || + !zupt_input_identity_equal(&input_identity, &final_identity)) { + fprintf(stderr, "Error: Input changed while reading '%s'\n", + disk_paths[fi]); + fclose(inf); + write_err = 1; + break; + } + index[fi].compressed_size = file_comp; index[fi].content_hash = file_hash_state; total_in += index[fi].uncompressed_size; total_out += index[fi].compressed_size; fclose(inf); - if (opts->verbose) { + if (zupt_internal_verbose(opts)) { char in_s[32], out_s[32]; zupt_format_size(index[fi].uncompressed_size, in_s, sizeof(in_s)); zupt_format_size(index[fi].compressed_size, out_s, sizeof(out_s)); @@ -1113,7 +2715,9 @@ zupt_error_t zupt_compress_files(const char *output_path, /* Check for write errors before writing the index */ if (write_err) { fprintf(stderr, "Error: Write errors occurred during compression.\n"); - free(index); free(rbuf); free(cbuf); fclose(out); + zupt_dedup_free(dedup); + free(index); free(rbuf); free(cbuf); + zupt_atomic_output_finish(atomic_output, 0); return ZUPT_ERR_IO; } @@ -1122,7 +2726,9 @@ zupt_error_t zupt_compress_files(const char *output_path, zupt_error_t cerr = write_comment_block(out, &hdr, opts, &total_blocks); if (cerr != ZUPT_OK) { fprintf(stderr, "Error: Failed to write comment block\n"); - free(index); free(rbuf); free(cbuf); fclose(out); + zupt_dedup_free(dedup); + free(index); free(rbuf); free(cbuf); + zupt_atomic_output_finish(atomic_output, 0); return cerr; } if (opts->has_comment && hdr.comment_offset != 0) { @@ -1131,9 +2737,11 @@ zupt_error_t zupt_compress_files(const char *output_path, * end of the function. */ int64_t save = ftello(out); fseeko(out, 0, SEEK_SET); - if (fwrite(&hdr, sizeof(hdr), 1, out) != 1) { + if (zupt_write_archive_header(out, &hdr) != 0) { fprintf(stderr, "Error: Failed to update header with comment offset\n"); - free(index); free(rbuf); free(cbuf); fclose(out); + zupt_dedup_free(dedup); + free(index); free(rbuf); free(cbuf); + zupt_atomic_output_finish(atomic_output, 0); return ZUPT_ERR_IO; } fseeko(out, save, SEEK_SET); @@ -1142,9 +2750,21 @@ zupt_error_t zupt_compress_files(const char *output_path, /* ─── Central Index ─── */ uint64_t index_offset = safe_ftello(out); + if (num_files < 0 || + (size_t)num_files > SIZE_MAX / (ZUPT_MAX_PATH + 128)) { + zupt_dedup_free(dedup); + free(index); free(rbuf); free(cbuf); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_OVERFLOW; + } size_t icap = (size_t)num_files * (ZUPT_MAX_PATH + 128); uint8_t *ibuf = (uint8_t*)malloc(icap); - if (!ibuf) { free(index); free(rbuf); free(cbuf); fclose(out); return ZUPT_ERR_NOMEM; } + if (!ibuf) { + zupt_dedup_free(dedup); + free(index); free(rbuf); free(cbuf); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_NOMEM; + } size_t ip = 0; ip += (size_t)zupt_encode_varint(ibuf + ip, (uint64_t)num_files); @@ -1164,6 +2784,12 @@ zupt_error_t zupt_compress_files(const char *output_path, size_t ic_cap = zupt_lzh_bound(ip); uint8_t *ic = (uint8_t*)malloc(ic_cap); + if (!ic) { + zupt_dedup_free(dedup); + free(ibuf); free(index); free(rbuf); free(cbuf); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_NOMEM; + } size_t ic_size = zupt_lzh_compress(ibuf, ip, ic, ic_cap, opts->level); uint16_t ic_codec = ZUPT_CODEC_ZUPT_LZH; const uint8_t *ic_pay; uint64_t ic_plen; @@ -1193,6 +2819,12 @@ zupt_error_t zupt_compress_files(const char *output_path, } else { enc_idx = zupt_encrypt_buffer(&opts->keyring, ic_pay, ic_plen, 0xFFFFFFFFFFFFFFFFULL, &enc_len); } + if (!enc_idx) { + zupt_dedup_free(dedup); + free(ic); free(ibuf); free(index); free(rbuf); free(cbuf); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_NOMEM; + } ic_pay = enc_idx; ic_plen = enc_len; idx_bflags |= ZUPT_BFLAG_ENCRYPTED; } @@ -1213,7 +2845,7 @@ zupt_error_t zupt_compress_files(const char *output_path, ft.archive_checksum = safe_ftello(out); ft.footer_magic[0]='Z'; ft.footer_magic[1]='E'; ft.footer_magic[2]='N'; ft.footer_magic[3]='D'; ft.footer_version = 1; - if (fwrite(&ft, sizeof(ft), 1, out) != 1) write_err = 1; + if (zupt_write_footer(out, &ft) != 0) write_err = 1; /* F-08 of v2.3.0: archive-integrity-trailer follows the footer. * Encrypted: HMAC over hdr || ft[0..23]. Plaintext: XXH64 best-effort. */ @@ -1222,10 +2854,12 @@ zupt_error_t zupt_compress_files(const char *output_path, if (zupt_format_ait_write(out, &hdr, &ft, kr) != 0) write_err = 1; } - fclose(out); + if (zupt_atomic_output_finish(atomic_output, !write_err) != 0) + write_err = 1; if (write_err) { - fprintf(stderr, "Error: Write errors occurred. Archive may be corrupt.\n"); + fprintf(stderr, "Error: Compression failed; no partial archive was published.\n"); + zupt_dedup_free(dedup); free(ic); free(ibuf); free(index); free(rbuf); free(cbuf); return ZUPT_ERR_IO; } @@ -1278,15 +2912,47 @@ zupt_error_t zupt_compress_solid(const char *output_path, const char **disk_paths, int num_files, zupt_options_t *opts) { - if (opts->block_size == 0) opts->block_size = auto_block_size(opts->level); + if (num_files < 0 || + (uint64_t)num_files > + (uint64_t)(ZUPT_MAX_INDEX_ALLOC_BYTES / + sizeof(zupt_index_entry_t))) { + fprintf(stderr, "Error: file count exceeds the safe index-memory limit.\n"); + return ZUPT_ERR_OVERFLOW; + } + zupt_error_t path_error = + zupt_validate_archive_destinations(arc_paths, num_files, 1); + if (path_error != ZUPT_OK) return path_error; + for (int file_index = 0; file_index < num_files; file_index++) { + int same_file = zupt_compress_paths_same_file( + output_path, disk_paths[file_index]); + if (same_file > 0) { + fprintf(stderr, + "Error: archive output and input '%s' are the same file.\n", + disk_paths[file_index]); + return ZUPT_ERR_INVALID; + } + if (same_file < 0) { + fprintf(stderr, + "Error: cannot inspect archive output/input identity safely: %s\n", + strerror(errno)); + return ZUPT_ERR_IO; + } + } + if (opts->block_size == 0) opts->block_size = opts->dedup ? auto_block_size_dedup(opts->level) : auto_block_size(opts->level); if (opts->block_size < 524288) opts->block_size = 524288; /* Resolve AUTO codec before compression */ if (opts->codec_id == ZUPT_CODEC_AUTO) opts->codec_id = zupt_resolve_auto_codec(); - FILE *out = fopen(output_path, "wb"); - if (!out) { fprintf(stderr, "Error: Cannot create '%s'\n", output_path); return ZUPT_ERR_IO; } + FILE *out = NULL; + zupt_atomic_output_t *atomic_output = + zupt_atomic_output_open(output_path, &out); + if (!atomic_output) { + fprintf(stderr, "Error: Cannot create '%s': %s\n", + output_path, strerror(errno)); + return ZUPT_ERR_IO; + } int write_err = 0; @@ -1303,32 +2969,56 @@ zupt_error_t zupt_compress_solid(const char *output_path, } hdr.creation_time = now_ns(); gen_uuid(hdr.archive_id); - if (fwrite(&hdr, sizeof(hdr), 1, out) != 1) write_err = 1; + if (zupt_write_archive_header(out, &hdr) != 0) write_err = 1; if (opts->encrypt) { zupt_error_t enc_err = write_enc_header(out, &hdr, opts); if (enc_err != ZUPT_OK) { - fclose(out); - unlink(output_path); + zupt_atomic_output_finish(atomic_output, 0); return enc_err; } } - zupt_index_entry_t *index = (zupt_index_entry_t*)calloc((size_t)num_files, sizeof(zupt_index_entry_t)); - if (!index) { fclose(out); return ZUPT_ERR_NOMEM; } + if ((size_t)num_files > + SIZE_MAX / (sizeof(zupt_index_entry_t) + + sizeof(zupt_input_identity_t))) { + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_OVERFLOW; + } + zupt_index_entry_t *index = (zupt_index_entry_t *)calloc( + (size_t)num_files, + sizeof(zupt_index_entry_t) + sizeof(zupt_input_identity_t)); + if (!index) { + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_NOMEM; + } + zupt_input_identity_t *source_identities = + (zupt_input_identity_t *)(void *)(index + num_files); uint64_t total_uncompressed = 0; for (int fi = 0; fi < num_files; fi++) { - FILE *inf = fopen(disk_paths[fi], "rb"); - if (!inf) continue; - fseeko(inf, 0, SEEK_END); - int64_t sz = ftello(inf); + zupt_input_identity_t input_identity; + FILE *inf = zupt_open_regular_input(disk_paths[fi], &input_identity); + if (!inf) { + fprintf(stderr, "Error: Cannot read input '%s': %s\n", + disk_paths[fi], strerror(errno)); + free(index); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_IO; + } + int64_t sz = input_identity.size <= INT64_MAX + ? (int64_t)input_identity.size : -1; fclose(inf); - if (sz < 0) continue; + if (sz < 0 || total_uncompressed > UINT64_MAX - (uint64_t)sz) { + free(index); + zupt_atomic_output_finish(atomic_output, 0); + return sz < 0 ? ZUPT_ERR_IO : ZUPT_ERR_OVERFLOW; + } + source_identities[fi] = input_identity; strncpy(index[fi].path, arc_paths[fi], ZUPT_MAX_PATH-1); index[fi].uncompressed_size = (uint64_t)sz; index[fi].first_block_offset = total_uncompressed; - index[fi].modification_time = get_mtime(disk_paths[fi]); + index[fi].modification_time = input_identity.archive_mtime; total_uncompressed += (uint64_t)sz; if (!opts->quiet) { @@ -1337,17 +3027,52 @@ zupt_error_t zupt_compress_solid(const char *output_path, } } - uint8_t *solid_buf = (uint8_t*)malloc((size_t)total_uncompressed); - if (!solid_buf) { free(index); fclose(out); return ZUPT_ERR_NOMEM; } + if (total_uncompressed > SIZE_MAX) { + free(index); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_OVERFLOW; + } + size_t solid_capacity = total_uncompressed == 0 + ? 1 : (size_t)total_uncompressed; + uint8_t *solid_buf = (uint8_t*)malloc(solid_capacity); + if (!solid_buf) { + free(index); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_NOMEM; + } size_t solid_pos = 0; for (int fi = 0; fi < num_files; fi++) { - if (index[fi].uncompressed_size == 0) continue; - FILE *inf = fopen(disk_paths[fi], "rb"); - if (!inf) continue; - if (fread(solid_buf + solid_pos, 1, (size_t)index[fi].uncompressed_size, inf) != (size_t)index[fi].uncompressed_size) { fclose(inf); continue; } + zupt_input_identity_t input_identity; + FILE *inf = zupt_open_regular_input(disk_paths[fi], &input_identity); + if (!inf) { + fprintf(stderr, "Error: Cannot reopen input '%s'\n", disk_paths[fi]); + free(solid_buf); free(index); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_IO; + } + size_t expected = (size_t)index[fi].uncompressed_size; + if (!zupt_input_identity_equal(&source_identities[fi], + &input_identity) || + fread(solid_buf + solid_pos, 1, expected, inf) != expected || + fgetc(inf) != EOF || ferror(inf)) { + fprintf(stderr, "Error: Input changed while reading '%s'\n", + disk_paths[fi]); + fclose(inf); free(solid_buf); free(index); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_IO; + } + zupt_input_identity_t final_identity; + if (!zupt_input_identity_from_stream(inf, &final_identity) || + !zupt_input_identity_equal(&input_identity, &final_identity)) { + fprintf(stderr, "Error: Input changed while reading '%s'\n", + disk_paths[fi]); + fclose(inf); free(solid_buf); free(index); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_IO; + } fclose(inf); - solid_pos += (size_t)index[fi].uncompressed_size; + solid_pos += expected; } uint64_t cum = 0; @@ -1359,7 +3084,11 @@ zupt_error_t zupt_compress_solid(const char *output_path, size_t block_cap = zupt_lzh_bound(opts->block_size) + 512; uint8_t *cbuf = (uint8_t*)malloc(block_cap); - if (!cbuf) { free(solid_buf); free(index); fclose(out); return ZUPT_ERR_NOMEM; } + if (!cbuf) { + free(solid_buf); free(index); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_NOMEM; + } uint64_t total_blocks = 0, total_out = 0, block_seq = 0; size_t remaining = (size_t)total_uncompressed; @@ -1440,7 +3169,14 @@ zupt_error_t zupt_compress_solid(const char *output_path, } else { enc_pay = zupt_encrypt_buffer(&opts->keyring, payload, payload_size, aad_seq, &enc_len); } - if (enc_pay) { payload = enc_pay; payload_size = enc_len; bflags |= ZUPT_BFLAG_ENCRYPTED; } + if (!enc_pay) { + free(solid_buf); free(cbuf); free(index); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_NOMEM; + } + payload = enc_pay; + payload_size = enc_len; + bflags |= ZUPT_BFLAG_ENCRYPTED; } w8(out, ZUPT_BLOCK_MAGIC_0); w8(out, ZUPT_BLOCK_MAGIC_1); @@ -1466,15 +3202,17 @@ zupt_error_t zupt_compress_solid(const char *output_path, zupt_error_t cerr = write_comment_block(out, &hdr, opts, &total_blocks); if (cerr != ZUPT_OK) { fprintf(stderr, "Error: Failed to write comment block (solid mode)\n"); - free(solid_buf); free(cbuf); free(index); fclose(out); + free(solid_buf); free(cbuf); free(index); + zupt_atomic_output_finish(atomic_output, 0); return cerr; } if (opts->has_comment && hdr.comment_offset != 0) { int64_t save = ftello(out); fseeko(out, 0, SEEK_SET); - if (fwrite(&hdr, sizeof(hdr), 1, out) != 1) { + if (zupt_write_archive_header(out, &hdr) != 0) { fprintf(stderr, "Error: Failed to update header with comment offset (solid)\n"); - free(solid_buf); free(cbuf); free(index); fclose(out); + free(solid_buf); free(cbuf); free(index); + zupt_atomic_output_finish(atomic_output, 0); return ZUPT_ERR_IO; } fseeko(out, save, SEEK_SET); @@ -1483,9 +3221,19 @@ zupt_error_t zupt_compress_solid(const char *output_path, /* Write central index (LE serialization) */ uint64_t index_offset = safe_ftello(out); + if (num_files < 0 || + (size_t)num_files > SIZE_MAX / (ZUPT_MAX_PATH + 128)) { + free(solid_buf); free(cbuf); free(index); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_OVERFLOW; + } size_t icap = (size_t)num_files * (ZUPT_MAX_PATH + 128); uint8_t *ibuf = (uint8_t*)malloc(icap); - if (!ibuf) { free(solid_buf); free(cbuf); free(index); fclose(out); return ZUPT_ERR_NOMEM; } + if (!ibuf) { + free(solid_buf); free(cbuf); free(index); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_NOMEM; + } size_t ip = 0; ip += (size_t)zupt_encode_varint(ibuf + ip, (uint64_t)num_files); @@ -1505,6 +3253,11 @@ zupt_error_t zupt_compress_solid(const char *output_path, size_t ic_cap = zupt_lzh_bound(ip); uint8_t *ic = (uint8_t*)malloc(ic_cap); + if (!ic) { + free(ibuf); free(solid_buf); free(cbuf); free(index); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_NOMEM; + } size_t ic_size = zupt_lzh_compress(ibuf, ip, ic, ic_cap, opts->level); uint16_t ic_codec = ZUPT_CODEC_ZUPT_LZH; const uint8_t *ic_pay; uint64_t ic_plen; @@ -1529,7 +3282,14 @@ zupt_error_t zupt_compress_solid(const char *output_path, } else { enc_idx = zupt_encrypt_buffer(&opts->keyring, ic_pay, ic_plen, 0xFFFFFFFFFFFFFFFFULL, &enc_len); } - if (enc_idx) { ic_pay = enc_idx; ic_plen = enc_len; idx_bflags |= ZUPT_BFLAG_ENCRYPTED; } + if (!enc_idx) { + free(ic); free(ibuf); free(solid_buf); free(cbuf); free(index); + zupt_atomic_output_finish(atomic_output, 0); + return ZUPT_ERR_NOMEM; + } + ic_pay = enc_idx; + ic_plen = enc_len; + idx_bflags |= ZUPT_BFLAG_ENCRYPTED; } w8(out, ZUPT_BLOCK_MAGIC_0); w8(out, ZUPT_BLOCK_MAGIC_1); @@ -1549,7 +3309,7 @@ zupt_error_t zupt_compress_solid(const char *output_path, ft.total_blocks = total_blocks; ft.footer_magic[0]='Z'; ft.footer_magic[1]='E'; ft.footer_magic[2]='N'; ft.footer_magic[3]='D'; ft.footer_version = 1; - if (fwrite(&ft, sizeof(ft), 1, out) != 1) write_err = 1; + if (zupt_write_footer(out, &ft) != 0) write_err = 1; /* F-08 of v2.3.0: archive-integrity-trailer (see compress-flat path). */ if (!write_err) { @@ -1557,10 +3317,11 @@ zupt_error_t zupt_compress_solid(const char *output_path, if (zupt_format_ait_write(out, &hdr, &ft, kr) != 0) write_err = 1; } - fclose(out); + if (zupt_atomic_output_finish(atomic_output, !write_err) != 0) + write_err = 1; if (write_err) { - fprintf(stderr, "Error: Write errors occurred. Archive may be corrupt.\n"); + fprintf(stderr, "Error: Compression failed; no partial archive was published.\n"); free(ic); free(ibuf); free(solid_buf); free(cbuf); free(index); return ZUPT_ERR_IO; } @@ -1595,7 +3356,10 @@ zupt_error_t zupt_compress_solid(const char *output_path, * ═══════════════════════════════════════════════════════════════════ */ static zupt_error_t read_header(FILE *f, zupt_archive_header_t *h) { - if (fread(h, sizeof(*h), 1, f) != 1) return ZUPT_ERR_IO; + uint8_t serialized[ZUPT_ARCHIVE_HEADER_SIZE]; + if (fread(serialized, 1, sizeof(serialized), f) != sizeof(serialized)) + return ZUPT_ERR_IO; + deserialize_archive_header(serialized, h); if (h->magic[0]!=ZUPT_MAGIC_0||h->magic[1]!=ZUPT_MAGIC_1|| h->magic[2]!=ZUPT_MAGIC_2||h->magic[3]!=ZUPT_MAGIC_3|| h->magic[4]!=ZUPT_MAGIC_4||h->magic[5]!=ZUPT_MAGIC_5) return ZUPT_ERR_BAD_MAGIC; @@ -1624,26 +3388,33 @@ static zupt_error_t locate_footer_v15(FILE *f, zupt_footer_t *ft, int *has_ait, uint8_t ait_buf[ZUPT_AIT_SIZE]) { fseeko(f, 0, SEEK_END); int64_t file_size = ftello(f); - if (file_size < (int64_t)sizeof(zupt_footer_t)) return ZUPT_ERR_CORRUPT; + if (file_size < (int64_t)ZUPT_FOOTER_SIZE) return ZUPT_ERR_CORRUPT; /* Try v1.5: footer at EOF-64, AIT at EOF-32 */ - if (file_size >= (int64_t)sizeof(zupt_footer_t) + ZUPT_AIT_SIZE) { + if (file_size >= (int64_t)ZUPT_FOOTER_SIZE + ZUPT_AIT_SIZE) { zupt_footer_t cand; - fseeko(f, -(int64_t)(sizeof(zupt_footer_t) + ZUPT_AIT_SIZE), SEEK_END); - if (fread(&cand, sizeof(cand), 1, f) == 1 && - cand.footer_magic[0]=='Z' && cand.footer_magic[1]=='E' && - cand.footer_magic[2]=='N' && cand.footer_magic[3]=='D' && - cand.footer_version == 1) { - if (fread(ait_buf, ZUPT_AIT_SIZE, 1, f) != 1) return ZUPT_ERR_IO; - *ft = cand; - *has_ait = 1; - return ZUPT_OK; + uint8_t serialized[ZUPT_FOOTER_SIZE]; + fseeko(f, -(int64_t)(ZUPT_FOOTER_SIZE + ZUPT_AIT_SIZE), SEEK_END); + if (fread(serialized, 1, sizeof(serialized), f) == sizeof(serialized)) { + deserialize_footer(serialized, &cand); + if (cand.footer_magic[0]=='Z' && cand.footer_magic[1]=='E' && + cand.footer_magic[2]=='N' && cand.footer_magic[3]=='D' && + cand.footer_version == 1) { + if (fread(ait_buf, ZUPT_AIT_SIZE, 1, f) != 1) + return ZUPT_ERR_IO; + *ft = cand; + *has_ait = 1; + return ZUPT_OK; + } } } /* Fall back to v1.4: footer at EOF-32, no AIT */ - fseeko(f, -(int64_t)sizeof(zupt_footer_t), SEEK_END); - if (fread(ft, sizeof(*ft), 1, f) != 1) return ZUPT_ERR_IO; + uint8_t serialized[ZUPT_FOOTER_SIZE]; + fseeko(f, -(int64_t)ZUPT_FOOTER_SIZE, SEEK_END); + if (fread(serialized, 1, sizeof(serialized), f) != sizeof(serialized)) + return ZUPT_ERR_IO; + deserialize_footer(serialized, ft); if (ft->footer_magic[0]!='Z'||ft->footer_magic[1]!='E'|| ft->footer_magic[2]!='N'||ft->footer_magic[3]!='D') return ZUPT_ERR_CORRUPT; if (ft->footer_version != 1) return ZUPT_ERR_BAD_VERSION; @@ -1660,8 +3431,10 @@ static zupt_error_t locate_footer_v15(FILE *f, zupt_footer_t *ft, static void ait_serialize_mac_input(const zupt_archive_header_t *hdr, const zupt_footer_t *ft, uint8_t buf[ZUPT_AIT_MAC_INPUT_LEN]) { - memcpy(buf, hdr, sizeof(*hdr)); - memcpy(buf + sizeof(*hdr), ft, 24); /* index_offset + total_blocks + archive_checksum */ + uint8_t serialized_footer[ZUPT_FOOTER_SIZE]; + zupt_serialize_archive_header(hdr, buf); + zupt_serialize_footer(ft, serialized_footer); + memcpy(buf + ZUPT_ARCHIVE_HEADER_SIZE, serialized_footer, 24); } /* Compute the trailing AIT field and emit ZUPT_AIT_SIZE bytes through fwrite. @@ -1693,13 +3466,7 @@ int zupt_format_ait_write(FILE *f, const zupt_archive_header_t *hdr, /* Verify the AIT field against header+footer. * - * Encrypted archives: HMAC-SHA256 with constant-time tag compare. - * Plaintext archives: XXH64 in the first 8 bytes, byte-wise compare of the - * remaining 24 bytes against zero. - * Returns ZUPT_OK iff the trailer authenticates the header+footer. */ -/* Verify the AIT field against header+footer. - * - * Encrypted archives: HMAC-SHA256 with constant-time tag compare. + * Encrypted archives: HMAC-SHA256 with the constant-time-intended tag compare. * Plaintext archives: XXH64 in the first 8 bytes, byte-wise compare of the * remaining 24 bytes against zero. * Returns ZUPT_OK iff the trailer authenticates the header+footer. */ @@ -1715,7 +3482,7 @@ static zupt_error_t ait_verify(const zupt_archive_header_t *hdr, uint8_t expected[ZUPT_AIT_SIZE]; zupt_hmac_sha256(kr_or_null->mac_key, ZUPT_HMAC_SIZE, mac_input, ZUPT_AIT_MAC_INPUT_LEN, expected); - /* CT-REQUIRED: constant-time compare via the audited primitive. */ + /* CT-REQUIRED: use the single constant-time-intended primitive. */ int eq = zupt_ct_memeq(expected, ait, ZUPT_AIT_SIZE); zupt_secure_wipe(expected, sizeof(expected)); result = eq ? ZUPT_OK : ZUPT_ERR_AUTH_FAIL; @@ -1743,6 +3510,8 @@ zupt_error_t zupt_format_ait_verify_extern(const zupt_archive_header_t *hdr, } zupt_error_t read_block(FILE *f, zupt_block_t *b) { + if (!f || !b) return ZUPT_ERR_INVALID; + memset(b, 0, sizeof(*b)); uint8_t m[2]; if (fread(m,1,2,f)!=2) return ZUPT_ERR_IO; if (m[0]!=ZUPT_BLOCK_MAGIC_0||m[1]!=ZUPT_BLOCK_MAGIC_1) return ZUPT_ERR_CORRUPT; @@ -1764,6 +3533,89 @@ zupt_error_t read_block(FILE *f, zupt_block_t *b) { return ZUPT_OK; } +void zupt_legacy_disk_aad_map_free(zupt_legacy_disk_aad_map_t *map) { + if (!map) return; + free(map->entries); + memset(map, 0, sizeof(*map)); +} + +zupt_error_t zupt_legacy_disk_aad_map_build( + FILE *stream, uint64_t first_block_offset, uint32_t block_count, + zupt_legacy_disk_aad_map_t *map) { + if (!stream || !map || first_block_offset > (uint64_t)INT64_MAX) + return ZUPT_ERR_INVALID; + memset(map, 0, sizeof(*map)); + int64_t saved_position = ftello(stream); + if (saved_position < 0 || + fseeko(stream, (int64_t)first_block_offset, SEEK_SET) != 0) + return ZUPT_ERR_IO; + + zupt_error_t result = ZUPT_OK; + for (uint64_t sequence = 0; sequence < block_count; sequence++) { + int64_t signed_offset = ftello(stream); + if (signed_offset < 0) { + result = ZUPT_ERR_IO; + break; + } + zupt_block_t block; + result = read_block(stream, &block); + if (result != ZUPT_OK) break; + if (block.block_type == ZUPT_BLOCK_DATA) { + if (map->count == map->capacity) { + size_t new_capacity = map->capacity ? map->capacity * 2u : 64u; + if (new_capacity < map->capacity || + new_capacity > SIZE_MAX / sizeof(*map->entries)) { + free(block.payload); + result = ZUPT_ERR_OVERFLOW; + break; + } + if (new_capacity > block_count) new_capacity = block_count; + zupt_legacy_disk_aad_entry_t *new_entries = + (zupt_legacy_disk_aad_entry_t *)realloc( + map->entries, + new_capacity * sizeof(*map->entries)); + if (!new_entries) { + free(block.payload); + result = ZUPT_ERR_NOMEM; + break; + } + map->entries = new_entries; + map->capacity = new_capacity; + } + map->entries[map->count].offset = (uint64_t)signed_offset; + map->entries[map->count].aad_seq = sequence; + map->count++; + } else if (block.block_type != ZUPT_BLOCK_DEDUP_REF) { + result = ZUPT_ERR_CORRUPT; + } + free(block.payload); + if (result != ZUPT_OK) break; + } + if (fseeko(stream, saved_position, SEEK_SET) != 0 && result == ZUPT_OK) + result = ZUPT_ERR_IO; + if (result != ZUPT_OK) zupt_legacy_disk_aad_map_free(map); + return result; +} + +int zupt_legacy_disk_aad_map_lookup( + const zupt_legacy_disk_aad_map_t *map, uint64_t offset, + uint64_t *aad_seq) { + if (!map || !aad_seq) return 0; + size_t left = 0; + size_t right = map->count; + while (left < right) { + size_t middle = left + (right - left) / 2u; + uint64_t candidate = map->entries[middle].offset; + if (candidate < offset) + left = middle + 1u; + else + right = middle; + } + if (left >= map->count || map->entries[left].offset != offset) return 0; + *aad_seq = map->entries[left].aad_seq; + return 1; +} + zupt_error_t decompress_block(const zupt_block_t *b, const zupt_keyring_t *kr, uint64_t block_seq, uint8_t **out, size_t *olen) { const uint8_t *comp_data = b->payload; @@ -1953,7 +3805,7 @@ zupt_error_t read_enc_header(FILE *f, zupt_archive_header_t *hdr, zupt_options_t } if (zupt_pqbox_decrypt_init(&opts->keyring, opts->keyfile, eb.payload, (size_t)eb.compressed_size) != 0) { - if (opts->verbose) { + if (zupt_internal_verbose(opts)) { fprintf(stderr, "Error: pq-box envelope decryption failed.\n" " This means wrong key, tampered envelope, or unsupported format.\n"); } @@ -1977,7 +3829,7 @@ zupt_error_t read_enc_header(FILE *f, zupt_archive_header_t *hdr, zupt_options_t /* F-11 of v2.4.2: same generic phrasing as the AIT-fail path * in open_archive(), for consistency across all key-mode * failures. Verbose mode adds the technical-detail line. */ - if (opts->verbose) { + if (zupt_internal_verbose(opts)) { fprintf(stderr, "Error: SDK-v2 PQ envelope decryption failed.\n" " This means wrong key, tampered envelope, or unsupported format.\n"); } @@ -1997,7 +3849,7 @@ zupt_error_t read_enc_header(FILE *f, zupt_archive_header_t *hdr, zupt_options_t if (zupt_sdk_password_decrypt_init(&opts->keyring, opts->password, eb.payload, (size_t)eb.compressed_size) != 0) { /* F-11 of v2.4.2: aligned with the AIT-fail path. */ - if (opts->verbose) { + if (zupt_internal_verbose(opts)) { fprintf(stderr, "Error: Argon2id password verification failed at envelope step.\n"); } fprintf(stderr, "Error: Authentication failed (wrong key, wrong password, or tampered archive).\n"); @@ -2022,6 +3874,21 @@ zupt_error_t read_enc_header(FILE *f, zupt_archive_header_t *hdr, zupt_options_t } free(eb.payload); return ZUPT_OK; + } else if (enc_type == ZUPT_ENC_PQ_ONLY) { + /* ─── FULL POST-QUANTUM MODE (ML-KEM-768 only) ─── */ + if (opts->keyfile[0] == '\0') { + fprintf(stderr, "Error: Archive uses full post-quantum encryption. Use --pq-only .\n"); + free(eb.payload); + return ZUPT_ERR_AUTH_FAIL; + } + if (zupt_pq_decrypt_init(&opts->keyring, opts->keyfile, + eb.payload, (size_t)eb.compressed_size) != 0) { + fprintf(stderr, "Error: full-PQ decryption key derivation failed (wrong key?).\n"); + free(eb.payload); + return ZUPT_ERR_AUTH_FAIL; + } + free(eb.payload); + return ZUPT_OK; } else if (enc_type == ZUPT_ENC_PBKDF2) { /* ─── PASSWORD MODE (v0.7+ format with enc_type prefix) ─── */ if (opts->password[0] == '\0') { @@ -2033,7 +3900,7 @@ zupt_error_t read_enc_header(FILE *f, zupt_archive_header_t *hdr, zupt_options_t uint8_t salt[32], nonce[16]; uint32_t iter; memcpy(salt, eb.payload + 1, 32); memcpy(nonce, eb.payload + 33, 16); - memcpy(&iter, eb.payload + 49, 4); + iter = zupt_le32_get(eb.payload + 49); free(eb.payload); /* SECURITY: reject an absurd attacker-supplied iteration count before * spending the CPU on it (KDF-amplification DoS). See @@ -2053,7 +3920,7 @@ zupt_error_t read_enc_header(FILE *f, zupt_archive_header_t *hdr, zupt_options_t uint8_t salt[32], nonce[16]; uint32_t iter; memcpy(salt, eb.payload, 32); memcpy(nonce, eb.payload + 32, 16); - memcpy(&iter, eb.payload + 48, 4); + iter = zupt_le32_get(eb.payload + 48); free(eb.payload); /* SECURITY: reject an absurd attacker-supplied iteration count before * spending the CPU on it (KDF-amplification DoS). */ @@ -2066,36 +3933,53 @@ zupt_error_t read_enc_header(FILE *f, zupt_archive_header_t *hdr, zupt_options_t static zupt_error_t parse_index(const uint8_t *buf, size_t blen, zupt_index_entry_t **ents, int *n) { + *ents = NULL; + *n = 0; size_t p = 0; uint64_t count; int vn = zupt_decode_varint(buf+p, blen-p, &count); if (vn < 0) return ZUPT_ERR_CORRUPT; p += (size_t)vn; if (count > ZUPT_MAX_FILES) return ZUPT_ERR_OVERFLOW; + if (count > (uint64_t)((blen - p) / ZUPT_MIN_INDEX_ENTRY_BYTES)) + return ZUPT_ERR_CORRUPT; /* Defense for 32-bit platforms: count * sizeof(entry) must fit in size_t. * Each entry is ~4 KB; on 32-bit, ~1M entries already exceeds 4 GiB. */ if (count > (uint64_t)(SIZE_MAX / sizeof(zupt_index_entry_t))) { return ZUPT_ERR_OVERFLOW; } - *n = (int)count; - *ents = (zupt_index_entry_t*)calloc((size_t)count, sizeof(zupt_index_entry_t)); - if (!*ents) return ZUPT_ERR_NOMEM; + if (count > (uint64_t)(ZUPT_MAX_INDEX_ALLOC_BYTES / + sizeof(zupt_index_entry_t))) + return ZUPT_ERR_OVERFLOW; + if (count == 0) + return p == blen ? ZUPT_OK : ZUPT_ERR_CORRUPT; + zupt_index_entry_t *parsed = + (zupt_index_entry_t*)calloc((size_t)count, sizeof(*parsed)); + if (!parsed) return ZUPT_ERR_NOMEM; for (uint64_t i = 0; i < count; i++) { - zupt_index_entry_t *e = &(*ents)[i]; + zupt_index_entry_t *e = &parsed[i]; uint64_t plen; vn = zupt_decode_varint(buf+p, blen-p, &plen); - if (vn<0) { free(*ents); return ZUPT_ERR_CORRUPT; } + if (vn<0) { free(parsed); return ZUPT_ERR_CORRUPT; } /* SECURITY: overflow-safe bound. The decoder consumes at most blen-p * bytes so p+vn<=blen and blen-p-vn cannot underflow. The previous * check `p+vn+plen>blen` wrapped around for an attacker-supplied * ~2^64 plen, passed, then drove an OOB memcpy of ZUPT_MAX_PATH-1 * bytes past the index buffer. */ - if (plen > (uint64_t)(blen - p - (size_t)vn)) { free(*ents); return ZUPT_ERR_CORRUPT; } + if (plen > (uint64_t)(blen - p - (size_t)vn)) { free(parsed); return ZUPT_ERR_CORRUPT; } p += (size_t)vn; - if (plen >= ZUPT_MAX_PATH) plen = ZUPT_MAX_PATH-1; + if (plen == 0 || plen >= ZUPT_MAX_PATH || + memchr(buf + p, '\0', (size_t)plen) != NULL) { + free(parsed); + return ZUPT_ERR_CORRUPT; + } memcpy(e->path, buf+p, (size_t)plen); e->path[plen]='\0'; p += (size_t)plen; + if (zupt_path_has_unsafe_text(e->path)) { + free(parsed); + return ZUPT_ERR_CORRUPT; + } - if (p+44>blen) { free(*ents); return ZUPT_ERR_CORRUPT; } + if (blen - p < 44) { free(parsed); return ZUPT_ERR_CORRUPT; } e->uncompressed_size = index_get_u64(buf+p); p+=8; e->compressed_size = index_get_u64(buf+p); p+=8; e->modification_time = index_get_u64(buf+p); p+=8; @@ -2103,11 +3987,66 @@ static zupt_error_t parse_index(const uint8_t *buf, size_t blen, e->first_block_offset= index_get_u64(buf+p); p+=8; uint64_t bc; vn = zupt_decode_varint(buf+p, blen-p, &bc); - if (vn<0) { free(*ents); return ZUPT_ERR_CORRUPT; } + if (vn<0 || bc > UINT32_MAX) { free(parsed); return ZUPT_ERR_CORRUPT; } p += (size_t)vn; e->block_count = (uint32_t)bc; - if (p+4>blen) { free(*ents); return ZUPT_ERR_CORRUPT; } + if (blen - p < 4) { free(parsed); return ZUPT_ERR_CORRUPT; } e->attributes = index_get_u32(buf+p); p+=4; } + if (p != blen) { free(parsed); return ZUPT_ERR_CORRUPT; } + const char **paths = (const char **)calloc((size_t)count, sizeof(*paths)); + if (!paths) { free(parsed); return ZUPT_ERR_NOMEM; } + for (uint64_t i = 0; i < count; i++) paths[i] = parsed[i].path; + zupt_error_t path_error = zupt_validate_archive_destinations( + paths, (int)count, 0); + free(paths); + if (path_error != ZUPT_OK) { + free(parsed); + return path_error; + } + *n = (int)count; + *ents = parsed; + return ZUPT_OK; +} + +/* Disk archives through v5.2.1 encoded their single-entry count and block + * count as fixed little-endian integers. Keep that published format readable + * while all new disk archives use the canonical varint index. */ +static zupt_error_t parse_legacy_disk_index( + const uint8_t *buf, size_t blen, zupt_index_entry_t **ents, int *n) { + *ents = NULL; + *n = 0; + if (!buf || blen < 4 || index_get_u32(buf) != 1) return ZUPT_ERR_CORRUPT; + size_t p = 4; + uint64_t path_length = 0; + int vn = zupt_decode_varint(buf + p, blen - p, &path_length); + if (vn < 0 || path_length == 0 || path_length >= ZUPT_MAX_PATH || + path_length > blen - p - (size_t)vn) + return ZUPT_ERR_CORRUPT; + p += (size_t)vn; + if (memchr(buf + p, '\0', (size_t)path_length) || + blen - p - (size_t)path_length != 48) + return ZUPT_ERR_CORRUPT; + + zupt_index_entry_t *entry = + (zupt_index_entry_t *)calloc(1, sizeof(*entry)); + if (!entry) return ZUPT_ERR_NOMEM; + memcpy(entry->path, buf + p, (size_t)path_length); + entry->path[path_length] = '\0'; + if (zupt_path_has_unsafe_text(entry->path)) { + free(entry); + return ZUPT_ERR_CORRUPT; + } + p += (size_t)path_length; + entry->uncompressed_size = index_get_u64(buf + p); p += 8; + entry->compressed_size = index_get_u64(buf + p); p += 8; + entry->modification_time = index_get_u64(buf + p); p += 8; + entry->content_hash = index_get_u64(buf + p); p += 8; + entry->first_block_offset = index_get_u64(buf + p); p += 8; + entry->block_count = index_get_u32(buf + p); p += 4; + entry->attributes = index_get_u32(buf + p); p += 4; + if (p != blen) { free(entry); return ZUPT_ERR_CORRUPT; } + *ents = entry; + *n = 1; return ZUPT_OK; } @@ -2125,6 +4064,33 @@ static zupt_error_t open_archive(FILE *f, zupt_options_t *opts, err = locate_footer_v15(f, ft, &has_ait, ait_buf); if (err != ZUPT_OK) return err; + /* Refuse integrity downgrades by default. This check deliberately does + * not trust ZUPT_FLAG_ENCRYPTED: an attacker removing the AIT could also + * clear that unauthenticated header bit and forge a legacy plaintext + * footer/index. Old no-AIT archives remain readable only through an + * explicit, narrowly named compatibility opt-in. */ + if (!has_ait && !zupt_internal_legacy_no_ait_allowed(opts)) { + fprintf(stderr, + "Error: archive has no archive-integrity trailer.\n" + " Refusing an unauthenticated legacy layout by default;\n" + " use --allow-legacy-no-ait only for a trusted old archive.\n"); + return ZUPT_ERR_AUTH_FAIL; + } + + if ((hdr->global_flags & ZUPT_FLAG_AUTH_DEDUP_REFS) != 0) { + const uint32_t required = ZUPT_FLAG_ENCRYPTED | ZUPT_FLAG_DEDUP | + ZUPT_FLAG_AAD_SEQ | ZUPT_FLAG_AAD_PREFACE; + if ((hdr->global_flags & required) != required) + return ZUPT_ERR_CORRUPT; + } + if ((hdr->global_flags & ZUPT_FLAG_AAD_PREFACE) != 0 && + (hdr->global_flags & (ZUPT_FLAG_ENCRYPTED | ZUPT_FLAG_AAD_SEQ)) != + (ZUPT_FLAG_ENCRYPTED | ZUPT_FLAG_AAD_SEQ)) + return ZUPT_ERR_CORRUPT; + if ((hdr->global_flags & ZUPT_FLAG_DISK_CONTENT_HASH) != 0 && + (hdr->global_flags & ZUPT_FLAG_DISK_IMAGE) == 0) + return ZUPT_ERR_CORRUPT; + err = read_enc_header(f, hdr, opts); if (err != ZUPT_OK) return err; @@ -2134,11 +4100,10 @@ static zupt_error_t open_archive(FILE *f, zupt_options_t *opts, * and MUST authenticate before we read any further. For plaintext archives, * the AIT is XXH64 best-effort. * - * v1.4 archives (no AIT) keep extracting unchanged — backward compatibility - * was the explicit design constraint when F-08 was opened. They emit a - * warning on stderr in encrypted modes so users notice the integrity - * downgrade. The warning text is stable (it's part of the threat model - * surface) and the message comes from one place. */ + * A legacy archive without AIT reaches this point only after the caller's + * explicit --allow-legacy-no-ait opt-in. The warning below applies to + * plaintext and encrypted legacy layouts alike because the unauthenticated + * ENCRYPTED bit cannot safely decide whether a trailer was stripped. */ if (has_ait) { int is_encrypted = (hdr->global_flags & ZUPT_FLAG_ENCRYPTED) != 0; const zupt_keyring_t *kr = is_encrypted ? &opts->keyring : NULL; @@ -2167,7 +4132,7 @@ static zupt_error_t open_archive(FILE *f, zupt_options_t *opts, * returns branchlessly. */ if (is_encrypted) { - if (opts->verbose) { + if (zupt_internal_verbose(opts)) { fprintf(stderr, "Error: archive-integrity-trailer (top-MAC) verification failed.\n" " This means EITHER wrong password/key OR a tampered\n" " header or footer. v2.4.2+ collapses both into one\n" @@ -2182,10 +4147,10 @@ static zupt_error_t open_archive(FILE *f, zupt_options_t *opts, } return aerr; } - } else if (hdr->global_flags & ZUPT_FLAG_ENCRYPTED) { - fprintf(stderr, "Warning: legacy v1.4 archive without top-MAC (F-08).\n" - " File contents are integrity-protected, but header\n" - " and footer metadata (timestamps, UUID, counts) are not.\n"); + } else { + fprintf(stderr, + "Warning: explicitly accepting a trusted legacy archive without\n" + " an archive-integrity trailer; metadata is unauthenticated.\n"); } /* F-09 of v2.3.1: propagate the archive-level preface-AAD policy into @@ -2256,28 +4221,63 @@ static zupt_error_t open_archive(FILE *f, zupt_options_t *opts, * parser ignored block_type at this position and decoded whatever it * found — making the byte truly unauthenticated. Now it is structurally * validated (rejected at parse time on mismatch), which is the - * OPAQUE-class coverage promised by PROMPT.md §5. */ + * OPAQUE-class structural coverage recorded in the audit history. */ if (ib.block_type != ZUPT_BLOCK_INDEX) { free(ib.payload); return ZUPT_ERR_CORRUPT; } uint8_t *id; size_t idlen; - err = decompress_block(&ib, &opts->keyring, 0xFFFFFFFFFFFFFFFFULL, &id, &idlen); + const zupt_keyring_t *index_keyring = &opts->keyring; + if ((hdr->global_flags & (ZUPT_FLAG_DISK_IMAGE | ZUPT_FLAG_ENCRYPTED)) == + (ZUPT_FLAG_DISK_IMAGE | ZUPT_FLAG_ENCRYPTED) && + !(hdr->global_flags & ZUPT_FLAG_DISK_CONTENT_HASH)) { + /* Legacy disk writers left the index plaintext. Preserve read + * compatibility, but new disk archives set DISK_CONTENT_HASH and + * authenticate this block like every other encrypted payload. */ + index_keyring = NULL; + fprintf(stderr, "Warning: legacy encrypted disk index is not authenticated.\n"); + } + err = decompress_block(&ib, index_keyring, UINT64_MAX, &id, &idlen); free(ib.payload); if (err != ZUPT_OK) return err; - err = parse_index(id, idlen, entries, num_entries); + if ((hdr->global_flags & ZUPT_FLAG_DISK_IMAGE) && + !(hdr->global_flags & ZUPT_FLAG_DISK_CONTENT_HASH)) + err = parse_legacy_disk_index(id, idlen, entries, num_entries); + else + err = parse_index(id, idlen, entries, num_entries); free(id); return err; } +zupt_error_t zupt_open_archive_internal(FILE *stream, zupt_options_t *opts, + zupt_archive_header_t *header, + zupt_footer_t *footer, + zupt_index_entry_t **entries, + int *num_entries) { + return open_archive(stream, opts, header, footer, entries, num_entries); +} + +static uint64_t archive_data_aad_seq(uint32_t global_flags, int entry_index, + uint64_t block_index) { + /* Disk writers, including 5.2.1, use one linear sequence across DATA and + * DEDUP_REF frames. Legacy file-archive dedup used sequence zero; new file + * archives bind file+block position. */ + if ((global_flags & ZUPT_FLAG_DISK_IMAGE) != 0) + return block_index; + if ((global_flags & ZUPT_FLAG_DEDUP) != 0 && + (global_flags & ZUPT_FLAG_AUTH_DEDUP_REFS) == 0) + return 0; + return (((uint64_t)(entry_index + 1)) << 32) | block_index; +} + /* ═══════════════════════════════════════════════════════════════════ * LIST * ═══════════════════════════════════════════════════════════════════ */ zupt_error_t zupt_list_archive(const char *arc, zupt_options_t *opts) { - FILE *f = fopen(arc, "rb"); + FILE *f = zupt_fopen_path(arc, "rb"); if (!f) { fprintf(stderr, "Error: Cannot open '%s'\n", arc); return ZUPT_ERR_IO; } zupt_archive_header_t hdr; zupt_footer_t ft; @@ -2285,7 +4285,7 @@ zupt_error_t zupt_list_archive(const char *arc, zupt_options_t *opts) { zupt_error_t err = open_archive(f, opts, &hdr, &ft, &ents, &n); if (err != ZUPT_OK) { fclose(f); return err; } - printf("\n ZUPT Archive: %s\n", arc); + printf("\n ZUPT archive: %s\n", arc); printf(" Format: v%u.%u | Blocks: %llu", hdr.version_major, hdr.version_minor, (unsigned long long)ft.total_blocks); if (hdr.global_flags & ZUPT_FLAG_ENCRYPTED) printf(" | Encrypted"); if (hdr.global_flags & ZUPT_FLAG_PQ_HYBRID) printf(" | PQ"); @@ -2321,7 +4321,7 @@ zupt_error_t zupt_list_archive(const char *arc, zupt_options_t *opts) { * ═══════════════════════════════════════════════════════════════════ */ zupt_error_t zupt_extract_archive(const char *arc, const char *dir, zupt_options_t *opts) { - FILE *f = fopen(arc, "rb"); + FILE *f = zupt_fopen_path(arc, "rb"); if (!f) { fprintf(stderr, "Error: Cannot open '%s'\n", arc); return ZUPT_ERR_IO; } zupt_archive_header_t hdr; zupt_footer_t ft; @@ -2329,7 +4329,6 @@ zupt_error_t zupt_extract_archive(const char *arc, const char *dir, zupt_options zupt_error_t err = open_archive(f, opts, &hdr, &ft, &ents, &n); if (err != ZUPT_OK) { fclose(f); fprintf(stderr, "Error: %s\n", zupt_strerror(err)); return err; } - if (dir) zupt_mkdir(dir); int ok=0, fail=0; uint64_t total_extracted = 0; time_t start = time(NULL); @@ -2339,7 +4338,7 @@ zupt_error_t zupt_extract_archive(const char *arc, const char *dir, zupt_options if (is_solid) { uint64_t total_size = 0; for (int i = 0; i < n; i++) { - if (total_size + ents[i].uncompressed_size < total_size) { + if (ents[i].uncompressed_size > UINT64_MAX - total_size) { fprintf(stderr, " Error: solid stream size overflow\n"); free(ents); fclose(f); return ZUPT_ERR_OVERFLOW; } @@ -2357,29 +4356,46 @@ zupt_error_t zupt_extract_archive(const char *arc, const char *dir, zupt_options free(ents); fclose(f); return ZUPT_ERR_OVERFLOW; } - uint8_t *solid_buf = (uint8_t*)malloc((size_t)total_size); + uint8_t *solid_buf = + (uint8_t*)malloc(total_size == 0 ? 1 : (size_t)total_size); if (!solid_buf) { free(ents); fclose(f); return ZUPT_ERR_NOMEM; } - fseeko(f, sizeof(zupt_archive_header_t), SEEK_SET); + fseeko(f, ZUPT_ARCHIVE_HEADER_SIZE, SEEK_SET); if (hdr.global_flags & ZUPT_FLAG_ENCRYPTED) { - zupt_block_t enc_blk; + zupt_block_t enc_blk = {0}; err = read_block(f, &enc_blk); - free(enc_blk.payload); if (err != ZUPT_OK) { free(solid_buf); free(ents); fclose(f); return err; } + free(enc_blk.payload); } size_t solid_pos = 0; uint64_t block_seq = 0; int dec_error = 0; + uint64_t solid_data_end = + hdr.comment_offset != 0 ? hdr.comment_offset : ft.index_offset; - while (solid_pos < (size_t)total_size) { + while (!dec_error) { + int64_t frame_position = ftello(f); + if (frame_position < 0) { + dec_error = 1; + break; + } + if ((uint64_t)frame_position == solid_data_end) break; + if ((uint64_t)frame_position > solid_data_end) { + dec_error = 1; + break; + } zupt_block_t blk; err = read_block(f, &blk); if (err != ZUPT_OK) { dec_error = 1; break; } - if (blk.block_type == ZUPT_BLOCK_INDEX) { free(blk.payload); break; } + if (blk.block_type != ZUPT_BLOCK_DATA) { + free(blk.payload); + dec_error = 1; + break; + } - uint8_t *dec; size_t dlen; + uint8_t *dec = NULL; size_t dlen = 0; /* Solid mode uses synthetic fi=0 (AAD = (1<<32) | block_seq) */ uint64_t aad_seq = ((uint64_t)1 << 32) | block_seq; err = decompress_block(&blk, &opts->keyring, aad_seq, &dec, &dlen); @@ -2390,14 +4406,24 @@ zupt_error_t zupt_extract_archive(const char *arc, const char *dir, zupt_options dec_error = 1; break; } - if (solid_pos + dlen > (size_t)total_size) dlen = (size_t)total_size - solid_pos; - memcpy(solid_buf + solid_pos, dec, dlen); + if (dlen > (size_t)total_size - solid_pos || (dlen > 0 && !dec)) { + free(dec); + dec_error = 1; + break; + } + if (dlen > 0) memcpy(solid_buf + solid_pos, dec, dlen); solid_pos += dlen; free(dec); block_seq++; } - if (dec_error) { + int64_t solid_end = ftello(f); + uint64_t solid_metadata_blocks = + 1u + (hdr.comment_offset != 0 ? 1u : 0u); + if (dec_error || solid_pos != (size_t)total_size || + ft.total_blocks < solid_metadata_blocks || + block_seq != ft.total_blocks - solid_metadata_blocks || + solid_end < 0 || (uint64_t)solid_end != solid_data_end) { free(solid_buf); free(ents); fclose(f); return ZUPT_ERR_CORRUPT; } @@ -2409,52 +4435,74 @@ zupt_error_t zupt_extract_archive(const char *arc, const char *dir, zupt_options fprintf(stderr, " Error: rejected unsafe path: %s\n", e->path); fail++; continue; } - char out_path[ZUPT_MAX_PATH + 256]; - if (dir) snprintf(out_path, sizeof(out_path), "%s%c%s", dir, ZUPT_PATH_SEP, e->path); - else { strncpy(out_path, e->path, sizeof(out_path)-1); out_path[sizeof(out_path)-1]='\0'; } - for (char *p=out_path;*p;p++) if (*p=='/') *p=ZUPT_PATH_SEP; - ensure_dirs(out_path); - - FILE *of = zupt_safe_fopen_output(out_path); - if (!of) { fail++; continue; } - uint64_t off = e->first_block_offset; uint64_t sz = e->uncompressed_size; /* SECURITY: overflow-safe bound. off and sz are both attacker- * controlled 64-bit index fields; the previous `off+sz<=total_size` * wrapped on overflow, letting solid_buf+off point far out of * bounds for an arbitrary-offset OOB heap read. */ - if (off <= total_size && sz <= total_size - off) { - if (fwrite(solid_buf + off, 1, (size_t)sz, of) != (size_t)sz) { - fprintf(stderr, "Error: write failed (disk full?) for %s\n", e->path); - fclose(of); - free(solid_buf); - return ZUPT_ERR_IO; - } - total_extracted += sz; - - /* Verify content hash (empty files have content_hash=0) */ - if (sz > 0) { - uint64_t ck = zupt_xxh64(solid_buf + off, (size_t)sz, 0); - if (ck == e->content_hash) ok++; - else { fprintf(stderr, " Checksum fail: %s\n", e->path); fail++; } - } else { - ok++; /* Empty file: nothing to verify */ - } - } else { + if (off > total_size || sz > total_size - off) { fprintf(stderr, " Invalid offset: %s\n", e->path); fail++; + continue; + } + uint64_t ck = sz > 0 ? + zupt_xxh64(solid_buf + off, (size_t)sz, 0) : 0; + if (ck != e->content_hash) { + fprintf(stderr, " Checksum fail: %s\n", e->path); + fail++; + continue; } - if (opts->verbose) { + char out_path[ZUPT_MAX_PATH + 256]; + zupt_output_file_t output; + if (!zupt_safe_fopen_output(dir, e->path, out_path, + sizeof(out_path), &output)) { + fprintf(stderr, " Error: cannot create %s\n", out_path); + fail++; + continue; + } + if ((sz > 0 && fwrite(solid_buf + off, 1, (size_t)sz, + output.stream) != (size_t)sz) || + zupt_finish_output(&output, 1, 0) != 0) { + fprintf(stderr, "Error: write failed (disk full?) for %s\n", e->path); + if (output.stream) zupt_finish_output(&output, 0, 0); + fail++; + continue; + } + total_extracted += sz; + ok++; + + if (zupt_internal_verbose(opts)) { char sz_s[16]; zupt_format_size(sz, sz_s, sizeof(sz_s)); fprintf(stderr, " %s (%s)\n", e->path, sz_s); } - fclose(of); } free(solid_buf); } else { /* ─── NON-SOLID EXTRACTION ─── */ + int legacy_encrypted_disk_dedup = + (hdr.global_flags & ZUPT_FLAG_DISK_IMAGE) != 0 && + (hdr.global_flags & ZUPT_FLAG_ENCRYPTED) != 0 && + (hdr.global_flags & ZUPT_FLAG_DEDUP) != 0 && + (hdr.global_flags & ZUPT_FLAG_AUTH_DEDUP_REFS) == 0; + zupt_legacy_disk_aad_map_t legacy_aad_map = {0}; + if (legacy_encrypted_disk_dedup) { + if (n != 1) { + free(ents); + fclose(f); + return ZUPT_ERR_CORRUPT; + } + err = zupt_legacy_disk_aad_map_build( + f, ents[0].first_block_offset, ents[0].block_count, + &legacy_aad_map); + if (err != ZUPT_OK) { + free(ents); + fclose(f); + return err; + } + } + /* Multi-threaded decompression: dispatch blocks to N workers. * Workers: decrypt → decompress → verify checksum. * Main thread: read blocks, dispatch, write output in order. */ @@ -2478,15 +4526,18 @@ zupt_error_t zupt_extract_archive(const char *arc, const char *dir, zupt_options fail++; continue; } char out_path[ZUPT_MAX_PATH + 256]; - if (dir) snprintf(out_path, sizeof(out_path), "%s%c%s", dir, ZUPT_PATH_SEP, e->path); - else { strncpy(out_path, e->path, sizeof(out_path)-1); out_path[sizeof(out_path)-1]='\0'; } - for (char *p=out_path;*p;p++) if (*p=='/') *p=ZUPT_PATH_SEP; - ensure_dirs(out_path); + zupt_output_file_t output; + if (!zupt_safe_fopen_output(dir, e->path, out_path, + sizeof(out_path), &output)) { + fprintf(stderr, " Error: cannot create %s\n", out_path); + fail++; + continue; + } + FILE *of = output.stream; + uint64_t file_extracted = 0; + uint64_t file_hash = 0; - FILE *of = zupt_safe_fopen_output(out_path); - if (!of) { fprintf(stderr, " Error: %s\n", out_path); fail++; continue; } - - if (opts->verbose) { + if (zupt_internal_verbose(opts)) { char sz[16]; zupt_format_size(e->uncompressed_size, sz, sizeof(sz)); fprintf(stderr, " %s (%s)\n", e->path, sz); } @@ -2511,60 +4562,87 @@ zupt_error_t zupt_extract_archive(const char *arc, const char *dir, zupt_options if (err != ZUPT_OK) { berr = 1; break; } /* Handle dedup ref blocks inline (can't submit to workers) */ - if (blk.block_type == ZUPT_BLOCK_DEDUP_REF && blk.compressed_size == 8 && blk.payload) { + if (blk.block_type == ZUPT_BLOCK_DEDUP_REF) { /* Flush pending workers first to maintain order */ for (int pi = 0; pi < npending; pi++) { zpar_slot_t *s = zpar_wait_slot(pctx, pending_slots[pi]); if (!s || s->error != ZUPT_OK) { berr = 1; } else if (s->output && s->output_len > 0) { - if (fwrite(s->output, 1, s->output_len, of) != s->output_len) berr = 1; - total_extracted += s->output_len; + if (!zupt_write_verified_chunk(of, s->output, + s->output_len, e->uncompressed_size, + &file_extracted, &file_hash)) berr = 1; } zpar_release_slot(pctx, pending_slots[pi]); } npending = 0; if (berr) { free(blk.payload); break; } - uint64_t ref_off = zupt_le64_get(blk.payload); - free(blk.payload); + uint64_t ref_off = 0, referenced_aad_seq = 0; int64_t cur2 = ftello(f); + int require_authentication = + (hdr.global_flags & ZUPT_FLAG_AUTH_DEDUP_REFS) != 0; + err = zupt_dedup_read_ref( + &blk, &opts->keyring, require_authentication, + require_authentication + ? archive_data_aad_seq( + hdr.global_flags, i, decomp_seq) + : 0, + &ref_off, &referenced_aad_seq); + if (err == ZUPT_OK && legacy_encrypted_disk_dedup && + !zupt_legacy_disk_aad_map_lookup( + &legacy_aad_map, ref_off, + &referenced_aad_seq)) + err = ZUPT_ERR_CORRUPT; /* Defense: ref_off must be earlier than current position * (dedup refs always point to previously-emitted blocks) * and must be within the file. */ - if ((int64_t)ref_off >= cur2 || (int64_t)ref_off < 0) { + if (err != ZUPT_OK || cur2 < 0 || + ref_off >= (uint64_t)cur2 || + fseeko(f, (int64_t)ref_off, SEEK_SET) != 0) { + free(blk.payload); berr = 1; break; } - fseeko(f, (int64_t)ref_off, SEEK_SET); zupt_block_t ref_blk; err = read_block(f, &ref_blk); - fseeko(f, cur2, SEEK_SET); - if (err != ZUPT_OK) { berr = 1; break; } + if (fseeko(f, cur2, SEEK_SET) != 0 && err == ZUPT_OK) + err = ZUPT_ERR_IO; + if (err != ZUPT_OK) { + free(blk.payload); berr = 1; break; + } /* Defense: refs must point to data blocks, not other refs. * Prevents amplification + infinite loop attacks. */ - if (ref_blk.block_type == ZUPT_BLOCK_DEDUP_REF) { - free(ref_blk.payload); berr = 1; break; + if (ref_blk.block_type != ZUPT_BLOCK_DATA || + ref_blk.uncompressed_size != blk.uncompressed_size || + ref_blk.checksum != blk.checksum) { + free(blk.payload); free(ref_blk.payload); + berr = 1; break; } + free(blk.payload); uint8_t *rdec; size_t rdlen; - err = decompress_block(&ref_blk, &opts->keyring, 0, &rdec, &rdlen); + err = decompress_block(&ref_blk, &opts->keyring, + referenced_aad_seq, + &rdec, &rdlen); free(ref_blk.payload); if (err != ZUPT_OK) { berr = 1; break; } - if (fwrite(rdec, 1, rdlen, of) != rdlen) berr = 1; - total_extracted += rdlen; + if (!zupt_write_verified_chunk(of, rdec, rdlen, + e->uncompressed_size, &file_extracted, + &file_hash)) berr = 1; free(rdec); blocks_remaining--; decomp_seq++; continue; } - /* AAD = ((file_index+1) << 32) | per_file_block_seq. - * decomp_seq counts blocks within the current file. - * Dedup mode uses sentinel seq=0. */ - uint64_t aad_seq; - if (hdr.global_flags & ZUPT_FLAG_DEDUP) { - aad_seq = 0; - } else { - aad_seq = (((uint64_t)(i + 1)) << 32) | decomp_seq; + if (blk.block_type != ZUPT_BLOCK_DATA) { + free(blk.payload); + berr = 1; + break; } + + /* Legacy file-archive dedup used sequence zero. New + * archives bind each DATA frame to this position. */ + uint64_t aad_seq = archive_data_aad_seq( + hdr.global_flags, i, decomp_seq); int slot = zpar_submit_decompress(pctx, blk.payload, (size_t)blk.compressed_size, aad_seq, blk.codec_id, blk.block_flags, @@ -2586,8 +4664,9 @@ zupt_error_t zupt_extract_archive(const char *arc, const char *dir, zupt_options continue; } if (s->output && s->output_len > 0) { - if (fwrite(s->output, 1, s->output_len, of) != s->output_len) berr = 1; - total_extracted += s->output_len; + if (!zupt_write_verified_chunk(of, s->output, + s->output_len, e->uncompressed_size, + &file_extracted, &file_hash)) berr = 1; } zpar_release_slot(pctx, pending_slots[pi]); } @@ -2602,61 +4681,96 @@ zupt_error_t zupt_extract_archive(const char *arc, const char *dir, zupt_options if (err != ZUPT_OK) { berr=1; break; } /* Handle dedup reference blocks */ - if (blk.block_type == ZUPT_BLOCK_DEDUP_REF && blk.compressed_size == 8 && blk.payload) { - uint64_t ref_off = zupt_le64_get(blk.payload); - free(blk.payload); + if (blk.block_type == ZUPT_BLOCK_DEDUP_REF) { + uint64_t ref_off = 0, referenced_aad_seq = 0; int64_t cur = ftello(f); - if ((int64_t)ref_off >= cur || (int64_t)ref_off < 0) { berr=1; break; } - fseeko(f, (int64_t)ref_off, SEEK_SET); + int require_authentication = + (hdr.global_flags & ZUPT_FLAG_AUTH_DEDUP_REFS) != 0; + err = zupt_dedup_read_ref( + &blk, &opts->keyring, require_authentication, + require_authentication + ? archive_data_aad_seq( + hdr.global_flags, i, (uint64_t)b) + : 0, + &ref_off, &referenced_aad_seq); + if (err == ZUPT_OK && legacy_encrypted_disk_dedup && + !zupt_legacy_disk_aad_map_lookup( + &legacy_aad_map, ref_off, + &referenced_aad_seq)) + err = ZUPT_ERR_CORRUPT; + if (err != ZUPT_OK || cur < 0 || + ref_off >= (uint64_t)cur || + fseeko(f, (int64_t)ref_off, SEEK_SET) != 0) { + free(blk.payload); berr=1; break; + } zupt_block_t ref_blk; err = read_block(f, &ref_blk); - fseeko(f, cur, SEEK_SET); - if (err != ZUPT_OK) { berr=1; break; } - if (ref_blk.block_type == ZUPT_BLOCK_DEDUP_REF) { - free(ref_blk.payload); berr=1; break; + if (fseeko(f, cur, SEEK_SET) != 0 && err == ZUPT_OK) + err = ZUPT_ERR_IO; + if (err != ZUPT_OK) { + free(blk.payload); berr=1; break; } - uint8_t *dec; size_t dlen; - /* Dedup refs use seq=0 (legacy MAC fallback handles this) */ - err = decompress_block(&ref_blk, &opts->keyring, 0, &dec, &dlen); + if (ref_blk.block_type != ZUPT_BLOCK_DATA || + ref_blk.uncompressed_size != blk.uncompressed_size || + ref_blk.checksum != blk.checksum) { + free(blk.payload); free(ref_blk.payload); + berr=1; break; + } + free(blk.payload); + uint8_t *dec = NULL; size_t dlen = 0; + err = decompress_block(&ref_blk, &opts->keyring, + referenced_aad_seq, + &dec, &dlen); free(ref_blk.payload); if (err != ZUPT_OK) { berr=1; break; } - if (fwrite(dec, 1, dlen, of) != dlen) berr = 1; - total_extracted += dlen; + if (!zupt_write_verified_chunk(of, dec, dlen, + e->uncompressed_size, &file_extracted, + &file_hash)) berr = 1; free(dec); continue; } - uint8_t *dec; size_t dlen; - /* AAD = ((file_index_in_archive + 1) << 32) | per_file_block_seq. - * Matches encrypt-side computation, prevents block-swap attacks. - * Dedup mode uses sentinel seq=0 (matches encrypt-side). */ - uint64_t aad_seq; - if (hdr.global_flags & ZUPT_FLAG_DEDUP) { - aad_seq = 0; - } else { - aad_seq = (((uint64_t)(i + 1)) << 32) | (uint64_t)b; + if (blk.block_type != ZUPT_BLOCK_DATA) { + free(blk.payload); + berr = 1; + break; } + + uint8_t *dec = NULL; size_t dlen = 0; + uint64_t aad_seq = archive_data_aad_seq( + hdr.global_flags, i, (uint64_t)b); err = decompress_block(&blk, &opts->keyring, aad_seq, &dec, &dlen); free(blk.payload); if (err != ZUPT_OK) { berr=1; break; } - if (fwrite(dec, 1, dlen, of) != dlen) berr = 1; - total_extracted += dlen; + if (!zupt_write_verified_chunk(of, dec, dlen, + e->uncompressed_size, &file_extracted, + &file_hash)) berr = 1; free(dec); } } file_done: - fclose(of); + ; + int require_content_hash = + !(hdr.global_flags & ZUPT_FLAG_DISK_IMAGE) || + (hdr.global_flags & ZUPT_FLAG_DISK_CONTENT_HASH); + if (!berr && (file_extracted != e->uncompressed_size || + (require_content_hash && + file_hash != e->content_hash))) { + fprintf(stderr, " Size or checksum mismatch: %s\n", e->path); + berr = 1; + } + if (zupt_finish_output(&output, !berr, 0) != 0) berr = 1; if (berr) { - /* Authentication failure or other error: remove partial/empty output */ - unlink(out_path); fail++; } else { + total_extracted += file_extracted; ok++; } } if (pctx) zpar_destroy(pctx); + zupt_legacy_disk_aad_map_free(&legacy_aad_map); } time_t elapsed = time(NULL) - start; @@ -2670,7 +4784,9 @@ file_done: /* F-12 of v2.4.3: print the archive comment, if any. open_archive * decrypted+stored it in opts->comment when the keyring was active. */ if (opts->has_comment && opts->comment[0] != '\0') { - fprintf(stderr, "\n Comment: %s\n", opts->comment); + fputs("\n Comment: ", stderr); + zupt_print_terminal_safe_text(stderr, opts->comment); + fputc('\n', stderr); } free(ents); fclose(f); @@ -2681,31 +4797,45 @@ file_done: * TEST * ═══════════════════════════════════════════════════════════════════ */ -zupt_error_t zupt_test_archive(const char *arc, zupt_options_t *opts) { - FILE *f = fopen(arc, "rb"); - if (!f) { fprintf(stderr, "Error: Cannot open '%s'\n", arc); return ZUPT_ERR_IO; } - +zupt_error_t zupt_test_archive_stream(FILE *f, zupt_options_t *opts) { + if (!f || !opts) return ZUPT_ERR_INVALID; + if (fseeko(f, 0, SEEK_SET) != 0) return ZUPT_ERR_IO; zupt_archive_header_t hdr; zupt_footer_t ft; zupt_index_entry_t *ents; int n; zupt_error_t err = open_archive(f, opts, &hdr, &ft, &ents, &n); - if (err != ZUPT_OK) { fclose(f); fprintf(stderr, "Error: %s\n", zupt_strerror(err)); return err; } + if (err != ZUPT_OK) { + fprintf(stderr, "Error: %s\n", zupt_strerror(err)); + return err; + } int pass=0, fail=0; int is_solid = (hdr.global_flags & ZUPT_FLAG_SOLID) != 0; if (is_solid) { uint64_t total_size = 0; - for (int i = 0; i < n; i++) total_size += ents[i].uncompressed_size; - - if (total_size > (uint64_t)ZUPT_MAX_BLOCK_SZ * 4096) { - fprintf(stderr, " Error: solid stream too large for test\n"); - free(ents); fclose(f); return ZUPT_ERR_OVERFLOW; + for (int i = 0; i < n; i++) { + if (ents[i].uncompressed_size > UINT64_MAX - total_size) { + fprintf(stderr, " Error: solid stream size overflow\n"); + free(ents); return ZUPT_ERR_OVERFLOW; + } + total_size += ents[i].uncompressed_size; } - uint8_t *solid_buf = (uint8_t*)malloc((size_t)total_size); - if (!solid_buf) { free(ents); fclose(f); return ZUPT_ERR_NOMEM; } + if (total_size > (uint64_t)4 * 1024 * 1024 * 1024) { + fprintf(stderr, " Error: solid stream too large for test\n"); + free(ents); return ZUPT_ERR_OVERFLOW; + } + if (total_size > (uint64_t)SIZE_MAX) { + fprintf(stderr, + " Error: solid stream exceeds size_t on this platform\n"); + free(ents); return ZUPT_ERR_OVERFLOW; + } - fseeko(f, sizeof(zupt_archive_header_t), SEEK_SET); + uint8_t *solid_buf = + (uint8_t*)malloc(total_size == 0 ? 1 : (size_t)total_size); + if (!solid_buf) { free(ents); return ZUPT_ERR_NOMEM; } + + fseeko(f, ZUPT_ARCHIVE_HEADER_SIZE, SEEK_SET); if (hdr.global_flags & ZUPT_FLAG_ENCRYPTED) { zupt_block_t enc_blk; err = read_block(f, &enc_blk); @@ -2715,14 +4845,30 @@ zupt_error_t zupt_test_archive(const char *arc, zupt_options_t *opts) { size_t solid_pos = 0; uint64_t block_seq = 0; int blocks_ok = 0, blocks_fail = 0; + uint64_t solid_data_end = + hdr.comment_offset != 0 ? hdr.comment_offset : ft.index_offset; - while (solid_pos < (size_t)total_size) { + while (blocks_fail == 0) { + int64_t frame_position = ftello(f); + if (frame_position < 0) { + blocks_fail++; + break; + } + if ((uint64_t)frame_position == solid_data_end) break; + if ((uint64_t)frame_position > solid_data_end) { + blocks_fail++; + break; + } zupt_block_t blk; err = read_block(f, &blk); if (err != ZUPT_OK) { blocks_fail++; break; } - if (blk.block_type == ZUPT_BLOCK_INDEX) { free(blk.payload); break; } + if (blk.block_type != ZUPT_BLOCK_DATA) { + free(blk.payload); + blocks_fail++; + break; + } - uint8_t *dec; size_t dlen; + uint8_t *dec = NULL; size_t dlen = 0; /* Solid mode AAD: synthetic fi=0 */ uint64_t aad_seq = ((uint64_t)1 << 32) | block_seq; err = decompress_block(&blk, &opts->keyring, aad_seq, &dec, &dlen); @@ -2733,17 +4879,31 @@ zupt_error_t zupt_test_archive(const char *arc, zupt_options_t *opts) { blocks_fail++; break; } - if (solid_pos + dlen > (size_t)total_size) dlen = (size_t)total_size - solid_pos; - memcpy(solid_buf + solid_pos, dec, dlen); + if (dlen > (size_t)total_size - solid_pos || (dlen > 0 && !dec)) { + free(dec); + blocks_fail++; + break; + } + if (dlen > 0) memcpy(solid_buf + solid_pos, dec, dlen); solid_pos += dlen; free(dec); blocks_ok++; block_seq++; } + int64_t solid_end = ftello(f); + uint64_t solid_metadata_blocks = + 1u + (hdr.comment_offset != 0 ? 1u : 0u); + if (blocks_fail == 0 && + (solid_pos != (size_t)total_size || + ft.total_blocks < solid_metadata_blocks || + block_seq != ft.total_blocks - solid_metadata_blocks || + solid_end < 0 || (uint64_t)solid_end != solid_data_end)) + blocks_fail++; + if (blocks_fail > 0) { fprintf(stderr, " Solid stream: %d blocks OK, %d failed\n", blocks_ok, blocks_fail); - free(solid_buf); free(ents); fclose(f); + free(solid_buf); free(ents); return ZUPT_ERR_CORRUPT; } @@ -2753,7 +4913,11 @@ zupt_error_t zupt_test_archive(const char *arc, zupt_options_t *opts) { uint64_t sz = e->uncompressed_size; int fok = 1; - if (off + sz > total_size) { + /* Overflow-safe bound: off+sz can wrap (both are attacker-controlled + * index fields), so `off + sz > total_size` could pass falsely and + * feed a wild pointer / oversized length to zupt_xxh64. Match the + * hardened extract path. */ + if (off > (uint64_t)total_size || sz > (uint64_t)total_size - off) { fok = 0; } else if (sz > 0) { uint64_t ck = zupt_xxh64(solid_buf + off, (size_t)sz, 0); @@ -2761,7 +4925,7 @@ zupt_error_t zupt_test_archive(const char *arc, zupt_options_t *opts) { } if (fok) { - if (opts->verbose) fprintf(stderr, " OK: %s\n", e->path); + if (zupt_internal_verbose(opts)) fprintf(stderr, " OK: %s\n", e->path); pass++; } else { fprintf(stderr, " FAIL: %s (checksum mismatch)\n", e->path); @@ -2771,38 +4935,134 @@ zupt_error_t zupt_test_archive(const char *arc, zupt_options_t *opts) { free(solid_buf); } else { + int legacy_encrypted_disk_dedup = + (hdr.global_flags & ZUPT_FLAG_ENCRYPTED) != 0 && + (hdr.global_flags & ZUPT_FLAG_DISK_IMAGE) != 0 && + (hdr.global_flags & ZUPT_FLAG_DEDUP) != 0 && + (hdr.global_flags & ZUPT_FLAG_AUTH_DEDUP_REFS) == 0; + zupt_legacy_disk_aad_map_t legacy_aad_map = {0}; + if (legacy_encrypted_disk_dedup) { + if (n != 1) { + free(ents); + return ZUPT_ERR_CORRUPT; + } + err = zupt_legacy_disk_aad_map_build( + f, ents[0].first_block_offset, ents[0].block_count, + &legacy_aad_map); + if (err != ZUPT_OK) { + free(ents); + return err; + } + } for (int i = 0; i < n; i++) { zupt_index_entry_t *e = &ents[i]; fseeko(f, (int64_t)e->first_block_offset, SEEK_SET); int fok = 1; + uint64_t tested_size = 0; + uint64_t tested_hash = 0; for (uint32_t b = 0; b < e->block_count; b++) { zupt_block_t blk; err = read_block(f, &blk); if (err != ZUPT_OK) { fok=0; break; } - uint8_t *dec; size_t dlen; - /* AAD = ((file_index+1) << 32) | per_file_block_seq (or - * sentinel 0 in dedup mode). Matches encrypt-side. */ - uint64_t aad_seq; - if (hdr.global_flags & ZUPT_FLAG_DEDUP) { - aad_seq = 0; + uint8_t *dec = NULL; size_t dlen = 0; + uint64_t aad_seq = archive_data_aad_seq( + hdr.global_flags, i, (uint64_t)b); + + if (blk.block_type == ZUPT_BLOCK_DEDUP_REF) { + uint64_t ref_offset = 0, referenced_aad_seq = 0; + int require_authentication = + (hdr.global_flags & ZUPT_FLAG_AUTH_DEDUP_REFS) != 0; + err = zupt_dedup_read_ref(&blk, &opts->keyring, + require_authentication, + require_authentication + ? aad_seq : 0, + &ref_offset, + &referenced_aad_seq); + if (err == ZUPT_OK && legacy_encrypted_disk_dedup && + !zupt_legacy_disk_aad_map_lookup( + &legacy_aad_map, ref_offset, + &referenced_aad_seq)) + err = ZUPT_ERR_CORRUPT; + int64_t resume = ftello(f); + if (err != ZUPT_OK) { + free(blk.payload); + fok = 0; + break; + } + if (resume < 0 || + ref_offset >= (uint64_t)resume || + fseeko(f, (int64_t)ref_offset, SEEK_SET) != 0) { + free(blk.payload); + err = ZUPT_ERR_CORRUPT; + fok = 0; + break; + } + zupt_block_t referenced; + err = read_block(f, &referenced); + if (fseeko(f, resume, SEEK_SET) != 0 && err == ZUPT_OK) + err = ZUPT_ERR_IO; + if (err == ZUPT_OK && + (referenced.block_type != ZUPT_BLOCK_DATA || + referenced.uncompressed_size != blk.uncompressed_size || + referenced.checksum != blk.checksum)) + err = ZUPT_ERR_CORRUPT; + free(blk.payload); + if (err == ZUPT_OK) + err = decompress_block(&referenced, &opts->keyring, + referenced_aad_seq, + &dec, &dlen); + free(referenced.payload); + } else if (blk.block_type == ZUPT_BLOCK_DATA) { + err = decompress_block(&blk, &opts->keyring, aad_seq, + &dec, &dlen); + free(blk.payload); } else { - aad_seq = (((uint64_t)(i + 1)) << 32) | (uint64_t)b; + free(blk.payload); + err = ZUPT_ERR_CORRUPT; } - err = decompress_block(&blk, &opts->keyring, aad_seq, &dec, &dlen); - free(blk.payload); if (err != ZUPT_OK) { fok=0; break; } + if (tested_size > e->uncompressed_size || + (uint64_t)dlen > e->uncompressed_size - tested_size) { + free(dec); + err = ZUPT_ERR_OVERFLOW; + fok = 0; + break; + } + tested_hash = zupt_xxh64(dec, dlen, tested_hash); + tested_size += dlen; free(dec); } - if (fok) { if (opts->verbose) fprintf(stderr, " OK: %s\n", e->path); pass++; } + int require_content_hash = + !(hdr.global_flags & ZUPT_FLAG_DISK_IMAGE) || + (hdr.global_flags & ZUPT_FLAG_DISK_CONTENT_HASH); + if (fok && (tested_size != e->uncompressed_size || + (require_content_hash && + tested_hash != e->content_hash))) { + err = ZUPT_ERR_BAD_CHECKSUM; + fok = 0; + } + if (fok) { if (zupt_internal_verbose(opts)) fprintf(stderr, " OK: %s\n", e->path); pass++; } else { fprintf(stderr, " FAIL: %s (%s)\n", e->path, zupt_strerror(err)); fail++; } } + zupt_legacy_disk_aad_map_free(&legacy_aad_map); } printf("\n Test: %d passed, %d failed (%d files)\n", pass, fail, n); - free(ents); fclose(f); + free(ents); return fail>0 ? ZUPT_ERR_BAD_CHECKSUM : ZUPT_OK; } +zupt_error_t zupt_test_archive(const char *arc, zupt_options_t *opts) { + FILE *f = zupt_fopen_path(arc, "rb"); + if (!f) { + fprintf(stderr, "Error: Cannot open '%s'\n", arc); + return ZUPT_ERR_IO; + } + zupt_error_t result = zupt_test_archive_stream(f, opts); + if (fclose(f) != 0 && result == ZUPT_OK) result = ZUPT_ERR_IO; + return result; +} + /* ═══════════════════════════════════════════════════════════════════ * ARCHIVE INFO — read-only metadata inspection (no password needed) * @@ -2811,17 +5071,20 @@ zupt_error_t zupt_test_archive(const char *arc, zupt_options_t *opts) { * Does NOT decrypt or verify checksums — works on any archive. * ═══════════════════════════════════════════════════════════════════ */ zupt_error_t zupt_archive_info(const char *path) { - FILE *f = fopen(path, "rb"); + FILE *f = zupt_fopen_path(path, "rb"); if (!f) { fprintf(stderr, "Error: Cannot open '%s': %s\n", path, strerror(errno)); return ZUPT_ERR_IO; } zupt_archive_header_t hdr; - if (fread(&hdr, sizeof(hdr), 1, f) != 1) { - fprintf(stderr, "Error: Not a zupt archive (file too small)\n"); + uint8_t serialized_header[ZUPT_ARCHIVE_HEADER_SIZE]; + if (fread(serialized_header, 1, sizeof(serialized_header), f) != + sizeof(serialized_header)) { + fprintf(stderr, "Error: Not a .zupt archive (file too small)\n"); fclose(f); return ZUPT_ERR_CORRUPT; } + deserialize_archive_header(serialized_header, &hdr); if (hdr.magic[0]!=ZUPT_MAGIC_0 || hdr.magic[1]!=ZUPT_MAGIC_1 || hdr.magic[2]!=ZUPT_MAGIC_2 || hdr.magic[3]!=ZUPT_MAGIC_3) { - fprintf(stderr, "Error: Not a zupt archive (bad magic)\n"); + fprintf(stderr, "Error: Not a .zupt archive (bad magic)\n"); fclose(f); return ZUPT_ERR_BAD_MAGIC; } @@ -2838,25 +5101,54 @@ zupt_error_t zupt_archive_info(const char *path) { uint64_t total_blocks = 0; int has_footer = 0; int has_ait = 0; - if (file_size >= sizeof(zupt_footer_t) + ZUPT_AIT_SIZE) { - fseeko(f, -(int64_t)(sizeof(zupt_footer_t) + ZUPT_AIT_SIZE), SEEK_END); + if (file_size >= ZUPT_FOOTER_SIZE + ZUPT_AIT_SIZE) { + fseeko(f, -(int64_t)(ZUPT_FOOTER_SIZE + ZUPT_AIT_SIZE), SEEK_END); zupt_footer_t ft; - if (fread(&ft, sizeof(ft), 1, f) == 1 && - ft.footer_magic[0]=='Z' && ft.footer_magic[1]=='E' && - ft.footer_magic[2]=='N' && ft.footer_magic[3]=='D') { - total_blocks = ft.total_blocks; - has_footer = 1; - has_ait = 1; + uint8_t serialized_footer[ZUPT_FOOTER_SIZE]; + if (fread(serialized_footer, 1, sizeof(serialized_footer), f) == + sizeof(serialized_footer)) { + deserialize_footer(serialized_footer, &ft); + if (ft.footer_magic[0]=='Z' && ft.footer_magic[1]=='E' && + ft.footer_magic[2]=='N' && ft.footer_magic[3]=='D') { + total_blocks = ft.total_blocks; + has_footer = 1; + has_ait = 1; + } } } - if (!has_footer && file_size > sizeof(zupt_footer_t)) { - fseeko(f, -(int64_t)sizeof(zupt_footer_t), SEEK_END); + if (!has_footer && file_size > ZUPT_FOOTER_SIZE) { + fseeko(f, -(int64_t)ZUPT_FOOTER_SIZE, SEEK_END); zupt_footer_t ft; - if (fread(&ft, sizeof(ft), 1, f) == 1 && - ft.footer_magic[0]=='Z' && ft.footer_magic[1]=='E' && - ft.footer_magic[2]=='N' && ft.footer_magic[3]=='D') { - total_blocks = ft.total_blocks; - has_footer = 1; + uint8_t serialized_footer[ZUPT_FOOTER_SIZE]; + if (fread(serialized_footer, 1, sizeof(serialized_footer), f) == + sizeof(serialized_footer)) { + deserialize_footer(serialized_footer, &ft); + if (ft.footer_magic[0]=='Z' && ft.footer_magic[1]=='E' && + ft.footer_magic[2]=='N' && ft.footer_magic[3]=='D') { + total_blocks = ft.total_blocks; + has_footer = 1; + } + } + } + + /* Read the real enc_type from the encryption-header block so `info` can + * distinguish hybrid --pq (0x02) from full --pq-only (0x06), the SDK-v2 + * (0x03) and sealed-box (0x05) modes — the ZUPT_FLAG_PQ_HYBRID header flag + * is a generic PQ indicator set by all of them. Block layout from + * write_enc_header: 7-byte prefix (magic0,magic1,block_type,codec u16, + * flags u16) + varint(len) + varint(len) + u64 xxh64 + enc_hdr[0]=enc_type. */ + uint8_t enc_type = 0; + /* encryption_header_off is attacker-controlled; bound it inside the file + * before the (off_t)+7 arithmetic so the signed addition cannot overflow + * (UB) and the seek stays in-range. All subsequent reads are EOF-checked. */ + if ((hdr.global_flags & ZUPT_FLAG_ENCRYPTED) && hdr.encryption_header_off != 0 && + hdr.encryption_header_off < file_size && (file_size - hdr.encryption_header_off) > 7 && + fseeko(f, (off_t)hdr.encryption_header_off + 7, SEEK_SET) == 0) { + uint64_t l1 = 0, l2 = 0; + if (zupt_read_varint(f, &l1) > 0 && zupt_read_varint(f, &l2) > 0 && + fseeko(f, 8, SEEK_CUR) == 0) { + uint8_t b; + if (fread(&b, 1, 1, f) == 1) enc_type = b; } } fclose(f); @@ -2894,8 +5186,23 @@ zupt_error_t zupt_archive_info(const char *path) { if (has_footer) printf(" Blocks: %llu\n", (unsigned long long)total_blocks); printf(" Encrypted: %s\n", (fl & ZUPT_FLAG_ENCRYPTED) ? "YES" : "no"); - if (fl & ZUPT_FLAG_PQ_HYBRID) - printf(" PQ Hybrid: YES (ML-KEM-768 + X25519)\n"); + if (fl & ZUPT_FLAG_PQ_HYBRID) { + switch (enc_type) { + case ZUPT_ENC_PQ_ONLY: + printf(" Post-quantum: YES (ML-KEM-768 only, no classical layer)\n"); + break; + case ZUPT_ENC_PQ_SDK_V2: + printf(" Post-quantum: YES (ML-KEM-768 + X25519, SDK v2 + HPKE)\n"); + break; + case ZUPT_ENC_PQ_BOX_V1: + printf(" Post-quantum: YES (ML-KEM-768 + X25519, sealed box)\n"); + break; + case ZUPT_ENC_PQ_HYBRID: + default: + printf(" Post-quantum: YES (ML-KEM-768 + X25519, hybrid)\n"); + break; + } + } if (fl & ZUPT_FLAG_SOLID) printf(" Solid: YES\n"); if (fl & ZUPT_FLAG_MULTITHREADED) @@ -2905,7 +5212,7 @@ zupt_error_t zupt_archive_info(const char *path) { if (fl & ZUPT_FLAG_DISK_IMAGE) printf(" Disk image: YES\n"); if (hdr.comment_offset != 0) - printf(" Comment: present (use 'zupt x' with the right key to read)\n"); + printf(" Comment: present (use 'zupt extract' with the right key to read)\n"); printf(" Flags: 0x%04X\n", fl); printf("\n"); diff --git a/src/zupt_internal.h b/src/zupt_internal.h new file mode 100644 index 0000000..6a6d391 --- /dev/null +++ b/src/zupt_internal.h @@ -0,0 +1,57 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ +#ifndef ZUPT_INTERNAL_H +#define ZUPT_INTERNAL_H + +#include "zupt.h" + +/* Keep the published 5.2.1 option layout intact. The high bit is private to + * the CLI/read path; ordinary nonzero verbose values retain their behavior. */ +#define ZUPT_INTERNAL_ALLOW_LEGACY_NO_AIT 0x40000000 + +static inline void zupt_internal_set_verbose(zupt_options_t *options) { + options->verbose |= 1; +} + +static inline int zupt_internal_verbose(const zupt_options_t *options) { + return options && + (options->verbose & ~ZUPT_INTERNAL_ALLOW_LEGACY_NO_AIT) != 0; +} + +static inline void zupt_internal_allow_legacy_no_ait( + zupt_options_t *options) { + options->verbose |= ZUPT_INTERNAL_ALLOW_LEGACY_NO_AIT; +} + +static inline int zupt_internal_legacy_no_ait_allowed( + const zupt_options_t *options) { + if (!options) return 0; + int value = options->verbose; + return (value & ZUPT_INTERNAL_ALLOW_LEGACY_NO_AIT) != 0 && + (value & ~(ZUPT_INTERNAL_ALLOW_LEGACY_NO_AIT | 1)) == 0; +} + +/* A negative encoded capacity records an incomplete collection without + * enlarging the published zupt_filelist_t structure. */ +static inline int zupt_internal_filelist_failed( + const zupt_filelist_t *filelist) { + return filelist && filelist->capacity < 0; +} + +static inline void zupt_internal_filelist_mark_failed( + zupt_filelist_t *filelist) { + if (filelist && filelist->capacity >= 0) + filelist->capacity = -filelist->capacity - 1; +} + +int zupt_dedup_lookup_secure( + zupt_dedup_ctx_t *context, uint64_t fingerprint, + const uint8_t digest[ZUPT_DEDUP_DIGEST_SIZE], + uint64_t *reference_offset, uint32_t *reference_size, + uint64_t *reference_aad_sequence); +int zupt_dedup_insert_secure( + zupt_dedup_ctx_t *context, uint64_t fingerprint, + const uint8_t digest[ZUPT_DEDUP_DIGEST_SIZE], + uint64_t block_offset, uint32_t block_size, + uint64_t block_aad_sequence); + +#endif diff --git a/src/zupt_keccak.c b/src/zupt_keccak.c index 87b30f0..2bb9772 100644 --- a/src/zupt_keccak.c +++ b/src/zupt_keccak.c @@ -1,5 +1,5 @@ /* - * Zupt — Backup-oriented compression with AES-256 encryption + * ZUPT — Backup-oriented compression with AES-256 encryption * Copyright (c) 2026 Cristian Cezar Moisés * SPDX-License-Identifier: AGPL-3.0-or-later * diff --git a/src/zupt_lz.c b/src/zupt_lz.c index 3c26a05..8c2c2b2 100644 --- a/src/zupt_lz.c +++ b/src/zupt_lz.c @@ -1,7 +1,7 @@ /* * SPDX-License-Identifier: AGPL-3.0-or-later * Copyright (c) 2025-2026 Cristian Cezar Moisés - * ZUPT - LZ77 Compression Engine v2 (Zupt-LZ codec 0x0008) + * ZUPT - LZ77 Compression Engine v2 (ZUPT-LZ codec 0x0008) * * Improvements over v0.1: * - 18-bit hash table (256K entries) for better match distribution diff --git a/src/zupt_lzh.c b/src/zupt_lzh.c index 787f08b..67a23fb 100644 --- a/src/zupt_lzh.c +++ b/src/zupt_lzh.c @@ -216,15 +216,16 @@ static void huff_build(const uint32_t *freq, int ns, hcode_t *codes) { int ni=0; while(hn>1){ - hnode_t a=hp[0];hp[0]=hp[--hn];if(hn>0)h_down(hp,hn,0); + hnode_t a=hp[0];hp[0]=hp[--hn];h_down(hp,hn,0); hnode_t b=hp[0];hp[0]=hp[--hn];if(hn>0)h_down(hp,hn,0); L[ni]=a.s; R[ni]=b.s; hnode_t in; in.f=a.f+b.f; in.s=-(ni+1); ni++; hp[hn]=in; h_up(hp,hn); hn++; } - uint8_t *dp=(uint8_t*)calloc(ns,1); - if(dp && hn==1) tree_depths(hp[0].s,0,L,R,dp,ns); + uint8_t *dp=(uint8_t*)calloc((size_t)ns, 1); + if (!dp) { free(hp); free(L); free(R); return; } + if(hn==1) tree_depths(hp[0].s,0,L,R,dp,ns); /* Enforce max code length using Kraft-sum based redistribution. * @@ -375,7 +376,7 @@ static size_t cl_encode(const uint8_t *lens, int count, uint8_t *out, size_t oca out[op++] = (uint8_t)(r - 11); i += r; run -= r; } else if (run >= 3) { - int r = run > 10 ? 10 : run; + int r = run; if (op + 2 > ocap) return 0; out[op++] = 17; out[op++] = (uint8_t)(r - 3); @@ -670,6 +671,8 @@ size_t zupt_lzh_compress(const uint8_t *src, size_t slen, /* Compress code lengths with RLE */ uint8_t ll_lens[LZH_MAX_LITLEN], d_lens[LZH_MAX_DIST]; + memset(ll_lens, 0, sizeof(ll_lens)); + memset(d_lens, 0, sizeof(d_lens)); for (int i = 0; i < ll_cnt; i++) ll_lens[i] = ll_codes[i].len; for (int i = 0; i < d_cnt; i++) d_lens[i] = d_codes[i].len; @@ -741,7 +744,6 @@ size_t zupt_lzh_decompress(const uint8_t *src, size_t slen, int rle_on = (flags & 0x01); uint32_t rle_orig = 0; if (rle_on) { - if (ip + 4 > slen) return 0; memcpy(&rle_orig, src + ip, 4); ip += 4; } diff --git a/src/zupt_main.c b/src/zupt_main.c index fcb0cff..a0d4a00 100644 --- a/src/zupt_main.c +++ b/src/zupt_main.c @@ -5,6 +5,7 @@ * Multi-threaded compression, AES-256 encryption, progress bars */ #include "zupt.h" +#include "zupt_internal.h" #include "zupt_thread.h" #include "zupt_cpuid.h" #include "vaptvupt.h" /* VAPTVUPT: codec ID */ @@ -12,18 +13,552 @@ #include #include #include +#include +#include +#include /* stat()/S_ISREG for the compress output-overwrite guard */ + +/* MSVC's defines _S_IFREG/S_IFREG but not the S_ISREG macro. */ +#ifndef S_ISREG +# define S_ISREG(m) (((m) & S_IFMT) == S_IFREG) +#endif #ifdef _WIN32 #include + #include + #include #else + #include + #include #include #endif +static double zupt_monotonic_seconds(void) { +#ifdef _WIN32 + LARGE_INTEGER frequency; + LARGE_INTEGER counter; + if (QueryPerformanceFrequency(&frequency) && + frequency.QuadPart > 0 && + QueryPerformanceCounter(&counter)) { + return (double)counter.QuadPart / (double)frequency.QuadPart; + } + return (double)GetTickCount64() / 1000.0; +#else + struct timespec now; + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) { + return (double)time(NULL); + } + return (double)now.tv_sec + (double)now.tv_nsec / 1e9; +#endif +} + +static int zupt_join_temp_path(char *output, size_t capacity, + const char *directory, const char *leaf) { + if (!output || !directory || !leaf || capacity == 0) return 0; + int written = snprintf(output, capacity, "%s%c%s", directory, + ZUPT_PATH_SEP, leaf); + return written >= 0 && (size_t)written < capacity; +} + +static int zupt_create_private_temp_directory(char *output, size_t capacity) { + if (!output || capacity == 0) return 0; +#ifdef _WIN32 + wchar_t temp_directory[MAX_PATH + 1]; + DWORD length = GetTempPathW(MAX_PATH + 1, temp_directory); + if (length == 0 || length > MAX_PATH || length + 58u > MAX_PATH) return 0; + static const wchar_t hex[] = L"0123456789abcdef"; + for (int attempt = 0; attempt < 64; attempt++) { + uint8_t nonce[16]; + wchar_t candidate[MAX_PATH + 1]; + zupt_random_bytes(nonce, sizeof(nonce)); + memcpy(candidate, temp_directory, + (size_t)length * sizeof(*candidate)); + size_t position = length; + if (position > 0 && candidate[position - 1] != L'\\' && + candidate[position - 1] != L'/') + candidate[position++] = L'\\'; + const wchar_t prefix[] = L"zupt-bench-"; + memcpy(candidate + position, prefix, + wcslen(prefix) * sizeof(*candidate)); + position += wcslen(prefix); + for (size_t i = 0; i < sizeof(nonce); i++) { + candidate[position++] = hex[nonce[i] >> 4]; + candidate[position++] = hex[nonce[i] & 0x0f]; + } + candidate[position] = L'\0'; + if (!CreateDirectoryW(candidate, NULL)) { + DWORD error = GetLastError(); + if (error == ERROR_ALREADY_EXISTS) continue; + return 0; + } + char *utf8 = zupt_win_wide_to_utf8_alloc(candidate); + if (!utf8 || strlen(utf8) >= capacity) { + free(utf8); + RemoveDirectoryW(candidate); + return 0; + } + memcpy(output, utf8, strlen(utf8) + 1u); + free(utf8); + return 1; + } + return 0; +#else + char temp_root[ZUPT_MAX_PATH]; + if (!realpath("/tmp", temp_root)) return 0; + int written = snprintf(output, capacity, "%s/zupt-bench-XXXXXX", + temp_root); + if (written < 0 || (size_t)written >= capacity) return 0; + if (!mkdtemp(output)) return 0; + if (chmod(output, 0700) != 0) { + rmdir(output); + output[0] = '\0'; + return 0; + } + return 1; +#endif +} + +#ifdef _WIN32 +static void zupt_win_set_cleanup_errno(NTSTATUS status) { + if (status == (NTSTATUS)0xC0000034L || /* STATUS_OBJECT_NAME_NOT_FOUND */ + status == (NTSTATUS)0xC000003AL) { /* STATUS_OBJECT_PATH_NOT_FOUND */ + errno = ENOENT; + } else { + errno = EACCES; + } +} + +/* Open one entry relative to a pinned parent. Omitting FILE_SHARE_DELETE + * keeps the name bound to this handle until cleanup finishes; opening the + * reparse point itself prevents a junction or symlink from redirecting the + * recursive walk. */ +static HANDLE zupt_win_open_cleanup_entry(HANDLE parent, + const wchar_t *name, + int directory_only, + int delete_access) { + size_t name_length = wcslen(name); + if (name_length == 0 || + name_length > (size_t)USHRT_MAX / sizeof(wchar_t)) { + errno = ENAMETOOLONG; + return INVALID_HANDLE_VALUE; + } + UNICODE_STRING object_name; + object_name.Buffer = (PWSTR)name; + object_name.Length = (USHORT)(name_length * sizeof(wchar_t)); + object_name.MaximumLength = object_name.Length + sizeof(wchar_t); + OBJECT_ATTRIBUTES attributes; + InitializeObjectAttributes(&attributes, &object_name, + OBJ_CASE_INSENSITIVE, parent, NULL); + IO_STATUS_BLOCK status_block; + HANDLE handle = INVALID_HANDLE_VALUE; + ACCESS_MASK access = FILE_LIST_DIRECTORY | FILE_TRAVERSE | + FILE_READ_ATTRIBUTES | SYNCHRONIZE; + if (delete_access) access |= DELETE; + ULONG share = FILE_SHARE_READ | FILE_SHARE_WRITE; + if (delete_access) share |= FILE_SHARE_DELETE; + ULONG options = FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT; + if (directory_only) options |= FILE_DIRECTORY_FILE; + NTSTATUS status = NtCreateFile( + &handle, access, &attributes, &status_block, NULL, + FILE_ATTRIBUTE_NORMAL, share, FILE_OPEN, + options, NULL, 0); + if (status < 0 || handle == INVALID_HANDLE_VALUE) { + zupt_win_set_cleanup_errno(status); + return INVALID_HANDLE_VALUE; + } + return handle; +} + +/* Mark the exact object held by an identity-checked deletion handle. */ +static int zupt_win_delete_cleanup_handle(HANDLE handle) { + FILE_DISPOSITION_INFO disposition; + disposition.DeleteFile = TRUE; + if (SetFileInformationByHandle(handle, FileDispositionInfo, + &disposition, sizeof(disposition))) + return 1; + errno = EACCES; + return 0; +} + +/* Reopen an emptied child only after closing its no-delete-sharing traversal + * handle. Comparing the filesystem identity before marking the new handle + * for deletion makes a close/reopen name exchange fail safely. */ +static int zupt_win_delete_cleanup_entry( + HANDLE parent, const wchar_t *name, + const BY_HANDLE_FILE_INFORMATION *expected) { + HANDLE handle = zupt_win_open_cleanup_entry(parent, name, 1, 1); + if (handle == INVALID_HANDLE_VALUE) return 0; + BY_HANDLE_FILE_INFORMATION current; + int same = GetFileInformationByHandle(handle, ¤t) && + (current.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (current.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && + current.dwVolumeSerialNumber == expected->dwVolumeSerialNumber && + current.nFileIndexHigh == expected->nFileIndexHigh && + current.nFileIndexLow == expected->nFileIndexLow; + int deleted = same && zupt_win_delete_cleanup_handle(handle); + int closed = CloseHandle(handle) != 0; + if (!same) errno = EBUSY; + return deleted && closed; +} + +static int zupt_win_plain_directory(HANDLE handle) { + BY_HANDLE_FILE_INFORMATION info; + return GetFileInformationByHandle(handle, &info) && + (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0; +} + +static int zupt_remove_tree_wide(HANDLE directory_handle, + const wchar_t *directory) { + size_t directory_length = wcslen(directory); + wchar_t *pattern = (wchar_t *)calloc(directory_length + 3u, + sizeof(*pattern)); + if (!pattern) return -1; + memcpy(pattern, directory, directory_length * sizeof(*pattern)); + pattern[directory_length] = L'\\'; + pattern[directory_length + 1u] = L'*'; + + WIN32_FIND_DATAW data; + HANDLE search = FindFirstFileW(pattern, &data); + DWORD search_error = search == INVALID_HANDLE_VALUE + ? GetLastError() : ERROR_SUCCESS; + free(pattern); + int failed = 0; + if (search != INVALID_HANDLE_VALUE) { + do { + if (wcscmp(data.cFileName, L".") == 0 || + wcscmp(data.cFileName, L"..") == 0) + continue; + size_t name_length = wcslen(data.cFileName); + wchar_t *child = (wchar_t *)calloc( + directory_length + name_length + 2u, sizeof(*child)); + if (!child) { + failed = 1; + continue; + } + memcpy(child, directory, directory_length * sizeof(*child)); + child[directory_length] = L'\\'; + memcpy(child + directory_length + 1u, data.cFileName, + (name_length + 1u) * sizeof(*child)); + if (DeleteFileW(child) || RemoveDirectoryW(child)) { + free(child); + continue; + } + DWORD delete_error = GetLastError(); + if (delete_error == ERROR_FILE_NOT_FOUND || + delete_error == ERROR_PATH_NOT_FOUND) { + free(child); + continue; + } + HANDLE child_handle = zupt_win_open_cleanup_entry( + directory_handle, data.cFileName, 1, 0); + if (child_handle == INVALID_HANDLE_VALUE) { + if (errno != ENOENT) failed = 1; + free(child); + continue; + } + int child_failed = 0; + BY_HANDLE_FILE_INFORMATION child_identity; + if (!GetFileInformationByHandle(child_handle, &child_identity) || + (child_identity.dwFileAttributes & + FILE_ATTRIBUTE_DIRECTORY) == 0 || + (child_identity.dwFileAttributes & + FILE_ATTRIBUTE_REPARSE_POINT) != 0 || + zupt_remove_tree_wide(child_handle, child) != 0) + child_failed = 1; + if (!CloseHandle(child_handle)) child_failed = 1; + if (!child_failed && !zupt_win_delete_cleanup_entry( + directory_handle, data.cFileName, &child_identity)) + child_failed = 1; + if (child_failed) failed = 1; + free(child); + } while (FindNextFileW(search, &data)); + if (GetLastError() != ERROR_NO_MORE_FILES) failed = 1; + if (!FindClose(search)) failed = 1; + } else if (search_error != ERROR_FILE_NOT_FOUND) { + failed = 1; + } + return failed ? -1 : 0; +} + +/* Resolve the absolute temporary path one component at a time and retain + * every ancestor handle. This makes the pathname used for enumeration + * stable even if another process tries to exchange an ancestor directory. */ +static int zupt_win_open_cleanup_path( + const wchar_t *directory, wchar_t full[ZUPT_MAX_PATH + 256], + HANDLE **handles_out, size_t *handle_count_out) { + if (!_wfullpath(full, directory, ZUPT_MAX_PATH + 256)) { + errno = EINVAL; + return 0; + } + for (wchar_t *p = full; *p; p++) if (*p == L'/') *p = L'\\'; + if ((full[0] == L'\\' && full[1] == L'\\') || + !(full[0] && full[1] == L':' && full[2] == L'\\')) { + errno = EINVAL; + return 0; + } + + size_t capacity = wcslen(full) + 1u; + HANDLE *handles = (HANDLE *)calloc(capacity, sizeof(*handles)); + if (!handles) return 0; + wchar_t drive_root[4] = {full[0], L':', L'\\', L'\0'}; + HANDLE current = CreateFileW( + drive_root, + FILE_LIST_DIRECTORY | FILE_TRAVERSE | FILE_READ_ATTRIBUTES | + SYNCHRONIZE, + FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + if (current == INVALID_HANDLE_VALUE || + !zupt_win_plain_directory(current)) { + DWORD open_error = current == INVALID_HANDLE_VALUE + ? GetLastError() : ERROR_ACCESS_DENIED; + if (current != INVALID_HANDLE_VALUE) CloseHandle(current); + free(handles); + errno = open_error == ERROR_FILE_NOT_FOUND || + open_error == ERROR_PATH_NOT_FOUND + ? ENOENT : EACCES; + return 0; + } + size_t count = 0; + handles[count++] = current; + + wchar_t *scan = full + 3; + while (*scan) { + wchar_t *separator = wcschr(scan, L'\\'); + if (separator) *separator = L'\0'; + HANDLE next = zupt_win_open_cleanup_entry( + current, scan, 1, 0); + if (separator) *separator = L'\\'; + if (next == INVALID_HANDLE_VALUE || + !zupt_win_plain_directory(next)) { + if (next != INVALID_HANDLE_VALUE) CloseHandle(next); + while (count > 0) CloseHandle(handles[--count]); + free(handles); + if (next != INVALID_HANDLE_VALUE) errno = EACCES; + return 0; + } + handles[count++] = next; + current = next; + if (!separator) break; + scan = separator + 1; + } + *handles_out = handles; + *handle_count_out = count; + return 1; +} +#endif + +#ifndef _WIN32 +/* Resolve every component without following symlinks and return both the + * pinned target and its pinned parent. The caller can therefore remove the + * final directory with unlinkat() instead of resolving its pathname again. */ +static int zupt_open_temp_tree(const char *path, int *parent_out, + int *directory_out, char *leaf, + size_t leaf_capacity) { + if (!path || !*path || !parent_out || !directory_out || !leaf || + leaf_capacity == 0) { + errno = EINVAL; + return 0; + } + int current = open(path[0] == '/' ? "/" : ".", + O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (current < 0) return 0; + + const char *cursor = path; + while (*cursor == '/') cursor++; + while (*cursor) { + const char *start = cursor; + while (*cursor && *cursor != '/') cursor++; + size_t component_length = (size_t)(cursor - start); + while (*cursor == '/') cursor++; + int final_component = *cursor == '\0'; + if ((component_length == 1u && start[0] == '.') || + component_length == 0u) { + if (final_component) { + close(current); + errno = EINVAL; + return 0; + } + continue; + } + if (component_length == 2u && start[0] == '.' && start[1] == '.') { + close(current); + errno = EINVAL; + return 0; + } + if (component_length >= leaf_capacity) { + close(current); + errno = ENAMETOOLONG; + return 0; + } + memcpy(leaf, start, component_length); + leaf[component_length] = '\0'; + int next = openat(current, leaf, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (next < 0) { + int saved_errno = errno; + close(current); + errno = saved_errno; + return 0; + } + if (final_component) { + *parent_out = current; + *directory_out = next; + return 1; + } + close(current); + current = next; + } + close(current); + errno = EINVAL; + return 0; +} + +/* Delete leaves before attempting to open them as directories. unlinkat() + * never follows a symlink; a directory is recursively visited only through + * an O_NOFOLLOW descriptor returned by openat(). */ +static int zupt_remove_temp_tree_fd(int directory_fd) { + DIR *stream = fdopendir(directory_fd); + if (!stream) { + close(directory_fd); + return -1; + } + int failed = 0; + int parent_fd = dirfd(stream); + for (;;) { + errno = 0; + struct dirent *entry = readdir(stream); + if (!entry) { + if (errno != 0) failed = 1; + break; + } + if (strcmp(entry->d_name, ".") == 0 || + strcmp(entry->d_name, "..") == 0) + continue; + if (unlinkat(parent_fd, entry->d_name, 0) == 0 || errno == ENOENT) + continue; + + int child_fd = openat(parent_fd, entry->d_name, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | + O_CLOEXEC); + if (child_fd < 0) { + if (errno != ENOENT) failed = 1; + continue; + } + if (zupt_remove_temp_tree_fd(child_fd) != 0) failed = 1; + if (unlinkat(parent_fd, entry->d_name, AT_REMOVEDIR) != 0 && + errno != ENOENT) + failed = 1; + } + if (closedir(stream) != 0) failed = 1; + return failed ? -1 : 0; +} +#endif + +static int zupt_remove_temp_tree(const char *directory) { + if (!directory || directory[0] == '\0') return 0; +#ifdef _WIN32 + wchar_t *wide = zupt_win_utf8_to_wide_alloc(directory); + if (!wide) return -1; + wchar_t full[ZUPT_MAX_PATH + 256]; + HANDLE *handles = NULL; + size_t handle_count = 0; + if (!zupt_win_open_cleanup_path(wide, full, &handles, &handle_count)) { + int result = errno == ENOENT ? 0 : -1; + free(wide); + return result; + } + HANDLE root_handle = handles[handle_count - 1u]; + int result = zupt_remove_tree_wide(root_handle, full); + BY_HANDLE_FILE_INFORMATION root_identity; + if (result == 0 && !GetFileInformationByHandle(root_handle, + &root_identity)) + result = -1; + const wchar_t *root_name = wcsrchr(full, L'\\'); + if (!root_name || root_name[1] == L'\0') result = -1; + else root_name++; + if (!CloseHandle(handles[--handle_count])) result = -1; + if (result == 0 && !zupt_win_delete_cleanup_entry( + handles[handle_count - 1u], root_name, &root_identity)) + result = -1; + while (handle_count > 0) + if (!CloseHandle(handles[--handle_count])) result = -1; + free(handles); + free(wide); + return result; +#else + int parent_fd = -1; + int directory_fd = -1; + char leaf[ZUPT_MAX_PATH]; + if (!zupt_open_temp_tree(directory, &parent_fd, &directory_fd, + leaf, sizeof(leaf))) + return errno == ENOENT ? 0 : -1; + int failed = zupt_remove_temp_tree_fd(directory_fd) != 0; + if (unlinkat(parent_fd, leaf, AT_REMOVEDIR) != 0 && errno != ENOENT) + failed = 1; + if (close(parent_fd) != 0) failed = 1; + return failed ? -1 : 0; +#endif +} + +static int zupt_write_benchmark_corpus(const char *directory) { + if (zupt_mkdir(directory) != 0) return 0; + char path[ZUPT_MAX_PATH + 64]; + FILE *stream = NULL; + int ok = 1; + + if (!zupt_join_temp_path(path, sizeof(path), directory, "text.txt") || + !(stream = zupt_fopen_path(path, "wb"))) + return 0; + for (int i = 0; i < 15000 && ok; i++) + if (fprintf(stream, + "The quick brown fox jumps over the lazy dog. Line %d value %d.\n", + i, i * 17 % 997) < 0) + ok = 0; + if (fclose(stream) != 0) ok = 0; + + if (!zupt_join_temp_path(path, sizeof(path), directory, "data.json") || + !(stream = zupt_fopen_path(path, "wb"))) + return 0; + for (int i = 0; i < 12000 && ok; i++) + if (fprintf(stream, + "{\"id\":%d,\"name\":\"user_%d\",\"score\":%d}\n", + i, i, i * 31 % 1000) < 0) + ok = 0; + if (fclose(stream) != 0) ok = 0; + + if (!zupt_join_temp_path(path, sizeof(path), directory, "records.csv") || + !(stream = zupt_fopen_path(path, "wb"))) + return 0; + if (fprintf(stream, "id,name,score\n") < 0) ok = 0; + for (int i = 0; i < 14000 && ok; i++) + if (fprintf(stream, "%d,user_%d,%d\n", i, i, i * 17 % 100) < 0) + ok = 0; + if (fclose(stream) != 0) ok = 0; + + if (!zupt_join_temp_path(path, sizeof(path), directory, "random.bin") || + !(stream = zupt_fopen_path(path, "wb"))) + return 0; + uint8_t random_bytes[4096]; + for (int i = 0; i < 64 && ok; i++) { + zupt_random_bytes(random_bytes, sizeof(random_bytes)); + if (fwrite(random_bytes, 1, sizeof(random_bytes), stream) != + sizeof(random_bytes)) + ok = 0; + } + if (fclose(stream) != 0) ok = 0; + return ok; +} + static void banner(void) { fprintf(stderr, "%s %s - %s\n" - "Format v%d.%d | Codec: VaptVupt + Zupt-LZ | Checksum: XXH64\n" - "Encryption: AES-256-CTR + HMAC-SHA256 | KDF: Argon2id (default) / PBKDF2 (--kdf pbkdf2)\n\n", + "Format v%d.%d | Codec: VaptVupt + ZUPT-LZ | Checksum: XXH64\n" + "Encryption: AES-256-CTR + HMAC-SHA256 | KDF: " +#ifdef ZUPT_WITH_SDK + "Argon2id (default) / PBKDF2 (--kdf pbkdf2)\n\n", +#else + "PBKDF2-SHA256 (Argon2id needs a WITH_SDK=1 build)\n\n", +#endif ZUPT_PRODUCT_NAME, ZUPT_VERSION_STRING, ZUPT_PRODUCT_TAGLINE, ZUPT_FORMAT_MAJOR, ZUPT_FORMAT_MINOR); } @@ -39,46 +574,59 @@ static void usage(void) { /* ── Section 1: synopsis ── */ fprintf(stderr, "Usage:\n" - " vaptvupt compress [OPTIONS] \n" - " vaptvupt extract [OPTIONS] \n" - " vaptvupt list [OPTIONS] \n" - " vaptvupt test [OPTIONS] \n" - " vaptvupt info Archive metadata (no password needed)\n" - " vaptvupt bench Compare levels 1-9\n" - " vaptvupt disk backup|restore Full-disk backup/restore\n" - " vaptvupt keygen Key generation\n" - " vaptvupt version\n" - " vaptvupt help\n" + " zupt compress [OPTIONS] \n" + " zupt extract [OPTIONS] \n" + " zupt list [OPTIONS] \n" + " zupt test [OPTIONS] \n" + " zupt info Archive metadata (no password needed)\n" + " zupt bench Compare levels 1-9\n" + " zupt disk backup|restore Full-disk backup/restore\n" + " zupt keygen Key generation\n" + " zupt version\n" + " zupt help\n" "\n" - "Note: archive extension stays .zupt for format continuity.\n" - " The `zupt` command is preserved as a legacy alias.\n" + "Note: archive extension and format remain .zupt/v1.6.\n" + " `zupt` is the primary command. A `vaptvupt` compatibility alias\n" + " is optional (INSTALL_LEGACY_ALIAS=1).\n" "\n"); /* ── Section 2: compress options ── */ fprintf(stderr, "Compress Options:\n" " -l, --level <1-9> Compression level (default: 7)\n" - " 1-2: fast, small window\n" - " 3-5: balanced\n" - " 6-7: high compression (default)\n" - " 8-9: maximum, 1MB window, deep search\n" - " -b, --block Block size in bytes (default: 128KB)\n" + " 1-2: fast, automatic 128 KiB blocks\n" + " 3-4: balanced, automatic 1 MiB blocks\n" + " 5-6: high, automatic 2 MiB blocks\n" + " 7: default, automatic 4 MiB blocks\n" + " 8-9: maximum, automatic 8 MiB blocks\n" + " -b, --block Override the automatic block size in bytes\n" " -s, --store Store without compression\n" " -f, --fast Use fast LZ codec (less compression)\n" - " --vv, --vaptvupt Use VaptVupt codec (LZ + ANS entropy, default)\n" - " --lzhp Use Zupt-LZHP codec (LZ77+Huffman, no SIMD needed)\n" - " -p, --password Encrypt with AES-256 (prompted if empty)\n" - " --kdf KDF for password mode. Default: argon2id (v2.4.1+).\n" + " Default codec: automatic; VaptVupt LZ + ANS on AVX2/NEON,\n" + " with portable ZUPT-LZHP fallback on other CPUs.\n" + " --vv, --vaptvupt Force VaptVupt codec (LZ + ANS entropy)\n" + " --lzhp Use ZUPT-LZHP codec (LZ77+Huffman, no SIMD needed)\n" + " -p, --password Encrypt with AES-256 (visible in process arguments)\n" + " --password-prompt Read the password interactively without echo\n" + " --pass-file Read the password from the first line of FILE\n" + " --pass-fd Read the password from an inherited file descriptor\n" + " All options must precede .\n" +#ifdef ZUPT_WITH_SDK + " --kdf KDF for password mode. Default: argon2id.\n" " Use 'pbkdf2' for v2.4.0-and-older reader compatibility.\n" +#else + " --kdf KDF for password mode. Default (and only, this build):\n" + " PBKDF2-SHA256 600k. Argon2id needs a WITH_SDK=1 build.\n" +#endif " -c, --comment Embed a free-form archive comment (v2.4.3+).\n" " --comment-file Read comment from file (max 4096 bytes).\n" - " --pq Post-quantum encryption (legacy XOR+SHA3 combiner)\n" - " --pq-sdk Post-quantum encryption via libzuptsdk\n" - " --pq-box Post-quantum sealed box via libpqvaptvupt (HKDF combiner)\n" - " (HKDF combiner + key commitment + HPKE binding\n" - " + Argon2id; recommended for new archives)\n" + " --pq Post-quantum HYBRID encryption (ML-KEM-768 + X25519) [recommended]\n" + " --pq-only FULL post-quantum encryption (ML-KEM-768 only, no classical layer)\n" + " --pq-sdk Post-quantum encryption via libvuptsdk (WITH_SDK=1 builds only)\n" + " --pq-box Post-quantum sealed box via libpqvaptvupt (WITH_PQBOX=1 builds only)\n" " --dedup, -D Block-level deduplication\n" " --solid Solid mode (single stream)\n" + " -y, --force Overwrite an existing non-.zupt file as the output archive\n" " -v, --verbose Verbose per-file output\n" " -t, --threads Thread count (0=auto, 1=single, 2-64=explicit)\n" "\n"); @@ -87,10 +635,15 @@ static void usage(void) { fprintf(stderr, "Extract/List/Test Options:\n" " -o, --output Output directory (extract only)\n" - " -p, --password Decryption password\n" - " --pq Post-quantum decryption (legacy combiner)\n" - " --pq-sdk Post-quantum decryption via libzuptsdk\n" + " -p, --password Decryption password (visible in process arguments)\n" + " --password-prompt Read the password interactively without echo\n" + " --pass-file Read the password from the first line of FILE\n" + " --pass-fd Read the password from an inherited file descriptor\n" + " --pq Post-quantum HYBRID decryption (ML-KEM-768 + X25519)\n" + " --pq-only FULL post-quantum decryption (ML-KEM-768 only)\n" + " --pq-sdk Post-quantum decryption via libvuptsdk (WITH_SDK=1 builds only)\n" " --pq-box Post-quantum sealed-box decryption (libpqvaptvupt)\n" + " --allow-legacy-no-ait Accept a trusted old archive without its integrity trailer\n" " -v, --verbose Verbose output\n" " -t, --threads Thread count for decompression\n" "\n" @@ -98,9 +651,11 @@ static void usage(void) { " -o Output keyfile path (required)\n" " --pub Export public key from existing private key (-k)\n" " -k Source private keyfile (with --pub)\n" - " --sdk, --pq-sdk Generate SDK v2 keypair (writes and .pub)\n" - " --box, --pq-box Generate pq-box keypair (libpqvaptvupt; writes and .pub)\n" - " Use these keys with --pq-sdk / --pq-box respectively.\n" + " (default) Generate HYBRID keypair (ML-KEM-768 + X25519) for --pq\n" + " --pq-only Generate FULL post-quantum keypair (ML-KEM-768 only) for --pq-only\n" + " --sdk, --pq-sdk Generate SDK v2 keypair (libvuptsdk; WITH_SDK=1 builds only)\n" + " --box, --pq-box Generate pq-box keypair (libpqvaptvupt; WITH_PQBOX=1 builds only)\n" + " Use each key with its matching mode.\n" "\n" "Directories are traversed recursively.\n" "\n"); @@ -108,73 +663,346 @@ static void usage(void) { /* ── Section 4: examples ── */ fprintf(stderr, "Examples:\n" - " # Legacy PQ workflow\n" - " vaptvupt keygen -o mykey.key # Generate keypair\n" - " vaptvupt keygen --pub -o pub.key -k mykey.key # Export public key\n" - " vaptvupt compress --pq pub.key backup.zupt ~/Documents/ # Encrypt\n" - " vaptvupt extract --pq mykey.key backup.zupt -o ~/restored/ # Decrypt\n" + " # Post-quantum HYBRID workflow (--pq, recommended)\n" + " zupt keygen -o mykey.key # Generate hybrid private key\n" + " zupt keygen --pub -o pub.key -k mykey.key # Export public key\n" + " zupt compress --pq pub.key backup.zupt ~/Documents/ # Encrypt\n" + " zupt extract --pq mykey.key backup.zupt -o ~/restored/ # Decrypt\n" "\n" - " # SDK v2 PQ workflow (recommended for new archives)\n" - " vaptvupt keygen --sdk -o mykey.priv # Writes mykey.priv + .pub\n" - " vaptvupt compress --pq-sdk mykey.priv.pub backup.zupt files/ # Encrypt (HKDF+commit+HPKE)\n" - " vaptvupt extract --pq-sdk mykey.priv backup.zupt # Decrypt\n" + " # Full (pure) post-quantum workflow (--pq-only, ML-KEM-768 only)\n" + " zupt keygen --pq-only -o pqkey # Generate pq-only private key\n" + " zupt keygen --pub --pq-only -o pqkey.pub -k pqkey # Export public key\n" + " zupt compress --pq-only pqkey.pub backup.zupt files/ # Encrypt (no classical layer)\n" + " zupt extract --pq-only pqkey backup.zupt -o out/ # Decrypt\n" "\n" - " # Conventional / password\n" - " vaptvupt compress backup.zupt ~/Documents/ # No encryption\n" - " vaptvupt compress -l 9 -p mysecret secure.zupt data/ # Password + max compression\n" - " vaptvupt list secure.zupt -p mysecret # List with password\n" - " vaptvupt extract -o restored/ -p mysecret secure.zupt # Extract with password\n" - " vaptvupt bench ~/Documents/ # Benchmark\n" + " # Conventional / password (PBKDF2-SHA256)\n" + " zupt compress backup.zupt ~/Documents/ # No encryption\n" + " zupt compress -l 9 -p mysecret secure.zupt data/ # Password + max compression\n" + " zupt list secure.zupt -p mysecret # List with password\n" + " zupt extract -o restored/ -p mysecret secure.zupt # Extract with password\n" + " zupt bench ~/Documents/ # Benchmark\n" + "\n" + " # Optional modes require system development packages at build time:\n" + " # WITH_SDK=1: keygen --sdk, compress/extract --pq-sdk\n" + " # WITH_PQBOX=1: keygen --box, compress/extract --pq-box\n" "\n"); /* ── Section 5: footer ── */ fprintf(stderr, - "Default codec: VaptVupt LZ + ANS " ZUPT_CODEC_RELEASE " (AVX2/NEON SIMD)\n" + "Default codec: Auto (VaptVupt " ZUPT_CODEC_RELEASE " with AVX2/NEON; LZHP fallback)\n" "Encryption: AES-256-CTR + HMAC-SHA256 (Encrypt-then-MAC)\n" - "KDF: Argon2id (default, v2.4.1+); PBKDF2-SHA256 600k iter via --kdf pbkdf2\n" - "Format: v1.6 (since v2.3.1); archives byte-compatible with v2.3.1+\n" +#ifdef ZUPT_WITH_SDK + "KDF: Argon2id (default); PBKDF2-SHA256 600k iter via --kdf pbkdf2\n" +#else + "KDF: PBKDF2-SHA256 600k iter (default; Argon2id needs WITH_SDK=1)\n" +#endif + "Post-quantum: --pq (hybrid ML-KEM-768 + X25519, recommended); --pq-only (ML-KEM-768 only)\n" + "Format: v1.6; 5.2.2 adds flag-gated disk/dedup records\n" "\n" - "License: AGPL-3.0-or-later (VaptVupt) + GPL-3.0-or-later (VaptVupt codec)\n" - " Dual-licensed: commercial license available: sac@securityops.co\n" - "Project: https://git.securityops.co/cristiancmoises/zupt\n" + "License: AGPL-3.0-or-later (ZUPT) + GPL-3.0-or-later (codec)\n" + " + BSD-2-Clause (xxHash-derived XXH64 routines)\n" + " + CC0-1.0 (pq-crystals/kyber-derived ML-KEM portions)\n" + " + BSD-3-Clause (curve25519-donna-derived X25519 portions)\n" + " Commercial terms may be available by agreement: sac@securityops.co\n" + "Project: https://github.com/cristiancmoises/zupt\n" ); } -/* Securely prompt for password (hide input) */ -static void prompt_password(const char *prompt, char *buf, size_t cap) { +#ifndef _WIN32 +static volatile sig_atomic_t zupt_password_prompt_signal; + +static void zupt_password_prompt_interrupted(int signal_number) { + zupt_password_prompt_signal = signal_number; +} +#endif + +/* Securely prompt for password (hide input). */ +static int prompt_password(const char *prompt, char *buf, size_t cap) { + if (!buf || cap < 2) return 0; + buf[0] = '\0'; +#ifdef _WIN32 + HANDLE input_handle = GetStdHandle(STD_INPUT_HANDLE); + DWORD input_mode = 0; + if (input_handle == NULL || input_handle == INVALID_HANDLE_VALUE || + GetFileType(input_handle) != FILE_TYPE_CHAR || + !GetConsoleMode(input_handle, &input_mode)) { + fprintf(stderr, "Error: password prompt requires a terminal.\n"); + return 0; + } +#else + if (!isatty(STDIN_FILENO)) { + fprintf(stderr, "Error: password prompt requires a terminal.\n"); + return 0; + } +#endif fprintf(stderr, "%s", prompt); #ifdef _WIN32 size_t i = 0; - while (i < cap - 1) { + int too_long = 0; + for (;;) { int c = _getch(); + if (c == EOF) { + zupt_secure_wipe(buf, cap); + fprintf(stderr, "\nError: cannot read password prompt.\n"); + return 0; + } if (c == '\r' || c == '\n') break; - if (c == '\b' && i > 0) { i--; continue; } - buf[i++] = (char)c; + if (c == 0 || c == 0xe0) { + (void)_getch(); + continue; + } + if (c == 3) { + zupt_secure_wipe(buf, cap); + fprintf(stderr, "\nError: password prompt interrupted.\n"); + return 0; + } + if (c == '\b') { + if (i > 0) i--; + continue; + } + if (i < cap - 1) buf[i++] = (char)c; + else too_long = 1; } buf[i] = '\0'; fprintf(stderr, "\n"); + if (too_long) { + fprintf(stderr, "Error: password exceeds %zu bytes.\n", cap - 1); + zupt_secure_wipe(buf, cap); + return 0; + } + return i > 0; #else struct termios old, new_t; - tcgetattr(0, &old); + static const int prompt_signals[] = {SIGINT, SIGTERM, SIGHUP, SIGQUIT}; + struct sigaction previous[sizeof(prompt_signals) / sizeof(prompt_signals[0])]; + struct sigaction temporary; + sigset_t prompt_signal_mask, previous_signal_mask; + size_t handlers_installed = 0; + if (tcgetattr(STDIN_FILENO, &old) != 0) { + fprintf(stderr, "\nError: cannot configure terminal input.\n"); + return 0; + } + memset(&temporary, 0, sizeof(temporary)); + temporary.sa_handler = zupt_password_prompt_interrupted; + sigemptyset(&prompt_signal_mask); + for (size_t index = 0; + index < sizeof(prompt_signals) / sizeof(prompt_signals[0]); + index++) + (void)sigaddset(&prompt_signal_mask, prompt_signals[index]); + temporary.sa_mask = prompt_signal_mask; + zupt_password_prompt_signal = 0; + for (size_t index = 0; + index < sizeof(prompt_signals) / sizeof(prompt_signals[0]); + index++) { + if (sigaction(prompt_signals[index], &temporary, + &previous[index]) != 0) { + while (handlers_installed > 0) { + handlers_installed--; + (void)sigaction(prompt_signals[handlers_installed], + &previous[handlers_installed], NULL); + } + fprintf(stderr, "\nError: cannot protect terminal state.\n"); + return 0; + } + handlers_installed++; + } new_t = old; /* Clear the ECHO bit. ~ECHO is `int` (negative); c_lflag is * tcflag_t (unsigned int). The cast makes the conversion * explicit and silences -Wsign-conversion. */ new_t.c_lflag &= (tcflag_t)~ECHO; - tcsetattr(0, TCSANOW, &new_t); - if (fgets(buf, (int)cap, stdin)) { - size_t len = strlen(buf); - if (len > 0 && buf[len-1] == '\n') buf[len-1] = '\0'; + if (tcsetattr(STDIN_FILENO, TCSANOW, &new_t) != 0) { + while (handlers_installed > 0) { + handlers_installed--; + (void)sigaction(prompt_signals[handlers_installed], + &previous[handlers_installed], NULL); + } + fprintf(stderr, "\nError: cannot disable terminal echo.\n"); + return 0; } - tcsetattr(0, TCSANOW, &old); + int ok = 0; + int too_long = 0; + if (zupt_password_prompt_signal == 0 && fgets(buf, (int)cap, stdin)) { + size_t len = strlen(buf); + if (len > 0 && buf[len-1] == '\n') { + buf[len-1] = '\0'; + } else { + int ch = fgetc(stdin); + if (ch != '\n' && ch != EOF) { + too_long = 1; + while ((ch = fgetc(stdin)) != '\n' && ch != EOF) {} + } + if (ferror(stdin)) too_long = 1; + } + ok = buf[0] != '\0'; + } + /* Block every handled prompt signal while restoring terminal state and + * the caller's handlers. Otherwise a second signal can interrupt the one + * tcsetattr attempt or land between the signal snapshot and restoration, + * leaving echo disabled or swallowing the later signal. */ + int signals_blocked = + sigprocmask(SIG_BLOCK, &prompt_signal_mask, &previous_signal_mask) == 0; + if (!signals_blocked) ok = 0; + int terminal_restore_status; + do { + terminal_restore_status = tcsetattr(STDIN_FILENO, TCSANOW, &old); + } while (terminal_restore_status != 0 && errno == EINTR); + if (terminal_restore_status != 0) ok = 0; + int interrupted_by = (int)zupt_password_prompt_signal; + while (handlers_installed > 0) { + handlers_installed--; + if (sigaction(prompt_signals[handlers_installed], + &previous[handlers_installed], NULL) != 0) + ok = 0; + } + if (signals_blocked && + sigprocmask(SIG_SETMASK, &previous_signal_mask, NULL) != 0) + ok = 0; fprintf(stderr, "\n"); + if (interrupted_by != 0) { + zupt_secure_wipe(buf, cap); + fprintf(stderr, "Error: password prompt interrupted.\n"); + (void)raise(interrupted_by); + errno = EINTR; + return 0; + } + if (too_long) { + fprintf(stderr, "Error: password exceeds %zu bytes.\n", cap - 1); + zupt_secure_wipe(buf, cap); + ok = 0; + } + return ok; #endif } +static int read_password_stream(FILE *stream, const char *source, + char *password, size_t capacity) { + if (!stream || !password || capacity < 2) return 0; + size_t length = 0; + int ch; + int too_long = 0; + while ((ch = fgetc(stream)) != EOF && ch != '\n') { + if (ch == '\0') { + fprintf(stderr, "Error: %s contains a NUL byte.\n", source); + zupt_secure_wipe(password, capacity); + return 0; + } + if (length + 1 >= capacity) { + too_long = 1; + continue; + } + password[length++] = (char)ch; + } + if (ferror(stream) || too_long) { + fprintf(stderr, "Error: cannot read %s or password exceeds %zu bytes.\n", + source, capacity - 1); + zupt_secure_wipe(password, capacity); + return 0; + } + if (length > 0 && password[length - 1] == '\r') length--; + password[length] = '\0'; + if (length == 0) { + fprintf(stderr, "Error: %s contains an empty password.\n", source); + return 0; + } + return 1; +} + +/* Parse the non-argv password sources shared by every encrypted command. + * Return 0 when argv[*index] is unrelated, 1 on success, and -1 on error. */ +static int parse_password_source(int argc, char **argv, int *index, + zupt_options_t *opts, int confirm) { + const char *option = argv[*index]; + if (strcmp(option, "--password-prompt") == 0) { + opts->encrypt = 1; + if (!prompt_password("Password: ", opts->password, + sizeof(opts->password))) { + fprintf(stderr, "Error: password cannot be empty.\n"); + return -1; + } + if (confirm) { + char confirmation[sizeof(opts->password)]; + if (!prompt_password("Confirm: ", confirmation, + sizeof(confirmation))) { + zupt_secure_wipe(confirmation, sizeof(confirmation)); + return -1; + } + int matches = strcmp(opts->password, confirmation) == 0; + zupt_secure_wipe(confirmation, sizeof(confirmation)); + if (!matches) { + fprintf(stderr, "Error: Passwords do not match.\n"); + zupt_secure_wipe(opts->password, sizeof(opts->password)); + return -1; + } + } + return 1; + } + if (strcmp(option, "--pass-file") == 0) { + if (*index + 1 >= argc) { + fprintf(stderr, "Error: --pass-file requires a path.\n"); + return -1; + } + const char *path = argv[++*index]; + FILE *stream = zupt_fopen_path(path, "rb"); + if (!stream) { + fprintf(stderr, "Error: cannot open password file '%s'.\n", path); + return -1; + } + opts->encrypt = 1; + int ok = read_password_stream(stream, "password file", + opts->password, sizeof(opts->password)); + if (fclose(stream) != 0) ok = 0; + return ok ? 1 : -1; + } + if (strcmp(option, "--pass-fd") == 0) { + if (*index + 1 >= argc) { + fprintf(stderr, "Error: --pass-fd requires a descriptor number.\n"); + return -1; + } + char *end = NULL; + errno = 0; + long descriptor = strtol(argv[++*index], &end, 10); + if (errno || !end || *end != '\0' || descriptor < 0 || + descriptor > INT_MAX) { + fprintf(stderr, "Error: invalid descriptor for --pass-fd.\n"); + return -1; + } +#ifdef _WIN32 + int duplicate = _dup((int)descriptor); +#else + int duplicate = dup((int)descriptor); +#endif + if (duplicate < 0) { + fprintf(stderr, "Error: cannot duplicate --pass-fd descriptor.\n"); + return -1; + } +#ifdef _WIN32 + FILE *stream = _fdopen(duplicate, "rb"); +#else + FILE *stream = fdopen(duplicate, "rb"); +#endif + if (!stream) { +#ifdef _WIN32 + _close(duplicate); +#else + close(duplicate); +#endif + fprintf(stderr, "Error: cannot read --pass-fd descriptor.\n"); + return -1; + } + opts->encrypt = 1; + int ok = read_password_stream(stream, "password descriptor", + opts->password, sizeof(opts->password)); + if (fclose(stream) != 0) ok = 0; + return ok ? 1 : -1; + } + return 0; +} + static int streq(const char *a, const char *b) { return strcmp(a,b)==0; } static int isopt(const char *a) { return a[0]=='-'; } -int main(int argc, char **argv) { +static int zupt_cli_main(int argc, char **argv) { /* Detect CPU features (AES-NI, AVX2) at startup */ zupt_detect_cpu(&zupt_cpu); @@ -183,15 +1011,41 @@ int main(int argc, char **argv) { if (streq(cmd,"help")||streq(cmd,"--help")||streq(cmd,"-h")) { usage(); return 0; } if (streq(cmd,"version")||streq(cmd,"--version")||streq(cmd,"-V")) { - printf("vaptvupt %s (formerly zupt; renamed in v3.0.0 — INPI Brasil trademark)\n" + printf("zupt %s (ZUPT)\n" "Format: v%d.%d | Archive extension: .zupt (unchanged)\n" "Codec: VaptVupt " ZUPT_CODEC_RELEASE " (0x%04X) — LZ + ANS, optimal parser + large-window extreme\n" "Encryption: AES-256-CTR + HMAC-SHA256\n" +#ifdef ZUPT_WITH_SDK "KDF: Argon2id (default) / PBKDF2-SHA256 %d iter (--kdf pbkdf2)\n" - "Post-quantum: ML-KEM-768 + X25519 hybrid (FIPS 203 + RFC 7748)\n" - "License: AGPL-3.0-or-later (VaptVupt) + GPL-3.0-or-later (codec)\n" - " Dual-licensed: commercial license available\n" - "Project: https://git.securityops.co/cristiancmoises/zupt\n" +#else + "KDF: PBKDF2-SHA256 %d iter (default; Argon2id needs WITH_SDK=1)\n" +#endif + "Post-quantum: --pq hybrid (ML-KEM-768 + X25519), --pq-only (ML-KEM-768 only)" +#ifdef ZUPT_WITH_SDK + ", --pq-sdk (libvuptsdk)" +#endif +#ifdef ZUPT_WITH_PQBOX + ", --pq-box (libpqvaptvupt)" +#endif + "\n" + "Build integrations: libvuptsdk=" +#ifdef ZUPT_WITH_SDK + "enabled" +#else + "disabled" +#endif + ", libpqvaptvupt=" +#ifdef ZUPT_WITH_PQBOX + "enabled\n" +#else + "disabled\n" +#endif + "License: AGPL-3.0-or-later (ZUPT) + GPL-3.0-or-later (codec)\n" + " + BSD-2-Clause (xxHash-derived XXH64 routines)\n" + " + CC0-1.0 (pq-crystals/kyber-derived ML-KEM portions)\n" + " + BSD-3-Clause (curve25519-donna-derived X25519 portions)\n" + " Commercial terms may be available by agreement\n" + "Project: https://github.com/cristiancmoises/zupt\n" "Commercial: sac@securityops.co\n", ZUPT_VERSION_STRING, ZUPT_FORMAT_MAJOR, ZUPT_FORMAT_MINOR, ZUPT_CODEC_VAPTVUPT, ZUPT_KDF_ITERATIONS); @@ -215,7 +1069,12 @@ int main(int argc, char **argv) { if (streq(cmd,"compress")||streq(cmd,"c")) { zupt_options_t opts; zupt_default_options(&opts); int ai = 2; + int force = 0; /* -y/--force: allow overwriting a non-.zupt output */ while (ai 0) { ai++; continue; } if ((streq(argv[ai],"-l")||streq(argv[ai],"--level"))&&ai+19)opts.level=9; } else if ((streq(argv[ai],"-b")||streq(argv[ai],"--block"))&&ai+1 0); fclose(cf); } else if (streq(argv[ai],"--kdf")&&ai+1ai ? argv[ai] : "")); + return 1; + } + } + /* Data-loss guard. `compress -p out.zupt a.txt b.txt` makes -p swallow + * "out.zupt" as the PASSWORD, shifts positionals so the output archive + * becomes "a.txt", and truncates a.txt (a user data file) with archive + * bytes — silently, exit 0. Refuse to overwrite an existing regular file + * that is not a .zupt archive unless -y/--force is given. Archives the + * tool writes end in .zupt, so this never blocks normal use. */ + { + size_t olen = strlen(output); + int is_zupt = (olen >= 5 && strcmp(output + olen - 5, ".zupt") == 0); + if (!force && !is_zupt && zupt_is_regular_file(output)) { + fprintf(stderr, + "Error: refusing to overwrite existing file '%s' as the output archive\n" + " (it does not end in .zupt). If you meant to set a password, use\n" + " '-p' or put '-p PASSWORD' BEFORE the archive name.\n" + " Pass -y/--force to overwrite '%s' anyway.\n", + output, output); + return 1; + } + } + + /* Skip a leading `--` separator before the file list. */ + if (ai < argc && streq(argv[ai], "--")) ai++; + + /* Collect files (expand directories recursively). Guard against the + * output archive also being one of the inputs (self-overwrite). */ + zupt_filelist_t fl; zupt_filelist_init(&fl); + for (int i=ai; i 0) { ai++; continue; } if ((streq(argv[ai],"-o")||streq(argv[ai],"--output"))&&ai+1 0) { ai++; continue; } + if (streq(argv[ai],"--allow-legacy-no-ait")) zupt_internal_allow_legacy_no_ait(&opts); + else if (streq(argv[ai],"-v")||streq(argv[ai],"--verbose")) zupt_internal_set_verbose(&opts); else if (streq(argv[ai],"-p")||streq(argv[ai],"--password")) { opts.encrypt=1; if (ai+1 0) { ai++; continue; } + if (streq(argv[ai],"--allow-legacy-no-ait")) zupt_internal_allow_legacy_no_ait(&opts); + else if (streq(argv[ai],"-v")||streq(argv[ai],"--verbose")) zupt_internal_set_verbose(&opts); else if (streq(argv[ai],"-p")||streq(argv[ai],"--password")) { opts.encrypt=1; if (ai+1= argc) { fprintf(stderr, "Error: bench requires or --compare\n"); return 1; } - /* Generate corpus if --compare with no files */ - char gen_dir[256] = {0}; + /* Every benchmark artifact lives under one private, unpredictable + * directory. No predictable /tmp leaf is ever opened or truncated. */ + char bench_root[ZUPT_MAX_PATH] = {0}; + char gen_dir[ZUPT_MAX_PATH] = {0}; if (compare_mode && ai >= argc) { - snprintf(gen_dir, sizeof(gen_dir), "/tmp/zupt_bench_corpus_%d", (int)getpid()); - zupt_mkdir(gen_dir); - char p[512]; FILE *gf; - snprintf(p, sizeof(p), "%s/text.txt", gen_dir); - gf = fopen(p, "wb"); - if (gf) { for (int i=0;i<15000;i++) fprintf(gf, "The quick brown fox jumps over the lazy dog. Line %d value %d.\n", i, i*17%997); fclose(gf); } - snprintf(p, sizeof(p), "%s/data.json", gen_dir); - gf = fopen(p, "wb"); - if (gf) { for (int i=0;i<12000;i++) fprintf(gf, "{\"id\":%d,\"name\":\"user_%d\",\"score\":%d}\n", i, i, i*31%1000); fclose(gf); } - snprintf(p, sizeof(p), "%s/records.csv", gen_dir); - gf = fopen(p, "wb"); - if (gf) { fprintf(gf,"id,name,score\n"); for (int i=0;i<14000;i++) fprintf(gf,"%d,user_%d,%d\n", i, i, i*17%100); fclose(gf); } - snprintf(p, sizeof(p), "%s/random.bin", gen_dir); - gf = fopen(p, "wb"); - if (gf) { uint8_t rb[4096]; for (int i=0;i<64;i++){zupt_random_bytes(rb,sizeof(rb));fwrite(rb,1,sizeof(rb),gf);} fclose(gf); } + if (!zupt_create_private_temp_directory( + bench_root, sizeof(bench_root)) || + !zupt_join_temp_path(gen_dir, sizeof(gen_dir), bench_root, + "corpus") || + !zupt_write_benchmark_corpus(gen_dir)) { + fprintf(stderr, + "Error: cannot create private benchmark corpus.\n"); + zupt_remove_temp_tree(bench_root); + return 1; + } /* Use gen_dir as the input path — need a writable argv slot */ - static char gen_arg[256]; + static char gen_arg[ZUPT_MAX_PATH]; strncpy(gen_arg, gen_dir, sizeof(gen_arg)-1); gen_arg[sizeof(gen_arg)-1] = '\0'; argv[argc] = gen_arg; @@ -532,11 +1474,30 @@ int main(int argc, char **argv) { zupt_filelist_t fl; zupt_filelist_init(&fl); for (int i = ai; i < argc; i++) zupt_collect_files(&fl, argv[i], argv[i]); - if (fl.count == 0) { fprintf(stderr, "No files found.\n"); zupt_filelist_free(&fl); return 1; } + if (zupt_internal_filelist_failed(&fl)) { + fprintf(stderr, "Input collection was incomplete.\n"); + zupt_filelist_free(&fl); + zupt_remove_temp_tree(bench_root); + return 1; + } + if (fl.count == 0) { + fprintf(stderr, "No files found.\n"); + zupt_filelist_free(&fl); + zupt_remove_temp_tree(bench_root); + return 1; + } + if (bench_root[0] == '\0' && + !zupt_create_private_temp_directory( + bench_root, sizeof(bench_root))) { + fprintf(stderr, + "Error: cannot create private benchmark workspace.\n"); + zupt_filelist_free(&fl); + return 1; + } uint64_t total_in = 0; for (int i = 0; i < fl.count; i++) { - FILE *tf = fopen(fl.paths[i], "rb"); + FILE *tf = zupt_fopen_path(fl.paths[i], "rb"); if (tf) { fseek(tf, 0, SEEK_END); total_in += (uint64_t)ftell(tf); fclose(tf); } } char isz[32]; zupt_format_size(total_in, isz, sizeof(isz)); @@ -547,16 +1508,24 @@ int main(int argc, char **argv) { fprintf(stderr, " %-20s %12s %12s %10s\n", "Codec", "Compress", "Decompress", "Ratio"); fprintf(stderr, " ────────────────────────────────────────────────────────────\n"); - char tmp_path[256], tmp_out[256]; - snprintf(tmp_path, sizeof(tmp_path), "/tmp/zupt_cmp_%d.zupt", (int)getpid()); - snprintf(tmp_out, sizeof(tmp_out), "/tmp/zupt_cmp_out_%d", (int)getpid()); + char tmp_path[ZUPT_MAX_PATH + 64]; + char tmp_out[ZUPT_MAX_PATH + 64]; + if (!zupt_join_temp_path(tmp_path, sizeof(tmp_path), bench_root, + "comparison.zupt") || + !zupt_join_temp_path(tmp_out, sizeof(tmp_out), bench_root, + "extracted")) { + fprintf(stderr, "Error: benchmark temporary path is too long.\n"); + zupt_filelist_free(&fl); + zupt_remove_temp_tree(bench_root); + return 1; + } struct { const char *name; uint16_t codec; int level; } codecs[] = { {"VaptVupt UF", ZUPT_CODEC_VAPTVUPT, 1}, {"VaptVupt BAL", ZUPT_CODEC_VAPTVUPT, 5}, {"VaptVupt EXT", ZUPT_CODEC_VAPTVUPT, 9}, - {"Zupt-LZHP", ZUPT_CODEC_ZUPT_LZHP,7}, - {"Zupt-LZ", ZUPT_CODEC_ZUPT_LZ, 5}, + {"ZUPT-LZHP", ZUPT_CODEC_ZUPT_LZHP,7}, + {"ZUPT-LZ", ZUPT_CODEC_ZUPT_LZ, 5}, }; int ncodecs = (int)(sizeof(codecs)/sizeof(codecs[0])); @@ -564,44 +1533,59 @@ int main(int argc, char **argv) { zupt_options_t opts; zupt_default_options(&opts); opts.codec_id = codecs[ci].codec; opts.level = codecs[ci].level; opts.quiet = 1; - struct timespec t0, t1; - clock_gettime(CLOCK_MONOTONIC, &t0); + double t0 = zupt_monotonic_seconds(); zupt_error_t cerr = zupt_compress_files(tmp_path, (const char**)fl.arc_paths, (const char**)fl.paths, fl.count, &opts); - clock_gettime(CLOCK_MONOTONIC, &t1); - double csec = (double)(t1.tv_sec-t0.tv_sec)+(double)(t1.tv_nsec-t0.tv_nsec)/1e9; + double csec = zupt_monotonic_seconds() - t0; if (csec < 0.001) csec = 0.001; if (cerr != ZUPT_OK) { fprintf(stderr, " %-20s FAILED\n", codecs[ci].name); continue; } - FILE *zf = fopen(tmp_path, "rb"); uint64_t zsize = 0; + FILE *zf = zupt_fopen_path(tmp_path, "rb"); uint64_t zsize = 0; if (zf) { fseek(zf,0,SEEK_END); zsize=(uint64_t)ftell(zf); fclose(zf); } zupt_options_t dopts; zupt_default_options(&dopts); dopts.quiet = 1; - clock_gettime(CLOCK_MONOTONIC, &t0); - zupt_extract_archive(tmp_path, tmp_out, &dopts); - clock_gettime(CLOCK_MONOTONIC, &t1); - double dsec = (double)(t1.tv_sec-t0.tv_sec)+(double)(t1.tv_nsec-t0.tv_nsec)/1e9; + t0 = zupt_monotonic_seconds(); + zupt_error_t derr = + zupt_extract_archive(tmp_path, tmp_out, &dopts); + double dsec = zupt_monotonic_seconds() - t0; if (dsec < 0.001) dsec = 0.001; + if (derr != ZUPT_OK) { + fprintf(stderr, " %-20s EXTRACT FAILED\n", + codecs[ci].name); + zupt_remove_temp_tree(tmp_out); + remove(tmp_path); + continue; + } + fprintf(stderr, " %-20s %9.1f MB/s %9.1f MB/s %8.2f:1\n", codecs[ci].name, (double)total_in/csec/1048576.0, (double)total_in/dsec/1048576.0, total_in>0&&zsize>0?(double)total_in/(double)zsize:1.0); - char rm[512]; snprintf(rm,sizeof(rm),"rm -rf '%s'",tmp_out); if (system(rm)) { /* ignore */ } + if (zupt_remove_temp_tree(tmp_out) != 0) + fprintf(stderr, + "Warning: could not remove benchmark extraction tree.\n"); remove(tmp_path); } /* External tools */ fprintf(stderr, " ────────────────────────────────────────────────────────────\n"); - char concat[256]; - snprintf(concat, sizeof(concat), "/tmp/zupt_cmp_cat_%d", (int)getpid()); - FILE *cf = fopen(concat, "wb"); + char concat[ZUPT_MAX_PATH + 64]; + if (!zupt_join_temp_path(concat, sizeof(concat), bench_root, + "concatenated-input")) { + fprintf(stderr, "Error: benchmark temporary path is too long.\n"); + zupt_filelist_free(&fl); + zupt_remove_temp_tree(bench_root); + return 1; + } + FILE *cf = zupt_fopen_path(concat, "wb"); if (cf) { - for (int i=0;i0)fwrite(buf,1,n,cf);fclose(inf);}} + for (int i=0;i0)fwrite(buf,1,n,cf);fclose(inf);}} fclose(cf); } +#ifndef _WIN32 const char *exts[][3] = { {"gzip -6","gzip -6 -k -f","gzip -d -k -f"}, {"lz4","lz4 -f","lz4 -d -f"}, @@ -612,28 +1596,33 @@ int main(int argc, char **argv) { const char *ext_sfx[] = {".gz",".lz4",".zst",".zst"}; for (int ti=0; exts[ti][0]; ti++) { char tn[32]; strncpy(tn,exts[ti][0],sizeof(tn)-1); char *sp=strchr(tn,' '); if(sp)*sp='\0'; - char wh[128]; snprintf(wh,sizeof(wh),"which %s >/dev/null 2>&1",tn); + char wh[128]; snprintf(wh,sizeof(wh),"command -v %s >/dev/null 2>&1",tn); if (system(wh)!=0) continue; - char co[256]; snprintf(co,sizeof(co),"%s%s",concat,ext_sfx[ti]); + char co[ZUPT_MAX_PATH + 80]; snprintf(co,sizeof(co),"%s%s",concat,ext_sfx[ti]); remove(co); - char ccmd[512]; snprintf(ccmd,sizeof(ccmd),"%s %s >/dev/null 2>&1",exts[ti][1],concat); - struct timespec t0,t1; - clock_gettime(CLOCK_MONOTONIC,&t0); if (system(ccmd)) { /* ignore */ } clock_gettime(CLOCK_MONOTONIC,&t1); - double csec=(double)(t1.tv_sec-t0.tv_sec)+(double)(t1.tv_nsec-t0.tv_nsec)/1e9; if(csec<0.001)csec=0.001; - FILE*ef=fopen(co,"rb"); uint64_t esz=0; if(ef){fseek(ef,0,SEEK_END);esz=(uint64_t)ftell(ef);fclose(ef);} + char ccmd[ZUPT_MAX_PATH + 160]; + snprintf(ccmd,sizeof(ccmd),"%s '%s' >/dev/null 2>&1",exts[ti][1],concat); + double t0 = zupt_monotonic_seconds(); + if (system(ccmd)) { /* ignore */ } + double csec = zupt_monotonic_seconds() - t0; + if(csec<0.001)csec=0.001; + FILE*ef=zupt_fopen_path(co,"rb"); uint64_t esz=0; if(ef){fseek(ef,0,SEEK_END);esz=(uint64_t)ftell(ef);fclose(ef);} - char dcmd[512]; snprintf(dcmd,sizeof(dcmd),"%s %s >/dev/null 2>&1",exts[ti][2],co); - clock_gettime(CLOCK_MONOTONIC,&t0); if (system(dcmd)) { /* ignore */ } clock_gettime(CLOCK_MONOTONIC,&t1); - double dsec=(double)(t1.tv_sec-t0.tv_sec)+(double)(t1.tv_nsec-t0.tv_nsec)/1e9; if(dsec<0.001)dsec=0.001; + char dcmd[ZUPT_MAX_PATH + 160]; + snprintf(dcmd,sizeof(dcmd),"%s '%s' >/dev/null 2>&1",exts[ti][2],co); + t0 = zupt_monotonic_seconds(); + if (system(dcmd)) { /* ignore */ } + double dsec = zupt_monotonic_seconds() - t0; + if(dsec<0.001)dsec=0.001; fprintf(stderr, " %-20s %9.1f MB/s %9.1f MB/s %8.2f:1\n", exts[ti][0], (double)total_in/csec/1048576.0, (double)total_in/dsec/1048576.0, total_in>0&&esz>0?(double)total_in/(double)esz:1.0); - remove(co); char dec[512]; snprintf(dec,sizeof(dec),"%s.dec",concat); remove(dec); + remove(co); char dec[ZUPT_MAX_PATH + 80]; snprintf(dec,sizeof(dec),"%s.dec",concat); remove(dec); } +#endif remove(concat); - if (gen_dir[0]) { char rm[512]; snprintf(rm,sizeof(rm),"rm -rf '%s'",gen_dir); if (system(rm)) { /* ignore */ } } fprintf(stderr, "\n"); } else { /* ═══ ORIGINAL PER-LEVEL BENCHMARK ═══ */ @@ -641,8 +1630,14 @@ int main(int argc, char **argv) { fprintf(stderr, " %-7s %12s %10s %10s %10s\n", "Level", "Compressed", "Ratio", "%", "Speed"); fprintf(stderr, " ─────────────────────────────────────────────────────────\n"); - char tmp_path[256]; - snprintf(tmp_path, sizeof(tmp_path), "/tmp/zupt_bench_%d.zupt", (int)getpid()); + char tmp_path[ZUPT_MAX_PATH + 64]; + if (!zupt_join_temp_path(tmp_path, sizeof(tmp_path), bench_root, + "levels.zupt")) { + fprintf(stderr, "Error: benchmark temporary path is too long.\n"); + zupt_filelist_free(&fl); + zupt_remove_temp_tree(bench_root); + return 1; + } for (int lvl = 1; lvl <= 9; lvl++) { zupt_options_t opts; zupt_default_options(&opts); @@ -657,7 +1652,7 @@ int main(int argc, char **argv) { if (elapsed < 1) elapsed = 1; if (err == ZUPT_OK) { - FILE *zf = fopen(tmp_path, "rb"); + FILE *zf = zupt_fopen_path(tmp_path, "rb"); uint64_t zsize = 0; if (zf) { fseek(zf, 0, SEEK_END); zsize = (uint64_t)ftell(zf); fclose(zf); } @@ -677,6 +1672,10 @@ int main(int argc, char **argv) { } zupt_filelist_free(&fl); + if (zupt_remove_temp_tree(bench_root) != 0) { + fprintf(stderr, "Error: could not remove private benchmark workspace.\n"); + return 1; + } return 0; } @@ -692,9 +1691,10 @@ int main(int argc, char **argv) { fprintf(stderr, " -p [PW] Password encryption\n"); fprintf(stderr, " --pq Post-quantum encryption\n"); fprintf(stderr, " --vv Force VaptVupt codec\n"); - fprintf(stderr, " --lzhp Force Zupt-LZHP codec\n"); + fprintf(stderr, " --lzhp Force ZUPT-LZHP codec\n"); fprintf(stderr, " -t Thread count\n"); fprintf(stderr, " -v Verbose\n"); + fprintf(stderr, " --allow-legacy-no-ait Restore a trusted old archive without AIT\n"); fprintf(stderr, "\nExamples:\n"); fprintf(stderr, " zupt disk backup backup.zupt /dev/sda1\n"); fprintf(stderr, " zupt disk backup -p secret encrypted.zupt /dev/nvme0n1p2\n"); @@ -713,6 +1713,10 @@ int main(int argc, char **argv) { zupt_options_t opts; zupt_default_options(&opts); int ai = 3; while (ai 0) { ai++; continue; } if ((streq(argv[ai],"-l")||streq(argv[ai],"--level"))&&ai+19)opts.level=9; } else if ((streq(argv[ai],"-b")||streq(argv[ai],"--block"))&&ai+1 0); fclose(cf); } else if (streq(argv[ai],"--kdf")&&ai+1\n"); return 1; } fprintf(stderr, " Exporting public key from: %s\n", privfile); - if (zupt_hybrid_export_pubkey(privfile, outfile) != 0) { - fprintf(stderr, "Error: Failed to export public key.\n"); return 1; + int erc = pqonly_mode ? zupt_pq_export_pubkey(privfile, outfile) + : zupt_hybrid_export_pubkey(privfile, outfile); + if (erc != 0) { + fprintf(stderr, "Error: Failed to export public key%s.\n", + pqonly_mode ? "" : " (for full-PQ keys use: keygen --pub --pq-only)"); + return 1; } fprintf(stderr, " Public key written to: %s\n", outfile); + } else if (pqonly_mode) { + fprintf(stderr, " Generating ML-KEM-768 keypair (full post-quantum, no X25519)...\n"); + if (zupt_pq_keygen(outfile) != 0) { + fprintf(stderr, "Error: full-PQ key generation failed.\n"); return 1; + } + fprintf(stderr, " Private key written to: %s\n", outfile); + fprintf(stderr, " SECURITY: Keep this file secret. Back it up securely.\n"); + fprintf(stderr, " To export public key: zupt keygen --pub --pq-only -o pub.key -k %s\n", outfile); } else if (box_mode) { fprintf(stderr, " Generating ML-KEM-768 + X25519 keypair (pq-box format)...\n"); char pubfile[512]; @@ -867,7 +1909,14 @@ int main(int argc, char **argv) { char pubfile[512]; snprintf(pubfile, sizeof(pubfile), "%s.pub", outfile); if (zupt_sdk_hybrid_keygen(outfile, pubfile) != 0) { - fprintf(stderr, "Error: SDK key generation failed.\n"); return 1; + fprintf(stderr, + "Error: SDK-v2 key generation is unavailable in this build.\n" + " --pq-sdk needs libvuptsdk, which is not part of the source-only\n" + " build. For post-quantum keys use one of the native modes:\n" + " zupt keygen -o key # hybrid ML-KEM-768 + X25519 (--pq)\n" + " zupt keygen --pq-only -o key # full PQ, ML-KEM-768 only (--pq-only)\n" + " (Rebuild upstream with 'make WITH_SDK=1' to enable --pq-sdk.)\n"); + return 1; } fprintf(stderr, " Private key: %s\n", outfile); fprintf(stderr, " Public key: %s\n", pubfile); @@ -887,3 +1936,35 @@ int main(int argc, char **argv) { fprintf(stderr, "Unknown command '%s'. Run 'zupt help'.\n", cmd); return 1; } + +#ifdef _WIN32 +int wmain(int argc, wchar_t **wide_argv); + +int wmain(int argc, wchar_t **wide_argv) { + char **utf8_argv = (char **)calloc((size_t)argc + 1, sizeof(char *)); + if (!utf8_argv) return 1; + for (int i = 0; i < argc; i++) { + utf8_argv[i] = zupt_win_wide_to_utf8_alloc(wide_argv[i]); + if (!utf8_argv[i]) { + for (int j = 0; j < i; j++) { + zupt_secure_wipe(utf8_argv[j], strlen(utf8_argv[j])); + free(utf8_argv[j]); + } + free(utf8_argv); + fprintf(stderr, "Error: command line is not valid Unicode.\n"); + return 1; + } + } + int result = zupt_cli_main(argc, utf8_argv); + for (int i = 0; i < argc; i++) { + zupt_secure_wipe(utf8_argv[i], strlen(utf8_argv[i])); + free(utf8_argv[i]); + } + free(utf8_argv); + return result; +} +#else +int main(int argc, char **argv) { + return zupt_cli_main(argc, argv); +} +#endif diff --git a/src/zupt_mlkem.c b/src/zupt_mlkem.c index 7bfdc2b..8c6b655 100644 --- a/src/zupt_mlkem.c +++ b/src/zupt_mlkem.c @@ -1,7 +1,13 @@ /* - * Zupt — Backup-oriented compression with AES-256 encryption + * ZUPT — Backup-oriented compression with AES-256 encryption * Copyright (c) 2026 Cristian Cezar Moisés - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-License-Identifier: AGPL-3.0-or-later AND CC0-1.0 + * + * Portions are adapted from the pq-crystals/kyber reference implementation, + * offered upstream under CC0-1.0 or Apache-2.0. ZUPT uses the CC0-1.0 + * option for those portions; see THIRD-PARTY-NOTICES.md. The exact upstream + * revision used for the original adaptation was not retained, so none is + * asserted here. * * ML-KEM-768 (FIPS 203, formerly CRYSTALS-Kyber). * Pure C11, zero dependencies. Uses zupt_keccak.h for SHA3/SHAKE. @@ -339,9 +345,11 @@ static void kpke_keygen(uint8_t pk[1184], uint8_t sk_pke[1152], const uint8_t d[ /* Generate matrix A (in NTT domain) from rho */ polyvec Ahat[MLKEM_K]; + /* FIPS 203 Algorithm 13 (K-PKE.KeyGen): Â[i][j] ← SampleNTT(XOF(ρ, j, i)). + * The XOF seed appends the COLUMN index j then the ROW index i. */ for (int i = 0; i < MLKEM_K; i++) for (int j = 0; j < MLKEM_K; j++) - poly_uniform(Ahat[i][j], rho, (uint8_t)i, (uint8_t)j); + poly_uniform(Ahat[i][j], rho, (uint8_t)j, (uint8_t)i); /* Sample secret vector s */ polyvec s; @@ -392,9 +400,11 @@ static void kpke_encrypt(uint8_t ct[1088], const uint8_t pk[1184], /* Regenerate A^T from rho (transposed) */ polyvec AT[MLKEM_K]; + /* FIPS 203 Algorithm 14 (K-PKE.Encrypt): Â[i][j] ← SampleNTT(XOF(ρ, i, j)). + * Encrypt uses the transpose of KeyGen's matrix: seed appends ROW i then COL j. */ for (int i = 0; i < MLKEM_K; i++) for (int j = 0; j < MLKEM_K; j++) - poly_uniform(AT[i][j], rho, (uint8_t)j, (uint8_t)i); + poly_uniform(AT[i][j], rho, (uint8_t)i, (uint8_t)j); /* Sample r_vec, e1, e2 */ polyvec r_vec; @@ -541,18 +551,14 @@ int zupt_mlkem768_encaps(uint8_t ct[1088], uint8_t ss[32], /* Encrypt m under pk with randomness r */ kpke_encrypt(ct, pk, m, kr + 32); - /* K = KDF(kr[0:32] ‖ H(ct)) */ - uint8_t h_ct[32]; - zupt_sha3_256(ct, 1088, h_ct); - uint8_t kdf_in[64]; - memcpy(kdf_in, kr, 32); - memcpy(kdf_in + 32, h_ct, 32); - zupt_shake256(kdf_in, 64, ss, 32); + /* FIPS 203, Algorithm 17 (ML-KEM.Encaps_internal): the shared secret K is + * the first 32 bytes of (K, r) = G(m ‖ H(ek)) DIRECTLY. Round-3 Kyber + * applied a final K = KDF(K̄ ‖ H(c)); FIPS 203 removed that step. */ + memcpy(ss, kr, 32); zupt_secure_wipe(m, 32); zupt_secure_wipe(kr, 64); zupt_secure_wipe(kr_input, 64); - zupt_secure_wipe(kdf_in, 64); return 0; } @@ -592,38 +598,38 @@ int zupt_mlkem768_decaps(uint8_t ss[32], const uint8_t ct[1088], uint8_t ct_prime[1088]; kpke_encrypt(ct_prime, pk, m_prime, kr + 32); - /* CT-REQUIRED: Compare ct and ct' via the single audited constant-time - * primitive (the same one used for MAC-tag verification; timing-tested - * by tests/test_ct_timing). A timing leak here would be a KEM + /* CT-REQUIRED: Compare ct and ct' via the single audited + * constant-time-intended primitive (the same one used for MAC-tag + * verification; regression-measured by tests/test_ct_timing when its + * control is conclusive). A timing leak here would be a KEM * decapsulation oracle — distinguishing valid from invalid ciphertexts - * breaks IND-CCA2 — so this comparison must be constant-time over all + * breaks IND-CCA2 — so the implementation requires content-independent + * behavior over all * 1088 ciphertext bytes. zupt_ct_memeq returns 1 if the buffers are * equal (ct matches → success), 0 otherwise. */ int ct_equal = zupt_ct_memeq(ct, ct_prime, 1088); - /* Compute success key: K = KDF(kr[0:32] ‖ H(ct)) */ - uint8_t h_ct[32]; - zupt_sha3_256(ct, 1088, h_ct); - - uint8_t kdf_success[64]; - memcpy(kdf_success, kr, 32); - memcpy(kdf_success + 32, h_ct, 32); + /* FIPS 203, Algorithm 18 (ML-KEM.Decaps_internal): + * success key K' = first 32 bytes of (K', r') = G(m' ‖ h) [no final KDF] + * reject key K̄ = J(z ‖ c) = SHAKE256(z ‖ full-ciphertext, 32) + * Both are computed unconditionally; the constant-time select below picks + * the reject key iff the re-encryption comparison fails. (Round-3 Kyber + * used K = KDF(K̄' ‖ H(c)) and K̄ = KDF(z ‖ H(c)); FIPS 203 changed both.) */ uint8_t ss_success[32]; - zupt_shake256(kdf_success, 64, ss_success, 32); + memcpy(ss_success, kr, 32); - /* Compute rejection key: K_bar = KDF(z ‖ H(ct)) */ - uint8_t kdf_reject[64]; + uint8_t kdf_reject[32 + 1088]; memcpy(kdf_reject, z, 32); - memcpy(kdf_reject + 32, h_ct, 32); + memcpy(kdf_reject + 32, ct, 1088); uint8_t ss_reject[32]; - zupt_shake256(kdf_reject, 64, ss_reject, 32); + zupt_shake256(kdf_reject, 32 + 1088, ss_reject, 32); /* CT-REQUIRED: Select success or reject key without branching. * ct_equal == 1 (ct matches): use ss_success → fail = 0. * ct_equal == 0 (ct differs): use ss_reject (implicit rejection) → fail = 1. */ uint8_t fail = (uint8_t)(1 - ct_equal); #ifdef ZUPT_USE_JASMIN - /* JASMIN-VERIFIED: CT select — proven by Jasmin type system. + /* JASMIN PATH: compiled masked select; no retained formal proof is claimed. * fail=0 → ss_success, fail=1 → ss_reject */ zupt_ct_select_32(ss, ss_success, ss_reject, (uint64_t)fail); #else @@ -635,8 +641,7 @@ int zupt_mlkem768_decaps(uint8_t ss[32], const uint8_t ct[1088], zupt_secure_wipe(kr, 64); zupt_secure_wipe(kr_input, 64); zupt_secure_wipe(ct_prime, sizeof(ct_prime)); - zupt_secure_wipe(kdf_success, 64); - zupt_secure_wipe(kdf_reject, 64); + zupt_secure_wipe(kdf_reject, sizeof(kdf_reject)); zupt_secure_wipe(ss_success, 32); zupt_secure_wipe(ss_reject, 32); return 0; diff --git a/src/zupt_mlock.c b/src/zupt_mlock.c index f33db52..a26dc87 100644 --- a/src/zupt_mlock.c +++ b/src/zupt_mlock.c @@ -1,5 +1,5 @@ /* - * Zupt — Memory Locking for Key Material + * ZUPT — Memory Locking for Key Material * Copyright (c) 2026 Cristian Cezar Moisés * SPDX-License-Identifier: AGPL-3.0-or-later * diff --git a/src/zupt_sha256_shani.c b/src/zupt_sha256_shani.c index 86dd581..211bf88 100644 --- a/src/zupt_sha256_shani.c +++ b/src/zupt_sha256_shani.c @@ -9,23 +9,21 @@ * zupt_sha256.c — bit-identical output — but 3-8x faster on CPUs that * implement the extensions (Intel Goldmont+/Ice Lake+, AMD Zen+). * - * Security note: SHA-NI is constant-time by construction. It performs - * no data-dependent memory accesses or branches, so it has a strictly - * stronger side-channel posture than any table- or branch-based - * software SHA-256. Since Zupt's authentication is HMAC-SHA256 over - * attacker-influenced ciphertext, a constant-time compression function - * is the right default wherever the hardware provides it. + * Security note: this fixed-round SHA-NI path is designed without intended + * data-dependent memory access or branches. Exact generated-code behavior is + * compiler-, CPU-, and platform-dependent; this is not a formal constant-time + * claim. Avoiding table lookups is nevertheless useful for HMAC-SHA256 over + * attacker-influenced ciphertext. * * Dispatch: sha256_transform() in zupt_sha256.c calls * zupt_sha256_transform_shani() when zupt_cpu.has_shani is set. On * non-x86_64 targets this file compiles to nothing (the symbol is * never referenced because has_shani is always 0). * - * Reference: Intel SHA Extensions whitepaper (Gulley, Gopal, Yap, - * Feghali, Guilford, Wolrich, 2013) and the public-domain intrinsic - * reference by Jeffrey Walton. This implementation was written against - * the FIPS 180-4 spec and validated bit-exact against the scalar path - * and the NIST FIPS 180-4 test vectors on both paths. + * Adapted from Jeffrey Walton's public-domain SHA-Intrinsics x86 reference, + * itself based on Intel and miTLS material; see THIRD-PARTY-NOTICES.md. The + * resulting implementation is validated bit-exact against the scalar path and + * the NIST FIPS 180-4 test vectors on both paths. */ #include "zupt.h" diff --git a/src/zupt_x25519.c b/src/zupt_x25519.c index 49dfd6d..3a45304 100644 --- a/src/zupt_x25519.c +++ b/src/zupt_x25519.c @@ -1,20 +1,24 @@ /* - * Zupt — Backup-oriented compression with AES-256 encryption + * ZUPT — Backup-oriented compression with AES-256 encryption * Copyright (c) 2026 Cristian Cezar Moisés - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-License-Identifier: AGPL-3.0-or-later AND BSD-3-Clause + * + * Portions are adapted from curve25519-donna by Google Inc. and Adam Langley. + * This distribution conservatively retains the upstream repository's + * BSD-3-Clause terms; see THIRD-PARTY-NOTICES.md. The exact upstream revision + * used for the original adaptation was not retained, so none is asserted. * * X25519 Diffie-Hellman (RFC 7748) over Curve25519. - * Field: GF(2^255-19), represented as 4 × 64-bit limbs (donna64 layout). - * Montgomery ladder: constant-time by construction (no secret-dependent branches). + * Field: GF(2^255-19), represented as 5 x 51-bit limbs following + * curve25519-donna's 64-bit implementation approach. + * Fixed-iteration Montgomery ladder with no intended secret-dependent branch + * or table access; exact compiled timing remains platform-dependent. * * CT-REQUIRED: Every operation in this file must be constant-time. * No branches on secret data. No secret-dependent memory access. * - * v2.0.0: Rewritten from 5×51-bit to 4×64-bit limb representation - * to match Jasmin zupt_fe_cswap (4×u64 masked XOR swap). - * - * Representation: f = f[0] + f[1]*2^64 + f[2]*2^128 + f[3]*2^192 - * where limbs can temporarily exceed 2^64 during intermediate calculations. + * Representation: f = f[0] + f[1]*2^51 + f[2]*2^102 + f[3]*2^153 + * + f[4]*2^204. Limbs may temporarily exceed 51 bits during arithmetic; * fe_reduce() brings the result back to canonical form mod 2^255-19. */ #include "zupt_x25519.h" @@ -23,30 +27,12 @@ #include /* ═══════════════════════════════════════════════════════════════════ - * FIELD ARITHMETIC: GF(2^255 - 19), 4 × 64-bit limbs + * FIELD ARITHMETIC: GF(2^255 - 19), 5 x 51-bit limbs * - * We use the 5×51-bit schoolbook approach internally for multiplication - * (to avoid requiring __int128 for 128×128 products) but store/swap - * in 4×64-bit layout to match Jasmin. - * - * Actually: we keep 5×51-bit for mul/sq (needs 64×64→128 products) - * and convert to/from 4×64-bit at the boundary (frombytes/tobytes/cswap). - * - * CORRECTION: To truly match Jasmin's 4×u64 layout for fe_cswap, - * the field elements in memory MUST be 4×u64. We use 5×51-bit - * internally in registers only, and store back as 4×u64 after each - * operation. This is the donna64 approach used by libsodium. - * - * SIMPLER APPROACH: Keep everything as 5×51-bit (the proven working - * implementation) and just adapt fe_cswap to operate on 5 limbs - * with the Jasmin function swapping the first 4 u64 values plus - * a C swap of the 5th. - * - * SIMPLEST CORRECT APPROACH (chosen): Keep the proven 5×51-bit - * arithmetic but store field elements as 5×u64 (40 bytes). The - * Jasmin fe_cswap swaps 4×u64 (32 bytes). We call it for the first - * 4 limbs and handle the 5th limb in C. This is minimal change, - * the arithmetic is identical, and the CT property is preserved. + * The optional Jasmin swap operates on the first four stored uint64_t limbs; + * the fifth limb uses the same masked-XOR pattern in C. The default build uses + * the C loop for all five limbs. No retained formal-verification artifact is + * claimed for either path. * ═══════════════════════════════════════════════════════════════════ */ typedef uint64_t fe[5]; /* Field element: 5 limbs, each < 2^52 */ @@ -115,12 +101,12 @@ static void fe_tobytes(uint8_t s[32], const fe h) { } /* CT-REQUIRED: conditional swap — no branches on secret bit. - * JASMIN-VERIFIED: First 4 limbs swapped by Jasmin when available; + * JASMIN PATH: first 4 limbs swapped by compiled Jasmin code when available; * 5th limb swapped in C (same constant-time XOR pattern). */ static void fe_cswap(fe a, fe b, uint64_t flag) { uint64_t mask = -(uint64_t)(flag & 1); #ifdef ZUPT_USE_JASMIN - /* JASMIN-VERIFIED: CT swap of first 32 bytes (4×u64). + /* JASMIN PATH: masked swap of first 32 bytes (4×u64). * The Jasmin function operates on 4 consecutive u64 values. */ zupt_fe_cswap(a, b, flag & 1); /* 5th limb: C fallback (same CT pattern) */ @@ -248,13 +234,13 @@ static void fe_inv(fe h, const fe f) { /* ═══════════════════════════════════════════════════════════════════ * X25519 MONTGOMERY LADDER - * CT-REQUIRED: No secret-dependent branches. The ladder is constant-time - * by construction: every iteration performs the same operations, with - * cswap selecting which point to operate on. + * CT-REQUIRED: no intended secret-dependent branches or memory access. Every + * iteration follows the same source-level operation sequence, with cswap + * selecting which point to operate on; this is not a compiled timing proof. * ═══════════════════════════════════════════════════════════════════ */ /* FRAMA-C: X25519 Diffie-Hellman key agreement (RFC 7748) - * CT-REQUIRED: Montgomery ladder — constant-time by construction */ + * CT-REQUIRED: fixed-iteration, constant-time-intended Montgomery ladder */ /*@ requires \valid(out + (0..31)); @ requires \valid_read(scalar + (0..31)); @ requires \valid_read(point + (0..31)); diff --git a/src/zupt_xxh.c b/src/zupt_xxh.c index 107d80b..bcb0fdc 100644 --- a/src/zupt_xxh.c +++ b/src/zupt_xxh.c @@ -1,5 +1,6 @@ /* - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-License-Identifier: AGPL-3.0-or-later AND BSD-2-Clause + * Copyright (c) 2012-2021 Yann Collet * Copyright (c) 2025-2026 Cristian Cezar Moisés * ZUPT - XXH64 Hash (based on xxHash by Yann Collet, BSD-2) */ diff --git a/tests/archive_path_fixture.c b/tests/archive_path_fixture.c new file mode 100644 index 0000000..e298df0 --- /dev/null +++ b/tests/archive_path_fixture.c @@ -0,0 +1,194 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later + * Copyright (c) 2025-2026 Cristian Cezar Moisés + * + * Build a minimal, structurally valid plaintext archive with an arbitrary + * index path. This is test infrastructure for extraction-path policy: unlike + * byte mutation, the resulting index checksum and archive-integrity trailer + * are valid, so a rejection necessarily reaches the path validation code. + */ +#include "zupt.h" + +#include +#include +#include +#include + +static int put_u8(FILE *stream, uint8_t value) { + return fputc(value, stream) == EOF ? -1 : 0; +} + +static int put_u16le(FILE *stream, uint16_t value) { + return put_u8(stream, (uint8_t)value) || + put_u8(stream, (uint8_t)(value >> 8)) ? -1 : 0; +} + +static size_t put_u32le(uint8_t *out, uint32_t value) { + for (size_t i = 0; i < 4; i++) out[i] = (uint8_t)(value >> (i * 8)); + return 4; +} + +static size_t put_u64le(uint8_t *out, uint64_t value) { + for (size_t i = 0; i < 8; i++) out[i] = (uint8_t)(value >> (i * 8)); + return 8; +} + +static size_t put_varint(uint8_t *out, uint64_t value) { + size_t count = 0; + while (value >= 0x80) { + out[count++] = (uint8_t)(value | 0x80); + value >>= 7; + } + out[count++] = (uint8_t)value; + return count; +} + +static int hex_nibble(unsigned char value) { + if (value >= '0' && value <= '9') return (int)(value - '0'); + if (value >= 'a' && value <= 'f') return (int)(value - 'a') + 10; + if (value >= 'A' && value <= 'F') return (int)(value - 'A') + 10; + return -1; +} + +static int decode_hex_entry(const char *hex, uint8_t *out, size_t capacity, + size_t *out_size) { + size_t hex_size = strlen(hex); + if (hex_size == 0 || (hex_size & 1u) != 0 || + hex_size / 2u >= capacity) + return -1; + + size_t decoded_size = hex_size / 2u; + for (size_t i = 0; i < decoded_size; i++) { + int high = hex_nibble((unsigned char)hex[i * 2u]); + int low = hex_nibble((unsigned char)hex[i * 2u + 1u]); + if (high < 0 || low < 0) return -1; + out[i] = (uint8_t)((high << 4) | low); + } + *out_size = decoded_size; + return 0; +} + +static int write_block(FILE *stream, uint8_t type, const uint8_t *payload, + size_t payload_size, uint64_t unpacked_size, + uint64_t checksum) { + uint8_t varint[10]; + size_t varint_size; + if (put_u8(stream, ZUPT_BLOCK_MAGIC_0) || + put_u8(stream, ZUPT_BLOCK_MAGIC_1) || put_u8(stream, type) || + put_u16le(stream, ZUPT_CODEC_STORE) || put_u16le(stream, 0)) + return -1; + varint_size = put_varint(varint, unpacked_size); + if (fwrite(varint, 1, varint_size, stream) != varint_size) return -1; + varint_size = put_varint(varint, payload_size); + if (fwrite(varint, 1, varint_size, stream) != varint_size) return -1; + uint8_t checksum_bytes[8]; + put_u64le(checksum_bytes, checksum); + if (fwrite(checksum_bytes, 1, sizeof(checksum_bytes), stream) != + sizeof(checksum_bytes)) + return -1; + return payload_size == 0 || + fwrite(payload, 1, payload_size, stream) == payload_size ? 0 : -1; +} + +int main(int argc, char **argv) { + static const uint8_t content[] = "fixture content\n"; + uint8_t decoded_entry[ZUPT_MAX_PATH]; + const uint8_t *entry = NULL; + size_t path_size = 0; + + if (argc == 3 && strncmp(argv[2], "--entry=", 8) == 0) { + entry = (const uint8_t *)argv[2] + 8; + path_size = strlen(argv[2] + 8); + } else if (argc == 3 && + strncmp(argv[2], "--entry-hex=", 12) == 0 && + decode_hex_entry(argv[2] + 12, decoded_entry, + sizeof(decoded_entry), &path_size) == 0) { + entry = decoded_entry; + } + if (!entry || argv[1][0] == '\0' || path_size == 0 || + path_size >= ZUPT_MAX_PATH) { + fprintf(stderr, + "usage: %s ARCHIVE --entry=ENTRY_PATH|--entry-hex=HEX_BYTES\n", + argv[0]); + return 2; + } + + FILE *stream = fopen(argv[1], "wb"); + if (!stream) return 1; + + zupt_archive_header_t header; + memset(&header, 0, sizeof(header)); + const uint8_t magic[6] = { ZUPT_MAGIC_0, ZUPT_MAGIC_1, ZUPT_MAGIC_2, + ZUPT_MAGIC_3, ZUPT_MAGIC_4, ZUPT_MAGIC_5 }; + memcpy(header.magic, magic, sizeof(magic)); + header.version_major = ZUPT_FORMAT_MAJOR; + header.version_minor = ZUPT_FORMAT_MINOR; + uint8_t serialized_header[ZUPT_ARCHIVE_HEADER_SIZE] = {0}; + memcpy(serialized_header, header.magic, sizeof(header.magic)); + serialized_header[6] = header.version_major; + serialized_header[7] = header.version_minor; + put_u32le(serialized_header + 8, header.global_flags); + put_u64le(serialized_header + 12, header.creation_time); + memcpy(serialized_header + 20, header.archive_id, sizeof(header.archive_id)); + put_u64le(serialized_header + 36, header.encryption_header_off); + put_u64le(serialized_header + 44, header.comment_offset); + memcpy(serialized_header + 52, header.reserved, sizeof(header.reserved)); + if (fwrite(serialized_header, 1, sizeof(serialized_header), stream) != + sizeof(serialized_header)) goto fail; + + const size_t content_size = sizeof(content) - 1; + uint64_t content_hash = zupt_xxh64(content, content_size, 0); + uint64_t data_offset = (uint64_t)ftell(stream); + if (write_block(stream, ZUPT_BLOCK_DATA, content, content_size, + content_size, content_hash) != 0) + goto fail; + + uint8_t index[ZUPT_MAX_PATH + 128]; + size_t index_size = 0; + index_size += put_varint(index + index_size, 1); + index_size += put_varint(index + index_size, path_size); + memcpy(index + index_size, entry, path_size); + index_size += path_size; + index_size += put_u64le(index + index_size, content_size); + index_size += put_u64le(index + index_size, content_size); + index_size += put_u64le(index + index_size, 0); + index_size += put_u64le(index + index_size, content_hash); + index_size += put_u64le(index + index_size, data_offset); + index_size += put_varint(index + index_size, 1); + index_size += put_u32le(index + index_size, 0600); + + uint64_t index_offset = (uint64_t)ftell(stream); + if (write_block(stream, ZUPT_BLOCK_INDEX, index, index_size, index_size, + zupt_xxh64(index, index_size, 0)) != 0) + goto fail; + + zupt_footer_t footer; + memset(&footer, 0, sizeof(footer)); + footer.index_offset = index_offset; + footer.total_blocks = 1; + footer.archive_checksum = (uint64_t)ftell(stream); + memcpy(footer.footer_magic, "ZEND", 4); + footer.footer_version = 1; + uint8_t serialized_footer[ZUPT_FOOTER_SIZE] = {0}; + put_u64le(serialized_footer, footer.index_offset); + put_u64le(serialized_footer + 8, footer.total_blocks); + put_u64le(serialized_footer + 16, footer.archive_checksum); + memcpy(serialized_footer + 24, footer.footer_magic, + sizeof(footer.footer_magic)); + put_u32le(serialized_footer + 28, footer.footer_version); + if (fwrite(serialized_footer, 1, sizeof(serialized_footer), stream) != + sizeof(serialized_footer)) goto fail; + + uint8_t mac_input[ZUPT_AIT_MAC_INPUT_LEN]; + uint8_t trailer[ZUPT_AIT_SIZE]; + memcpy(mac_input, serialized_header, sizeof(serialized_header)); + memcpy(mac_input + sizeof(serialized_header), serialized_footer, 24); + memset(trailer, 0, sizeof(trailer)); + put_u64le(trailer, zupt_xxh64(mac_input, sizeof(mac_input), 0)); + if (fwrite(trailer, sizeof(trailer), 1, stream) != 1 || fclose(stream) != 0) + return 1; + return 0; + +fail: + fclose(stream); + return 1; +} diff --git a/tests/archive_surgery.py b/tests/archive_surgery.py new file mode 100644 index 0000000..cb4a1b1 --- /dev/null +++ b/tests/archive_surgery.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Strict structural mutations used by archive-authentication tests.""" + +import argparse +import pathlib +import sys + + +ARCHIVE_HEADER_SIZE = 64 +FOOTER_SIZE = 32 +AIT_SIZE = 32 +BLOCK_DATA = 0x00 +BLOCK_INDEX = 0x02 +BLOCK_ENC_HEADER = 0x03 +BLOCK_DEDUP_REF = 0x04 +BLOCK_COMMENT = 0x05 +BLOCK_FLAG_ENCRYPTED = 0x01 + + +class ArchiveError(ValueError): + pass + + +def _u16le(data, offset): + return int.from_bytes(data[offset:offset + 2], "little") + + +def _u64le(data, offset): + return int.from_bytes(data[offset:offset + 8], "little") + + +def _read_varint(data, offset, limit): + value = 0 + for byte_number in range(10): + if offset >= limit: + raise ArchiveError("truncated varint") + byte = data[offset] + offset += 1 + if byte_number == 9 and byte > 1: + raise ArchiveError("varint exceeds uint64") + value |= (byte & 0x7f) << (7 * byte_number) + if (byte & 0x80) == 0: + if byte_number and value < (1 << (7 * byte_number)): + raise ArchiveError("non-canonical varint") + return value, offset + raise ArchiveError("unterminated varint") + + +def _parse_frame(data, offset, limit): + start = offset + if limit - offset < 17: + raise ArchiveError("truncated block header") + if data[offset:offset + 2] != b"\xbb\x01": + raise ArchiveError("invalid block magic") + block_type = data[offset + 2] + codec = _u16le(data, offset + 3) + flags = _u16le(data, offset + 5) + offset += 7 + uncompressed_size, offset = _read_varint(data, offset, limit) + compressed_size, offset = _read_varint(data, offset, limit) + if limit - offset < 8: + raise ArchiveError("truncated block checksum") + checksum = _u64le(data, offset) + payload_start = offset + 8 + if compressed_size > limit - payload_start: + raise ArchiveError("block payload exceeds structural boundary") + end = payload_start + compressed_size + return { + "type": block_type, + "codec": codec, + "flags": flags, + "uncompressed_size": uncompressed_size, + "compressed_size": compressed_size, + "checksum": checksum, + "start": start, + "payload_start": payload_start, + "end": end, + } + + +def _parse_current_archive(data): + minimum = ARCHIVE_HEADER_SIZE + FOOTER_SIZE + AIT_SIZE + if len(data) < minimum: + raise ArchiveError("archive is too short") + if data[:6] != b"ZUPT\x1a\x00": + raise ArchiveError("invalid archive magic") + + footer_start = len(data) - FOOTER_SIZE - AIT_SIZE + if data[footer_start + 24:footer_start + 28] != b"ZEND": + raise ArchiveError("current footer before AIT not found") + if int.from_bytes(data[footer_start + 28:footer_start + 32], + "little") != 1: + raise ArchiveError("unsupported footer version") + + index_offset = _u64le(data, footer_start) + if index_offset < ARCHIVE_HEADER_SIZE or index_offset >= footer_start: + raise ArchiveError("index offset is outside the archive body") + + frames = [] + offset = ARCHIVE_HEADER_SIZE + while offset < index_offset: + frame = _parse_frame(data, offset, index_offset) + if frame["type"] == BLOCK_INDEX: + raise ArchiveError("index frame occurs before footer index offset") + frames.append(frame) + offset = frame["end"] + if offset != index_offset: + raise ArchiveError("archive body does not end at index offset") + + index = _parse_frame(data, index_offset, footer_start) + if index["type"] != BLOCK_INDEX: + raise ArchiveError("footer does not point to an index frame") + if index["end"] != footer_start: + raise ArchiveError("bytes remain between index and footer") + + return { + "frames": frames, + "index": index, + "footer_start": footer_start, + } + + +def _kind_value(name): + return {"data": BLOCK_DATA, "enc": BLOCK_ENC_HEADER, + "ref": BLOCK_DEDUP_REF}[name] + + +def _matching_frames(layout, kind, require_encrypted): + matches = [frame for frame in layout["frames"] + if frame["type"] == _kind_value(kind)] + if require_encrypted: + matches = [frame for frame in matches + if frame["flags"] & BLOCK_FLAG_ENCRYPTED] + return matches + + +def _same_metadata(left, right): + fields = ("type", "codec", "flags", "uncompressed_size", + "compressed_size", "checksum") + return all(left[field] == right[field] for field in fields) + + +def _select_equal_length_pair(frames, same_metadata): + for index, left in enumerate(frames): + for right in frames[index + 1:]: + if left["end"] - left["start"] != right["end"] - right["start"]: + continue + if same_metadata and not _same_metadata(left, right): + continue + return left, right + qualifier = " with identical metadata" if same_metadata else "" + raise ArchiveError("no two equal-length frames" + qualifier) + + +def _write(destination, data): + pathlib.Path(destination).write_bytes(data) + + +def command_strip_ait(args): + data = pathlib.Path(args.source).read_bytes() + layout = _parse_current_archive(data) + _write(args.destination, data[:layout["footer_start"] + FOOTER_SIZE]) + + +def command_flip_payload(args): + data = bytearray(pathlib.Path(args.source).read_bytes()) + layout = _parse_current_archive(data) + frames = _matching_frames(layout, args.kind, args.require_encrypted) + if not frames: + raise ArchiveError("requested frame was not found") + frame = frames[0] + if frame["compressed_size"] == 0: + raise ArchiveError("requested frame has no payload") + position = frame["payload_start"] + frame["compressed_size"] // 2 + data[position] ^= 0x01 + _write(args.destination, data) + + +def command_swap_frames(args): + data = bytearray(pathlib.Path(args.source).read_bytes()) + layout = _parse_current_archive(data) + frames = _matching_frames(layout, args.kind, args.require_encrypted) + left, right = _select_equal_length_pair(frames, args.same_metadata) + left_bytes = bytes(data[left["start"]:left["end"]]) + right_bytes = bytes(data[right["start"]:right["end"]]) + if left_bytes == right_bytes: + raise ArchiveError("selected frames are byte-identical; swap is a no-op") + data[left["start"]:left["end"]] = right_bytes + data[right["start"]:right["end"]] = left_bytes + _write(args.destination, data) + + +def command_replay_frame(args): + data = bytearray(pathlib.Path(args.source).read_bytes()) + layout = _parse_current_archive(data) + frames = _matching_frames(layout, args.kind, args.require_encrypted) + source, destination = _select_equal_length_pair(frames, + args.same_metadata) + replay = bytes(data[source["start"]:source["end"]]) + if replay == bytes(data[destination["start"]:destination["end"]]): + raise ArchiveError("selected frames are already byte-identical") + data[destination["start"]:destination["end"]] = replay + _write(args.destination, data) + + +def command_preface_positions(args): + data = pathlib.Path(args.source).read_bytes() + layout = _parse_current_archive(data) + for frame in layout["frames"] + [layout["index"]]: + for position in range(frame["start"], frame["payload_start"]): + print(position) + + +def command_set_frame_type(args): + data = bytearray(pathlib.Path(args.source).read_bytes()) + layout = _parse_current_archive(data) + frames = _matching_frames(layout, args.kind, args.require_encrypted) + if not frames: + raise ArchiveError("requested frame was not found") + replacement = {"data": BLOCK_DATA, "comment": BLOCK_COMMENT}[args.type] + data[frames[0]["start"] + 2] = replacement + _write(args.destination, data) + + +def _add_frame_options(parser): + parser.add_argument("source") + parser.add_argument("destination") + parser.add_argument("--kind", choices=("data", "enc", "ref"), required=True) + parser.add_argument("--require-encrypted", action="store_true") + + +def main(): + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command") + + strip_ait = commands.add_parser("strip-ait") + strip_ait.add_argument("source") + strip_ait.add_argument("destination") + strip_ait.set_defaults(function=command_strip_ait) + + flip = commands.add_parser("flip-payload") + _add_frame_options(flip) + flip.set_defaults(function=command_flip_payload) + + swap = commands.add_parser("swap-frames") + _add_frame_options(swap) + swap.add_argument("--same-metadata", action="store_true") + swap.set_defaults(function=command_swap_frames) + + replay = commands.add_parser("replay-frame") + _add_frame_options(replay) + replay.add_argument("--same-metadata", action="store_true") + replay.set_defaults(function=command_replay_frame) + + prefaces = commands.add_parser("preface-positions") + prefaces.add_argument("source") + prefaces.set_defaults(function=command_preface_positions) + + set_type = commands.add_parser("set-frame-type") + _add_frame_options(set_type) + set_type.add_argument("--type", choices=("data", "comment"), required=True) + set_type.set_defaults(function=command_set_frame_type) + + args = parser.parse_args() + if not hasattr(args, "function"): + parser.error("a mutation command is required") + try: + args.function(args) + except (ArchiveError, OSError) as error: + parser.error(str(error)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/fixture_hex_decode.c b/tests/fixture_hex_decode.c new file mode 100644 index 0000000..7d4a808 --- /dev/null +++ b/tests/fixture_hex_decode.c @@ -0,0 +1,46 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ +#include +#include + +static int hex_value(int character) { + if (character >= '0' && character <= '9') return character - '0'; + if (character >= 'a' && character <= 'f') return character - 'a' + 10; + if (character >= 'A' && character <= 'F') return character - 'A' + 10; + return -1; +} + +int main(int argc, char **argv) { + if (argc != 3) return 2; + FILE *input = fopen(argv[1], "rb"); + FILE *output = input ? fopen(argv[2], "wb") : NULL; + if (!input || !output) { + if (input) fclose(input); + if (output) fclose(output); + return 1; + } + int high_nibble = -1; + int character; + int failed = 0; + while ((character = fgetc(input)) != EOF) { + if (isspace((unsigned char)character)) continue; + int value = hex_value(character); + if (value < 0) { + failed = 1; + break; + } + if (high_nibble < 0) { + high_nibble = value; + } else { + if (fputc((high_nibble << 4) | value, output) == EOF) { + failed = 1; + break; + } + high_nibble = -1; + } + } + if (ferror(input) || high_nibble >= 0 || fflush(output) != 0) + failed = 1; + if (fclose(input) != 0) failed = 1; + if (fclose(output) != 0) failed = 1; + return failed ? 1 : 0; +} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..ae54d45 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,29 @@ +# Compatibility fixtures + +SPDX-License-Identifier: AGPL-3.0-or-later + +`v5.2.1-encrypted-dedup-disk.zupt.hex` is a textual hexadecimal encoding of +a 718-byte archive produced by the unmodified VaptVupt `v5.2.1` tag at commit +`3f897190564d23dd8682f1a07aec62db376b0137`. + +The input is four 65,536-byte blocks: `A`, `B`, `B`, then `C`. The archive was +created with: + +```text +vaptvupt disk backup --dedup -b 65536 \ + -p vaptvupt-5.2.1-fixture legacy-abbc.zupt legacy-abbc.img +``` + +The repeated third block creates a legacy unauthenticated dedup reference to +the non-zero AAD sequence used by the second DATA frame. The fourth DATA frame +proves that the 5.2.1 linear AAD sequence advances across that reference. The +regression decodes the text only in a temporary directory. No binary archive +is tracked or included as a precompiled program/library. + +```text +input SHA-256: f144a6486d4971d5af80597dc283254abf3e40a3ea48cb1326eb78a32df009a6 +archive SHA-256: 7aedc693450ff048348730c2d17502499055420d87918d6801dffe87580905bc +``` + +The fixture is test data generated by the VaptVupt project and is distributed +under the project license, AGPL-3.0-or-later. diff --git a/tests/fixtures/v5.2.1-encrypted-dedup-disk.zupt.hex b/tests/fixtures/v5.2.1-encrypted-dedup-disk.zupt.hex new file mode 100644 index 0000000..27ee553 --- /dev/null +++ b/tests/fixtures/v5.2.1-encrypted-dedup-disk.zupt.hex @@ -0,0 +1 @@ +5a5550541a000106c1000000004c4bcb31d9ce1871636dd7ea914893a9ec6b3ac330947f40000000000000000000000000000000000000000000000000000000bb0103000000003535dfcd5687362b923e017cc0d3d4dbe979c8ffc1294ca8f9735f6a1dca2eada0761483cf98ad06c75a87bb737c1adfa566513e1114d1e6f8326dc0270900bb01001000010080800475cf99e954d44bb621fc91737f492f2cd390cdbeaadb23a6bc42250a8c15f8e41284be8b5ed8c00ca3c548b80e08dbd4edda4926c7e4150a53ee84f2ad94d74d6610bce4d72f9fa1a255a71a9b8bb3dc56d6feaa292e8ba02c82d6820872a5b37434c86dc59d0ddccd58c12b2968c8cd1bc3aaa53c37940b75820b5b7ff4bb01001000010080800475647dfd91a6b9e6c021d49988ac9bf13ed8106c6c7505353e7857f699687e25392fabd4e8bc6287a156474f0dbf608801c28e062505b42073e7c8d982f4beddc9e3bd70643e56f21b008e40be5a0924e1ce7d6499047d6e5eae782aa5f64a85231a382a5436f958880a2cf937272b34b9b9048df33a738f7b3a3c452d0bbb01040000000080800408647dfd91a6b9e6c00e01000000000000bb0100100001008080047561edd57b45ef972a0a654bc02830ee9b4db7bd1431f80e984e2e78cd3843dd129dbdfdfa36f37997f55edf6205afe1593b4c993a0a5db70cb3ea1af3f231e09c074233e2f9c1b1047d30d880e891d4f7f9a5d5b42b202d92839deca17ffa5cb6c53c5c1326298dd76006d2437bfe209092e93ba811198f3b4afcccbbd7bb010200000000444427de2204fe74e31a010000000f6c65676163792d616262632e696d67000004000000000067010000000000000016e60632d9ce180d922d4614e9c20186000000000000000400000000000000390200000000000004000000000000004435c96b5a53aaaf5a454e440100000072d5f64874d717f7784f84b625d43ac127352bd3a37b9f2804056d0d475b12c9 diff --git a/tests/fuzz_decompress.c b/tests/fuzz_decompress.c index c0a7f5a..026b9bf 100644 --- a/tests/fuzz_decompress.c +++ b/tests/fuzz_decompress.c @@ -1,7 +1,7 @@ /* * SPDX-License-Identifier: AGPL-3.0-or-later * Copyright (c) 2025-2026 Cristian Cezar Moisés - * Zupt v2.0.0 — AFL++ Fuzzing Harness: Archive Decompression + * ZUPT v2.0.0 — AFL++ Fuzzing Harness: Archive Decompression * Copyright (c) 2026 Cristian Cezar Moisés — AGPL-3.0-or-later * * Reads a fuzzed .zupt archive from stdin, attempts to extract it. diff --git a/tests/fuzz_vv_decompress.c b/tests/fuzz_vv_decompress.c index 978eb91..e161a5c 100644 --- a/tests/fuzz_vv_decompress.c +++ b/tests/fuzz_vv_decompress.c @@ -1,11 +1,11 @@ /* * SPDX-License-Identifier: AGPL-3.0-or-later * Copyright (c) 2025-2026 Cristian Cezar Moisés - * Zupt v2.0.0 — AFL++ Fuzzing Harness: VaptVupt Codec + * ZUPT v2.0.0 — AFL++ Fuzzing Harness: VaptVupt Codec * Copyright (c) 2026 Cristian Cezar Moisés — AGPL-3.0-or-later * * Reads fuzzed VaptVupt frame data from stdin, attempts decompression. - * Tests the VaptVupt codec directly (bypassing Zupt archive format). + * Tests the VaptVupt codec directly (bypassing ZUPT archive format). * * Build: * afl-clang-fast -fsanitize=address,undefined -g -O1 -mavx2 \ diff --git a/tests/mlkem_fips203_harness.c b/tests/mlkem_fips203_harness.c new file mode 100644 index 0000000..ae78eb7 --- /dev/null +++ b/tests/mlkem_fips203_harness.c @@ -0,0 +1,65 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later + * Deterministic ML-KEM-768 harness for FIPS 203 conformance testing against an + * external reference (OpenSSL 3.5+). Uses the project's PUBLIC KEM API over raw + * FIPS 203 byte strings (ek=1184, dk=2400, ct=1088, ss=32). + * + * keygen -> ek.bin, dk.bin (d,z consumed from MLKEM_RAND if set) + * encaps -> ct.bin, ss.bin (m consumed from MLKEM_RAND if set) + * decaps -> ss.bin + * + * When env MLKEM_RAND names a file, zupt_random_bytes() consumes it SEQUENTIALLY + * (keygen reads d then z; encaps reads m), so the same FIPS 203 seed fed to a + * reference implementation produces byte-identical ek/dk/ct/ss. + * + * Built by tests/test_mlkem_fips203.sh against src/zupt_mlkem.c + src/zupt_keccak.c + * (no -DZUPT_USE_JASMIN, so the portable constant-time select is used). */ +#include +#include +#include +#include +#include "zupt_mlkem.h" + +static FILE *g_rand; static int g_rand_init; +void zupt_random_bytes(uint8_t *buf, size_t len) { + if (!g_rand_init) { + const char *p = getenv("MLKEM_RAND"); + g_rand = fopen(p ? p : "/dev/urandom", "rb"); + g_rand_init = 1; + } + if (!g_rand || fread(buf, 1, len, g_rand) != len) { fprintf(stderr, "rand fail\n"); exit(2); } +} +int zupt_ct_memeq(const void *a, const void *b, size_t n) { + const uint8_t *x = a, *y = b; uint8_t d = 0; + for (size_t i = 0; i < n; i++) d |= (uint8_t)(x[i] ^ y[i]); + return d == 0 ? 1 : 0; +} +static void wr(const char *p, const uint8_t *b, size_t n) { + FILE *f = fopen(p, "wb"); + if (!f || fwrite(b, 1, n, f) != n) { fprintf(stderr, "write %s\n", p); exit(2); } fclose(f); +} +static size_t rd(const char *p, uint8_t *b, size_t n) { + FILE *f = fopen(p, "rb"); if (!f) { fprintf(stderr, "open %s\n", p); exit(2); } + size_t g = fread(b, 1, n, f); fclose(f); return g; +} +int main(int argc, char **argv) { + if (argc >= 2 && !strcmp(argv[1], "keygen")) { + uint8_t ek[1184], dk[2400]; + if (zupt_mlkem768_keygen(ek, dk)) return 2; + wr("ek.bin", ek, 1184); wr("dk.bin", dk, 2400); return 0; + } + if (argc == 3 && !strcmp(argv[1], "encaps")) { + uint8_t ek[1184], ct[1088], ss[32]; + if (rd(argv[2], ek, 1184) != 1184) return 2; + if (zupt_mlkem768_encaps(ct, ss, ek)) return 2; + wr("ct.bin", ct, 1088); wr("ss.bin", ss, 32); return 0; + } + if (argc == 4 && !strcmp(argv[1], "decaps")) { + uint8_t dk[2400], ct[1088], ss[32]; + if (rd(argv[2], dk, 2400) != 2400) return 2; + if (rd(argv[3], ct, 1088) != 1088) return 2; + if (zupt_mlkem768_decaps(ss, ct, dk)) return 2; + wr("ss.bin", ss, 32); return 0; + } + fprintf(stderr, "usage: keygen | encaps | decaps \n"); + return 1; +} diff --git a/tests/regression.sh b/tests/regression.sh index 5c7d3e4..0b76eb3 100644 --- a/tests/regression.sh +++ b/tests/regression.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés # ZUPT v2.0.0 — Comprehensive Regression Test Suite @@ -6,7 +6,7 @@ # Run: sh tests/regression.sh set +e # Don't exit on failure — we track pass/fail ourselves -ZUPT="./zupt" +ZUPT="${1:-./zupt}" T="/tmp/zupt_regression_$$" PASS=0; FAIL=0; TOTAL=0 @@ -233,15 +233,17 @@ N_SZ=$(stat -c%s "$T/normal.zupt" 2>/dev/null || stat -f%z "$T/normal.zupt" 2>/d tar cf - -C "$T" data/ 2>/dev/null | gzip -9 > "$T/gz.tar.gz" G_SZ=$(stat -c%s "$T/gz.tar.gz" 2>/dev/null || stat -f%z "$T/gz.tar.gz" 2>/dev/null) -SR=$(echo "scale=2; $TOTAL_SZ / $S_SZ" | bc) -NR=$(echo "scale=2; $TOTAL_SZ / $N_SZ" | bc) -GR=$(echo "scale=2; $TOTAL_SZ / $G_SZ" | bc) +SR=$(awk -v total="$TOTAL_SZ" -v size="$S_SZ" 'BEGIN { printf "%.2f", total / size }') +NR=$(awk -v total="$TOTAL_SZ" -v size="$N_SZ" 'BEGIN { printf "%.2f", total / size }') +GR=$(awk -v total="$TOTAL_SZ" -v size="$G_SZ" 'BEGIN { printf "%.2f", total / size }') echo " gzip -9: $G_SZ bytes ${GR}:1" echo " ZUPT normal: $N_SZ bytes ${NR}:1" echo " ZUPT solid: $S_SZ bytes ${SR}:1" if [ "$S_SZ" -le "$G_SZ" ]; then - pass "Solid beats gzip ($(echo "scale=1; ($G_SZ-$S_SZ)*100/$G_SZ" | bc)% smaller)" + SAVING=$(awk -v gzip="$G_SZ" -v solid="$S_SZ" \ + 'BEGIN { printf "%.1f", (gzip - solid) * 100 / gzip }') + pass "Solid beats gzip (${SAVING}% smaller)" else echo " NOTE: gzip wins (normal for small non-backup corpus)" pass "Compression comparison complete" diff --git a/tests/run_quick.sh b/tests/run_quick.sh index 24ad797..1145ef9 100644 --- a/tests/run_quick.sh +++ b/tests/run_quick.sh @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés set +e -Z="./zupt"; T=$(mktemp -d); trap 'rm -rf "$T"' EXIT +Z=${1:-./zupt}; T=$(mktemp -d); trap 'rm -rf "$T"' EXIT mkdir -p "$T/d"; echo "hello" > "$T/d/a.txt" dd if=/dev/urandom bs=1024 count=10 of="$T/d/b.bin" 2>/dev/null; touch "$T/d/e.txt" P=0; F=0; ok() { echo " OK: $1"; P=$((P+1)); }; fl() { echo " FAIL: $1"; F=$((F+1)); } @@ -23,10 +23,14 @@ E=$(find "$T/o7" -name a.txt -type f 2>/dev/null|head -1); [ -n "$E" ] && diff - $Z keygen -o "$T/k.key" 2>/dev/null && $Z keygen --pub -o "$T/p.key" -k "$T/k.key" 2>/dev/null $Z compress --pq "$T/p.key" "$T/8.zupt" "$T/d/" 2>/dev/null && $Z extract --pq "$T/k.key" -o "$T/o8" "$T/8.zupt" 2>/dev/null E=$(find "$T/o8" -name a.txt -type f 2>/dev/null|head -1); [ -n "$E" ] && diff -q "$T/d/a.txt" "$E" >/dev/null 2>&1 && ok "PQ" || fl "PQ" +# Full post-quantum (ML-KEM-768 only, no X25519) round-trip. +$Z keygen --pq-only -o "$T/kq.key" 2>/dev/null && $Z keygen --pub --pq-only -o "$T/pq.key" -k "$T/kq.key" 2>/dev/null +$Z compress --pq-only "$T/pq.key" "$T/9.zupt" "$T/d/" 2>/dev/null && $Z extract --pq-only "$T/kq.key" -o "$T/o9" "$T/9.zupt" 2>/dev/null +E=$(find "$T/o9" -name a.txt -type f 2>/dev/null|head -1); [ -n "$E" ] && diff -q "$T/d/a.txt" "$E" >/dev/null 2>&1 && ok "PQ-only" || fl "PQ-only" R=$($Z test "$T/1.zupt" 2>&1); echo "$R"|grep -q "0 failed" && ok "Integrity" || fl "Integrity" # F-01 (2.2.4): every help command verb starts its own line. # Pre-fix output ran "keygen … Key generation zupt version" on one # wrapped line due to a missing \n in src/zupt_main.c:41. -HC=$($Z help 2>&1 | grep -cE '^ (vaptvupt|zupt) ') +HC=$($Z help 2>&1 | grep -cE '^ zupt ') [ "$HC" -ge 10 ] && ok "Help command lines ($HC)" || fl "Help command lines ($HC, need ≥10)" -echo ""; echo " Results: $P passed, $F failed (10 tests)"; [ "$F" -eq 0 ] && exit 0 || exit 1 +echo ""; echo " Results: $P passed, $F failed (11 tests)"; [ "$F" -eq 0 ] && exit 0 || exit 1 diff --git a/tests/test_arg_order.sh b/tests/test_arg_order.sh index 9859d72..8d8c536 100755 --- a/tests/test_arg_order.sh +++ b/tests/test_arg_order.sh @@ -5,7 +5,11 @@ # Bug #15 (v2.2.2): options after the positional archive argument were # silently dropped. e.g. `zupt x arch.zupt -o out` ignored `-o out`. -ZUPT_BIN="$(realpath ./zupt)" +ZUPT_BIN=${1:-./zupt} +case $ZUPT_BIN in + /*) ;; + *) ZUPT_BIN=$PWD/${ZUPT_BIN#./} ;; +esac TMPDIR=$(mktemp -d) trap "rm -rf $TMPDIR" EXIT cd "$TMPDIR" diff --git a/tests/test_atomic_archive_output.sh b/tests/test_atomic_archive_output.sh new file mode 100644 index 0000000..036ebad --- /dev/null +++ b/tests/test_atomic_archive_output.sh @@ -0,0 +1,413 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +set -Eeuo pipefail + +bin=${1:-./zupt} +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P) +case "$bin" in + /*) ;; + *) bin="$(pwd -P)/${bin#./}" ;; +esac +surgery="$repo_root/tests/archive_surgery.py" +tmp=$(mktemp -d "${TMPDIR:-/tmp}/zupt-atomic-output.XXXXXX") +trap 'rm -rf "$tmp"' EXIT + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +command -v python3 >/dev/null 2>&1 || fail 'python3 is required' + +assert_no_temps() { + if find "$tmp" -name '.zupt-archive-*' -print -quit | grep -q .; then + fail 'private archive temporary was not removed' + fi +} + +printf 'archive payload\n' > "$tmp/input.txt" +printf 'victim must remain unchanged\n' > "$tmp/victim.txt" +cp "$tmp/victim.txt" "$tmp/victim.expected" + +# Writers must never create an archive that their own extraction policy would +# reject. A parent component in the user-supplied input name fails before any +# output is published. +mkdir "$tmp/parent-input-work" +printf 'parent input\n' > "$tmp/parent-input.txt" +if (cd "$tmp/parent-input-work" && + MSYS2_ARG_CONV_EXCL='../parent-input.txt' \ + "$bin" compress -s parent-path.zupt ../parent-input.txt \ + >/dev/null 2>&1); then + fail 'compression accepted an unsafe parent-component archive name' +fi +test ! -e "$tmp/parent-input-work/parent-path.zupt" || + fail 'unsafe parent-component input published an archive' + +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) ;; + *) + mkdir -p "$tmp/collision/in/foo" + printf 'literal backslash\n' > "$tmp/collision/in/foo\\bar" + printf 'nested separator\n' > "$tmp/collision/in/foo/bar" + if "$bin" compress -s "$tmp/collision.zupt" \ + "$tmp/collision/in" >/dev/null 2>&1; then + fail 'compression accepted colliding slash/backslash destinations' + fi + test ! -e "$tmp/collision.zupt" || + fail 'colliding archive paths published an archive' + mkdir "$tmp/case-collision" + printf 'upper\n' > "$tmp/case-collision/Name.txt" + printf 'lower\n' > "$tmp/case-collision/name.txt" + if [[ $(find "$tmp/case-collision" -type f | wc -l) -eq 2 ]]; then + if "$bin" compress -s "$tmp/case-collision.zupt" \ + "$tmp/case-collision" >/dev/null 2>&1; then + fail 'compression accepted ASCII case-colliding destinations' + fi + test ! -e "$tmp/case-collision.zupt" || + fail 'case-colliding archive paths published an archive' + fi + ;; +esac + +# Normal compression must not publish an archive over any spelling or link +# alias of an input file. --force does not bypass this data-loss boundary. +mkdir "$tmp/self-input" +printf 'self input must survive\n' > "$tmp/self-input/self.zupt" +cp "$tmp/self-input/self.zupt" "$tmp/self-input.expected" +if "$bin" compress -s "$tmp/self-input/./self.zupt" \ + "$tmp/self-input/self.zupt" >/dev/null 2>&1; then + fail 'compression accepted an alternate spelling of its input as output' +fi +if "$bin" compress --solid -s "$tmp/self-input/./self.zupt" \ + "$tmp/self-input/self.zupt" >/dev/null 2>&1; then + fail 'solid compression accepted an alternate spelling of its input as output' +fi +cmp "$tmp/self-input.expected" "$tmp/self-input/self.zupt" || + fail 'alternate-spelling self compression changed its input' + +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) + # Native Windows publication uses handle-relative APIs and rejects + # reparse-point ancestors. Exercise the portable guarantees here; + # POSIX symlink, hardlink, ulimit and raw-device cases are reported as + # skipped instead of imposing contradictory MSYS semantics on the PE. + printf 'existing Windows output\n' > "$tmp/windows-output.zupt" + "$bin" compress -s "$tmp/windows-output.zupt" "$tmp/input.txt" \ + >/dev/null 2>&1 || fail 'Windows archive replacement failed' + "$bin" test "$tmp/windows-output.zupt" >/dev/null 2>&1 || + fail 'Windows atomically published archive is invalid' + mkdir "$tmp/windows-directory.zupt" + if "$bin" compress -s "$tmp/windows-directory.zupt" \ + "$tmp/input.txt" >/dev/null 2>&1; then + fail 'Windows directory destination was replaced' + fi + dd if=/dev/urandom of="$tmp/windows-disk.img" bs=65536 count=2 \ + 2>/dev/null + "$bin" disk backup -s -b 65536 "$tmp/windows-disk.zupt" \ + "$tmp/windows-disk.img" >/dev/null 2>&1 || + fail 'Windows disk backup failed' + "$bin" test "$tmp/windows-disk.zupt" >/dev/null 2>&1 || + fail 'Windows disk archive is invalid' + mkdir "$tmp/windows-disk-extracted" + "$bin" extract -o "$tmp/windows-disk-extracted" \ + "$tmp/windows-disk.zupt" >/dev/null 2>&1 || + fail 'Windows disk archive generic extraction failed' + cmp "$tmp/windows-disk.img" \ + "$tmp/windows-disk-extracted/windows-disk.img" || + fail 'Windows disk archive generic extraction mismatch' + "$bin" disk restore "$tmp/windows-disk.zupt" \ + "$tmp/windows-restored.img" >/dev/null 2>&1 || + fail 'Windows disk restore failed' + cmp "$tmp/windows-disk.img" "$tmp/windows-restored.img" || + fail 'Windows disk restore mismatch' + python3 "$surgery" flip-payload "$tmp/windows-disk.zupt" \ + "$tmp/windows-disk-corrupt.zupt" --kind data || + fail 'could not corrupt Windows disk archive fixture' + printf 'Windows restore sentinel\n' > "$tmp/windows-restore-target" + cp "$tmp/windows-restore-target" "$tmp/windows-restore.expected" + if "$bin" disk restore "$tmp/windows-disk-corrupt.zupt" \ + "$tmp/windows-restore-target" >/dev/null 2>&1; then + fail 'Windows disk restore accepted corrupt DATA' + fi + cmp "$tmp/windows-restore.expected" "$tmp/windows-restore-target" || + fail 'Windows corrupt disk restore changed its target' + assert_no_temps + printf 'SKIP: POSIX symlink, hardlink, ulimit and raw-device atomic cases\n' + printf 'atomic archive output Windows subset: PASS\n' + exit 0 + ;; +esac + +printf 'hardlinked input must survive\n' > "$tmp/self-hard-input" +cp "$tmp/self-hard-input" "$tmp/self-hard.expected" +ln "$tmp/self-hard-input" "$tmp/self-hard-output.zupt" +if "$bin" compress -y -s "$tmp/self-hard-output.zupt" \ + "$tmp/self-hard-input" >/dev/null 2>&1; then + fail 'compression accepted a hardlink alias of its input as output' +fi +if "$bin" compress --solid -y -s "$tmp/self-hard-output.zupt" \ + "$tmp/self-hard-input" >/dev/null 2>&1; then + fail 'solid compression accepted a hardlink alias of its input as output' +fi +test "$tmp/self-hard-input" -ef "$tmp/self-hard-output.zupt" || + fail 'rejected compression hardlink alias was replaced' +cmp "$tmp/self-hard.expected" "$tmp/self-hard-input" || + fail 'hardlink-alias compression changed its input' + +printf 'symlinked input must survive\n' > "$tmp/self-symlink-input" +cp "$tmp/self-symlink-input" "$tmp/self-symlink.expected" +ln -s self-symlink-input "$tmp/self-symlink-output.zupt" +if "$bin" compress -s "$tmp/self-symlink-output.zupt" \ + "$tmp/self-symlink-input" >/dev/null 2>&1; then + fail 'compression accepted a symlink alias of its input as output' +fi +if "$bin" compress --solid -s "$tmp/self-symlink-output.zupt" \ + "$tmp/self-symlink-input" >/dev/null 2>&1; then + fail 'solid compression accepted a symlink alias of its input as output' +fi +test -L "$tmp/self-symlink-output.zupt" || + fail 'rejected compression symlink alias was replaced' +cmp "$tmp/self-symlink.expected" "$tmp/self-symlink-input" || + fail 'symlink-alias compression changed its input' + +# Replacing the output entry must not open or truncate its symlink target. +ln -s victim.txt "$tmp/symlink.zupt" +"$bin" compress -s "$tmp/symlink.zupt" "$tmp/input.txt" >/dev/null 2>&1 +cmp "$tmp/victim.expected" "$tmp/victim.txt" || fail 'symlink target changed' +test ! -L "$tmp/symlink.zupt" || fail 'archive remained a symlink' +"$bin" test "$tmp/symlink.zupt" >/dev/null 2>&1 || fail 'published archive is invalid' +assert_no_temps + +# The same directory-entry replacement rule protects another name linked to +# the old inode. The victim keeps its bytes while the output gets a new inode. +printf 'hardlink victim\n' > "$tmp/hard-victim" +cp "$tmp/hard-victim" "$tmp/hard.expected" +ln "$tmp/hard-victim" "$tmp/hardlink.zupt" +"$bin" compress --solid -s "$tmp/hardlink.zupt" "$tmp/input.txt" >/dev/null 2>&1 +cmp "$tmp/hard.expected" "$tmp/hard-victim" || fail 'hardlink peer changed' +if test "$tmp/hard-victim" -ef "$tmp/hardlink.zupt"; then + fail 'archive reused victim inode' +fi +"$bin" test "$tmp/hardlink.zupt" >/dev/null 2>&1 || fail 'solid archive is invalid' +assert_no_temps + +# A symlink explicitly present in the user-selected POSIX parent is resolved +# once, then the physical directory is pinned for the entire publication. +mkdir "$tmp/real-parent" +ln -s real-parent "$tmp/parent-link" +"$bin" compress -s "$tmp/parent-link/through-link.zupt" \ + "$tmp/input.txt" >/dev/null 2>&1 || fail 'symlinked parent was unusable' +"$bin" test "$tmp/real-parent/through-link.zupt" >/dev/null 2>&1 || + fail 'archive through resolved parent is invalid' +assert_no_temps + +# A directory at the final name cannot be replaced. The publication failure +# must remove the private temporary and leave the old directory untouched. +mkdir "$tmp/final-is-directory.zupt" +printf 'directory sentinel\n' > "$tmp/final-is-directory.zupt/sentinel" +if "$bin" compress -s "$tmp/final-is-directory.zupt" \ + "$tmp/input.txt" >/dev/null 2>&1; then + fail 'directory destination was replaced' +fi +grep -qx 'directory sentinel' "$tmp/final-is-directory.zupt/sentinel" || + fail 'directory destination changed after failed publication' +assert_no_temps + +# Force a write/fsync failure after the temporary has been opened. A prior +# destination must survive byte-for-byte and no partial archive may appear. +head -c 16384 /dev/urandom > "$tmp/large-input.bin" +printf 'previous archive sentinel\n' > "$tmp/write-failure.zupt" +cp "$tmp/write-failure.zupt" "$tmp/write-failure.expected" +if (trap '' XFSZ; ulimit -f 1; "$bin" compress -s \ + "$tmp/write-failure.zupt" "$tmp/large-input.bin" \ + >/dev/null 2>&1); then + fail 'forced write failure unexpectedly succeeded' +fi +cmp "$tmp/write-failure.expected" "$tmp/write-failure.zupt" || + fail 'prior archive changed after write failure' +assert_no_temps + +# Two publishers may race for the same directory entry. Each builds a private +# complete archive; whichever rename wins must leave a valid final archive. +"$bin" compress -s "$tmp/concurrent.zupt" "$tmp/input.txt" \ + >/dev/null 2>&1 & +first_pid=$! +"$bin" compress --solid -s "$tmp/concurrent.zupt" "$tmp/input.txt" \ + >/dev/null 2>&1 & +second_pid=$! +wait "$first_pid" || fail 'first concurrent publisher failed' +wait "$second_pid" || fail 'second concurrent publisher failed' +"$bin" test "$tmp/concurrent.zupt" >/dev/null 2>&1 || + fail 'concurrent final archive is invalid' +assert_no_temps + +# Disk-image backup uses the same atomic publisher. +printf 'disk image bytes\n' > "$tmp/disk.img" + +# A disk backup must never replace its only source name with the archive. The +# identity check covers direct spelling, hardlink aliases, and symlink aliases. +cp "$tmp/disk.img" "$tmp/disk-same.img" +cp "$tmp/disk-same.img" "$tmp/disk-same.expected" +if "$bin" disk backup -s "$tmp/disk-same.img" "$tmp/disk-same.img" \ + >/dev/null 2>&1; then + fail 'disk backup accepted the same source and output path' +fi +cmp "$tmp/disk-same.expected" "$tmp/disk-same.img" || + fail 'same-path disk backup changed its source' + +cp "$tmp/disk.img" "$tmp/disk-hardlink-source" +cp "$tmp/disk-hardlink-source" "$tmp/disk-hardlink.expected" +ln "$tmp/disk-hardlink-source" "$tmp/disk-hardlink-output.zupt" +if "$bin" disk backup -s "$tmp/disk-hardlink-output.zupt" \ + "$tmp/disk-hardlink-source" >/dev/null 2>&1; then + fail 'disk backup accepted a hardlink alias of its source' +fi +test "$tmp/disk-hardlink-source" -ef "$tmp/disk-hardlink-output.zupt" || + fail 'rejected disk hardlink alias was replaced' +cmp "$tmp/disk-hardlink.expected" "$tmp/disk-hardlink-source" || + fail 'hardlink-alias disk backup changed its source' + +cp "$tmp/disk.img" "$tmp/disk-symlink-source" +cp "$tmp/disk-symlink-source" "$tmp/disk-symlink.expected" +ln -s disk-symlink-source "$tmp/disk-symlink-output.zupt" +if "$bin" disk backup -s "$tmp/disk-symlink-output.zupt" \ + "$tmp/disk-symlink-source" >/dev/null 2>&1; then + fail 'disk backup accepted a symlink alias of its source' +fi +test -L "$tmp/disk-symlink-output.zupt" || + fail 'rejected disk symlink alias was replaced' +cmp "$tmp/disk-symlink.expected" "$tmp/disk-symlink-source" || + fail 'symlink-alias disk backup changed its source' +assert_no_temps + +printf 'disk victim\n' > "$tmp/disk-victim" +cp "$tmp/disk-victim" "$tmp/disk.expected" +ln -s disk-victim "$tmp/disk.zupt" +"$bin" disk backup -s "$tmp/disk.zupt" "$tmp/disk.img" >/dev/null 2>&1 +cmp "$tmp/disk.expected" "$tmp/disk-victim" || fail 'disk backup followed symlink' +test ! -L "$tmp/disk.zupt" || fail 'disk archive remained a symlink' +"$bin" disk restore "$tmp/disk.zupt" "$tmp/disk-restored.img" \ + >/dev/null 2>&1 || fail 'disk archive could not be restored' +cmp "$tmp/disk.img" "$tmp/disk-restored.img" || fail 'disk restore mismatch' +"$bin" test "$tmp/disk.zupt" >/dev/null 2>&1 || fail 'disk archive test failed' +"$bin" list "$tmp/disk.zupt" >/dev/null 2>&1 || fail 'disk archive list failed' +mkdir "$tmp/disk-extracted" +"$bin" extract -o "$tmp/disk-extracted" "$tmp/disk.zupt" \ + >/dev/null 2>&1 || fail 'absolute-source disk archive generic extraction failed' +cmp "$tmp/disk.img" "$tmp/disk-extracted/disk.img" || + fail 'absolute-source disk archive generic extraction mismatch' +assert_no_temps + +# Restore must fail closed if it cannot create its private source snapshot; +# it may not fall back to validating and consuming a mutable pathname. +printf 'not a directory\n' > "$tmp/not-a-snapshot-directory" +printf 'snapshot failure target\n' > "$tmp/snapshot-failure-target" +cp "$tmp/snapshot-failure-target" "$tmp/snapshot-failure.expected" +if ZUPT_TMPDIR="$tmp/not-a-snapshot-directory" \ + "$bin" disk restore "$tmp/disk.zupt" \ + "$tmp/snapshot-failure-target" >/dev/null 2>&1; then + fail 'disk restore continued without a private archive snapshot' +fi +cmp "$tmp/snapshot-failure.expected" "$tmp/snapshot-failure-target" || + fail 'snapshot creation failure changed the restore target' +assert_no_temps + +# Restore targets are destructive by nature. A final-component symlink must +# be rejected without following it or replacing it, and its external target +# must remain byte-for-byte unchanged. +printf 'external restore target\n' > "$tmp/restore-symlink-victim" +cp "$tmp/restore-symlink-victim" "$tmp/restore-symlink.expected" +ln -s restore-symlink-victim "$tmp/restore-symlink-target" +if "$bin" disk restore "$tmp/disk.zupt" "$tmp/restore-symlink-target" \ + >/dev/null 2>&1; then + fail 'disk restore accepted a symlink target' +fi +test -L "$tmp/restore-symlink-target" || + fail 'disk restore replaced the rejected symlink' +cmp "$tmp/restore-symlink.expected" "$tmp/restore-symlink-victim" || + fail 'disk restore changed the symlink target' + +# A regular target with st_nlink > 1 must also be rejected. Both directory +# entries must still name the original inode and retain its original bytes. +printf 'multiply linked restore target\n' > "$tmp/restore-hardlink-peer" +cp "$tmp/restore-hardlink-peer" "$tmp/restore-hardlink.expected" +ln "$tmp/restore-hardlink-peer" "$tmp/restore-hardlink-target" +if "$bin" disk restore "$tmp/disk.zupt" "$tmp/restore-hardlink-target" \ + >/dev/null 2>&1; then + fail 'disk restore accepted a multiply-linked target' +fi +test "$tmp/restore-hardlink-peer" -ef "$tmp/restore-hardlink-target" || + fail 'disk restore replaced the rejected hardlink entry' +cmp "$tmp/restore-hardlink.expected" "$tmp/restore-hardlink-peer" || + fail 'disk restore changed the hardlink peer' +cmp "$tmp/restore-hardlink.expected" "$tmp/restore-hardlink-target" || + fail 'disk restore changed the multiply-linked target' + +# A target that is another hardlink to the archive itself is rejected before +# opening either inode for writing. The archive must remain readable. +cp "$tmp/disk.zupt" "$tmp/same-inode.zupt" +ln "$tmp/same-inode.zupt" "$tmp/same-inode-target" +cp "$tmp/same-inode.zupt" "$tmp/same-inode.expected" +if "$bin" disk restore "$tmp/same-inode.zupt" "$tmp/same-inode-target" \ + >/dev/null 2>&1; then + fail 'disk restore accepted its own archive inode as the target' +fi +cmp "$tmp/same-inode.expected" "$tmp/same-inode.zupt" || + fail 'same-inode restore attempt changed the archive' +test "$tmp/same-inode.zupt" -ef "$tmp/same-inode-target" || + fail 'same-inode restore attempt replaced one hardlink' +"$bin" test "$tmp/same-inode.zupt" >/dev/null 2>&1 || + fail 'same-inode restore attempt corrupted the archive' + +# Removing the trailing archive-integrity field creates the structurally valid +# legacy framing used by the downgrade attack. Disk restore must reject it by +# default and leave a preexisting regular target untouched. +python3 "$surgery" strip-ait "$tmp/disk.zupt" \ + "$tmp/disk-without-ait.zupt" || fail 'could not remove disk archive AIT' +printf 'existing no-AIT restore target\n' > "$tmp/no-ait-restore-target" +cp "$tmp/no-ait-restore-target" "$tmp/no-ait-restore.expected" +if "$bin" disk restore "$tmp/disk-without-ait.zupt" \ + "$tmp/no-ait-restore-target" >/dev/null 2>&1; then + fail 'disk restore accepted a no-AIT archive by default' +fi +cmp "$tmp/no-ait-restore.expected" "$tmp/no-ait-restore-target" || + fail 'no-AIT disk archive changed the existing restore target' + +# Late DATA corruption must be discovered before publishing over an existing +# regular target. This specifically guards against open(O_TRUNC)-then-verify +# behavior and partial output left behind after a checksum/authentication +# failure. +python3 "$surgery" flip-payload "$tmp/disk.zupt" \ + "$tmp/corrupt-disk.zupt" --kind data || + fail 'could not construct corrupt disk archive' +printf 'existing regular restore target\n' > "$tmp/restore-existing" +cp "$tmp/restore-existing" "$tmp/restore-existing.expected" +if "$bin" disk restore "$tmp/corrupt-disk.zupt" "$tmp/restore-existing" \ + >/dev/null 2>&1; then + fail 'disk restore accepted a corrupt DATA block' +fi +cmp "$tmp/restore-existing.expected" "$tmp/restore-existing" || + fail 'corrupt archive changed the existing restore target' +assert_no_temps + +# Encrypted dedup references carry the original DATA frame AAD sequence and +# authenticate their own logical position; restore must reproduce the bytes. +dd if=/dev/urandom of="$tmp/repeated-block" bs=65536 count=1 2>/dev/null +cp "$tmp/repeated-block" "$tmp/dedup-disk.img" +dd if="$tmp/repeated-block" of="$tmp/dedup-disk.img" bs=65536 seek=1 \ + conv=notrunc 2>/dev/null +printf 'atomic-disk-test-password\n' > "$tmp/disk-password" +chmod 600 "$tmp/disk-password" +"$bin" disk backup --dedup -b 65536 --pass-file "$tmp/disk-password" -s \ + "$tmp/dedup-encrypted.zupt" "$tmp/dedup-disk.img" >/dev/null 2>&1 || + fail 'encrypted dedup disk backup failed' +"$bin" test --pass-file "$tmp/disk-password" "$tmp/dedup-encrypted.zupt" \ + >/dev/null 2>&1 || fail 'encrypted dedup disk archive test failed' +"$bin" disk restore --pass-file "$tmp/disk-password" \ + "$tmp/dedup-encrypted.zupt" "$tmp/dedup-restored.img" >/dev/null 2>&1 || + fail 'encrypted dedup disk restore failed' +cmp "$tmp/dedup-disk.img" "$tmp/dedup-restored.img" || + fail 'encrypted dedup disk restore mismatch' +assert_no_temps + +printf 'atomic archive output: PASS\n' diff --git a/tests/test_audit.sh b/tests/test_audit.sh index 7a4dd54..351c17c 100755 --- a/tests/test_audit.sh +++ b/tests/test_audit.sh @@ -1,20 +1,25 @@ #!/bin/bash # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés -# zupt audit test suite — double-validated security checks for zupt 2.2+ +# ZUPT audit test suite — double-validated security checks. # Each property is checked via TWO independent paths. -ZUPT_BIN="$(realpath ./zupt)" -# Source-only build (WITH_SDK=0) has no libzuptsdk: the SDK-mode paths this -# test exercises are unavailable, so skip cleanly instead of failing. -_sdkck="$(mktemp -d)" -if ! "$ZUPT_BIN" keygen --sdk -o "$_sdkck/p" >/dev/null 2>&1; then - rm -rf "$_sdkck"; echo " SKIP: built without libzuptsdk (source-only) - SDK-mode test not applicable"; exit 0 +set -Eeuo pipefail + +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P) +ZUPT_BIN=${ZUPT_BIN:-$repo_root/zupt} +if [[ ! -x $ZUPT_BIN ]]; then + printf ' FAIL: %s not found; build ZUPT first\n' "$ZUPT_BIN" >&2 + exit 1 +fi +version=$("$ZUPT_BIN" --version 2>&1) +if ! grep -Fq 'libvuptsdk=enabled' <<<"$version"; then + echo ' SKIP: system libvuptsdk integration is disabled (build with WITH_SDK=1)' + exit 0 fi -rm -rf "$_sdkck" TMPDIR=$(mktemp -d) -trap "rm -rf $TMPDIR" EXIT +trap 'rm -rf -- "$TMPDIR"' EXIT cd "$TMPDIR" PASS=0; FAIL=0 @@ -37,15 +42,23 @@ echo " [A. Authenticated archives]" # A1. Wrong key rejected: SDK key vs SDK archive (path A) + Legacy key vs SDK archive (path B) echo "data" > input.txt "$ZUPT_BIN" c --pq-sdk k.priv.pub a.zupt input.txt > /dev/null 2>&1 -mkdir -p ea && (cd ea && "$ZUPT_BIN" x --pq-sdk ../other.priv ../a.zupt > /dev/null 2>&1) -A=$([ ! -f ea/input.txt ] && echo 1 || echo 0) -mkdir -p eb && (cd eb && "$ZUPT_BIN" x --pq legacy.key ../a.zupt > /dev/null 2>&1) -B=$([ ! -f eb/input.txt ] && echo 1 || echo 0) +mkdir -p ea +set +e +(cd ea && "$ZUPT_BIN" x --pq-sdk ../other.priv ../a.zupt > /dev/null 2>&1) +A_RC=$? +set -e +A=$([ "$A_RC" -ne 0 ] && [ ! -f ea/input.txt ] && echo 1 || echo 0) +mkdir -p eb +set +e +(cd eb && "$ZUPT_BIN" x --pq legacy.key ../a.zupt > /dev/null 2>&1) +B_RC=$? +set -e +B=$([ "$B_RC" -ne 0 ] && [ ! -f eb/input.txt ] && echo 1 || echo 0) DCHK "Wrong key rejected (SDK key + legacy key paths)" "$A" "$B" # A2. Tamper at byte position N detected. # -# F-02 (Zupt 2.2.4): the previous version flipped a byte at len-50 for +# F-02 (ZUPT 2.2.4): the previous version flipped a byte at len-50 for # path B. SDK-PQ archive sizes vary by 1-2 bytes per run (ciphertext # encoding), so len-50 occasionally landed inside the *index* region # (between footer.index_offset and the trailing 32-byte footer), which @@ -69,16 +82,21 @@ python3 -c " b = bytearray(open('t2.zupt','rb').read()) b[500] ^= 1 open('t2.zupt','wb').write(bytes(b))" 2>/dev/null -mkdir -p t1e && (cd t1e && "$ZUPT_BIN" x --pq-sdk ../k.priv ../t1.zupt > /dev/null 2>&1) -mkdir -p t2e && (cd t2e && "$ZUPT_BIN" x --pq-sdk ../k.priv ../t2.zupt > /dev/null 2>&1) -A=$([ ! -f t1e/input.txt ] && echo 1 || echo 0) -B=$([ ! -f t2e/input.txt ] && echo 1 || echo 0) +mkdir -p t1e t2e +set +e +(cd t1e && "$ZUPT_BIN" x --pq-sdk ../k.priv ../t1.zupt > /dev/null 2>&1) +A_RC=$? +(cd t2e && "$ZUPT_BIN" x --pq-sdk ../k.priv ../t2.zupt > /dev/null 2>&1) +B_RC=$? +set -e +A=$([ "$A_RC" -ne 0 ] && [ ! -f t1e/input.txt ] && echo 1 || echo 0) +B=$([ "$B_RC" -ne 0 ] && [ ! -f t2e/input.txt ] && echo 1 || echo 0) DCHK "Tamper detected at body offset 200 and 500" "$A" "$B" echo " [B. Format security]" # B1. Zero-byte file (path A) + 1-byte file (path B): both must roundtrip -> empty.txt +: >empty.txt echo -n "x" > one.txt "$ZUPT_BIN" c --pq-sdk k.priv.pub e.zupt empty.txt > /dev/null 2>&1 "$ZUPT_BIN" c --pq-sdk k.priv.pub o.zupt one.txt > /dev/null 2>&1 @@ -101,24 +119,42 @@ DCHK "1MB roundtrip (random + structured)" "$A" "$B" # B3. Truncated archive rejected (path A: cut last 50 bytes) (path B: cut at midpoint) cp a.zupt tr1.zupt; cp a.zupt tr2.zupt -truncate -s -50 tr1.zupt -truncate -s 100 tr2.zupt +python3 - <<'PY' +from pathlib import Path + +first = Path("tr1.zupt") +first.write_bytes(first.read_bytes()[:-50]) +second = Path("tr2.zupt") +second.write_bytes(second.read_bytes()[:100]) +PY mkdir -p tr1e tr2e +set +e (cd tr1e && "$ZUPT_BIN" x --pq-sdk ../k.priv ../tr1.zupt > /dev/null 2>&1) +A_RC=$? (cd tr2e && "$ZUPT_BIN" x --pq-sdk ../k.priv ../tr2.zupt > /dev/null 2>&1) -A=$([ ! -f tr1e/input.txt ] && echo 1 || echo 0) -B=$([ ! -f tr2e/input.txt ] && echo 1 || echo 0) +B_RC=$? +set -e +A=$([ "$A_RC" -ne 0 ] && [ ! -f tr1e/input.txt ] && echo 1 || echo 0) +B=$([ "$B_RC" -ne 0 ] && [ ! -f tr2e/input.txt ] && echo 1 || echo 0) DCHK "Truncated archive rejected" "$A" "$B" echo " [C. Format compatibility]" # C1. Mode confusion: SDK archive cannot be read with --pq (legacy) -mkdir -p mc1 && (cd mc1 && "$ZUPT_BIN" x --pq ../legacy.key ../a.zupt > /dev/null 2>&1) -A=$([ ! -f mc1/input.txt ] && echo 1 || echo 0) +mkdir -p mc1 +set +e +(cd mc1 && "$ZUPT_BIN" x --pq ../legacy.key ../a.zupt > /dev/null 2>&1) +A_RC=$? +set -e +A=$([ "$A_RC" -ne 0 ] && [ ! -f mc1/input.txt ] && echo 1 || echo 0) # Also: legacy archive cannot be read with --pq-sdk "$ZUPT_BIN" c --pq legacy.key leg.zupt input.txt > /dev/null 2>&1 -mkdir -p mc2 && (cd mc2 && "$ZUPT_BIN" x --pq-sdk ../k.priv ../leg.zupt > /dev/null 2>&1) -B=$([ ! -f mc2/input.txt ] && echo 1 || echo 0) +mkdir -p mc2 +set +e +(cd mc2 && "$ZUPT_BIN" x --pq-sdk ../k.priv ../leg.zupt > /dev/null 2>&1) +B_RC=$? +set -e +B=$([ "$B_RC" -ne 0 ] && [ ! -f mc2/input.txt ] && echo 1 || echo 0) DCHK "Mode confusion prevented (SDK↔legacy)" "$A" "$B" # C2. Legacy archive readable with legacy key (compat baseline) @@ -132,17 +168,26 @@ DCHK "Both SDK and legacy paths roundtrip independently" "$A" "$B" echo " [D. Robustness]" # D1. Non-existent input handled +set +e "$ZUPT_BIN" c --pq-sdk k.priv.pub nx.zupt /nonexistent_file_12345 > /dev/null 2>&1 -A=$([ ! -f nx.zupt ] && echo 1 || echo 0) +A_RC=$? "$ZUPT_BIN" c --pq-sdk k.priv.pub nx2.zupt /dev/nonexistent > /dev/null 2>&1 -B=$([ ! -f nx2.zupt ] && echo 1 || echo 0) +B_RC=$? +set -e +A=$([ "$A_RC" -ne 0 ] && [ ! -f nx.zupt ] && echo 1 || echo 0) +B=$([ "$B_RC" -ne 0 ] && [ ! -f nx2.zupt ] && echo 1 || echo 0) DCHK "Missing input file rejected cleanly" "$A" "$B" # D2. Non-existent key handled -mkdir -p nk1 && (cd nk1 && "$ZUPT_BIN" x --pq-sdk /nonexistent.key ../a.zupt > /dev/null 2>&1) -A=$([ ! -f nk1/input.txt ] && echo 1 || echo 0) +mkdir -p nk1 +set +e +(cd nk1 && "$ZUPT_BIN" x --pq-sdk /nonexistent.key ../a.zupt > /dev/null 2>&1) +A_RC=$? "$ZUPT_BIN" c --pq-sdk /nonexistent.pub bbnk.zupt input.txt > /dev/null 2>&1 -B=$([ ! -s bbnk.zupt ] && echo 1 || echo 0) +B_RC=$? +set -e +A=$([ "$A_RC" -ne 0 ] && [ ! -f nk1/input.txt ] && echo 1 || echo 0) +B=$([ "$B_RC" -ne 0 ] && [ ! -s bbnk.zupt ] && echo 1 || echo 0) DCHK "Missing key file rejected cleanly" "$A" "$B" # D3. Multiple files in one archive @@ -158,4 +203,4 @@ echo echo " ───────────────────────────────────────" echo " Audit results: $PASS passed, $FAIL failed" echo " ───────────────────────────────────────" -[ $FAIL -eq 0 ] +((FAIL == 0)) diff --git a/tests/test_audit_flake.sh b/tests/test_audit_flake.sh index f6ce7ec..b48724a 100755 --- a/tests/test_audit_flake.sh +++ b/tests/test_audit_flake.sh @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés # -# Flake-stress harness — §3 of PROMPT.md. +# Repeated-suite flake-stress harness. # # Runs every short test suite N times (default 50) and aborts on the # first non-deterministic outcome. Specifically targeted at the audit @@ -21,7 +21,7 @@ set -u # F-02's repro needed 50 runs to be statistically convincing (~10% # baseline flake rate), but at 20 runs we still have ~88% chance of # catching a 10%-flake — fine for routine CI. For a hardened audit -# pass, invoke with 50 or 100 (see PROMPT.md §3). +# pass, invoke with 50 or 100 for a deeper audit run. N="${1:-20}" ZUPT_BIN="${ZUPT_BIN:-./zupt}" diff --git a/tests/test_authenticated_dedup_reorder.sh b/tests/test_authenticated_dedup_reorder.sh new file mode 100644 index 0000000..909caf0 --- /dev/null +++ b/tests/test_authenticated_dedup_reorder.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +set -Eeuo pipefail + +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P) +bin=${1:-$repo_root/zupt} +case "$bin" in + /*) ;; + *) bin="$(pwd -P)/${bin#./}" ;; +esac +surgery="$repo_root/tests/archive_surgery.py" +tmp=$(mktemp -d "${TMPDIR:-/tmp}/zupt-dedup-auth.XXXXXX") +trap 'rm -rf "$tmp"' EXIT + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +expect_rejected() { + archive=$1 + description=$2 + stderr="$tmp/rejected.stderr" + if "$bin" test --pass-file "$tmp/password" "$archive" \ + >/dev/null 2>"$stderr"; then + fail "$description was accepted" + fi + grep -F 'Authentication failed' "$stderr" >/dev/null || + fail "$description was rejected for a reason other than authentication" +} + +test -x "$bin" || fail "$bin is not executable" +command -v python3 >/dev/null 2>&1 || fail 'python3 is required' + +printf 'authenticated-dedup-test-password\n' > "$tmp/password" +chmod 600 "$tmp/password" + +# Equal-size, distinct blocks exercise DATA frame position binding while the +# archive is in dedup mode. Whole-frame swaps and replays preserve each +# frame's internal HMAC, so only its logical-position AAD can reject them +# before extraction trusts the data. +dd if=/dev/urandom of="$tmp/data-a" bs=65536 count=1 2>/dev/null +dd if=/dev/urandom of="$tmp/data-b" bs=65536 count=1 2>/dev/null +cp "$tmp/data-a" "$tmp/two-data-blocks.bin" +dd if="$tmp/data-b" of="$tmp/two-data-blocks.bin" bs=65536 seek=1 \ + conv=notrunc 2>/dev/null + +"$bin" compress --dedup --store --block 65536 --threads 1 --kdf pbkdf2 \ + --pass-file "$tmp/password" "$tmp/data.zupt" \ + "$tmp/two-data-blocks.bin" >/dev/null 2>&1 || + fail 'could not create encrypted dedup DATA fixture' +"$bin" test --pass-file "$tmp/password" "$tmp/data.zupt" \ + >/dev/null 2>&1 || fail 'clean encrypted dedup DATA fixture is invalid' + +python3 "$surgery" swap-frames "$tmp/data.zupt" \ + "$tmp/data-swapped.zupt" --kind data --require-encrypted || + fail 'could not construct DATA swap mutation' +expect_rejected "$tmp/data-swapped.zupt" 'encrypted dedup DATA swap' + +python3 "$surgery" replay-frame "$tmp/data.zupt" \ + "$tmp/data-replayed.zupt" --kind data --require-encrypted || + fail 'could not construct DATA replay mutation' +expect_rejected "$tmp/data-replayed.zupt" 'encrypted dedup DATA replay' + +# Three duplicate blocks produce one DATA frame followed by at least two REF +# frames with the same logical content and metadata. Swapping or replaying +# those REF frames does not alter reconstructed bytes, so content hashes +# cannot mask a missing REF-position binding. +dd if=/dev/urandom of="$tmp/repeated-block" bs=65536 count=1 2>/dev/null +cp "$tmp/repeated-block" "$tmp/repeated.bin" +dd if="$tmp/repeated-block" of="$tmp/repeated.bin" bs=65536 seek=1 \ + conv=notrunc 2>/dev/null +dd if="$tmp/repeated-block" of="$tmp/repeated.bin" bs=65536 seek=2 \ + conv=notrunc 2>/dev/null + +"$bin" compress --dedup --store --block 65536 --threads 1 --kdf pbkdf2 \ + --pass-file "$tmp/password" "$tmp/ref.zupt" "$tmp/repeated.bin" \ + >/dev/null 2>&1 || fail 'could not create encrypted dedup REF fixture' +"$bin" test --pass-file "$tmp/password" "$tmp/ref.zupt" \ + >/dev/null 2>&1 || fail 'clean encrypted dedup REF fixture is invalid' + +python3 "$surgery" swap-frames "$tmp/ref.zupt" \ + "$tmp/ref-swapped.zupt" --kind ref --require-encrypted \ + --same-metadata || fail 'could not construct same-content REF swap' +expect_rejected "$tmp/ref-swapped.zupt" 'encrypted dedup REF swap' + +python3 "$surgery" replay-frame "$tmp/ref.zupt" \ + "$tmp/ref-replayed.zupt" --kind ref --require-encrypted \ + --same-metadata || fail 'could not construct same-content REF replay' +expect_rejected "$tmp/ref-replayed.zupt" 'encrypted dedup REF replay' + +printf 'authenticated dedup reorder/replay: PASS\n' diff --git a/tests/test_benchmark_temp_safety.sh b/tests/test_benchmark_temp_safety.sh new file mode 100755 index 0000000..954b5fb --- /dev/null +++ b/tests/test_benchmark_temp_safety.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +set -Eeuo pipefail + +bin=${1:-./zupt} +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P) +case $bin in + /*) ;; + *) bin="$(pwd -P)/${bin#./}" ;; +esac +tmp=$(mktemp -d "${TMPDIR:-/tmp}/zupt-bench-safety.XXXXXXXX") +trap 'rm -rf -- "$tmp"' EXIT HUP INT TERM + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +# CodeQL #7 reported the old lstat(child) -> recursive pathname operation as +# cpp/toctou-race-condition. Keep the platform-specific cleanup primitives in +# the source gate as well as exercising the runtime symlink boundary below. +cleanup_source=$repo_root/src/zupt_main.c +grep -Fq 'static int zupt_remove_temp_tree_fd(int directory_fd)' \ + "$cleanup_source" || fail 'POSIX descriptor-relative cleanup is missing' +grep -Fq 'unlinkat(parent_fd, entry->d_name, 0)' "$cleanup_source" || + fail 'POSIX leaf cleanup is not unlinkat-relative' +grep -Fq 'directory_handle, data.cFileName, 1, 0)' "$cleanup_source" || + fail 'Windows recursive cleanup is not handle-relative' +grep -Fq 'FILE_OPEN_REPARSE_POINT' "$cleanup_source" || + fail 'Windows cleanup no longer opens reparse points without following' +grep -Fq 'zupt_win_delete_cleanup_entry(' "$cleanup_source" || + fail 'Windows cleanup lacks identity-checked handle deletion' +grep -Fq 'current.nFileIndexLow == expected->nFileIndexLow' "$cleanup_source" || + fail 'Windows cleanup no longer rejects a close/reopen name exchange' +if grep -Fq 'RemoveDirectoryW(full)' "$cleanup_source"; then + fail 'Windows root cleanup restored post-handle pathname deletion' +fi +if grep -Fq 'lstat(child' "$cleanup_source" || + grep -Fq 'zupt_remove_temp_tree(child' "$cleanup_source"; then + fail 'temporary cleanup restored a check-then-use pathname traversal' +fi + +case $(uname -s 2>/dev/null || printf unknown) in + MINGW*|MSYS*|CYGWIN*) + "$bin" bench --compare >/dev/null 2>&1 || + fail 'native Windows handle-relative benchmark cleanup failed' + printf 'SKIP: adversarial POSIX symlink injection is not native on Windows\n' + printf 'private Windows handle-relative benchmark workspace: PASS\n' + exit 0 + ;; +esac + +printf 'benchmark sentinel must remain unchanged\n' > "$tmp/sentinel" +cp "$tmp/sentinel" "$tmp/sentinel.expected" + +# The historical implementation derived this public directory from its PID +# and followed a precreated text.txt symlink. A fresh Bash process has `$$` +# equal to the PID retained by exec, including on macOS Bash 3.2, so the test +# recreates that exact attack without guessing another process. +bash -c ' + set -e + old_directory="/tmp/zupt_bench_corpus_$$" + printf "%s\n" "$old_directory" > "$2/old-directory" + mkdir "$old_directory" + ln -s "$2/sentinel" "$old_directory/text.txt" + test -L "$old_directory/text.txt" + exec "$1" bench --compare >/dev/null 2>&1 +' zupt-benchmark-test "$bin" "$tmp" || fail 'benchmark comparison failed' + +cmp "$tmp/sentinel.expected" "$tmp/sentinel" || + fail 'benchmark followed the historical predictable temporary symlink' +old_directory=$(sed -n '1p' "$tmp/old-directory") +case $old_directory in + /tmp/zupt_bench_corpus_[0-9]*) ;; + *) fail 'unexpected historical temporary path' ;; +esac +if [[ -d $old_directory ]]; then + mv "$old_directory" "$tmp/historical-remnant" +fi + +# Inject a directory symlink into the private workspace while a real benchmark +# is active. Cleanup must remove the link itself and never visit its target. +mkdir "$tmp/symlink-target" +printf 'cleanup sentinel must survive\n' > "$tmp/symlink-target/sentinel" +cp "$tmp/symlink-target/sentinel" "$tmp/symlink-target.expected" +dd if=/dev/urandom of="$tmp/injection-input" bs=65536 count=128 2>/dev/null + +physical_tmp=$(CDPATH='' cd -P -- /tmp && pwd -P) +: > "$tmp/preexisting-workspaces" +for candidate in "$physical_tmp"/zupt-bench-*; do + if [[ -d $candidate && ! -L $candidate ]]; then + printf '%s\n' "$candidate" >> "$tmp/preexisting-workspaces" + fi +done + +(cd "$tmp" && "$bin" bench injection-input >/dev/null 2>&1) & +bench_pid=$! +injected=0 +injected_workspace= +attempt=0 +while (( attempt < 1000 )); do + for candidate in "$physical_tmp"/zupt-bench-*; do + [[ -d $candidate && ! -L $candidate ]] || continue + if grep -Fqx -- "$candidate" "$tmp/preexisting-workspaces"; then + continue + fi + if ln -s "$tmp/symlink-target" "$candidate/attacker-link" \ + 2>/dev/null; then + injected=1 + injected_workspace=$candidate + break + fi + done + (( injected == 1 )) && break + kill -0 "$bench_pid" 2>/dev/null || break + sleep 0.01 + attempt=$((attempt + 1)) +done +wait "$bench_pid" || fail 'benchmark with injected symlink failed' +(( injected == 1 )) || fail 'could not observe the private benchmark workspace' +if [[ -e $injected_workspace || -L $injected_workspace ]]; then + fail 'injected workspace was not the benchmark tree that was removed' +fi +cmp "$tmp/symlink-target.expected" "$tmp/symlink-target/sentinel" || + fail 'temporary cleanup followed an injected directory symlink' + +printf 'private descriptor/handle-relative benchmark workspace: PASS\n' diff --git a/tests/test_block_swap.sh b/tests/test_block_swap.sh index acc61c7..f01a6e1 100755 --- a/tests/test_block_swap.sh +++ b/tests/test_block_swap.sh @@ -23,7 +23,11 @@ # 3. Verifies extract REJECTS the swapped archive (auth failure) # 4. Also verifies normal extract still works (regression guard) -ZUPT_BIN="$(realpath ./zupt)" +ZUPT_BIN=${1:-./zupt} +case $ZUPT_BIN in + /*) ;; + *) ZUPT_BIN=$PWD/${ZUPT_BIN#./} ;; +esac TMPDIR=$(mktemp -d) trap "rm -rf $TMPDIR" EXIT cd "$TMPDIR" @@ -135,7 +139,8 @@ if [ $swap_status -eq 0 ]; then fi chk "Block-swap attack rejected (cross-file reorder)" else - echo " ⊘ Block-swap attack test skipped (couldn't locate block boundaries)" + false + chk "Block-swap attack rejected (test archive could not be constructed)" fi # P3: Single-block file (boundary case — empty seq_AAD doesn't degenerate) diff --git a/tests/test_block_type_confusion.sh b/tests/test_block_type_confusion.sh new file mode 100755 index 0000000..aa91622 --- /dev/null +++ b/tests/test_block_type_confusion.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +set -Eeuo pipefail + +bin=${1:-./zupt} +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P) +surgery="$repo_root/tests/archive_surgery.py" +tmp=$(mktemp -d "${TMPDIR:-/tmp}/zupt-block-type.XXXXXXXX") +trap 'rm -rf -- "$tmp"' EXIT HUP INT TERM + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +command -v python3 >/dev/null 2>&1 || fail 'python3 is required' +dd if=/dev/zero bs=65536 count=3 2>/dev/null | tr '\000' 'T' > "$tmp/input.bin" +"$bin" compress -s -b 65536 -t 2 "$tmp/original.zupt" "$tmp/input.bin" \ + >/dev/null 2>&1 || fail 'could not create block-type fixture' +python3 "$surgery" set-frame-type "$tmp/original.zupt" \ + "$tmp/comment-frame.zupt" --kind data --type comment || + fail 'could not change DATA frame type' + +if "$bin" test "$tmp/comment-frame.zupt" >/dev/null 2>&1; then + fail 'archive test accepted COMMENT in a DATA range' +fi +for threads in 1 2; do + mkdir "$tmp/out-$threads" + if "$bin" extract -t "$threads" -o "$tmp/out-$threads" \ + "$tmp/comment-frame.zupt" >/dev/null 2>&1; then + fail "${threads}-thread extraction accepted COMMENT in a DATA range" + fi + if find "$tmp/out-$threads" -type f -print -quit | grep -q .; then + fail "${threads}-thread extraction published output after type rejection" + fi +done + +printf 'archive DATA-frame type enforcement: PASS\n' diff --git a/tests/test_codec_exact_size.c b/tests/test_codec_exact_size.c index dcb479b..d4a1fde 100644 --- a/tests/test_codec_exact_size.c +++ b/tests/test_codec_exact_size.c @@ -22,6 +22,7 @@ */ #include "vaptvupt.h" #include "vaptvupt_api.h" +#include "vv_bcj.h" #include #include #include @@ -72,11 +73,72 @@ static void fill_elfish(uint8_t *p, size_t n) { } } +static uint32_t bcj_prng_state = 0x7a5b3c1du; + +static uint32_t bcj_prng(void) { + uint32_t x = bcj_prng_state; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + bcj_prng_state = x; + return x; +} + +static int test_bcj_bijections(void) { + uint8_t original[4097]; + uint8_t transformed[4097]; + + for (unsigned iteration = 0; iteration < 1024; iteration++) { + size_t n = (size_t)(bcj_prng() % sizeof(original)); + uint32_t ip = bcj_prng(); + for (size_t i = 0; i < n; i++) + original[i] = (uint8_t)bcj_prng(); + + /* Force dense branch-like operands in half the corpus so both filters + * exercise their rewrite paths rather than only scanning random data. */ + if ((iteration & 1u) != 0) { + for (size_t i = 0; i < n; i++) { + static const uint8_t pattern[] = { + 0xe8, 0x00, 0x00, 0x00, 0x00, + 0xe9, 0xff, 0xff, 0xff, 0xff, + 0x00, 0x00, 0x00, 0x94 + }; + original[i] = pattern[i % sizeof(pattern)]; + } + } + + memcpy(transformed, original, n); + (void)vv_bcj_x86(transformed, n, ip, 1); + (void)vv_bcj_x86(transformed, n, ip, 0); + if (memcmp(transformed, original, n) != 0) { + fprintf(stderr, " x86 BCJ bijection failed: iteration=%u size=%zu\n", + iteration, n); + return 1; + } + + memcpy(transformed, original, n); + (void)vv_bcj_arm64(transformed, n, ip, 1); + (void)vv_bcj_arm64(transformed, n, ip, 0); + if (memcmp(transformed, original, n) != 0) { + fprintf(stderr, " AArch64 BCJ bijection failed: iteration=%u size=%zu\n", + iteration, n); + return 1; + } + } + printf(" BCJ bijections: 1024 deterministic randomized/adversarial cases passed\n"); + return 0; +} + int main(void) { printf("Codec exact-content_size decode (OOB regression, codec 2.60.4)\n"); srand(424242); int fail = 0, pass = 0; + if (test_bcj_bijections() != 0) + fail++; + else + pass++; + /* Tail coverage: n mod 32 in {1, 7, 31, 32 (0), >32 leftovers} at * block-ish sizes, plus tiny buffers. */ static const size_t sizes[] = { diff --git a/tests/test_codec_exact_size.sh b/tests/test_codec_exact_size.sh index 0958552..a7c3bf7 100755 --- a/tests/test_codec_exact_size.sh +++ b/tests/test_codec_exact_size.sh @@ -11,7 +11,11 @@ set -u ARCH=$(uname -m) SIMD="" -[ "$ARCH" = "x86_64" ] && SIMD="-mavx2" +if [[ $ARCH == x86_64 ]] && grep -qiw avx2 /proc/cpuinfo 2>/dev/null; then + SIMD="-mavx2" +else + echo " SKIP: AVX2-specific subpath unavailable; scalar exact-size test remains enabled" +fi TMP=$(mktemp -d) rc=0 @@ -31,12 +35,12 @@ fi # Tool-level BCJ roundtrip: real binary fixture at L5 (BALANCED+auto-filter) # and L9 (EXTREME+auto-filter); byte-exact extraction required. Guards the # F-16 defect class (old in-tree BCJ wrote undecodable streams). -FX=/tmp/bench/fixtures/binary.dat -if [ -f "$FX" ] && [ -x ./vaptvupt ]; then +FX=${ZUPT_BIN:-./zupt} +if [ -f "$FX" ] && [ -x ./zupt ]; then for L in 5 9; do rm -rf "$TMP/o$L"; mkdir -p "$TMP/o$L" - ./vaptvupt c -l $L "$TMP/a$L.zupt" "$FX" >/dev/null 2>&1 - ./vaptvupt x -o "$TMP/o$L" "$TMP/a$L.zupt" >/dev/null 2>&1 + ./zupt c -l $L "$TMP/a$L.zupt" "$FX" >/dev/null 2>&1 + ./zupt x -o "$TMP/o$L" "$TMP/a$L.zupt" >/dev/null 2>&1 F=$(find "$TMP/o$L" -type f | head -1) if [ -n "$F" ] && diff -q "$F" "$FX" >/dev/null 2>&1; then echo " ✓ BCJ roundtrip L$L (binary fixture) byte-exact" @@ -45,7 +49,7 @@ if [ -f "$FX" ] && [ -x ./vaptvupt ]; then fi done else - echo " - BCJ tool roundtrip skipped (fixture or binary missing)" + echo " - BCJ tool roundtrip skipped (source-built executable missing)" fi rm -rf "$TMP" diff --git a/tests/test_completions_manpage.sh b/tests/test_completions_manpage.sh index 0e4a5e1..2880bbe 100755 --- a/tests/test_completions_manpage.sh +++ b/tests/test_completions_manpage.sh @@ -1,224 +1,207 @@ -#!/bin/bash +#!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés -# -# Sprint 2.4.7 regression: shell completions + manpage. -# -# Asserts: -# - completions/vaptvupt.bash has bash-clean syntax -# - completions/_vaptvupt has zsh-clean syntax (if zsh available) -# - completions/vaptvupt.fish has fish-clean syntax (if fish available) -# - Each completion file mentions all the major CLI flags the binary -# actually parses (--kdf, --comment, --pq-sdk, --dedup, ...) -# - doc/zupt.1 mentions current v2.4.x features (--kdf, --comment, -# Argon2id, F-11, comment-file) -# - doc/zupt.1 has the standard sections (NAME, SYNOPSIS, DESCRIPTION, -# COMMANDS, EXAMPLES) -set -u - -PASS=0 -FAIL=0 -P() { PASS=$((PASS+1)); echo " ✓ $1"; } -F() { FAIL=$((FAIL+1)); echo " ✗ $1"; } -SKIP() { echo " - skipped: $1"; } +set -Eeuo pipefail cd "$(dirname "$0")/.." -VERSION=$(grep '^#define ZUPT_VERSION_STRING' include/zupt.h | awk -F'"' '{print $2}') -echo "Completions + manpage (vaptvupt $VERSION)" +pass_count=0 +fail_count=0 +skip_count=0 +pass() { pass_count=$((pass_count + 1)); printf ' PASS: %s\n' "$1"; } +fail() { fail_count=$((fail_count + 1)); printf ' FAIL: %s\n' "$1"; } +skip() { skip_count=$((skip_count + 1)); printf ' SKIP: %s\n' "$1"; } -# ─── Bash completion ─── -if [ -f completions/vaptvupt.bash ]; then - if bash -n completions/vaptvupt.bash 2>/dev/null; then - P "bash completion: syntax clean" - else - F "bash completion: syntax error" - fi - # Should define a _zupt function and register it via complete -F - if grep -q "^_vaptvupt()" completions/vaptvupt.bash; then - P "bash completion: defines _vaptvupt function" - else - F "bash completion: missing _vaptvupt function" - fi - if grep -qE "^complete -F _vaptvupt (vaptvupt|zupt)" completions/vaptvupt.bash; then - P "bash completion: registers via complete -F" - else - F "bash completion: missing complete -F registration" - fi +version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) +[[ -n $version ]] || { printf 'FAIL: cannot determine version\n' >&2; exit 1; } +printf 'ZUPT %s completion and manual-page checks\n' "$version" + +completion_files=( + completions/zupt.bash + completions/_zupt + completions/zupt.fish +) + +for file in "${completion_files[@]}"; do + [[ -f $file ]] && pass "$file exists" || fail "$file is missing" +done + +if bash -n completions/zupt.bash; then + pass 'bash completion parses' else - F "completions/vaptvupt.bash missing" + fail 'bash completion has a syntax error' fi -# ─── Zsh completion ─── -if [ -f completions/_vaptvupt ]; then - if command -v zsh >/dev/null 2>&1; then - if zsh -n completions/_vaptvupt 2>/dev/null; then - P "zsh completion: syntax clean" - else - F "zsh completion: syntax error" - fi +if command -v zsh >/dev/null 2>&1; then + if zsh -n completions/_zupt; then + pass 'zsh completion parses' else - SKIP "zsh not installed — skipping syntax check" - fi - # Should have #compdef directive - if grep -qE "^#compdef vaptvupt( zupt)?$" completions/_vaptvupt; then - P "zsh completion: has #compdef vaptvupt directive" - else - F "zsh completion: missing #compdef directive" + fail 'zsh completion has a syntax error' fi else - F "completions/_vaptvupt missing" + skip 'zsh is unavailable' fi -# ─── Fish completion ─── -if [ -f completions/vaptvupt.fish ]; then - if command -v fish >/dev/null 2>&1; then - if fish -n completions/vaptvupt.fish 2>/dev/null; then - P "fish completion: syntax clean" - else - F "fish completion: syntax error" - fi +if command -v fish >/dev/null 2>&1; then + if fish -n completions/zupt.fish; then + pass 'fish completion parses' else - SKIP "fish not installed — skipping syntax check" - fi - # Should have complete -c zupt entries - if grep -qE "^complete -c (vaptvupt|zupt)" completions/vaptvupt.fish; then - P "fish completion: has complete -c vaptvupt entries" - else - F "fish completion: no complete -c vaptvupt entries" + fail 'fish completion has a syntax error' fi else - F "completions/vaptvupt.fish missing" + skip 'fish is unavailable' fi -# ─── Flag-coverage check (across all three completion files) ─── -# Every flag the binary actually parses should appear in every completion file. -# Each completion format has its own way of writing long options: -# bash: --flag -# zsh: --flag -# fish: -l flag (or --flag in comments) -critical_flags=(kdf comment comment-file pq pq-sdk dedup solid verbose quiet threads level block store fast lzhp vaptvupt) +if grep -qxF 'complete -F _zupt zupt' completions/zupt.bash && + ! grep -Eq '^complete[[:space:]].*[[:space:]]vaptvupt([[:space:]]|$)' completions/zupt.bash; then + pass 'bash registers only zupt' +else + fail 'bash completion is not limited to the primary zupt command' +fi -for f in completions/vaptvupt.bash completions/_vaptvupt; do - [ -f "$f" ] || continue - name=$(basename "$f") - missing="" - for flag in "${critical_flags[@]}"; do - if ! grep -qF -- "--$flag" "$f"; then - missing="$missing --$flag" +if [[ $(sed -n '1p' completions/_zupt) == '#compdef zupt' ]]; then + pass 'zsh registers only zupt' +else + fail 'zsh #compdef is not limited to zupt' +fi + +if grep -q '^complete -c zupt' completions/zupt.fish && + ! grep -q '^complete -c vaptvupt\([[:space:]]\|$\)' completions/zupt.fish; then + pass 'fish registers only zupt' +else + fail 'fish completion is not limited to the primary zupt command' +fi + +required_flags=( + password-prompt pass-file pass-fd allow-legacy-no-ait kdf comment comment-file + pq pq-only pq-sdk pq-box dedup solid force verbose threads + level block store fast lzhp vaptvupt compare output key pub + sdk box pqonly help version +) + +for file in "${completion_files[@]}"; do + missing=() + for flag in "${required_flags[@]}"; do + if ! grep -qF -- "--$flag" "$file" && + ! grep -qE -- "-l[[:space:]]+$flag([[:space:]]|$)" "$file"; then + missing+=("--$flag") fi done - if [ -z "$missing" ]; then - P "$name: covers all ${#critical_flags[@]} critical flags" + if ((${#missing[@]} == 0)); then + pass "$file covers current critical flags" else - F "$name: missing flags:$missing" + fail "$file is missing: ${missing[*]}" fi done -if [ -f completions/vaptvupt.fish ]; then - name="vaptvupt.fish" - missing="" - for flag in "${critical_flags[@]}"; do - # fish uses `-l flag-name` for long opts - if ! grep -qE -- "(-l $flag|--$flag)" completions/vaptvupt.fish; then - missing="$missing $flag" +unsupported_flags=(quiet jobs codec keyfile sync no-mtime strip-components block-size) +for file in "${completion_files[@]}"; do + advertised=() + for flag in "${unsupported_flags[@]}"; do + if grep -qF -- "--$flag" "$file" || + grep -qE -- "-l[[:space:]]+$flag([[:space:]]|$)" "$file"; then + advertised+=("--$flag") fi done - if [ -z "$missing" ]; then - P "$name: covers all ${#critical_flags[@]} critical flags (via -l form)" + if ((${#advertised[@]} == 0)); then + pass "$file does not advertise unsupported flags" else - F "$name: missing flags:$missing" + fail "$file advertises unsupported flags: ${advertised[*]}" fi -fi +done -# ─── Manpage refresh ─── -if [ -f doc/zupt.1 ]; then - # v2.4.x features must be mentioned. Use shell-friendly regexes that - # match groff's `\-\-` escape (literal backslash, dash, backslash, dash). - declare -a manpage_checks=( - "kdf:--kdf option" - "comment:--comment option" - "argon2id:Argon2id KDF" - "Argon2id:Argon2id KDF (capital)" - "verbal probe-oracle:F-11 message change" - "ML-KEM-768:post-quantum KEM" - ) - manpage_misses=0 - for entry in "${manpage_checks[@]}"; do - key="${entry%%:*}" - desc="${entry#*:}" - if grep -qF "$key" doc/zupt.1; then - : - else - F "manpage: doesn't mention '$desc' (looking for '$key')" - manpage_misses=$((manpage_misses+1)) - fi - done - # Two additional checks for groff-escaped hyphens (--comment-file, --pq-sdk - # render as `\-\-comment\-file` and `\-\-pq\-sdk` in the source) - if grep -qE "comment\\\\-file|comment-file" doc/zupt.1; then - : - else - F "manpage: doesn't mention --comment-file (looking for comment\\-file or comment-file)" - manpage_misses=$((manpage_misses+1)) - fi - if grep -qE "pq\\\\-sdk|pq-sdk" doc/zupt.1; then - : - else - F "manpage: doesn't mention --pq-sdk (looking for pq\\-sdk or pq-sdk)" - manpage_misses=$((manpage_misses+1)) - fi - if [ "$manpage_misses" = 0 ]; then - P "manpage: mentions all v2.4.x features" - fi - - # Required sections - for section in NAME SYNOPSIS DESCRIPTION COMMANDS EXAMPLES; do - if grep -qE "^\.SH $section" doc/zupt.1; then - : - else - F "manpage: missing section '.SH $section'" - fi - done - P "manpage: required sections present" - - # Version header - if grep -qE "\"(vaptvupt|zupt) $VERSION\"" doc/zupt.1; then - P "manpage: TH version matches include/zupt.h ($VERSION)" - else - F "manpage: TH version doesn't match include/zupt.h" - fi - - # Try to render with groff if available - if command -v groff >/dev/null 2>&1; then - if groff -mandoc -Tutf8 doc/zupt.1 > /tmp/render.txt 2>/tmp/groff_warn.txt; then - LINES=$(wc -l < /tmp/render.txt) - if [ "$LINES" -gt 50 ]; then - P "manpage: renders cleanly with groff ($LINES lines)" - else - F "manpage: groff produced suspiciously short output ($LINES lines)" - fi - else - F "manpage: groff rendering failed" - fi - rm -f /tmp/render.txt /tmp/groff_warn.txt - elif command -v mandoc >/dev/null 2>&1; then - if mandoc -Tlint doc/zupt.1 >/tmp/mandoc.out 2>&1; then - P "manpage: mandoc lint clean" - else - P "manpage: mandoc lint had warnings (acceptable)" - fi - rm -f /tmp/mandoc.out - else - SKIP "no groff or mandoc — skipping render lint" - fi +if [[ ! -e doc/vaptvupt.1 && ! -L doc/vaptvupt.1 ]]; then + pass 'former primary man page is absent from the source tree' else - F "doc/zupt.1 missing" + fail 'doc/vaptvupt.1 remains despite the zupt-only default installation' fi -echo "" -echo " ───────────────────────────────────────" -echo " completions + manpage: $PASS passed, $FAIL failed" -echo " ───────────────────────────────────────" -[ "$FAIL" = 0 ] || exit 1 +manpage=doc/zupt.1 +if [[ ! -f $manpage ]]; then + fail "$manpage is missing" +else + required_sections=(NAME SYNOPSIS DESCRIPTION COMMANDS PASSWORD\ INPUT EXAMPLES EXIT\ STATUS LICENSE) + missing_sections=() + for section in "${required_sections[@]}"; do + grep -qxF ".SH $section" "$manpage" || missing_sections+=("$section") + done + if ((${#missing_sections[@]} == 0)); then + pass 'manpage contains required sections' + else + fail "manpage is missing sections: ${missing_sections[*]}" + fi + + if grep -qF "ZUPT $version" "$manpage"; then + pass 'manpage version matches include/zupt.h' + else + fail 'manpage version does not match include/zupt.h' + fi + + required_man_flags=( + password-prompt pass-file pass-fd allow-legacy-no-ait kdf comment comment-file + pq pq-only pq-sdk pq-box dedup solid force verbose threads + level block store fast lzhp vaptvupt compare output key pub + sdk box pqonly help version + ) + missing=() + for flag in "${required_man_flags[@]}"; do + grep -qF -- "--$flag" "$manpage" || missing+=("--$flag") + done + if ((${#missing[@]} == 0)); then + pass 'manpage documents current critical flags' + else + fail "manpage is missing: ${missing[*]}" + fi + + advertised=() + for flag in "${unsupported_flags[@]}"; do + grep -qF -- "--$flag" "$manpage" && advertised+=("--$flag") + done + if ((${#advertised[@]} == 0)); then + pass 'manpage does not document unsupported flags' + else + fail "manpage documents unsupported flags: ${advertised[*]}" + fi + + if grep -qF 'Plain archives provide compression checksums' "$manpage" && + grep -qF 'does not restore ownership' "$manpage" && + grep -qF 'Automatic codec selection' "$manpage"; then + pass 'manpage states current integrity, metadata, and codec behavior' + else + fail 'manpage is missing current behavioral limits' + fi + + if grep -q '^\.B 2$\|^\.B 3$\|^\.B 4$\|^\.B 5$' "$manpage"; then + fail 'manpage advertises exit statuses not emitted by the CLI' + else + pass 'manpage documents only emitted exit statuses' + fi + + lint_tmp=$(mktemp -d "${TMPDIR:-/tmp}/zupt-man-lint.XXXXXXXX") + trap 'rm -rf -- "$lint_tmp"' EXIT HUP INT TERM + if command -v mandoc >/dev/null 2>&1; then + if mandoc -Tlint "$manpage" >"$lint_tmp/mandoc.log" 2>&1; then + pass 'mandoc lint passes' + else + fail 'mandoc lint reports diagnostics' + sed -n '1,10p' "$lint_tmp/mandoc.log" + fi + elif command -v groff >/dev/null 2>&1; then + if groff -mandoc -Tutf8 "$manpage" >"$lint_tmp/rendered" 2>"$lint_tmp/groff.log" && + [[ ! -s $lint_tmp/groff.log ]] && + (($(wc -l <"$lint_tmp/rendered") > 50)); then + pass 'groff renders the manpage without diagnostics' + else + fail 'groff manpage rendering failed or emitted diagnostics' + sed -n '1,10p' "$lint_tmp/groff.log" + fi + else + skip 'mandoc and groff are unavailable' + fi + rm -rf -- "$lint_tmp" + trap - EXIT HUP INT TERM +fi + +printf '\nSummary: PASS=%d FAIL=%d SKIP=%d\n' "$pass_count" "$fail_count" "$skip_count" +((fail_count == 0)) diff --git a/tests/test_ct_timing.c b/tests/test_ct_timing.c index 2884dd5..a7ed9b3 100644 --- a/tests/test_ct_timing.c +++ b/tests/test_ct_timing.c @@ -2,7 +2,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later * Copyright (c) 2025-2026 Cristian Cezar Moisés * - * Constant-time verification of zupt_ct_memeq (v3.5.0) — dudect-style. + * Constant-time timing regression measurement for zupt_ct_memeq (v3.5.0). * * The MAC-tag comparison is the most timing-sensitive operation in the * codebase: if "wrong on byte 0" were measurably faster than "wrong on @@ -194,8 +194,8 @@ int main(void) { if (t_memcmp < CONTROL_STRONG) { printf(" - control |t|=%.1f below %.0f: host under contention this run;\n", t_memcmp, CONTROL_STRONG); printf(" control and ct_memeq are in a common noise band, ratio not meaningful\n"); - printf(" - INCONCLUSIVE this run (zupt_ct_memeq is OR-accumulate, no branch; rerun on a quiet host)\n"); - printf(" Constant-time: 0 passed, 0 failed (inconclusive — measurement env)\n"); + printf(" SKIP: timing measurement inconclusive on this host; rerun on a quiet host\n"); + printf(" Constant-time timing gate: SKIP (measurement environment)\n"); return 0; } printf(" \xE2\x9C\x93 control: memcmp leaks strongly (|t|=%.1f, harness is sensitive)\n", t_memcmp); @@ -204,11 +204,11 @@ int main(void) { double ratio = t_ct / t_memcmp; printf(" ratio zupt_ct_memeq/memcmp = %.3f (must be <= %.2f)\n", ratio, MAX_RATIO); if (ratio <= MAX_RATIO) { - printf(" \xE2\x9C\x93 zupt_ct_memeq shows no data-dependent timing (%.1f%% of leak signal)\n", + printf(" \xE2\x9C\x93 no timing-regression signal observed for zupt_ct_memeq (%.1f%% of control)\n", ratio * 100.0); pass++; } else { - printf(" \xE2\x9C\x97 zupt_ct_memeq timing tracks the data (%.1f%% of leak signal) — NOT constant-time\n", + printf(" \xE2\x9C\x97 zupt_ct_memeq timing tracks the input classes (%.1f%% of control)\n", ratio * 100.0); fail++; } @@ -226,15 +226,13 @@ int main(void) { * over 1088 bytes is no longer a cleanly-leaking control (its own * timing is data-dependent in ways unrelated to early-exit). The * environment-relative ratio that is meaningful at 32 bytes is not - * meaningful here on a shared vCPU. What actually establishes the - * property is: (a) the 32-byte pass/fail check above proves - * zupt_ct_memeq is constant-time, and (b) zupt_ct_memeq is - * length-independent by construction (OR-accumulate, no early exit, - * no data-dependent branch — same code path for every byte and every - * length). The decaps compare uses exactly this primitive (verified - * by the source-routing assertion in tests/test_ct_timing.sh), so its - * constant-timeness follows from (a)+(b). We print the 1088B numbers - * for transparency but do not gate on them. */ + * meaningful here on a shared vCPU. The 32-byte gate is only regression + * evidence when its control is conclusive; it is not a constant-time + * proof. Source inspection shows an OR-accumulate loop without intended + * data-dependent exit or access, and tests/test_ct_timing.sh confirms that + * decapsulation routes through this primitive. Exact compiled behavior + * remains compiler- and platform-dependent. We print the 1088B numbers for + * transparency but do not gate on them. */ printf("\n -- ML-KEM ciphertext compare (1088 bytes, informational) --\n"); double mc1088_runs[5], ct1088_runs[5]; for (int r = 0; r < 5; r++) { @@ -246,12 +244,12 @@ int main(void) { printf(" memcmp 1088B: |t| = %8.2f (not a clean control at this size)\n", mc1088_runs[2]); printf(" zupt_ct_memeq 1088B: |t| = %8.2f\n", ct1088_runs[2]); - printf(" note: constant-timeness of the 1088B decaps compare follows from the\n"); - printf(" 32B pass above + zupt_ct_memeq being length-independent by\n"); - printf(" construction; the decaps path uses this exact primitive.\n"); + printf(" note: the 1088B result is informational; source routing uses the same\n"); + printf(" fixed-length OR-accumulate primitive, but this is not a proof of\n"); + printf(" constant-time behavior for the compiled target.\n"); printf("\n ───────────────────────────────────────\n"); - printf(" Constant-time: %d passed, %d failed\n", pass, fail); + printf(" Timing regression checks: %d passed, %d failed\n", pass, fail); printf(" ───────────────────────────────────────\n"); return fail ? 1 : 0; } diff --git a/tests/test_ct_timing.sh b/tests/test_ct_timing.sh index 1fc9471..a5efcc3 100755 --- a/tests/test_ct_timing.sh +++ b/tests/test_ct_timing.sh @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés # -# dudect-style constant-time verification of zupt_ct_memeq (v3.5.0). +# dudect-style timing regression measurement for zupt_ct_memeq (v3.5.0). # Builds at -O2 (the shipped optimisation level — so this tests the code # as users run it, including that the volatile accumulator survives the # optimiser) and runs the Welch t-test harness. @@ -15,7 +15,6 @@ # passing vacuously. set -u -SDK_DIR="${ZUPTSDK_DIR:-vendor/zuptsdk}" ARCH=$(uname -m) if [ "$ARCH" = "x86_64" ] || [ "$ARCH" = "i686" ]; then SHANI="-msha -mssse3 -msse4.1" @@ -24,19 +23,14 @@ else fi TMP=$(mktemp -d) -# This test uses only native CT primitives (zupt_ct_memeq, ML-KEM CT compare); -# the libzuptsdk linkage is vestigial. Link it only when the vendored library -# is present (WITH_SDK builds); source-only builds compile+run without it. -SDK_LINK="" -if ls "$SDK_DIR"/libzuptsdk.so* >/dev/null 2>&1; then - SDK_LINK="-L$SDK_DIR -lzuptsdk -Wl,-rpath,$(cd "$SDK_DIR" && pwd)" -fi -if gcc -Iinclude -Isrc -I"$SDK_DIR/include" -Wall -Wextra -Werror $SHANI -O2 -std=c11 \ +# This test uses only native CT primitives; it deliberately has no optional +# SDK linkage so the baseline source build is the exact path under test. +if "${CC:-cc}" -Iinclude -Isrc -Wall -Wextra -Werror $SHANI -O2 -std=c11 \ tests/test_ct_timing.c \ src/zupt_crypto.c src/zupt_sha256.c src/zupt_sha256_shani.c src/zupt_aes256.c \ src/zupt_xxh.c src/zupt_keccak.c src/zupt_x25519.c src/zupt_mlkem.c \ src/zupt_cpuid.c src/zupt_mlock.c \ - $SDK_LINK -lm \ + -lm \ -o "$TMP/t" 2>"$TMP/cc.log"; then "$TMP/t"; rc=$? else @@ -48,8 +42,8 @@ rm -rf "$TMP" # Source-routing guard: the security-critical compares must use the single # audited zupt_ct_memeq primitive, not a reintroduced inline byte-OR loop. -# This is what makes the 32-byte timing proof transfer to the ML-KEM -# 1088-byte decaps compare (same function, length-independent). +# This confirms that the measured primitive is also used by the ML-KEM +# 1088-byte decapsulation comparison; it is not a formal timing proof. echo "" echo " -- source routing (audited primitive) --" ROUTE_OK=0 diff --git a/tests/test_dedup_nonce.sh b/tests/test_dedup_nonce.sh new file mode 100644 index 0000000..769b9a5 --- /dev/null +++ b/tests/test_dedup_nonce.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# Regression: dedup-encrypted archives must NOT reuse the AES-256-CTR nonce +# across blocks. +# +# v4.2.0 fix: the old per-block nonce was base_nonce XOR block_seq, but dedup +# mode hard-codes block_seq==0 for every data block (the sentinel needed so +# cross-file dedup references authenticate consistently). That collapsed every +# dedup block's nonce to a single value, reusing the CTR keystream across +# distinct plaintexts — a many-time-pad. The nonce is now a fresh random 128-bit +# value per block. This test asserts every encrypted DATA block in a +# dedup-encrypted archive carries a distinct stored nonce. +set -u +ZUPT=${1:-${ZUPT_BIN:-./zupt}} +echo "Dedup nonce uniqueness (keystream-reuse regression)" + +if ! command -v python3 >/dev/null 2>&1; then + echo " FAIL: python3 is required for the dedup nonce gate" >&2 + exit 1 +fi + +T=$(mktemp -d); trap 'rm -rf "$T"' EXIT +# 1 MiB of random data => many distinct 128 KiB blocks (random never dedups). +head -c 1048576 /dev/urandom > "$T/f.bin" +"$ZUPT" compress --dedup -p testpw "$T/a.zupt" "$T/f.bin" >/dev/null 2>&1 + +python3 - "$T/a.zupt" <<'PY' +import sys +d = open(sys.argv[1], 'rb').read() +nonces = []; i = 0 +def rv(p): + v = s = 0 + while True: + b = d[p]; p += 1; v |= (b & 127) << s + if not (b & 128): break + s += 7 + return v, p +while True: + j = d.find(b'\xbb\x01', i) + if j < 0 or j + 7 > len(d): break + bt = d[j+2]; flags = d[j+5] | (d[j+6] << 8) + if bt == 0 and (flags & 1): # DATA + ENCRYPTED + p = j + 7 + _, p = rv(p); _, p = rv(p); p += 8 # skip usz, csz, xxh64 + nonces.append(bytes(d[p:p+16])) # 16-byte nonce prefix + i = j + 2 +if len(nonces) < 2: + print(" - inconclusive: only %d encrypted block(s) parsed" % len(nonces)); sys.exit(0) +if len(set(nonces)) == len(nonces): + print(" ✓ %d encrypted dedup blocks, all %d nonces distinct" % (len(nonces), len(set(nonces)))) + sys.exit(0) +print(" ✗ %d blocks but only %d distinct nonces — CTR KEYSTREAM REUSE" % (len(nonces), len(set(nonces)))) +sys.exit(1) +PY +rc=$? +[ $rc -eq 0 ] && echo " Dedup nonce: 1 passed, 0 failed" || echo " Dedup nonce: 0 passed, 1 failed" +exit $rc diff --git a/tests/test_dedup_props.sh b/tests/test_dedup_props.sh index b17ad32..5d5cd50 100755 --- a/tests/test_dedup_props.sh +++ b/tests/test_dedup_props.sh @@ -6,10 +6,16 @@ # (a) compressed output is correct (byte-exact roundtrip) and # (b) dedup actually saves space when duplicates are present. -ZUPT_BIN="$(realpath ./zupt)" +REPO_ROOT=$(pwd -P) +ZUPT_BIN=${1:-./zupt} +case $ZUPT_BIN in + /*) ;; + *) ZUPT_BIN=$PWD/${ZUPT_BIN#./} ;; +esac +ARCHIVE_SURGERY="$REPO_ROOT/tests/archive_surgery.py" TMPDIR=$(mktemp -d) -trap "rm -rf $TMPDIR" EXIT -cd "$TMPDIR" +trap 'rm -rf "$TMPDIR"' EXIT +cd "$TMPDIR" || exit 1 PASS=0; FAIL=0 chk() { @@ -24,24 +30,27 @@ echo " [P1. Dedup roundtrip preserves all bytes]" mkdir input for i in $(seq 1 10); do - dd if=/dev/urandom of=input/file_$i.bin bs=4K count=$((RANDOM % 8 + 1)) 2>/dev/null + dd if=/dev/urandom of="input/file_$i.bin" bs=4K \ + count=$((RANDOM % 8 + 1)) 2>/dev/null done # 5 exact duplicates (same content as file_1..5) for i in 1 2 3 4 5; do - cp input/file_$i.bin input/dup_$i.bin + cp "input/file_$i.bin" "input/dup_$i.bin" done "$ZUPT_BIN" c --dedup test_dedup.zupt input/*.bin > /dev/null 2>&1 chk "Compress with --dedup succeeds" -mkdir extracted && cd extracted +mkdir extracted +cd extracted || exit 1 "$ZUPT_BIN" x ../test_dedup.zupt > /dev/null 2>&1 chk "Extract --dedup archive succeeds" all_match=1 for i in $(seq 1 10); do - if ! diff -q ../input/file_$i.bin tmp*/input/file_$i.bin > /dev/null 2>&1 \ - && ! diff -q ../input/file_$i.bin input/file_$i.bin > /dev/null 2>&1; then + candidate=$(find . -type f -path "*/input/file_$i.bin" -print -quit) + if [ -z "$candidate" ] || + ! cmp "../input/file_$i.bin" "$candidate" >/dev/null 2>&1; then all_match=0; break fi done @@ -50,13 +59,12 @@ chk "All 10 base files roundtrip byte-exact" dup_match=1 for i in 1 2 3 4 5; do - found=0 - for d in tmp*/input input; do - if [ -f "$d/dup_$i.bin" ] && diff -q ../input/dup_$i.bin "$d/dup_$i.bin" > /dev/null 2>&1; then - found=1; break - fi - done - [ $found -eq 1 ] || { dup_match=0; break; } + candidate=$(find . -type f -path "*/input/dup_$i.bin" -print -quit) + if [ -z "$candidate" ] || + ! cmp "../input/dup_$i.bin" "$candidate" >/dev/null 2>&1; then + dup_match=0 + break + fi done [ $dup_match -eq 1 ] chk "All 5 duplicate files roundtrip byte-exact" @@ -68,7 +76,7 @@ echo " [P2. Dedup compresses better than non-dedup on duplicate-heavy data]" mkdir dups for i in $(seq 1 20); do - cp input/file_1.bin dups/copy_$i.bin + cp input/file_1.bin "dups/copy_$i.bin" done "$ZUPT_BIN" c no_dedup.zupt dups/*.bin > /dev/null 2>&1 @@ -87,7 +95,8 @@ chk "Dedup achieves >50% reduction (got $ratio% of original)" # ─── Property 3: dedup roundtrip preserves data on duplicate-only sets ── echo " [P3. 100% duplicate file set extracts correctly]" -mkdir extr_dups && cd extr_dups +mkdir extr_dups +cd extr_dups || exit 1 "$ZUPT_BIN" x ../with_dedup.zupt > /dev/null 2>&1 chk "Extract heavy-duplicate archive succeeds" @@ -96,25 +105,28 @@ n_extracted=$(find . -name "copy_*.bin" 2>/dev/null | wc -l) chk "All 20 duplicate copies extracted (got $n_extracted)" all_dup_match=1 -for f in $(find . -name "copy_*.bin"); do - if ! diff -q "$f" ../input/file_1.bin > /dev/null 2>&1; then +while IFS= read -r f; do + if ! cmp "$f" ../input/file_1.bin >/dev/null 2>&1; then all_dup_match=0; break fi -done +done < <(find . -type f -name 'copy_*.bin' -print) [ $all_dup_match -eq 1 ] chk "All extracted duplicates byte-exact match the original" cd .. # ─── Property 4: dedup + encryption coexist correctly ─────────────────── -echo " [P4. Dedup + SDK encryption work together]" +echo " [P4. Dedup + password encryption work together]" -"$ZUPT_BIN" keygen --sdk -o k.priv > /dev/null 2>&1 -"$ZUPT_BIN" c --dedup --pq-sdk k.priv.pub enc_dedup.zupt dups/*.bin > /dev/null 2>&1 +"$ZUPT_BIN" c --dedup -p dedup-test-password enc_dedup.zupt dups/*.bin > /dev/null 2>&1 chk "Encrypt + dedup compress succeeds" -mkdir extr_enc && cd extr_enc -"$ZUPT_BIN" x --pq-sdk ../k.priv ../enc_dedup.zupt > /dev/null 2>&1 +"$ZUPT_BIN" t -p dedup-test-password enc_dedup.zupt > /dev/null 2>&1 +chk "Encrypt + dedup archive test succeeds" + +mkdir extr_enc +cd extr_enc || exit 1 +"$ZUPT_BIN" x -p dedup-test-password ../enc_dedup.zupt > /dev/null 2>&1 chk "Encrypt + dedup extract succeeds" n=$(find . -name "copy_*.bin" 2>/dev/null | wc -l) @@ -123,6 +135,21 @@ chk "All 20 copies recovered after enc+dedup ($n found)" cd .. +# The offset inside a new encrypted DEDUP_REF is itself authenticated. A +# payload-only mutation must fail before it can redirect extraction. +if python3 "$ARCHIVE_SURGERY" flip-payload enc_dedup.zupt \ + tampered_ref.zupt --kind ref --require-encrypted; then + if "$ZUPT_BIN" t -p dedup-test-password tampered_ref.zupt \ + > /dev/null 2>&1; then + false + else + true + fi +else + false +fi +chk "Encrypted dedup reference offset rejects tampering" + echo echo " ───────────────────────────────────────" echo " Dedup property results: $PASS passed, $FAIL failed" diff --git a/tests/test_disk_device_capacity.sh b/tests/test_disk_device_capacity.sh new file mode 100755 index 0000000..7ea9d54 --- /dev/null +++ b/tests/test_disk_device_capacity.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +set -Eeuo pipefail + +bin=${1:-./zupt} +tmp=$(mktemp -d "${TMPDIR:-/tmp}/zupt-device-capacity.XXXXXXXX") +loop_device= + +cleanup() { + local status=$? + trap - EXIT HUP INT TERM + if [[ -n $loop_device ]]; then + losetup -d "$loop_device" >/dev/null 2>&1 || true + fi + rm -rf -- "$tmp" + exit "$status" +} +trap cleanup EXIT HUP INT TERM + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) + printf 'SKIP: raw-device capacity ioctl tests are POSIX-only\n' + exit 0 + ;; +esac + +dd if=/dev/zero bs=65536 count=2 2>/dev/null | tr '\000' 'C' > "$tmp/source.img" +"$bin" disk backup -s -b 65536 "$tmp/source.zupt" "$tmp/source.img" \ + >/dev/null 2>&1 || fail 'could not build device-capacity fixture' + +# Character devices without a demonstrable media size must be rejected before +# any write. /dev/null provides an unprivileged regression for that policy. +if [[ -w /dev/null ]]; then + if "$bin" disk restore "$tmp/source.zupt" /dev/null \ + >/dev/null 2>"$tmp/unknown-capacity.err"; then + fail 'disk restore accepted a character device of unknown capacity' + fi + grep -Fq 'cannot determine restore device capacity safely' \ + "$tmp/unknown-capacity.err" || + fail 'character-device rejection did not exercise the capacity guard' + printf 'disk device unknown-capacity guard: PASS\n' +else + printf 'SKIP: unknown-capacity character-device test cannot write /dev/null\n' +fi + +if [[ $(uname -s) != Linux ]]; then + printf 'SKIP: undersized loop-device test is Linux-specific\n' + exit 0 +fi +if [[ $(id -u) -ne 0 || ! -e /dev/loop-control ]] || + ! command -v losetup >/dev/null 2>&1 || + ! losetup --find >/dev/null 2>&1; then + printf 'SKIP: undersized loop-device test needs root and an available loop device\n' + exit 0 +fi + +dd if=/dev/zero of="$tmp/small-backing.img" bs=65536 count=1 2>/dev/null +cp "$tmp/small-backing.img" "$tmp/small-backing.expected" +loop_device=$(losetup --find --show "$tmp/small-backing.img") || { + loop_device= + printf 'SKIP: could not attach an undersized loop device\n' + exit 0 +} +if "$bin" disk restore "$tmp/source.zupt" "$loop_device" \ + >/dev/null 2>"$tmp/undersized.err"; then + fail 'disk restore accepted an image larger than the target device' +fi +grep -Fq 'exceeds restore device capacity' "$tmp/undersized.err" || + fail 'loop-device rejection did not exercise the size guard' +losetup -d "$loop_device" +loop_device= +cmp "$tmp/small-backing.expected" "$tmp/small-backing.img" || + fail 'undersized restore wrote to the device before rejecting it' + +printf 'disk device capacity guard: PASS (undersized device unchanged)\n' diff --git a/tests/test_dist_reproducible.sh b/tests/test_dist_reproducible.sh index 099408a..78d6d78 100755 --- a/tests/test_dist_reproducible.sh +++ b/tests/test_dist_reproducible.sh @@ -1,159 +1,111 @@ -#!/bin/bash +#!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés -# -# Sprint 2.4.4 regression test: `make dist` reproducibility. -# -# Asserts that running `make dist` twice on the same source tree -# produces byte-identical tarballs (same sha256, same size). This is -# the foundational property for downstream Debian / AUR / Homebrew -# packaging — without it, distros can't pin a sha256 for the source -# tarball in their recipes. -# -# Also asserts that the dist tarball contains the right things: -# - source code (src/, include/, tests/) -# - the three libzuptsdk symlinks + the real .so file -# - no built binaries (zupt, test_vectors, *.o) -# - no .git/ tree -# -# Exit non-zero on first failure. -set -u +set -Eeuo pipefail -PASS=0 -FAIL=0 -P() { PASS=$((PASS+1)); echo " ✓ $1"; } -F() { FAIL=$((FAIL+1)); echo " ✗ $1"; } +export LC_ALL=C +umask 077 -# Run from the project root regardless of where the test was invoked. -cd "$(dirname "$0")/.." +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' "$root/include/zupt.h") +[[ -n $version ]] || { printf 'FAIL: cannot determine version\n' >&2; exit 1; } -# 1. First dist build -make dist >/tmp/dist1.log 2>&1 -RC=$? -if [ $RC -ne 0 ]; then - echo " ✗ make dist failed on first run; see /tmp/dist1.log" - tail -10 /tmp/dist1.log - exit 1 -fi -VERSION=$(grep '^#define ZUPT_VERSION_STRING' include/zupt.h | awk -F'"' '{print $2}') -# v3.0.0: TARGET=vaptvupt, so the tarball is now /tmp/vaptvupt-${VERSION}.tar.gz. -# Test both possible filenames so this works on any future rename. -TARBALL="/tmp/vaptvupt-${VERSION}.tar.gz" -[ ! -f "$TARBALL" ] && TARBALL="/tmp/zupt-${VERSION}.tar.gz" -# Derive top-level dir inside the tarball from the filename -TARBALL_BASE=$(basename "$TARBALL" .tar.gz) # e.g. vaptvupt-3.0.0 -if [ ! -f "$TARBALL" ]; then - echo " ✗ expected $TARBALL not produced" - exit 1 -fi -P "first make dist produced $TARBALL" -SHA1=$(sha256sum "$TARBALL" | awk '{print $1}') -SIZE1=$(wc -c < "$TARBALL") -cp "$TARBALL" "${TARBALL%.tar.gz}.first.tar.gz" - -# 2. Second dist build — should produce byte-identical tarball -make dist >/tmp/dist2.log 2>&1 -RC=$? -if [ $RC -ne 0 ]; then - echo " ✗ make dist failed on second run; see /tmp/dist2.log" - tail -10 /tmp/dist2.log - exit 1 -fi -SHA2=$(sha256sum "$TARBALL" | awk '{print $1}') -SIZE2=$(wc -c < "$TARBALL") -if [ "$SHA1" = "$SHA2" ]; then - P "byte-identical sha256 across two runs: $SHA1" -else - F "sha256 diverged: $SHA1 vs $SHA2" -fi -if [ "$SIZE1" = "$SIZE2" ]; then - P "byte-identical size: $SIZE1" -else - F "size diverged: $SIZE1 vs $SIZE2" -fi - -# 3. Content checks -NUM_FILES=$(tar tzf "$TARBALL" | wc -l) -if [ "$NUM_FILES" -gt 100 ]; then - P "tarball has $NUM_FILES entries (sanity: > 100)" -else - F "tarball suspiciously small: $NUM_FILES entries" -fi - -if tar tzf "$TARBALL" | grep -q "${TARBALL_BASE}/src/zupt_format.c"; then - P "src/zupt_format.c present" -else - F "src/zupt_format.c missing" -fi - -if tar tzf "$TARBALL" | grep -q "${TARBALL_BASE}/include/zupt.h"; then - P "include/zupt.h present" -else - F "include/zupt.h missing" -fi - -# All three libzuptsdk variants -SO_REAL=$(tar tzf "$TARBALL" | grep -c "libzuptsdk.so.2.0.0$") -SO_LINKS=$(tar tzf "$TARBALL" | grep -cE "libzuptsdk.so$|libzuptsdk.so.2$") -if [ "$SO_REAL" = "1" ] && [ "$SO_LINKS" = "2" ]; then - P "libzuptsdk: 1 real .so + 2 symlinks" -else - F "libzuptsdk shipping wrong: real=$SO_REAL links=$SO_LINKS (expected 1 + 2)" -fi - -# No built binaries (vaptvupt or legacy zupt symlink or test_* harnesses) -if tar tzf "$TARBALL" | grep -qE "(vaptvupt|zupt)-${VERSION}/(vaptvupt|zupt)(\$|_asan\$)|(vaptvupt|zupt)-${VERSION}/test_vectors\$|(vaptvupt|zupt)-${VERSION}/test_vaptvupt\$"; then - F "tarball contains built binaries" -else - P "tarball contains no built binaries" -fi - -# No .o files -if tar tzf "$TARBALL" | grep -qE "\.o$"; then - F "tarball contains stale .o files" -else - P "tarball contains no .o files" -fi - -# No .git -if tar tzf "$TARBALL" | grep -q "\.git/"; then - F "tarball contains .git/ tree" -else - P "tarball contains no .git/ tree" -fi - -# 4. Build & smoke-test from the dist tarball -WORK=$(mktemp -d) -( cd "$WORK" && tar xzf "$TARBALL" && cd "${TARBALL_BASE}" && make -j"$(nproc)" >/tmp/distbuild.log 2>&1 ) || { - F "build from dist tarball failed; see /tmp/distbuild.log" - rm -rf "$WORK" - [ "$FAIL" = 0 ] || exit 1 -} -# v3.0.0: binary may be named `vaptvupt` (default) or legacy `zupt`. -# Pick whichever the dist-tarball build produced. -DISTBIN="" -for cand in vaptvupt zupt; do - if [ -x "$WORK/${TARBALL_BASE}/$cand" ]; then DISTBIN="$WORK/${TARBALL_BASE}/$cand"; break; fi -done -if [ -n "$DISTBIN" ]; then - P "binary builds from dist tarball ($(basename "$DISTBIN"))" - "$DISTBIN" version > /tmp/distver.txt 2>&1 - if grep -q "$VERSION" /tmp/distver.txt; then - P "built binary reports correct version ($VERSION)" +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' else - F "binary version mismatch: $(cat /tmp/distver.txt)" + printf 'FAIL: sha256sum or shasum is required\n' >&2 + return 1 fi -else - F "no binary produced from dist build" +} + +tmp=$(mktemp -d "${TMPDIR:-/tmp}/zupt-dist-test.XXXXXXXX") +trap 'chmod -R u+rwX "$tmp" 2>/dev/null || true; rm -rf -- "$tmp"' EXIT HUP INT TERM + +first=$tmp/zupt-$version.first.tar.gz +second=$tmp/zupt-$version.second.tar.gz + +make -C "$root" DIST_TARBALL="$first" dist +make -C "$root" DIST_TARBALL="$second" dist + +first_sha=$(sha256_file "$first") +second_sha=$(sha256_file "$second") +[[ $first_sha == "$second_sha" ]] || { + printf 'FAIL: source archive hashes differ: %s %s\n' "$first_sha" "$second_sha" >&2 + exit 1 +} +cmp -- "$first" "$second" +printf 'PASS: two source archives are byte-identical (%s)\n' "$first_sha" + +# Git adds the archived commit ID to a PAX header when given a commit object. +# Exercise the real dist rule in an isolated repository and prove that changing +# only an export-ignored checksum recipe cannot perturb the release tarball. +ignored_repo=$tmp/export-ignored-repo +mkdir -p "$ignored_repo/include" "$ignored_repo/packaging/homebrew" \ + "$ignored_repo/sdk" +cp -- "$root/Makefile" "$ignored_repo/Makefile" +cp -- "$root/include/zupt.h" "$ignored_repo/include/zupt.h" +cp -- "$root/sdk/Makefile.sdk" "$ignored_repo/sdk/Makefile.sdk" +printf '/packaging/homebrew/** export-ignore\n' >"$ignored_repo/.gitattributes" +printf '1788134400\n' >"$ignored_repo/.source-date-epoch" +printf '#!/usr/bin/env bash\nexit 0\n' >"$ignored_repo/source-audit.sh" +printf 'normal exported source\n' >"$ignored_repo/source.txt" +printf 'sha256 "REPLACE_AFTER_FINAL_RELEASE_ARCHIVE_IS_BUILT"\n' \ + >"$ignored_repo/packaging/homebrew/zupt.rb" +chmod +x "$ignored_repo/source-audit.sh" +git -C "$ignored_repo" init -q +git -C "$ignored_repo" add -- . +git -C "$ignored_repo" -c user.name='ZUPT release test' \ + -c user.email='release-test@invalid.example' commit -qm 'initial source' + +ignored_first=$tmp/export-ignored.first.tar.gz +ignored_second=$tmp/export-ignored.second.tar.gz +make -C "$ignored_repo" --no-print-directory \ + SOURCE_AUDIT=source-audit.sh DIST_TARBALL="$ignored_first" dist +printf 'sha256 "final-release-digest"\n' \ + >"$ignored_repo/packaging/homebrew/zupt.rb" +git -C "$ignored_repo" add -- packaging/homebrew/zupt.rb +git -C "$ignored_repo" -c user.name='ZUPT release test' \ + -c user.email='release-test@invalid.example' commit -qm 'pin release checksum' +make -C "$ignored_repo" --no-print-directory \ + SOURCE_AUDIT=source-audit.sh DIST_TARBALL="$ignored_second" dist +cmp -- "$ignored_first" "$ignored_second" || { + printf 'FAIL: export-ignored-only commit changed source archive bytes\n' >&2 + exit 1 +} +printf 'PASS: export-ignored-only commit leaves source archive byte-identical (%s)\n' \ + "$(sha256_file "$ignored_first")" + +bash "$root/scripts/check-source-only.sh" --archive "$first" + +members_file=$tmp/archive-members.txt +tar -tzf "$first" >"$members_file" +member_count=$(wc -l <"$members_file") +((member_count > 100)) || { printf 'FAIL: source archive has too few entries\n' >&2; exit 1; } +prefix=zupt-$version/ +for required in src/zupt_main.c include/zupt.h Makefile scripts/check-source-only.sh; do + grep -Fxq "$prefix$required" "$members_file" || { + printf 'FAIL: source archive is missing %s\n' "$required" >&2 + exit 1 + } +done +if grep -Eq '/(\.git|build|dist|out|target)(/|$)' "$members_file"; then + printf 'FAIL: source archive contains an internal/generated directory\n' >&2 + exit 1 fi -rm -rf "$WORK" +printf 'PASS: source archive layout and required sources\n' -# Cleanup -rm -f "/tmp/zupt-${VERSION}.first.tar.gz" - -echo "" -echo " ───────────────────────────────────────" -echo " dist reproducibility: $PASS passed, $FAIL failed" -echo " ───────────────────────────────────────" -[ "$FAIL" = 0 ] || exit 1 +tar -xzf "$first" -C "$tmp" +tree=$tmp/zupt-$version +bash "$tree/scripts/check-source-only.sh" --tree "$tree" +make -C "$tree" clean +make -C "$tree" -j"${JOBS:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf 2)}" \ + WITH_SDK=0 WITH_PQBOX=0 V=1 +make -C "$tree" WITH_SDK=0 WITH_PQBOX=0 check +bash "$tree/scripts/test-installed-zupt.sh" "$tree/zupt" +make -C "$tree" clean +bash "$tree/scripts/check-source-only.sh" --tree "$tree" +printf 'PASS: clean source archive builds, checks and passes the functional smoke test\n' diff --git a/tests/test_f06_hmac.c b/tests/test_f06_hmac.c index 82a003d..02baec0 100644 --- a/tests/test_f06_hmac.c +++ b/tests/test_f06_hmac.c @@ -1,7 +1,7 @@ /* SPDX-License-Identifier: AGPL-3.0-or-later * Copyright (c) 2025-2026 Cristian Cezar Moisés * - * F-06 regression test (Zupt 2.2.5). + * F-06 regression test (ZUPT 2.2.5). * * The original combined-diff in zupt_decrypt_buffer was * uint64_t diff = diff_v2 & diff_v1; diff --git a/tests/test_f08_topmac.sh b/tests/test_f08_topmac.sh index d448cc2..e5f6735 100755 --- a/tests/test_f08_topmac.sh +++ b/tests/test_f08_topmac.sh @@ -2,36 +2,20 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés # -# F-08 regression test (Zupt 2.3.0). +# F-08 regression test (ZUPT 2.3.0). # -# Two directions: -# 1. v1.5 archive: tamper at each previously-cosmetic header/footer byte -# MUST be detected (top-MAC verifies header+footer[0..23]). -# 2. v1.4 archive (built by Zupt 2.2.5 binary, embedded as a fixture): -# MUST extract cleanly with the legacy-downgrade warning on stderr. -# -# The v1.4 fixture is built at test time IF a 2.2.5 binary is available -# under tests/fixtures/, else direction #2 is skipped with a NOTE. +# A v1.5+ archive is tampered at each previously-cosmetic header/footer byte; +# every mutation MUST be detected (top-MAC verifies header+footer[0..23]). +# Removing the AIT entirely must also fail closed without a compatibility opt-in. +# Legacy v1.4 compatibility needs a reproducible source-generated fixture and +# is reported as skipped until one is available; compiled fixtures are banned. -set -u +set -Eeuo pipefail PASS=0 FAIL=0 -ZUPT="${ZUPT_BIN:-./zupt}" -# Source-only build (WITH_SDK=0) has no libzuptsdk: the SDK-mode paths this -# test exercises are unavailable, so skip cleanly instead of failing. -_sdkck="$(mktemp -d)" -if ! "$ZUPT" keygen --sdk -o "$_sdkck/p" >/dev/null 2>&1; then - rm -rf "$_sdkck"; echo " SKIP: built without libzuptsdk (source-only) - SDK-mode test not applicable"; exit 0 -fi -rm -rf "$_sdkck" - -# Resolve to absolute path so the test continues to find the binary after cd. -case "$ZUPT" in - /*) ;; - *) ZUPT="$PWD/$ZUPT" ;; -esac -ROOT="$PWD" +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P) +ZUPT=${ZUPT_BIN:-$repo_root/zupt} P() { PASS=$((PASS+1)); echo " ✓ $1"; } F() { FAIL=$((FAIL+1)); echo " ✗ $1"; } @@ -40,16 +24,75 @@ if [ ! -x "$ZUPT" ]; then echo " ✗ $ZUPT not found — run 'make' first" >&2 exit 1 fi +if ! command -v python3 >/dev/null 2>&1; then + echo ' ✗ python3 is required for structural archive mutations' >&2 + exit 1 +fi TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"' EXIT cd "$TMPDIR" -echo " [Direction 1: v1.5 archive detects header+footer tamper]" +echo " [AIT removal is rejected by default]" + +printf 'data\n' > input.txt +printf 'source-only-test-password\n' > password.txt +chmod 600 password.txt +if "$ZUPT" c --kdf pbkdf2 --pass-file password.txt \ + password.zupt input.txt >/dev/null 2>&1 && + "$ZUPT" t --pass-file password.txt password.zupt >/dev/null 2>&1; then + P "clean password archive passes authentication" +else + F "clean password archive could not be authenticated" +fi + +if python3 "$repo_root/tests/archive_surgery.py" strip-ait \ + password.zupt stripped-ait.zupt; then + if "$ZUPT" t --pass-file password.txt stripped-ait.zupt \ + >/dev/null 2>&1; then + F "archive with its AIT removed was accepted by default" + else + P "archive with its AIT removed is rejected by default" + fi + + if "$ZUPT" list --pass-file password.txt stripped-ait.zupt \ + >/dev/null 2>&1; then + F "list accepted an archive with its AIT removed" + else + P "list rejects an archive with its AIT removed" + fi + + mkdir stripped-output + printf 'existing extraction target\n' > stripped-output/sentinel + cp stripped-output/sentinel stripped-output.expected + if "$ZUPT" extract --pass-file password.txt -o stripped-output \ + stripped-ait.zupt >/dev/null 2>&1; then + F "extract accepted an archive with its AIT removed" + elif cmp stripped-output.expected stripped-output/sentinel >/dev/null 2>&1 && + [ ! -e stripped-output/input.txt ]; then + P "AIT-removal rejection preserves the extraction destination" + else + F "AIT-removal rejection changed the extraction destination" + fi +else + F "could not construct archive with a structurally removed AIT" +fi + +version=$("$ZUPT" --version 2>&1) +if ! grep -Fq 'libvuptsdk=enabled' <<<"$version"; then + echo ' SKIP: exhaustive SDK top-MAC sweep needs WITH_SDK=1 and system libvuptsdk' + echo + echo " ───────────────────────────────────────" + echo " F-08 regression: $PASS passed, $FAIL failed" + echo " ───────────────────────────────────────" + [ "$FAIL" = 0 ] || exit 1 + exit 0 +fi + +echo " [v1.5+ archive detects header+footer tamper]" "$ZUPT" keygen --sdk -o k.priv >/dev/null 2>&1 -echo "data" > input.txt "$ZUPT" c --pq-sdk k.priv.pub a.zupt input.txt >/dev/null 2>&1 SZ=$(wc -c < a.zupt) @@ -85,8 +128,7 @@ b=bytearray(open('t.zupt','rb').read()) b[$POS] ^= 1 open('t.zupt','wb').write(bytes(b))" rm -rf out && mkdir out - ( cd out && "$ZUPT" x --pq-sdk ../k.priv ../t.zupt >/dev/null 2>&1 ) - if [ -f out/input.txt ]; then + if (cd out && "$ZUPT" x --pq-sdk ../k.priv ../t.zupt >/dev/null 2>&1); then ALL_DETECTED=0 echo " silent-accepted tamper at byte $POS" fi @@ -109,7 +151,7 @@ b=bytearray(open('t.zupt','rb').read()) b[20] ^= 1 # archive_id byte open('t.zupt','wb').write(bytes(b))" rm -rf out && mkdir out -ERR=$( cd out && "$ZUPT" x --pq-sdk ../k.priv ../t.zupt 2>&1 || true ) +ERR=$( (cd out && "$ZUPT" x --pq-sdk ../k.priv ../t.zupt) 2>&1 || true ) if echo "$ERR" | grep -qE "Authentication failed|top-MAC"; then P "tamper produces a clear auth/integrity error" else @@ -118,7 +160,7 @@ fi # Verbose mode: top-MAC wording must still surface for debugging rm -rf out && mkdir out -ERR_V=$( cd out && "$ZUPT" x --verbose --pq-sdk ../k.priv ../t.zupt 2>&1 || true ) +ERR_V=$( (cd out && "$ZUPT" x --verbose --pq-sdk ../k.priv ../t.zupt) 2>&1 || true ) if echo "$ERR_V" | grep -q "top-MAC"; then P "tamper with --verbose surfaces top-MAC detail" else @@ -126,35 +168,8 @@ else fi echo "" -echo " [Direction 2: v1.4 backward-compat]" - -FIXTURE_BIN="$ROOT/tests/fixtures/zupt-2.2.5" -if [ -x "$FIXTURE_BIN" ]; then - # Build v1.4 archive using the 2.2.5 binary. - "$FIXTURE_BIN" keygen --sdk -o k14.priv >/dev/null 2>&1 - "$FIXTURE_BIN" c --pq-sdk k14.priv.pub a14.zupt input.txt >/dev/null 2>&1 - - # v2.3.0 info should say v1.4 / no top-MAC. - INFO14=$("$ZUPT" info a14.zupt 2>&1) - if echo "$INFO14" | grep -q "Format: *v1.4" && echo "$INFO14" | grep -q "Top-MAC: *no"; then - P "v1.4 archive reported as v1.4 / no top-MAC" - else - F "v1.4 info report wrong" - fi - - # v2.3.0 extract should succeed with warning. - mkdir out14 - OUT=$( cd out14 && "$ZUPT" x --pq-sdk ../k14.priv ../a14.zupt 2>&1 ) - if [ -f out14/input.txt ] && echo "$OUT" | grep -qi "legacy v1.4 archive"; then - P "v1.4 archive extracts with legacy warning" - else - F "v1.4 backward-compat broken: $OUT" - fi -else - echo " NOTE: tests/fixtures/zupt-2.2.5 not present — direction 2 skipped" - echo " (build it once with: cd tests/fixtures && tar xzf zupt-2.2.5.tar.gz" - echo " && cd zupt-2.2.5 && make && cp zupt ../zupt-2.2.5)" -fi +echo " SKIP: v1.4 compatibility needs a reproducible source-generated fixture" +echo " (compiled historical fixtures are not permitted in this repository)" echo "" echo " ───────────────────────────────────────" diff --git a/tests/test_f09_preface.sh b/tests/test_f09_preface.sh index b8640dd..2578553 100755 --- a/tests/test_f09_preface.sh +++ b/tests/test_f09_preface.sh @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés # -# F-09 regression test (Zupt 2.3.1). +# F-09 regression test (ZUPT 2.3.1). # # F-09 closed the per-block frame preface tamper window by: # 1. Binding the canonical preface (block_type, codec_id, block_flags, @@ -12,18 +12,20 @@ # block's frame preface in read_enc_header (same pattern as F-07 # for the index block in v2.2.5). # -# This test does the full exhaustive byte sweep on a small v1.6 PQ-SDK -# archive: every byte from 0 to N-1 is flipped one at a time, and we -# assert the extract fails for ALL of them. With pre-F-09 code this -# would show 15-18 silent acceptances; post-F-09 it must show zero. +# This test flips every serialized block-preface byte in a small v1.6 PBKDF2 +# archive and asserts that each mutation is rejected. With pre-F-09 code this +# would show silent acceptances; post-F-09 it must show zero. A PQ-SDK archive +# receives the historical full-archive byte sweep when system libvuptsdk is +# enabled. # -# Why limit to PQ-SDK encrypted: plaintext archives have no HMAC at -# all (XXH64 best-effort only), so per-byte coverage is intentionally -# weaker and a different, separately-tracked promise. +# Plaintext archives have no HMAC (XXH64 best-effort only), so per-byte +# coverage is intentionally weaker and a different, separately-tracked +# promise. -set -u +set -Eeuo pipefail -ZUPT="${ZUPT_BIN:-./zupt}" +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P) +ZUPT="${ZUPT_BIN:-$repo_root/zupt}" case "$ZUPT" in /*) ;; *) ZUPT="$PWD/$ZUPT" ;; @@ -33,58 +35,129 @@ if [ ! -x "$ZUPT" ]; then echo " ✗ $ZUPT not found — run 'make' first" >&2 exit 1 fi +if ! command -v python3 >/dev/null 2>&1; then + echo ' ✗ python3 is required for byte-level archive mutations' >&2 + exit 1 +fi TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"' EXIT -cd "$TMPDIR" +cd "$TMPDIR" || exit 1 -"$ZUPT" keygen --sdk -o k.priv >/dev/null 2>&1 -echo "F-09 regression test payload" > input.txt -"$ZUPT" c --pq-sdk k.priv.pub a.zupt input.txt >/dev/null 2>&1 +printf 'F-09 regression test payload\n' > input.txt +printf 'source-only-preface-password\n' > password.txt +chmod 600 password.txt -SZ=$(wc -c < a.zupt) -if [ "$SZ" -lt 100 ] || [ "$SZ" -gt 10000 ]; then - echo " ✗ unexpected archive size $SZ" >&2 - exit 1 -fi +run_sweep() { + local label=$1 + local archive=$2 + local scope=$3 + shift 3 + local -a auth_options=("$@") + local -a positions=() + local size + local position + local positions_output + local tested=0 + local undetected_positions='' + local undetected_count + local clean_dir="clean-$label" -# Sanity: clean archive extracts. -mkdir -p clean -( cd clean && "$ZUPT" x --pq-sdk ../k.priv ../a.zupt >/dev/null 2>&1 ) -if [ ! -f clean/input.txt ]; then - echo " ✗ clean v1.6 PQ-SDK archive doesn't extract" >&2 - exit 1 -fi - -# Exhaustive sweep. -echo " [F-09: exhaustive byte sweep of $SZ-byte v1.6 PQ-SDK archive]" -UNDETECTED_POSITIONS="" -TAMPER_SAMPLED=0 -for POS in $(seq 0 $((SZ - 1))); do - cp a.zupt t.zupt - python3 -c " -b=bytearray(open('t.zupt','rb').read()) -b[$POS] ^= 1 -open('t.zupt','wb').write(bytes(b))" - rm -rf out && mkdir out - ( cd out && "$ZUPT" x --pq-sdk ../k.priv ../t.zupt >/dev/null 2>&1 ) - TAMPER_SAMPLED=$((TAMPER_SAMPLED + 1)) - if [ -f out/input.txt ]; then - UNDETECTED_POSITIONS="$UNDETECTED_POSITIONS $POS" + size=$(wc -c < "$archive") + if [ "$size" -lt 100 ] || [ "$size" -gt 10000 ]; then + echo " ✗ $label archive has unexpected size $size" >&2 + return 1 fi -done -UNDETECTED_COUNT=$(echo $UNDETECTED_POSITIONS | wc -w) + mkdir "$clean_dir" + if ! "$ZUPT" extract "${auth_options[@]}" -o "$clean_dir" "$archive" \ + >/dev/null 2>&1 || + ! cmp input.txt "$clean_dir/input.txt" >/dev/null 2>&1; then + echo " ✗ clean $label archive does not extract byte-exact" >&2 + return 1 + fi -echo "" -echo " ───────────────────────────────────────" -if [ "$UNDETECTED_COUNT" = 0 ]; then - echo " F-09 regression: $TAMPER_SAMPLED tamper positions tested, 0 silent-accepted ✓" - echo " ───────────────────────────────────────" - exit 0 -else - echo " F-09 regression: $UNDETECTED_COUNT silent-accepted positions (must be 0)" - echo " positions:$UNDETECTED_POSITIONS" - echo " ───────────────────────────────────────" + if [ "$scope" = preface ]; then + if ! positions_output=$(python3 \ + "$repo_root/tests/archive_surgery.py" preface-positions \ + "$archive") || [ -z "$positions_output" ]; then + echo " ✗ could not locate $label block prefaces" >&2 + return 1 + fi + while IFS= read -r position; do + [ -n "$position" ] && positions+=("$position") + done <<<"$positions_output" + echo " [F-09: all block-preface bytes in $size-byte $label archive]" + elif [ "$scope" = full ]; then + for ((position = 0; position < size; position++)); do + positions+=("$position") + done + echo " [F-09: exhaustive byte sweep of $size-byte $label archive]" + else + echo " ✗ internal error: unknown sweep scope $scope" >&2 + return 1 + fi + + for position in "${positions[@]}"; do + if ! python3 - "$archive" t.zupt "$position" <<'PY' +import pathlib +import sys + +source = pathlib.Path(sys.argv[1]).read_bytes() +mutated = bytearray(source) +mutated[int(sys.argv[3])] ^= 0x01 +pathlib.Path(sys.argv[2]).write_bytes(mutated) +PY + then + echo " ✗ could not mutate $label archive byte $position" >&2 + return 1 + fi + tested=$((tested + 1)) + if "$ZUPT" test "${auth_options[@]}" t.zupt >/dev/null 2>&1; then + undetected_positions="$undetected_positions $position" + fi + done + + undetected_count=$(printf '%s\n' "$undetected_positions" | wc -w) + if [ "$undetected_count" -ne 0 ]; then + echo " ✗ $label: $undetected_count silent-accepted positions (must be 0)" + echo " positions:$undetected_positions" + return 1 + fi + echo " ✓ $label: $tested tamper positions tested, 0 accepted" +} + +FAIL=0 + +if ! "$ZUPT" compress --store --kdf pbkdf2 --pass-file password.txt \ + pbkdf2.zupt input.txt >/dev/null 2>&1; then + echo ' ✗ could not create source-only PBKDF2 archive' >&2 exit 1 fi +if ! run_sweep PBKDF2 pbkdf2.zupt preface --pass-file password.txt; then + FAIL=$((FAIL + 1)) +fi + +version=$("$ZUPT" --version 2>&1) +if grep -Fq 'libvuptsdk=enabled' <<<"$version"; then + if ! "$ZUPT" keygen --sdk -o k.priv >/dev/null 2>&1 || + ! "$ZUPT" compress --store --pq-sdk k.priv.pub pq-sdk.zupt \ + input.txt >/dev/null 2>&1; then + echo ' ✗ could not create PQ-SDK archive' >&2 + FAIL=$((FAIL + 1)) + elif ! run_sweep PQ-SDK pq-sdk.zupt full --pq-sdk k.priv; then + FAIL=$((FAIL + 1)) + fi +else + echo ' SKIP: additional PQ-SDK sweep needs WITH_SDK=1 and system libvuptsdk' +fi + +echo +echo " ───────────────────────────────────────" +if [ "$FAIL" -eq 0 ]; then + echo " F-09 regression: PASS" +else + echo " F-09 regression: FAIL ($FAIL archive variants)" +fi +echo " ───────────────────────────────────────" +[ "$FAIL" -eq 0 ] diff --git a/tests/test_f10_kdf_default.sh b/tests/test_f10_kdf_default.sh index 3b1bee4..5f20dbc 100755 --- a/tests/test_f10_kdf_default.sh +++ b/tests/test_f10_kdf_default.sh @@ -1,148 +1,143 @@ #!/bin/bash # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés -# -# F-10 regression test (Zupt 2.4.1). -# -# F-10: default password-mode KDF flipped from PBKDF2-SHA256 to Argon2id. -# PBKDF2 remains available via --kdf pbkdf2 for compatibility with -# v2.4.0-and-older readers. -# -# Three assertions: -# 1. `zupt c -p PW out.zupt input` writes an enc-header with type byte -# 0x04 (ZUPT_ENC_PW_ARGON2), and the stderr message says Argon2id. -# 2. `zupt c -p PW --kdf pbkdf2 out.zupt input` writes type byte 0x01 -# (ZUPT_ENC_PBKDF2), and the stderr message says PBKDF2. -# 3. Both archive types roundtrip byte-exact via `zupt x -p PW`. -# 4. Wrong password is rejected for both archive types. +# F-10: KDF defaults must reflect whether system libvuptsdk is enabled. -set -u +set -Eeuo pipefail -ZUPT="${ZUPT_BIN:-./zupt}" -# Source-only build (WITH_SDK=0) has no libzuptsdk: the SDK-mode paths this -# test exercises are unavailable, so skip cleanly instead of failing. -_sdkck="$(mktemp -d)" -if ! "$ZUPT" keygen --sdk -o "$_sdkck/p" >/dev/null 2>&1; then - rm -rf "$_sdkck"; echo " SKIP: built without libzuptsdk (source-only) - SDK-mode test not applicable"; exit 0 -fi -rm -rf "$_sdkck" - -case "$ZUPT" in - /*) ;; - *) ZUPT="$PWD/$ZUPT" ;; -esac - -if [ ! -x "$ZUPT" ]; then - echo " ✗ $ZUPT not found — run 'make' first" >&2 +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P) +zupt=${ZUPT_BIN:-$repo_root/zupt} +if [[ ! -x $zupt ]]; then + printf ' FAIL: %s not found; build ZUPT first\n' "$zupt" >&2 exit 1 fi -PASS=0 -FAIL=0 -P() { PASS=$((PASS+1)); echo " ✓ $1"; } -F() { FAIL=$((FAIL+1)); echo " ✗ $1"; } +version=$("$zupt" --version 2>&1) +sdk_enabled=0 +if grep -Fq 'libvuptsdk=enabled' <<<"$version"; then + sdk_enabled=1 +fi -TMPDIR=$(mktemp -d) -trap 'rm -rf "$TMPDIR"' EXIT -cd "$TMPDIR" +passed=0 +failed=0 +pass() { printf ' ✓ %s\n' "$1"; passed=$((passed + 1)); } +fail() { printf ' ✗ %s\n' "$1"; failed=$((failed + 1)); } -echo "F-10 regression: password-mode KDF default" +tmpdir=$(mktemp -d) +trap 'rm -rf -- "$tmpdir"' EXIT +cd "$tmpdir" -# Helper: read the enc_type byte (payload[0] of the enc-header block). enc_type_of() { - python3 -c " + python3 - "$1" <<'PY' +from pathlib import Path import sys -b = open('$1','rb').read() -off = int.from_bytes(b[36:44],'little') -def vread(buf,o): - v=0;s=0 + +data = Path(sys.argv[1]).read_bytes() +offset = int.from_bytes(data[36:44], "little") + +def read_varint(buf, pos): + value = 0 + shift = 0 while True: - x=buf[o]; o+=1; v|=(x&0x7f)< input.txt +echo 'F-10 regression: password-mode KDF default' +printf 'secret payload for KDF test\n' >input.txt -# 1. Default → Argon2id (0x04) -STDERR_DEFAULT=$("$ZUPT" c -p secret default.zupt input.txt 2>&1) -ETYPE=$(enc_type_of default.zupt) -if [ "$ETYPE" = "04" ]; then - P "default: enc_type = 0x04 (ZUPT_ENC_PW_ARGON2)" +default_stderr=$("$zupt" c -p secret default.zupt input.txt 2>&1) +default_type=$(enc_type_of default.zupt) +if ((sdk_enabled)); then + if [[ $default_type == 04 ]]; then + pass 'WITH_SDK=1 default uses Argon2id (enc_type 0x04)' + else + fail "WITH_SDK=1 default enc_type is 0x$default_type, expected 0x04" + fi + if grep -qi 'Argon2id' <<<"$default_stderr"; then + pass 'default message names Argon2id' + else + fail 'default message does not name Argon2id' + fi else - F "default: enc_type = 0x$ETYPE (expected 0x04)" -fi -if echo "$STDERR_DEFAULT" | grep -qi "Argon2id"; then - P "default: stderr message names Argon2id" -else - F "default: stderr message doesn't name Argon2id" + if [[ $default_type == 01 ]]; then + pass 'source-only default uses PBKDF2 (enc_type 0x01)' + else + fail "source-only default enc_type is 0x$default_type, expected 0x01" + fi + if grep -qi 'PBKDF2' <<<"$default_stderr"; then + pass 'source-only default message names PBKDF2' + else + fail 'source-only default message does not name PBKDF2' + fi + echo ' SKIP: Argon2id default/explicit coverage needs system libvuptsdk (WITH_SDK=1)' fi -# 2. --kdf pbkdf2 → PBKDF2 (0x01) -STDERR_PB=$("$ZUPT" c -p secret --kdf pbkdf2 legacy.zupt input.txt 2>&1) -ETYPE2=$(enc_type_of legacy.zupt) -if [ "$ETYPE2" = "01" ]; then - P "--kdf pbkdf2: enc_type = 0x01 (ZUPT_ENC_PBKDF2)" +mkdir default-out +if (cd default-out && "$zupt" x -p secret ../default.zupt >/dev/null 2>&1) && + cmp -s input.txt default-out/input.txt; then + pass 'default-KDF archive roundtrips byte-exact' else - F "--kdf pbkdf2: enc_type = 0x$ETYPE2 (expected 0x01)" + fail 'default-KDF archive roundtrips byte-exact' fi -if echo "$STDERR_PB" | grep -qi "PBKDF2"; then - P "--kdf pbkdf2: stderr message names PBKDF2" +mkdir default-wrong +if (cd default-wrong && "$zupt" x -p wrong ../default.zupt >/dev/null 2>&1); then + fail 'default-KDF archive rejects a wrong password' else - F "--kdf pbkdf2: stderr message doesn't name PBKDF2" + pass 'default-KDF archive rejects a wrong password' fi -# 3. Roundtrips -mkdir out_a && (cd out_a && "$ZUPT" x -p secret ../default.zupt >/dev/null 2>&1) -if [ -f out_a/input.txt ] && diff -q input.txt out_a/input.txt >/dev/null 2>&1; then - P "Argon2id archive roundtrips byte-exact" +pbkdf_stderr=$("$zupt" c -p secret --kdf pbkdf2 pbkdf.zupt input.txt 2>&1) +pbkdf_type=$(enc_type_of pbkdf.zupt) +if [[ $pbkdf_type == 01 ]]; then + pass '--kdf pbkdf2 uses enc_type 0x01' else - F "Argon2id roundtrip" + fail "--kdf pbkdf2 enc_type is 0x$pbkdf_type, expected 0x01" +fi +if grep -qi 'PBKDF2' <<<"$pbkdf_stderr"; then + pass '--kdf pbkdf2 message names PBKDF2' +else + fail '--kdf pbkdf2 message does not name PBKDF2' fi -mkdir out_p && (cd out_p && "$ZUPT" x -p secret ../legacy.zupt >/dev/null 2>&1) -if [ -f out_p/input.txt ] && diff -q input.txt out_p/input.txt >/dev/null 2>&1; then - P "PBKDF2 archive roundtrips byte-exact" +mkdir pbkdf-out +if (cd pbkdf-out && "$zupt" x -p secret ../pbkdf.zupt >/dev/null 2>&1) && + cmp -s input.txt pbkdf-out/input.txt; then + pass 'PBKDF2 archive roundtrips byte-exact' else - F "PBKDF2 roundtrip" + fail 'PBKDF2 archive roundtrips byte-exact' +fi +mkdir pbkdf-wrong +if (cd pbkdf-wrong && "$zupt" x -p wrong ../pbkdf.zupt >/dev/null 2>&1); then + fail 'PBKDF2 archive rejects a wrong password' +else + pass 'PBKDF2 archive rejects a wrong password' fi -# 4. Wrong password rejected (both) -mkdir out_wa && (cd out_wa && "$ZUPT" x -p wrong ../default.zupt >/dev/null 2>&1) -if [ ! -f out_wa/input.txt ]; then - P "Argon2id: wrong password rejected" -else - F "Argon2id: wrong password accepted" -fi -mkdir out_wp && (cd out_wp && "$ZUPT" x -p wrong ../legacy.zupt >/dev/null 2>&1) -if [ ! -f out_wp/input.txt ]; then - P "PBKDF2: wrong password rejected" -else - F "PBKDF2: wrong password accepted" +if ((sdk_enabled)); then + "$zupt" c -p secret --kdf argon2id explicit.zupt input.txt >/dev/null 2>&1 + explicit_type=$(enc_type_of explicit.zupt) + if [[ $explicit_type == 04 ]]; then + pass '--kdf argon2id uses enc_type 0x04' + else + fail "--kdf argon2id enc_type is 0x$explicit_type, expected 0x04" + fi fi -# 5. --kdf argon2id (explicit form) → same as default -STDERR_E=$("$ZUPT" c -p secret --kdf argon2id explicit.zupt input.txt 2>&1) -ETYPE3=$(enc_type_of explicit.zupt) -if [ "$ETYPE3" = "04" ]; then - P "--kdf argon2id (explicit): enc_type = 0x04" +if "$zupt" c -p secret --kdf invalid invalid.zupt input.txt >/dev/null 2>&1; then + fail 'unknown --kdf value is rejected' else - F "--kdf argon2id (explicit): enc_type = 0x$ETYPE3" + pass 'unknown --kdf value is rejected' fi -# 6. --kdf garbage → reject -if "$ZUPT" c -p secret --kdf garbage garbage.zupt input.txt >/dev/null 2>&1; then - F "--kdf garbage was accepted (should reject)" -else - P "--kdf garbage rejected" -fi - -echo "" -echo " ───────────────────────────────────────" -echo " F-10 regression: $PASS passed, $FAIL failed" -echo " ───────────────────────────────────────" -[ "$FAIL" = 0 ] || exit 1 +printf '\n F-10 regression: %d passed, %d failed\n' "$passed" "$failed" +((failed == 0)) diff --git a/tests/test_f11_authfail_message.sh b/tests/test_f11_authfail_message.sh index a23ca20..29e48ac 100755 --- a/tests/test_f11_authfail_message.sh +++ b/tests/test_f11_authfail_message.sh @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés # -# F-11 regression test (Zupt 2.4.2). +# F-11 regression test (ZUPT 2.4.2). # # F-11: pre-2.4.2 the AIT-fail message said "archive header or footer has # been tampered with" in both the actual-tamper case AND the wrong-password @@ -16,32 +16,39 @@ # message for both cases eliminates a verbal probe-oracle. Plaintext-mode # tamper detection (no key involvement) keeps detailed wording. -set -u +set -Eeuo pipefail -ZUPT="${ZUPT_BIN:-./zupt}" -# Source-only build (WITH_SDK=0) has no libzuptsdk: the SDK-mode paths this -# test exercises are unavailable, so skip cleanly instead of failing. -_sdkck="$(mktemp -d)" -if ! "$ZUPT" keygen --sdk -o "$_sdkck/p" >/dev/null 2>&1; then - rm -rf "$_sdkck"; echo " SKIP: built without libzuptsdk (source-only) - SDK-mode test not applicable"; exit 0 -fi -rm -rf "$_sdkck" - -case "$ZUPT" in - /*) ;; - *) ZUPT="$PWD/$ZUPT" ;; -esac +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P) +ZUPT=${ZUPT_BIN:-$repo_root/zupt} if [ ! -x "$ZUPT" ]; then echo " ✗ $ZUPT not found — run 'make' first" >&2 exit 1 fi +version=$("$ZUPT" --version 2>&1) +SDK_ENABLED=0 +if grep -Fq 'libvuptsdk=enabled' <<<"$version"; then + SDK_ENABLED=1 +fi PASS=0 FAIL=0 P() { PASS=$((PASS+1)); echo " ✓ $1"; } F() { FAIL=$((FAIL+1)); echo " ✗ $1"; } +capture_expected_failure() { + local output_name=$1 label=$2 directory=$3 output status + shift 3 + set +e + output=$(cd "$directory" && "$@" 2>&1) + status=$? + set -e + if ((status == 0)); then + F "$label returned success" + fi + printf -v "$output_name" '%s' "$output" +} + TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"' EXIT cd "$TMPDIR" @@ -50,46 +57,49 @@ echo "F-11 regression: error-message hygiene" echo "F-11 payload" > input.txt -# Test 1: wrong-password message on Argon2id default (no --verbose) +# Test 1: wrong-password message on the build's default KDF (no --verbose) "$ZUPT" c -p correct argon.zupt input.txt >/dev/null 2>&1 mkdir out1 -ERR=$( (cd out1 && "$ZUPT" x -p wrong ../argon.zupt) 2>&1 || true ) +capture_expected_failure ERR 'default KDF wrong-pw' out1 \ + "$ZUPT" x -p wrong ../argon.zupt if echo "$ERR" | grep -q "Authentication failed (wrong key, wrong password, or tampered archive)"; then - P "Argon2id wrong-pw default: generic auth-fail message" + P "default KDF wrong-pw: generic auth-fail message" else - F "Argon2id wrong-pw default: message wrong: '$ERR'" + F "default KDF wrong-pw: message wrong: '$ERR'" fi # Must NOT contain the standalone "header or footer has been tampered with" if ! echo "$ERR" | grep -q "header or footer has been tampered with"; then - P "Argon2id wrong-pw default: no standalone tamper claim" + P "default KDF wrong-pw: no standalone tamper claim" else - F "Argon2id wrong-pw default: still claims archive tampered" + F "default KDF wrong-pw: still claims archive tampered" fi # Must NOT contain the verbose top-MAC line if ! echo "$ERR" | grep -q "archive-integrity-trailer (top-MAC)"; then - P "Argon2id wrong-pw default: no top-MAC technical detail" + P "default KDF wrong-pw: no top-MAC technical detail" else - F "Argon2id wrong-pw default: top-MAC leaked without --verbose" + F "default KDF wrong-pw: top-MAC leaked without --verbose" fi # Test 2: --verbose surfaces the technical detail mkdir out2 -ERR_V=$( (cd out2 && "$ZUPT" x -p wrong --verbose ../argon.zupt) 2>&1 || true ) +capture_expected_failure ERR_V 'default KDF wrong-pw --verbose' out2 \ + "$ZUPT" x -p wrong --verbose ../argon.zupt if echo "$ERR_V" | grep -q "top-MAC"; then - P "Argon2id wrong-pw --verbose: top-MAC detail shown" + P "default KDF wrong-pw --verbose: top-MAC detail shown" else - F "Argon2id wrong-pw --verbose: top-MAC missing" + F "default KDF wrong-pw --verbose: top-MAC missing" fi if echo "$ERR_V" | grep -q "Authentication failed (wrong key, wrong password, or tampered archive)"; then - P "Argon2id wrong-pw --verbose: still has the generic line" + P "default KDF wrong-pw --verbose: still has the generic line" else - F "Argon2id wrong-pw --verbose: missing generic line" + F "default KDF wrong-pw --verbose: missing generic line" fi # Test 3: PBKDF2 archive same behaviour "$ZUPT" c -p correct --kdf pbkdf2 pbkdf.zupt input.txt >/dev/null 2>&1 mkdir out3 -ERR3=$( (cd out3 && "$ZUPT" x -p wrong ../pbkdf.zupt) 2>&1 || true ) +capture_expected_failure ERR3 'PBKDF2 wrong-pw' out3 \ + "$ZUPT" x -p wrong ../pbkdf.zupt if echo "$ERR3" | grep -q "Authentication failed (wrong key, wrong password, or tampered archive)"; then P "PBKDF2 wrong-pw default: generic auth-fail message" else @@ -104,7 +114,8 @@ b = bytearray(open('tampered.zupt','rb').read()) b[15] ^= 1 # creation_time byte open('tampered.zupt','wb').write(bytes(b))" mkdir out4 -ERR4=$( (cd out4 && "$ZUPT" x -p correct ../tampered.zupt) 2>&1 || true ) +capture_expected_failure ERR4 'encrypted header tamper' out4 \ + "$ZUPT" x -p correct ../tampered.zupt if echo "$ERR4" | grep -q "Authentication failed (wrong key, wrong password, or tampered archive)"; then P "Actual tamper (encrypted): same generic message — no verbal oracle" else @@ -125,7 +136,8 @@ b = bytearray(open('ptamp.zupt','rb').read()) b[10] ^= 1 open('ptamp.zupt','wb').write(bytes(b))" mkdir out5 -ERR5=$( (cd out5 && "$ZUPT" x ../ptamp.zupt) 2>&1 || true ) +capture_expected_failure ERR5 'plaintext header tamper' out5 \ + "$ZUPT" x ../ptamp.zupt if echo "$ERR5" | grep -q "corrupted or tampered"; then P "Plaintext tamper: detailed XXH64-failure message kept" else @@ -146,16 +158,21 @@ else F "Correct password: regression — extract broken" fi -# Test 7: PQ-SDK wrong key triggers the same generic message -"$ZUPT" keygen --sdk -o k.priv >/dev/null 2>&1 -"$ZUPT" keygen --sdk -o other.priv >/dev/null 2>&1 -"$ZUPT" c --pq-sdk k.priv.pub pq.zupt input.txt >/dev/null 2>&1 -mkdir out7 -ERR7=$( (cd out7 && "$ZUPT" x --pq-sdk ../other.priv ../pq.zupt) 2>&1 || true ) -if echo "$ERR7" | grep -q "Authentication failed (wrong key, wrong password, or tampered archive)"; then - P "PQ-SDK wrong-key: generic auth-fail message" +# Test 7: when enabled, PQ-SDK wrong key triggers the same generic message. +if ((SDK_ENABLED)); then + "$ZUPT" keygen --sdk -o k.priv >/dev/null 2>&1 + "$ZUPT" keygen --sdk -o other.priv >/dev/null 2>&1 + "$ZUPT" c --pq-sdk k.priv.pub pq.zupt input.txt >/dev/null 2>&1 + mkdir out7 + capture_expected_failure ERR7 'PQ-SDK wrong key' out7 \ + "$ZUPT" x --pq-sdk ../other.priv ../pq.zupt + if echo "$ERR7" | grep -q "Authentication failed (wrong key, wrong password, or tampered archive)"; then + P "PQ-SDK wrong-key: generic auth-fail message" + else + F "PQ-SDK wrong-key: didn't get generic message: '$ERR7'" + fi else - F "PQ-SDK wrong-key: didn't get generic message: '$ERR7'" + echo ' SKIP: PQ-SDK wrong-key message needs system libvuptsdk (WITH_SDK=1)' fi echo "" diff --git a/tests/test_f12_comment.sh b/tests/test_f12_comment.sh index 5a080a9..e10f9c6 100755 --- a/tests/test_f12_comment.sh +++ b/tests/test_f12_comment.sh @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés # -# F-12 regression test (Zupt 2.4.3). +# F-12 regression test (ZUPT 2.4.3, hardened in 5.2.2). # # F-12: implement the reserved `comment_offset` field in zupt_archive_header_t. # Adds ZUPT_BLOCK_COMMENT (0x05) block type written between data blocks and @@ -13,7 +13,7 @@ # # Assertions: # 1. Roundtrip the comment text in plaintext mode. -# 2. Roundtrip the comment text in Argon2id-password mode. +# 2. Roundtrip the comment text in the build's default password mode. # 3. Roundtrip the comment text in PBKDF2-password mode. # 4. Roundtrip the comment text in PQ-SDK mode. # 5. `zupt info` reports the presence of a comment without revealing it @@ -23,26 +23,21 @@ # 8. An archive without a comment shows no Comment: line in info. # 9. --comment-file path reads the comment from disk. # 10. Empty comment string is treated as no-comment (header offset stays 0). +# 11. Terminal control bytes are escaped when a comment is displayed. -set -u +set -Eeuo pipefail -ZUPT="${ZUPT_BIN:-./zupt}" -# Source-only build (WITH_SDK=0) has no libzuptsdk: the SDK-mode paths this -# test exercises are unavailable, so skip cleanly instead of failing. -_sdkck="$(mktemp -d)" -if ! "$ZUPT" keygen --sdk -o "$_sdkck/p" >/dev/null 2>&1; then - rm -rf "$_sdkck"; echo " SKIP: built without libzuptsdk (source-only) - SDK-mode test not applicable"; exit 0 -fi -rm -rf "$_sdkck" - -case "$ZUPT" in - /*) ;; - *) ZUPT="$PWD/$ZUPT" ;; -esac +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P) +ZUPT=${ZUPT_BIN:-$repo_root/zupt} if [ ! -x "$ZUPT" ]; then echo " ✗ $ZUPT not found — run 'make' first" >&2 exit 1 fi +version=$("$ZUPT" --version 2>&1) +SDK_ENABLED=0 +if grep -Fq 'libvuptsdk=enabled' <<<"$version"; then + SDK_ENABLED=1 +fi PASS=0 FAIL=0 @@ -68,14 +63,15 @@ else F "plaintext: comment not shown on extract" fi -# Test 2: Argon2id-password roundtrip +# Test 2: default password-KDF roundtrip (PBKDF2 in the source-only build, +# Argon2id when system libvuptsdk is enabled). "$ZUPT" c -c "$COMMENT" -p secret arg.zupt input.txt >/dev/null 2>&1 mkdir out_a OUT=$( (cd out_a && "$ZUPT" x -p secret ../arg.zupt) 2>&1 ) if echo "$OUT" | grep -qF "$COMMENT"; then - P "Argon2id: comment roundtrips" + P "default password KDF: comment roundtrips" else - F "Argon2id: comment not shown" + F "default password KDF: comment not shown" fi # Test 3: PBKDF2-password roundtrip @@ -88,15 +84,19 @@ else F "PBKDF2: comment not shown" fi -# Test 4: PQ-SDK roundtrip -"$ZUPT" keygen --sdk -o k.priv >/dev/null 2>&1 -"$ZUPT" c -c "$COMMENT" --pq-sdk k.priv.pub pq.zupt input.txt >/dev/null 2>&1 -mkdir out_pq -OUT=$( (cd out_pq && "$ZUPT" x --pq-sdk ../k.priv ../pq.zupt) 2>&1 ) -if echo "$OUT" | grep -qF "$COMMENT"; then - P "PQ-SDK: comment roundtrips" +# Test 4: optional PQ-SDK roundtrip. +if ((SDK_ENABLED)); then + "$ZUPT" keygen --sdk -o k.priv >/dev/null 2>&1 + "$ZUPT" c -c "$COMMENT" --pq-sdk k.priv.pub pq.zupt input.txt >/dev/null 2>&1 + mkdir out_pq + OUT=$( (cd out_pq && "$ZUPT" x --pq-sdk ../k.priv ../pq.zupt) 2>&1 ) + if echo "$OUT" | grep -qF "$COMMENT"; then + P "PQ-SDK: comment roundtrips" + else + F "PQ-SDK: comment not shown" + fi else - F "PQ-SDK: comment not shown" + echo ' SKIP: PQ-SDK comment roundtrip needs system libvuptsdk (WITH_SDK=1)' fi # Test 5: info doesn't leak comment plaintext for encrypted archives @@ -115,33 +115,39 @@ fi # Test 6: tampering the comment block payload is rejected # Find the comment block offset: it's stored in hdr[44..51] (comment_offset). COMM_OFF=$(python3 -c " -b = open('pq.zupt','rb').read() +b = open('arg.zupt','rb').read() print(int.from_bytes(b[44:52],'little')) ") # Tamper a byte inside the comment block payload (skip the 2-byte magic). # Pick offset COMM_OFF + 20 which should land inside encrypted payload bytes. -cp pq.zupt tamp_comment.zupt +cp arg.zupt tamp_comment.zupt python3 -c " b = bytearray(open('tamp_comment.zupt','rb').read()) b[$COMM_OFF + 20] ^= 1 open('tamp_comment.zupt','wb').write(bytes(b))" mkdir out_tc -ERR=$( (cd out_tc && "$ZUPT" x --pq-sdk ../k.priv ../tamp_comment.zupt) 2>&1 || true ) -if [ ! -f out_tc/input.txt ]; then +set +e +(cd out_tc && "$ZUPT" x -p secret ../tamp_comment.zupt >/dev/null 2>&1) +tampered_comment_status=$? +set -e +if [ "$tampered_comment_status" -ne 0 ] && [ ! -f out_tc/input.txt ]; then P "comment-block tamper rejected (per-block HMAC)" else F "comment-block tamper silently accepted" fi # Test 7: tampering hdr.comment_offset is rejected (covered by AIT) -cp pq.zupt tamp_offset.zupt +cp arg.zupt tamp_offset.zupt python3 -c " b = bytearray(open('tamp_offset.zupt','rb').read()) b[44] ^= 1 # low byte of comment_offset field open('tamp_offset.zupt','wb').write(bytes(b))" mkdir out_to -ERR=$( (cd out_to && "$ZUPT" x --pq-sdk ../k.priv ../tamp_offset.zupt) 2>&1 || true ) -if [ ! -f out_to/input.txt ]; then +set +e +(cd out_to && "$ZUPT" x -p secret ../tamp_offset.zupt >/dev/null 2>&1) +tampered_offset_status=$? +set -e +if [ "$tampered_offset_status" -ne 0 ] && [ ! -f out_to/input.txt ]; then P "comment_offset tamper rejected (AIT covers header)" else F "comment_offset tamper silently accepted" @@ -176,6 +182,19 @@ else F "empty -c written as a comment block (should be no-op)" fi +# Test 11: authenticated comments are still untrusted terminal input. Newline, +# ESC/OSC, DEL, and C1 controls must be rendered as visible escapes. +printf 'trusted ação 安全\nforged\033]52;c;Y2xpcGJvYXJk\007\177\302\200' > control.txt +"$ZUPT" c --comment-file control.txt control.zupt input.txt >/dev/null 2>&1 +mkdir out_control +OUT=$( (cd out_control && "$ZUPT" x ../control.zupt) 2>&1 ) +if [[ $OUT != *$'\033'* ]] && + grep -Fq 'Comment: trusted ação 安全\x0Aforged\x1B]52;c;Y2xpcGJvYXJk\x07\x7F\xC2\x80' <<<"$OUT"; then + P "terminal controls are escaped while printable UTF-8 is preserved" +else + F "terminal controls in comments reached output unsanitized: $OUT" +fi + echo "" echo " ───────────────────────────────────────" echo " F-12 regression: $PASS passed, $FAIL failed" diff --git a/tests/test_format_little_endian.sh b/tests/test_format_little_endian.sh new file mode 100644 index 0000000..da754c3 --- /dev/null +++ b/tests/test_format_little_endian.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +set -Eeuo pipefail + +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P) +bin=${1:-$repo_root/zupt} +case "$bin" in + /*) ;; + *) bin="$(pwd -P)/${bin#./}" ;; +esac + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +test -x "$bin" || fail "$bin is not executable" +command -v python3 >/dev/null 2>&1 || fail 'python3 is required' + +tmp=$(mktemp -d "${TMPDIR:-/tmp}/zupt-format-le.XXXXXX") +trap 'rm -rf "$tmp"' EXIT +printf 'little-endian format fixture\n' > "$tmp/input" +printf 'format-test-password\n' > "$tmp/password" +chmod 600 "$tmp/password" + +"$bin" compress --store --kdf pbkdf2 --pass-file "$tmp/password" \ + "$tmp/format.zupt" "$tmp/input" >/dev/null 2>&1 || + fail 'could not create PBKDF2 archive fixture' + +python3 - "$tmp/format.zupt" <<'PY' +import pathlib +import struct +import sys + +path = pathlib.Path(sys.argv[1]) +data = path.read_bytes() + +def reject(message): + raise SystemExit(f"FAIL: {message}") + +def varint(offset): + value = 0 + shift = 0 + start = offset + while offset < len(data) and shift <= 63: + byte = data[offset] + offset += 1 + value |= (byte & 0x7f) << shift + if byte & 0x80 == 0: + encoded = data[start:offset] + canonical = bytearray() + remaining = value + while remaining >= 0x80: + canonical.append((remaining & 0x7f) | 0x80) + remaining >>= 7 + canonical.append(remaining) + if bytes(canonical) != encoded: + reject("non-canonical varint in generated archive") + return value, offset + shift += 7 + reject("unterminated varint") + +if len(data) < 64 + 32 + 32: + reject("archive is too small") +if data[:6] != b"ZUPT\x1a\x00" or data[6:8] != bytes((1, 6)): + reject("header magic/version mismatch") + +flags = struct.unpack_from(" "$tmp/one-byte" +"$bin" compress --store "$tmp/varint-base.zupt" "$tmp/one-byte" \ + >/dev/null 2>&1 || fail 'could not create varint fixture' +python3 - "$tmp/varint-base.zupt" "$tmp" <<'PY' +import pathlib +import struct +import sys + +source = pathlib.Path(sys.argv[1]).read_bytes() +out = pathlib.Path(sys.argv[2]) +if len(source) < 128 or source[64:67] != b"\xbb\x01\x00": + raise SystemExit("FAIL: unexpected varint fixture layout") + +footer = len(source) - 64 +index_offset = struct.unpack_from("/dev/null 2>&1; then + fail "non-canonical or overflowing varint was accepted: ${malformed##*/}" + fi +done +printf 'non-canonical and overflowing uint64 varints: PASS\n' diff --git a/tests/test_gui_branding.sh b/tests/test_gui_branding.sh index fc988ab..7593077 100755 --- a/tests/test_gui_branding.sh +++ b/tests/test_gui_branding.sh @@ -2,19 +2,20 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés # -# Regression test for GUI branding + licensing. +# Regression test for ZUPT GUI branding + licensing. # -# History: in v3.0.0 the GUI shipped with two real bugs: -# 1. An MIT license credit line in the about panel — the GUI is -# AGPL-3.0-or-later with commercial dual-licensing; "MIT" was -# false and inherited from an early templating mistake. +# History: in v3.0.0 the GUI shipped with two documentation/code bugs: +# 1. The about panel described the current GUI simply as MIT even though the +# current source carried AGPL-3.0-or-later notices. Published earlier MIT +# grants remain valid for the exact historical material covered by them. # 2. A version-string parser using `replace("zupt ", "")` which # matched the wrong substring after the v3.0.0 rename. The # version banner became `vaptvupt 3.0.0 (formerly zupt; # renamed in v3.0.0 — INPI Brasil trademark)` and that # `replace` chewed up "zupt " inside the parenthetical too. # -# This test asserts both classes of bug stay fixed. +# This test keeps the current about-panel statement aligned with current SPDX +# notices without denying the historical license record, and covers the parser. set -u PASS=0; FAIL=0 @@ -26,13 +27,14 @@ GUI=gui/src/zupt_gui.py echo "GUI branding + licensing" -# ─── MIT reference checks ─── -# Any MIT credit line in the GUI source is a bug. +# ─── Current and historical license checks ─── +# The current about-panel implementation must not advertise the current GUI as +# MIT-only. Historical license information belongs in the license notice. if grep -nE '"MIT"|"MIT [Ll]icense"| MIT[^A-Za-z]' "$GUI" >/dev/null 2>&1; then - F "GUI source contains an MIT reference" + F "GUI source advertises the current GUI as MIT" grep -nE '"MIT"|"MIT [Ll]icense"| MIT[^A-Za-z]' "$GUI" | sed 's/^/ /' else - P "GUI source contains no MIT references" + P "GUI source does not advertise the current GUI as MIT" fi # The GUI's own LICENSE-GUI file must be AGPL (or pointed to AGPL). @@ -42,11 +44,18 @@ if [ -f gui/LICENSE-GUI ]; then else F "gui/LICENSE-GUI is not AGPL — got: $(head -1 gui/LICENSE-GUI)" fi - # Specifically, it shouldn't START with "MIT License" + # The current notice starts with AGPL, while retaining the factual erratum. if head -1 gui/LICENSE-GUI | grep -qE "^MIT License"; then - F "gui/LICENSE-GUI starts with 'MIT License' — that's the bug we just fixed" + F "gui/LICENSE-GUI presents MIT as the current license" else - P "gui/LICENSE-GUI does not start with 'MIT License'" + P "gui/LICENSE-GUI presents AGPL as the current license" + fi + if grep -q 'd4660e6539c8b6eeba81751c018217d978fdd618' gui/LICENSE-GUI && + grep -q 'v2.2.2' gui/LICENSE-GUI && + grep -q 'does not revoke or reinterpret a historical grant' gui/LICENSE-GUI; then + P "gui/LICENSE-GUI preserves the evidenced historical MIT grant" + else + F "gui/LICENSE-GUI is missing the factual historical-license erratum" fi fi @@ -77,13 +86,23 @@ else F "GUI is missing the anchored version regex (_VERSION_RE)" fi -# ─── Brand-string check ─── -# Splash and about-panel headers should say VAPTVUPT (the v3.0.0 name), -# not ZUPT. -if grep -q 'QLabel("ZUPT")' "$GUI"; then - F "GUI still uses QLabel(\"ZUPT\") — should be QLabel(\"VAPTVUPT\")" +# Package and promotion gates consume this as a machine-readable identity. +# Keep binding and discovered-CLI diagnostics in the UI rather than appending +# them to the stable --version line. +if [ "$(grep -Fc 'print(f"zupt-gui {ZUPT_VER_NUMBER}' "$GUI")" -eq 1 ] && + grep -Fqx ' print(f"zupt-gui {ZUPT_VER_NUMBER}")' "$GUI"; then + P "GUI --version emits the stable exact product/version line" else - P "GUI uses VAPTVUPT (not ZUPT) in QLabel headers" + F "GUI --version output is not the stable exact product/version line" +fi + +# ─── Brand-string check ─── +# Release 5.2.2 restores the original ZUPT identity in every current panel. +if grep -q 'QLabel("ZUPT")' "$GUI" && + ! grep -q 'QLabel("VAPTVUPT")' "$GUI"; then + P "GUI uses ZUPT in current QLabel headers" +else + F "GUI current headers are not consistently branded ZUPT" fi # Crypto stack should include Argon2id (the default since v2.4.1). @@ -109,9 +128,8 @@ fi # ─── Functional check ─── # If the CLI binary is available, exercise _VERSION_RE end-to-end. -if [ -x ./vaptvupt ] || [ -x ./zupt ]; then - BIN=./vaptvupt - [ ! -x "$BIN" ] && BIN=./zupt +BIN=${1:-${ZUPT_BIN:-./zupt}} +if [ -x "$BIN" ]; then OUT=$("$BIN" version 2>&1 | head -1) EXTRACTED=$(python3 -c " import re, sys @@ -126,7 +144,7 @@ print(m.group(1) if m else 'NONE') F "version regex extracted '$EXTRACTED', expected '$EXPECTED'" fi else - echo " - skipped: ./vaptvupt not built — skipping functional version test" + echo " - skipped: ZUPT binary not built — skipping functional version test" fi echo "" diff --git a/tests/test_help_consistency.sh b/tests/test_help_consistency.sh index 5cf8f46..597ab1a 100755 --- a/tests/test_help_consistency.sh +++ b/tests/test_help_consistency.sh @@ -2,16 +2,17 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés # -# Regression test for the `vaptvupt help` output. +# Regression test for the `zupt help` output. # # History: # F-13 (v3.0.2): the usage() string literal exceeded C99's 4095-char # limit (4121 chars), triggering -Woverlength-strings. Also, the -# help text had drifted out of date during the v3.0.0 rename: -# - Examples still said `zupt compress`, `zupt extract`, etc. +# help text had drifted out of date during the former v3.0.0 rename. +# Release 5.2.2 restores ZUPT/zupt as the public product and command: # - "Compression: LZ77 (1MB window) + Huffman entropy coding" — # false; the default codec is now VaptVupt LZ + ANS 2.48.5 -# - "License: AGPL-3.0-or-later (Zupt)" — should be (VaptVupt) +# - the first-party license label must say ZUPT while retaining the +# separately attributed VaptVupt codec name. # # This test asserts the help output stays consistent with reality. # Run from repo root after a build. @@ -21,8 +22,7 @@ PASS=0; FAIL=0 P() { echo " ✓ $1"; PASS=$((PASS+1)); } F() { echo " ✗ $1"; FAIL=$((FAIL+1)); } -BIN=./vaptvupt -[ -x ./vaptvupt ] || BIN=./zupt +BIN=${1:-${ZUPT_BIN:-./zupt}} [ -x "$BIN" ] || { echo "ERROR: no built binary found"; exit 2; } HELP=$("$BIN" help 2>&1) @@ -62,20 +62,20 @@ else fi # ─── Brand consistency ─── -# The help output must use the new binary name in examples, not the old one. -if echo "$HELP" | grep -qE '^\s+vaptvupt (compress|extract|list|test|bench|keygen|info|disk)'; then - P "examples use 'vaptvupt' command name" +# The help output must use the restored primary binary name in examples. +if echo "$HELP" | grep -qE '^\s+zupt (compress|extract|list|test|bench|keygen|info|disk)'; then + P "examples use 'zupt' command name" else - F "examples don't use 'vaptvupt' — still saying 'zupt'?" + F "examples don't use the primary 'zupt' command" fi -# Conversely, the example lines shouldn't start with `zupt ` (the -# bare legacy name in example commands is the drift we just fixed). -LEGACY_EX=$(echo "$HELP" | grep -cE '^\s{1,4}zupt (compress|extract|list|test|bench|keygen) ') +# The former public command may be offered as a compatibility symlink, but +# current examples must not make it the primary interface. +LEGACY_EX=$(echo "$HELP" | grep -cE '^\s{1,4}vaptvupt (compress|extract|list|test|bench|keygen) ') if [ "$LEGACY_EX" -eq 0 ]; then - P "no examples use the bare legacy 'zupt' command name" + P "no examples use the former 'vaptvupt' command name" else - F "$LEGACY_EX example lines still use the legacy 'zupt' command name" + F "$LEGACY_EX example lines still use the former 'vaptvupt' command name" fi # ─── Codec consistency ─── @@ -95,10 +95,10 @@ else fi # ─── License consistency ─── -if echo "$HELP" | grep -q "AGPL-3.0-or-later (VaptVupt)"; then - P "help shows the correct license attribution (VaptVupt)" +if echo "$HELP" | grep -q "AGPL-3.0-or-later (ZUPT)"; then + P "help shows the correct first-party license attribution (ZUPT)" else - F "help has wrong license attribution — should say AGPL-3.0-or-later (VaptVupt)" + F "help has wrong license attribution — should say AGPL-3.0-or-later (ZUPT)" fi # Commercial-licensing contact visible. @@ -109,11 +109,19 @@ else fi # ─── KDF consistency ─── -# Argon2id is the default since v2.4.1; the help must say so. -if echo "$HELP" | grep -qE "Argon2id.*default"; then - P "help correctly identifies Argon2id as the default KDF" +# The help must state the ACTUAL default KDF for this build: PBKDF2-SHA256 on +# the source-only build (WITH_SDK=0), Argon2id only when built with WITH_SDK=1. +# A build that advertises Argon2id-by-default but derives PBKDF2 keys overstates +# its GPU/ASIC resistance (regression from v4.2.1). +if echo "$HELP" | grep -qiE "argon2id.*WITH_SDK=1"; then + P "help correctly scopes Argon2id to WITH_SDK=1 (source-only build)" +elif echo "$HELP" | grep -qE "PBKDF2.*[Dd]efault|[Dd]efault.*PBKDF2"; then + P "help correctly identifies PBKDF2-SHA256 as the default KDF" +elif echo "$HELP" | grep -qE "Argon2id.*[Dd]efault"; then + # A WITH_SDK=1 build legitimately defaults to Argon2id. + P "help identifies Argon2id as the default KDF (WITH_SDK=1 build)" else - F "help doesn't identify Argon2id as the default KDF" + F "help does not state the default password KDF" fi # ─── Format consistency ─── @@ -125,9 +133,9 @@ fi # ─── Functional check: help command works ─── if "$BIN" help >/dev/null 2>&1; then - P "vaptvupt help exits successfully" + P "zupt help exits successfully" else - F "vaptvupt help exits with non-zero status" + F "zupt help exits with non-zero status" fi echo "" diff --git a/tests/test_kdf_transparency.c b/tests/test_kdf_transparency.c index c4389f1..a1e9df7 100644 --- a/tests/test_kdf_transparency.c +++ b/tests/test_kdf_transparency.c @@ -20,17 +20,16 @@ * keys for both (so old archives keep opening). * 3. decrypt-init REFUSES an unknown profile rather than guessing a * derivation (fail-closed). - * 4. The underlying libzuptsdk Argon2id KDF is deterministic and - * memory-hard (a coarse cost floor) — this catches an SDK that has - * been swapped for a fast/weak stand-in at build time, before a - * user discovers their backup won't open or is under-protected. + * 4. The system libzuptsdk Argon2id KDF is deterministic and memory-hard + * (a coarse cost floor). This detects an unexpectedly weak system + * implementation before users depend on archives produced by it. */ #include "zupt.h" #include #include #include -/* easy-derive is the only KDF symbol the vendored SDK exports. */ +/* easy-derive is the KDF symbol exposed by the system SDK integration. */ int zuptsdk_easy_derive_key(const char *password, const uint8_t salt[16], uint8_t key_out[32]); int zupt_sdk_password_encrypt_init(zupt_keyring_t *kr, const char *password, uint8_t *enc_hdr, size_t *enc_hdr_len); @@ -112,7 +111,7 @@ int main(void) { ok("Argon2id KDF cost floor met (memory-hard preset active)"); else { char buf[96]; - snprintf(buf, sizeof buf, "Argon2id KDF suspiciously fast (%.1f ms) — weak/stub SDK?", ms); + snprintf(buf, sizeof buf, "Argon2id KDF suspiciously fast (%.1f ms) — weak/stub system SDK?", ms); bad(buf); } } diff --git a/tests/test_kdf_transparency.sh b/tests/test_kdf_transparency.sh index e9f2b8c..1629a0d 100755 --- a/tests/test_kdf_transparency.sh +++ b/tests/test_kdf_transparency.sh @@ -3,17 +3,20 @@ # Copyright (c) 2025-2026 Cristian Cezar Moisés # # F-15 — Argon2id KDF parameter transparency (v3.4.0). -# Builds and runs tests/test_kdf_transparency.c against the vendored SDK. +# Builds and runs tests/test_kdf_transparency.c against a system libvuptsdk. set -u -SDK_DIR="${ZUPTSDK_DIR:-vendor/zuptsdk}" -# Source-only build (WITH_SDK=0) has no libzuptsdk: the SDK-mode paths this -# test exercises are unavailable, so skip cleanly instead of failing. -_sdkck="$(mktemp -d)" -if ! ls "$SDK_DIR"/libzuptsdk.so* >/dev/null 2>&1; then - rm -rf "$_sdkck"; echo " SKIP: built without libzuptsdk (source-only) - SDK-mode test not applicable"; exit 0 +SDK_CFLAGS=${SDK_CFLAGS:-} +SDK_LIBS=${SDK_LIBS:-} +if [ -z "$SDK_CFLAGS$SDK_LIBS" ] && command -v pkg-config >/dev/null 2>&1 && \ + pkg-config --exists libvuptsdk; then + SDK_CFLAGS=$(pkg-config --cflags libvuptsdk) + SDK_LIBS=$(pkg-config --libs libvuptsdk) +fi +if [ -z "$SDK_LIBS" ]; then + echo " SKIP: system libvuptsdk development package unavailable" + exit 0 fi -rm -rf "$_sdkck" ARCH=$(uname -m) if [ "$ARCH" = "x86_64" ] || [ "$ARCH" = "i686" ]; then @@ -23,12 +26,13 @@ else fi TMP=$(mktemp -d) -if gcc -Iinclude -Isrc -I"$SDK_DIR/include" -Wall -Wextra -Werror $SHANI -O2 -std=c11 \ +# shellcheck disable=SC2086 # SDK flags intentionally expand to compiler words. +if "${CC:-cc}" -Iinclude -Isrc $SDK_CFLAGS -Wall -Wextra -Werror $SHANI -O2 -std=c11 \ tests/test_kdf_transparency.c \ src/zupt_crypto_sdk.c src/zupt_crypto.c src/zupt_sha256.c src/zupt_sha256_shani.c \ src/zupt_aes256.c src/zupt_xxh.c src/zupt_keccak.c src/zupt_x25519.c \ src/zupt_mlkem.c src/zupt_cpuid.c src/zupt_mlock.c \ - -L"$SDK_DIR" -lzuptsdk -Wl,-rpath,"$(cd "$SDK_DIR" && pwd)" -lm \ + $SDK_LIBS -lm \ -o "$TMP/t" 2>"$TMP/cc.log"; then "$TMP/t"; rc=$? else diff --git a/tests/test_key_files.sh b/tests/test_key_files.sh new file mode 100644 index 0000000..8c232ff --- /dev/null +++ b/tests/test_key_files.sh @@ -0,0 +1,430 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (c) 2026 Cristian Cezar Moises +# +# Key-file security regression coverage for the native ZKEY and ZPQK formats. + +set -uo pipefail + +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P) +zupt_bin=${1:-$repo_root/zupt} +if [[ $zupt_bin != /* ]]; then + zupt_bin=$(CDPATH='' cd -- "$(dirname -- "$zupt_bin")" 2>/dev/null && pwd -P)/$(basename -- "$zupt_bin") +fi +if [[ ! -x $zupt_bin ]]; then + printf 'FAIL: executable not found: %s\n' "$zupt_bin" >&2 + exit 1 +fi + +test_root=$(mktemp -d "${TMPDIR:-/tmp}/zupt-key-files.XXXXXXXX") || exit 1 +trap 'chmod -R u+rwX "$test_root" 2>/dev/null || true; rm -rf -- "$test_root"' EXIT HUP INT TERM + +passes=0 +failures=0 +case_number=0 + +pass() { + passes=$((passes + 1)) + printf ' PASS: %s\n' "$1" +} + +fail() { + failures=$((failures + 1)) + printf ' FAIL: %s\n' "$1" >&2 +} + +file_mode() { + if stat -c '%a' "$1" >/dev/null 2>&1; then + stat -c '%a' "$1" + else + stat -f '%Lp' "$1" + fi +} + +windows_private_acl() { + local output=$1 windows_path + command -v cygpath >/dev/null 2>&1 || return 1 + command -v powershell.exe >/dev/null 2>&1 || return 1 + windows_path=$(cygpath -aw -- "$output") || return 1 + # PowerShell variables must remain literal until powershell.exe evaluates + # this single-quoted Bash argument. + # shellcheck disable=SC2016 + ZUPT_KEY_ACL_PATH=$windows_path powershell.exe -NoLogo -NoProfile \ + -NonInteractive -Command ' + $ErrorActionPreference = "Stop" + $acl = Get-Acl -LiteralPath $env:ZUPT_KEY_ACL_PATH + $sidType = [System.Security.Principal.SecurityIdentifier] + $rules = @($acl.GetAccessRules($true, $true, $sidType)) + $currentSid = + [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + if (-not $acl.AreAccessRulesProtected) { + throw "private-key DACL permits inheritance" + } + if ($rules.Count -ne 1) { + throw "private-key DACL does not contain exactly one ACE" + } + $rule = $rules[0] + if ($rule.IsInherited) { + throw "private-key ACE is inherited" + } + if ($rule.AccessControlType -ne + [System.Security.AccessControl.AccessControlType]::Allow) { + throw "private-key ACE is not an allow rule" + } + if ($rule.IdentityReference.Value -ne $currentSid) { + throw "private-key ACE is not restricted to the current user" + } + if ($rule.InheritanceFlags -ne + [System.Security.AccessControl.InheritanceFlags]::None -or + $rule.PropagationFlags -ne + [System.Security.AccessControl.PropagationFlags]::None) { + throw "private-key ACE unexpectedly propagates" + } + $fullControl = [int64]( + [System.Security.AccessControl.FileSystemRights]::FullControl) + $actualRights = [int64]($rule.FileSystemRights) + if (($actualRights -band $fullControl) -ne $fullControl) { + throw "private-key ACE does not grant current-user full control" + } + ' /dev/null +} + +generate_with_mode() { + local label=$1 mask=$2 output=$3 + shift 3 + if (umask "$mask"; "$zupt_bin" keygen "$@" -o "$output" >/dev/null 2>&1); then + case $(uname -s 2>/dev/null || printf unknown) in + MINGW*|MSYS*|CYGWIN*) + if windows_private_acl "$output"; then + pass "$label has a protected current-user-only DACL under umask $mask" + else + fail "$label lacks a protected current-user-only DACL under umask $mask" + fi + ;; + *) + local mode + mode=$(file_mode "$output") + if [[ $mode == 600 ]]; then + pass "$label is mode 0600 under umask $mask" + else + fail "$label mode under umask $mask is $mode, expected 600" + fi + ;; + esac + else + fail "$label generation failed under umask $mask" + fi +} + +expect_generation_refused() { + local label=$1 output=$2 expected=$3 + shift 3 + if "$zupt_bin" keygen "$@" -o "$output" >/dev/null 2>&1; then + fail "$label unexpectedly replaced an existing destination" + elif [[ -f $output && ! -L $output && $(<"$output") == "$expected" ]]; then + pass "$label refuses an existing file without modifying it" + else + fail "$label changed or removed an existing file" + fi +} + +expect_symlink_refused() { + local label=$1 link=$2 target=$3 expected=$4 + shift 4 + if "$zupt_bin" keygen "$@" -o "$link" >/dev/null 2>&1; then + fail "$label unexpectedly followed an output symlink" + elif [[ -L $link && -f $target && $(<"$target") == "$expected" ]]; then + pass "$label refuses a symlink without modifying its target" + else + fail "$label changed the symlink or its target" + fi +} + +# Mutate a valid native key. Header-only mutations receive a newly calculated +# XXH64 so they prove the parser checks magic/version/flags/reserved/role rather +# than merely reaching the checksum rejection. XXH64 remains a corruption check, +# not authentication of an intentionally substituted public key. +mutate_key() { + python3 - "$1" "$2" "$3" <<'PY' +import struct +import sys + +MASK = (1 << 64) - 1 +P1 = 11400714785074694791 +P2 = 14029467366897019727 +P3 = 1609587929392839161 +P4 = 9650029242287828579 +P5 = 2870177450012600261 + +def rol(value, bits): + return ((value << bits) | (value >> (64 - bits))) & MASK + +def round64(acc, value): + acc = (acc + value * P2) & MASK + acc = rol(acc, 31) + return (acc * P1) & MASK + +def merge_round(acc, value): + acc ^= round64(0, value) + return (acc * P1 + P4) & MASK + +def xxh64(data, seed=0): + length = len(data) + pos = 0 + if length >= 32: + v1 = (seed + P1 + P2) & MASK + v2 = (seed + P2) & MASK + v3 = seed & MASK + v4 = (seed - P1) & MASK + limit = length - 32 + while pos <= limit: + v1 = round64(v1, struct.unpack_from('> 33 + result = (result * P2) & MASK + result ^= result >> 29 + result = (result * P3) & MASK + result ^= result >> 32 + return result & MASK + +source, destination, mutation = sys.argv[1:] +data = bytearray(open(source, 'rb').read()) +if len(data) < 16: + raise SystemExit('source key is unexpectedly short') +stored = int.from_bytes(data[-8:], 'little') +if stored != xxh64(data[:-8]): + raise SystemExit('source key checksum does not match the format') + +recheck = False +if mutation == 'magic': + data[0] ^= 0x20 + recheck = True +elif mutation == 'version': + data[4] = 2 + recheck = True +elif mutation == 'flag': + data[5] = 0x80 + recheck = True +elif mutation == 'reserved': + data[6] = 1 + recheck = True +elif mutation == 'role': + data[5] ^= 1 + recheck = True +elif mutation == 'key': + data[16] ^= 1 +elif mutation == 'secret': + if data[5] != 1: + raise SystemExit('secret mutation requires a private key') + data[-16] ^= 1 +elif mutation == 'checksum': + data[-1] ^= 1 +elif mutation == 'truncated': + del data[-1] +elif mutation == 'appended': + data.append(0x41) +else: + raise SystemExit('unknown mutation: ' + mutation) + +if recheck: + data[-8:] = xxh64(data[:-8]).to_bytes(8, 'little') +open(destination, 'wb').write(data) +PY +} + +expect_public_rejected() { + local format=$1 option=$2 key=$3 label=$4 + case_number=$((case_number + 1)) + local archive=$test_root/rejected-public-$case_number.zupt + if "$zupt_bin" compress "$option" "$key" "$archive" \ + "$test_root/input.txt" >/dev/null 2>&1; then + fail "$format public key accepts $label" + elif [[ -e $archive ]]; then + fail "$format public key rejection published an archive for $label" + else + pass "$format public key rejects $label" + fi +} + +expect_private_rejected() { + local format=$1 key=$2 label=$3 + case_number=$((case_number + 1)) + local public=$test_root/rejected-private-$case_number.pub + if [[ $format == ZKEY ]]; then + if "$zupt_bin" keygen --pub -o "$public" -k "$key" >/dev/null 2>&1; then + fail "$format private key accepts $label" + return + fi + else + if "$zupt_bin" keygen --pub --pq-only -o "$public" -k "$key" \ + >/dev/null 2>&1; then + fail "$format private key accepts $label" + return + fi + fi + if [[ -e $public ]]; then + fail "$format private key rejection published output for $label" + else + pass "$format private key rejects $label" + fi +} + +printf 'key-file security regression input\n' >"$test_root/input.txt" + +printf 'Key-file permissions and no-replace publication\n' +generate_with_mode 'ZKEY private key' 022 "$test_root/hybrid-022.key" +generate_with_mode 'ZKEY private key' 000 "$test_root/hybrid-000.key" +generate_with_mode 'ZPQK private key' 022 "$test_root/pq-022.key" --pq-only +generate_with_mode 'ZPQK private key' 000 "$test_root/pq-000.key" --pq-only + +printf 'hybrid sentinel' >"$test_root/existing-hybrid.key" +expect_generation_refused 'ZKEY generation' "$test_root/existing-hybrid.key" \ + 'hybrid sentinel' +printf 'pq sentinel' >"$test_root/existing-pq.key" +expect_generation_refused 'ZPQK generation' "$test_root/existing-pq.key" \ + 'pq sentinel' --pq-only + +if ln -s "$test_root/hybrid-target" "$test_root/hybrid-link" 2>/dev/null; then + printf 'hybrid target sentinel' >"$test_root/hybrid-target" + expect_symlink_refused 'ZKEY generation' "$test_root/hybrid-link" \ + "$test_root/hybrid-target" 'hybrid target sentinel' +else + printf ' SKIP: symlinks unavailable for ZKEY no-follow test\n' +fi +if ln -s "$test_root/pq-target" "$test_root/pq-link" 2>/dev/null; then + printf 'pq target sentinel' >"$test_root/pq-target" + expect_symlink_refused 'ZPQK generation' "$test_root/pq-link" \ + "$test_root/pq-target" 'pq target sentinel' --pq-only +else + printf ' SKIP: symlinks unavailable for ZPQK no-follow test\n' +fi + +cp "$test_root/hybrid-022.key" "$test_root/hybrid-before-same-path.key" +if "$zupt_bin" keygen --pub -o "$test_root/hybrid-022.key" \ + -k "$test_root/hybrid-022.key" >/dev/null 2>&1; then + fail 'ZKEY public export accepted the private input as its output path' +elif cmp -s "$test_root/hybrid-before-same-path.key" \ + "$test_root/hybrid-022.key"; then + pass 'ZKEY same-path public export preserves the private key' +else + fail 'ZKEY same-path public export modified the private key' +fi + +cp "$test_root/pq-022.key" "$test_root/pq-before-same-path.key" +if "$zupt_bin" keygen --pub --pq-only -o "$test_root/pq-022.key" \ + -k "$test_root/pq-022.key" >/dev/null 2>&1; then + fail 'ZPQK public export accepted the private input as its output path' +elif cmp -s "$test_root/pq-before-same-path.key" "$test_root/pq-022.key"; then + pass 'ZPQK same-path public export preserves the private key' +else + fail 'ZPQK same-path public export modified the private key' +fi + +printf '\nValid key workflows\n' +if "$zupt_bin" keygen --pub -o "$test_root/hybrid.pub" \ + -k "$test_root/hybrid-022.key" >/dev/null 2>&1 && + "$zupt_bin" compress --pq "$test_root/hybrid.pub" \ + "$test_root/hybrid.zupt" "$test_root/input.txt" >/dev/null 2>&1 && + "$zupt_bin" extract --pq "$test_root/hybrid-022.key" \ + -o "$test_root/hybrid-out" "$test_root/hybrid.zupt" >/dev/null 2>&1 && + hybrid_extracted=$(find "$test_root/hybrid-out" -name input.txt -type f \ + -print -quit) && [[ -n $hybrid_extracted ]] && + cmp -s "$test_root/input.txt" "$hybrid_extracted"; then + pass 'valid ZKEY public/private round trip' +else + fail 'valid ZKEY public/private round trip' +fi + +if "$zupt_bin" keygen --pub --pq-only -o "$test_root/pq.pub" \ + -k "$test_root/pq-022.key" >/dev/null 2>&1 && + "$zupt_bin" compress --pq-only "$test_root/pq.pub" \ + "$test_root/pq.zupt" "$test_root/input.txt" >/dev/null 2>&1 && + "$zupt_bin" extract --pq-only "$test_root/pq-022.key" \ + -o "$test_root/pq-out" "$test_root/pq.zupt" >/dev/null 2>&1 && + pq_extracted=$(find "$test_root/pq-out" -name input.txt -type f \ + -print -quit) && [[ -n $pq_extracted ]] && + cmp -s "$test_root/input.txt" "$pq_extracted"; then + pass 'valid ZPQK public/private round trip' +else + fail 'valid ZPQK public/private round trip' +fi + +# Compatibility: native readers historically allowed the private file itself +# wherever a public recipient key was accepted. +if "$zupt_bin" compress --pq "$test_root/hybrid-022.key" \ + "$test_root/hybrid-private-recipient.zupt" "$test_root/input.txt" \ + >/dev/null 2>&1; then + pass 'valid private ZKEY remains accepted as recipient input' +else + fail 'valid private ZKEY recipient compatibility' +fi +if "$zupt_bin" compress --pq-only "$test_root/pq-022.key" \ + "$test_root/pq-private-recipient.zupt" "$test_root/input.txt" \ + >/dev/null 2>&1; then + pass 'valid private ZPQK remains accepted as recipient input' +else + fail 'valid private ZPQK recipient compatibility' +fi + +printf '\nMalformed native key rejection\n' +metadata_mutations=(magic version flag reserved role key checksum truncated appended) +for mutation in "${metadata_mutations[@]}"; do + hybrid_bad=$test_root/hybrid-public-$mutation.key + if mutate_key "$test_root/hybrid.pub" "$hybrid_bad" "$mutation"; then + expect_public_rejected ZKEY --pq "$hybrid_bad" "$mutation" + else + fail "could not create ZKEY public mutation: $mutation" + fi + + pq_bad=$test_root/pq-public-$mutation.key + if mutate_key "$test_root/pq.pub" "$pq_bad" "$mutation"; then + expect_public_rejected ZPQK --pq-only "$pq_bad" "$mutation" + else + fail "could not create ZPQK public mutation: $mutation" + fi +done + +private_mutations=(magic version flag reserved role key secret checksum truncated appended) +for mutation in "${private_mutations[@]}"; do + hybrid_bad=$test_root/hybrid-private-$mutation.key + if mutate_key "$test_root/hybrid-022.key" "$hybrid_bad" "$mutation"; then + expect_private_rejected ZKEY "$hybrid_bad" "$mutation" + else + fail "could not create ZKEY private mutation: $mutation" + fi + + pq_bad=$test_root/pq-private-$mutation.key + if mutate_key "$test_root/pq-022.key" "$pq_bad" "$mutation"; then + expect_private_rejected ZPQK "$pq_bad" "$mutation" + else + fail "could not create ZPQK private mutation: $mutation" + fi +done + +printf '\nKey-file results: %d passed, %d failed\n' "$passes" "$failures" +((failures == 0)) diff --git a/tests/test_legacy_disk_5_2_1.sh b/tests/test_legacy_disk_5_2_1.sh new file mode 100755 index 0000000..0d16820 --- /dev/null +++ b/tests/test_legacy_disk_5_2_1.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +set -Eeuo pipefail + +bin=${1:-./zupt} +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P) +fixture="$repo_root/tests/fixtures/v5.2.1-encrypted-dedup-disk.zupt.hex" +tmp=$(mktemp -d "${TMPDIR:-/tmp}/zupt-legacy-disk.XXXXXXXX") +trap 'rm -rf -- "$tmp"' EXIT HUP INT TERM + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +"${CC:-cc}" -std=c11 -Wall -Wextra -Werror \ + "$repo_root/tests/fixture_hex_decode.c" -o "$tmp/fixture-decode" || + fail 'could not build fixture decoder' +"$tmp/fixture-decode" "$fixture" "$tmp/legacy.zupt" || + fail 'could not decode v5.2.1 fixture' + +{ + dd if=/dev/zero bs=65536 count=1 2>/dev/null | tr '\000' 'A' + dd if=/dev/zero bs=65536 count=1 2>/dev/null | tr '\000' 'B' + dd if=/dev/zero bs=65536 count=1 2>/dev/null | tr '\000' 'B' + dd if=/dev/zero bs=65536 count=1 2>/dev/null | tr '\000' 'C' +} > "$tmp/expected.img" +printf '%s\n' 'vaptvupt-5.2.1-fixture' > "$tmp/password" +chmod 600 "$tmp/password" + +"$bin" list --pass-file "$tmp/password" "$tmp/legacy.zupt" >/dev/null 2>&1 || + fail 'v5.2.1 encrypted+dedup disk fixture could not be listed' +"$bin" test --pass-file "$tmp/password" "$tmp/legacy.zupt" >/dev/null 2>&1 || + fail 'v5.2.1 encrypted+dedup disk fixture failed validation' +mkdir "$tmp/extracted" +"$bin" extract --pass-file "$tmp/password" -o "$tmp/extracted" \ + "$tmp/legacy.zupt" >/dev/null 2>&1 || + fail 'v5.2.1 encrypted+dedup disk fixture could not be extracted' +cmp "$tmp/expected.img" "$tmp/extracted/legacy-abbc.img" || + fail 'v5.2.1 encrypted+dedup generic extraction mismatch' +"$bin" disk restore --pass-file "$tmp/password" \ + "$tmp/legacy.zupt" "$tmp/restored.img" >/dev/null 2>&1 || + fail 'v5.2.1 encrypted+dedup disk fixture could not be restored' +cmp "$tmp/expected.img" "$tmp/restored.img" || + fail 'v5.2.1 encrypted+dedup disk restore mismatch' + +printf 'v5.2.1 encrypted+dedup disk list/test/extract/restore compatibility: PASS\n' diff --git a/tests/test_mlkem_fips203.sh b/tests/test_mlkem_fips203.sh new file mode 100755 index 0000000..b9251d0 --- /dev/null +++ b/tests/test_mlkem_fips203.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# FIPS 203 CONFORMANCE test for the in-tree ML-KEM-768. +# +# Self-consistency (encaps/decaps round-trip) does NOT prove conformance: a +# transposed matrix convention round-trips fine but is not interoperable. This +# test validates against an EXTERNAL FIPS 203 reference — OpenSSL 3.5+, which +# ships ML-KEM-768 — three ways: +# 1. deterministic keygen: our ek == OpenSSL's ek for the same seed (d||z) +# 2. our encaps -> OpenSSL decap: shared secrets match +# 3. OpenSSL encap -> our decaps: shared secrets match +# +# Skips gracefully (exit 0) when the toolchain or an ML-KEM-capable OpenSSL is +# unavailable, so it is safe inside distro package builds. +set -u +echo "ML-KEM-768 FIPS 203 conformance (interop vs OpenSSL)" +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +CC="${CC:-cc}" + +command -v openssl >/dev/null 2>&1 || { echo " SKIP: no openssl"; exit 0; } +if ! openssl list -kem-algorithms 2>/dev/null | grep -qiE "ML-KEM-768|MLKEM768"; then + echo " SKIP: openssl has no ML-KEM-768 (need 3.5+)"; exit 0 +fi +command -v "$CC" >/dev/null 2>&1 || CC=gcc +command -v "$CC" >/dev/null 2>&1 || { echo " SKIP: no C compiler"; exit 0; } +command -v od >/dev/null 2>&1 || { echo " SKIP: no od"; exit 0; } + +T=$(mktemp -d); trap 'rm -rf "$T"' EXIT +H="$T/harness" +if ! "$CC" -O2 -I"$ROOT/include" -I"$ROOT/src" "$HERE/mlkem_fips203_harness.c" \ + "$ROOT/src/zupt_mlkem.c" "$ROOT/src/zupt_keccak.c" -o "$H" 2>"$T/cc.err"; then + echo " FAIL: ML-KEM interoperability harness build failed" >&2 + sed 's/^/ /' "$T/cc.err" | head -3 >&2 + exit 1 +fi +hx(){ od -A n -v -t x1 "$1" | tr -d ' \n'; } +P=0; F=0; ok(){ echo " ✓ $1"; P=$((P+1)); }; bad(){ echo " ✗ $1"; F=$((F+1)); } +cd "$T" || exit 1 + +# 1) deterministic keygen ek match +head -c 64 /dev/urandom > dz.bin +SEED=$(hx dz.bin) +openssl genpkey -algorithm ML-KEM-768 -pkeyopt hexseed:"$SEED" -out osl.pem 2>/dev/null +openssl pkey -in osl.pem -pubout -outform DER -out osl_pub.der 2>/dev/null +tail -c 1184 osl_pub.der > osl_ek.bin +MLKEM_RAND="$T/dz.bin" "$H" keygen +if cmp -s ek.bin osl_ek.bin; then + ok "keygen ek == OpenSSL (byte-for-byte, same seed)" +else + bad "keygen ek differs from OpenSSL" +fi + +# 2) my encaps -> openssl decap +unset MLKEM_RAND +"$H" encaps osl_ek.bin >/dev/null 2>&1; cp ss.bin ss_mine.bin +openssl pkeyutl -decap -inkey osl.pem -in ct.bin -secret ss_osl.bin 2>/dev/null +if cmp -s ss_mine.bin ss_osl.bin; then + ok "my encaps -> OpenSSL decap: shared secret matches" +else + bad "my encaps not interoperable" +fi + +# 3) openssl encap -> my decap +HDR=$(( $(wc -c < osl_pub.der) - 1184 )); head -c "$HDR" osl_pub.der > hdr.bin +"$H" keygen +cat hdr.bin ek.bin > my_pub.der +openssl pkeyutl -encap -pubin -inkey my_pub.der -secret ss_osl2.bin -out ct2.bin 2>/dev/null +"$H" decaps dk.bin ct2.bin >/dev/null 2>&1; cp ss.bin ss_mine2.bin +if cmp -s ss_mine2.bin ss_osl2.bin; then + ok "OpenSSL encap -> my decap: shared secret matches" +else + bad "my decap not interoperable" +fi + +echo " Conformance: $P passed, $F failed" +[ "$F" -eq 0 ] && exit 0 || exit 1 diff --git a/tests/test_packaging_syntax.sh b/tests/test_packaging_syntax.sh index 8d50c03..b811a3a 100755 --- a/tests/test_packaging_syntax.sh +++ b/tests/test_packaging_syntax.sh @@ -1,339 +1,402 @@ -#!/bin/bash +#!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés -# -# Sprint 2.4.5 regression: packaging-recipe syntax checks. -# -# Ensures the recipes under packaging/{aur,debian,rpm,homebrew,nix}/ -# are syntactically valid. Doesn't try to actually build the packages -# (that needs distro-specific tooling), but catches: -# - shell syntax errors in PKGBUILD -# - malformed Debian control / changelog / copyright -# - missing fields in RPM spec -# - Ruby syntax errors in the Homebrew formula (if ruby is available) -# - Nix flake parse errors (if nix is available) -# -# Plus structural checks that don't need external tools: -# - debian/rules is executable -# - all recipes reference the same version as include/zupt.h -set -u +set -Eeuo pipefail +export LC_ALL=C -PASS=0 -FAIL=0 -P() { PASS=$((PASS+1)); echo " ✓ $1"; } -F() { FAIL=$((FAIL+1)); echo " ✗ $1"; } -SKIP() { echo " - skipped: $1"; } +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +cd -- "$root" -cd "$(dirname "$0")/.." +pass_count=0 +fail_count=0 +skip_count=0 +pass() { pass_count=$((pass_count + 1)); printf 'PASS: %s\n' "$*"; } +fail() { fail_count=$((fail_count + 1)); printf 'FAIL: %s\n' "$*" >&2; } +skip() { skip_count=$((skip_count + 1)); printf 'SKIP: %s\n' "$*"; } -VERSION=$(grep '^#define ZUPT_VERSION_STRING' include/zupt.h | awk -F'"' '{print $2}') -echo "Packaging syntax checks (zupt $VERSION)" - -# ─── AUR PKGBUILD ─── -if [ -f packaging/aur/PKGBUILD ]; then - if bash -n packaging/aur/PKGBUILD 2>/dev/null; then - P "AUR PKGBUILD: bash syntax clean" - else - F "AUR PKGBUILD: bash syntax error" - fi - if grep -q "^pkgver=$VERSION$" packaging/aur/PKGBUILD; then - P "AUR PKGBUILD: pkgver matches include/zupt.h ($VERSION)" - else - F "AUR PKGBUILD: pkgver mismatch (expected $VERSION; got $(grep '^pkgver=' packaging/aur/PKGBUILD))" - fi - for field in pkgname pkgver pkgrel pkgdesc arch url license depends; do - if grep -qE "^$field=" packaging/aur/PKGBUILD; then - : - else - F "AUR PKGBUILD: missing required field '$field'" - continue +has_exact_line_crlf_safe() { + local expected=$1 path=$2 line + while IFS= read -r line || [[ -n $line ]]; do + line=${line%$'\r'} + if [[ $line == "$expected" ]]; then + return 0 fi - done - P "AUR PKGBUILD: required fields present (pkgname, pkgver, pkgrel, pkgdesc, arch, url, license, depends)" + done < "$path" + return 1 +} + +version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h) +[[ -n $version ]] || { printf 'FAIL: cannot determine upstream version\n' >&2; exit 1; } + +for script in packaging/build-deb.sh packaging/build-rpm.sh \ + packaging/build-appimage.sh packaging/build-gui-appimage.sh \ + packaging/build-gui-deb.sh packaging/build-gui-rpm.sh \ + gui/packaging/appimage/build-appimage.sh packaging/build-dmg.sh \ + scripts/check-source-only.sh scripts/export-opensuse-package.sh \ + scripts/test-installed-zupt.sh packaging/opensuse/source-audit.sh; do + if bash -n "$script"; then pass "$script shell syntax"; else fail "$script shell syntax"; fi +done + +if ! command -v make >/dev/null 2>&1; then + skip 'make unavailable for Debian rules syntax' +elif make -n -f packaging/debian/rules override_dh_auto_build >/dev/null; then + pass 'Debian rules make syntax' else - F "AUR PKGBUILD: file missing" + fail 'Debian rules make syntax' fi -# ─── Debian source package ─── -for f in control rules changelog copyright source/format; do - if [ -f "packaging/debian/$f" ]; then - : +check_recipe_version() { + local recipe=$1 recipe_version=$2 + if [[ $recipe_version == "$version" ]]; then + pass "$recipe version is $version" else - F "Debian: packaging/debian/$f missing" + fail "$recipe version is '$recipe_version' (expected $version)" fi -done -if [ -f packaging/debian/control ] && [ -f packaging/debian/rules ]; then - P "Debian: control, rules, changelog, copyright, source/format all present" -fi -if [ -x packaging/debian/rules ]; then - P "Debian: rules is executable" +} + +check_recipe_version AUR \ + "$(sed -n 's/^pkgver=//p' packaging/aur/PKGBUILD)" +check_recipe_version Debian \ + "$(sed -n '1s/^zupt (\([^-]*\)-.*/\1/p' packaging/debian/changelog)" +check_recipe_version Fedora \ + "$(awk '/^Version:/{print $2; exit}' packaging/rpm/zupt.spec)" +check_recipe_version Homebrew \ + "$(sed -n 's/^[[:space:]]*version "\([^"]*\)".*/\1/p' packaging/homebrew/zupt.rb)" +check_recipe_version Nix \ + "$(sed -n 's/^[[:space:]]*version = "\([^"]*\)";.*/\1/p' packaging/nix/flake.nix | head -1)" +check_recipe_version Guix \ + "$(sed -n 's/^(define %zupt-version "\([^"]*\)")/\1/p' packaging/guix/zupt.scm)" +check_recipe_version openSUSE \ + "$(awk '/^Version:/{print $2; exit}' packaging/opensuse/zupt.spec)" +check_recipe_version GUI-Deb-Control \ + "$(awk '/^Version:/{print $2; exit}' gui/packaging/deb/control)" + +if grep -Fqx "VERSION=\${VERSION:-$version}" install.sh && \ + has_exact_line_crlf_safe \ + "if not defined VERSION set \"VERSION=$version\"" \ + gui/packaging/windows/build-windows.bat && \ + grep -Fqx "Depends: python3 (>= 3.9), python3-pyqt6 | python3-pyside6.qtwidgets, zupt (= $version)" \ + gui/packaging/deb/control; then + pass 'installer and static GUI package defaults match the upstream version' else - F "Debian: rules is not executable" + fail 'installer or static GUI package defaults do not match the upstream version' fi -if grep -qE "^Source: (vaptvupt|zupt)$" packaging/debian/control; then - P "Debian control: Source field correct" + +if grep -En 'REPLACE_AFTER|REPLACE_WITH|sha256sums=\(.SKIP.|base32 .REPLACE' \ + packaging/aur/PKGBUILD packaging/homebrew/zupt.rb packaging/guix/zupt.scm; then + if git tag --points-at HEAD 2>/dev/null | grep -Fxq "v$version"; then + fail 'tagged release recipes contain an unpinned source checksum' + else + pass 'release recipe checksums are explicitly pending final archive generation' + fi else - F "Debian control: Source field wrong/missing" + pass 'release recipe source checksums are pinned' fi -if grep -qE "^(vaptvupt|zupt) \($VERSION-[0-9]+\) " packaging/debian/changelog; then - P "Debian changelog: top entry matches $VERSION" + +if [[ -x packaging/debian/rules ]]; then + pass 'Debian rules is executable' else - F "Debian changelog: top entry version doesn't match include/zupt.h" + fail 'Debian rules is not executable' fi if command -v dpkg-parsechangelog >/dev/null 2>&1; then - if dpkg-parsechangelog -l packaging/debian/changelog >/dev/null 2>&1; then - P "Debian changelog: dpkg-parsechangelog accepts it" + if dpkg-parsechangelog -l packaging/debian/changelog >/dev/null; then + pass 'Debian changelog parses' else - F "Debian changelog: dpkg-parsechangelog rejected it" + fail 'Debian changelog does not parse' fi else - SKIP "dpkg-parsechangelog not available (dpkg-dev not installed)" -fi -if [ "$(cat packaging/debian/source/format)" = "3.0 (quilt)" ]; then - P "Debian source/format: 3.0 (quilt)" -else - F "Debian source/format: wrong content" + skip 'dpkg-parsechangelog unavailable' fi -# ─── RPM spec ─── -if [ -f packaging/rpm/vaptvupt.spec ]; then - for field in Name Version Release Summary License URL Source0; do - if grep -qE "^$field:" packaging/rpm/vaptvupt.spec; then - : - else - F "RPM spec: missing tag '$field:'" - fi - done - P "RPM spec: required header tags present" - SPEC_VER=$(grep -E "^Version:" packaging/rpm/vaptvupt.spec | awk '{print $2}') - if [ "$SPEC_VER" = "$VERSION" ]; then - P "RPM spec: Version: matches include/zupt.h ($VERSION)" +if command -v ruby >/dev/null 2>&1; then + if ruby -c packaging/homebrew/zupt.rb >/dev/null; then + pass 'Homebrew formula Ruby syntax' else - F "RPM spec: Version: '$SPEC_VER' != include/zupt.h '$VERSION'" - fi - for section in "%prep" "%build" "%install" "%files" "%changelog"; do - if grep -qF "$section" packaging/rpm/vaptvupt.spec; then - : - else - F "RPM spec: missing section '$section'" - fi - done - P "RPM spec: %prep, %build, %install, %files, %changelog sections present" - if command -v rpmlint >/dev/null 2>&1; then - rpmlint packaging/rpm/vaptvupt.spec >/tmp/rpmlint.out 2>&1 - if [ -s /tmp/rpmlint.out ] && grep -qE " E: " /tmp/rpmlint.out; then - F "RPM spec: rpmlint errors (see /tmp/rpmlint.out):" - grep " E: " /tmp/rpmlint.out | head -3 - else - P "RPM spec: rpmlint clean (warnings allowed)" - fi - else - SKIP "rpmlint not available" + fail 'Homebrew formula Ruby syntax' fi else - F "RPM spec: file missing" + skip 'Ruby unavailable for Homebrew syntax' fi -# ─── Homebrew formula ─── -if [ -f packaging/homebrew/vaptvupt.rb ]; then - HB_VER=$(grep -E '^\s*version\s' packaging/homebrew/vaptvupt.rb | head -1 | awk -F'"' '{print $2}') - if [ "$HB_VER" = "$VERSION" ]; then - P "Homebrew formula: version matches include/zupt.h ($VERSION)" +if command -v nix-instantiate >/dev/null 2>&1; then + if nix-instantiate --parse packaging/nix/flake.nix >/dev/null; then + pass 'Nix flake syntax' else - F "Homebrew formula: version '$HB_VER' != include/zupt.h '$VERSION'" + fail 'Nix flake syntax' fi - if command -v ruby >/dev/null 2>&1; then - if ruby -c packaging/homebrew/vaptvupt.rb >/dev/null 2>&1; then - P "Homebrew formula: ruby syntax clean" - else - F "Homebrew formula: ruby syntax error" - ruby -c packaging/homebrew/vaptvupt.rb 2>&1 | head -3 - fi - else - SKIP "ruby not available — skipping Homebrew syntax parse" - fi - for kw in 'class (Vaptvupt|Zupt)' 'desc ' 'homepage ' 'url ' 'version ' 'sha256 ' 'license '; do - if grep -qE "^\s*${kw}" packaging/homebrew/vaptvupt.rb; then - : - else - F "Homebrew formula: missing DSL line starting with '$kw'" - fi - done - # install is a method definition; test is a block - if grep -qE "^\s*def\s+install\b" packaging/homebrew/vaptvupt.rb; then - : - else - F "Homebrew formula: missing method 'def install'" - fi - if grep -qE "^\s*test\s+do\b" packaging/homebrew/vaptvupt.rb; then - : - else - F "Homebrew formula: missing 'test do' block" - fi - P "Homebrew formula: class + required DSL keywords + install method + test block present" else - F "Homebrew formula: file missing" + skip 'nix-instantiate unavailable for flake syntax' fi -# ─── Nix flake ─── -if [ -f packaging/nix/flake.nix ]; then - if command -v nix >/dev/null 2>&1 && nix --version 2>/dev/null | grep -qE "nix \(Nix\) [2-9]"; then - if nix flake metadata packaging/nix --no-update-lock-file >/dev/null 2>&1; then - P "Nix flake: nix accepts metadata" - else - F "Nix flake: nix flake metadata failed" - fi +if command -v guile >/dev/null 2>&1; then + if guile -c '(use-modules (guix gexp)) (call-with-input-file "packaging/guix/zupt.scm" (lambda (p) (let loop ((x (read p))) (unless (eof-object? x) (loop (read p))))))'; then + pass 'Guix recipe reader syntax' else - SKIP "nix not available — skipping flake check" - fi - NIX_VER=$(grep -E 'version = "' packaging/nix/flake.nix | head -1 | awk -F'"' '{print $2}') - if [ "$NIX_VER" = "$VERSION" ]; then - P "Nix flake: version matches include/zupt.h ($VERSION)" - else - F "Nix flake: version '$NIX_VER' != include/zupt.h '$VERSION'" - fi - # Structural check: must have outputs and a zupt package definition - if grep -qE "outputs\s*=" packaging/nix/flake.nix && \ - grep -qE 'pname = "(vaptvupt|zupt)"' packaging/nix/flake.nix; then - P "Nix flake: outputs + zupt package definition present" - else - F "Nix flake: structure incomplete" + fail 'Guix recipe reader syntax' fi else - F "Nix flake: file missing" + skip 'Guile unavailable for Guix syntax' fi -# ─── openSUSE OBS recipe (renamed zupt.* -> vaptvupt.* in 3.2.0) ─── -if [ -f packaging/opensuse/vaptvupt.spec ] && [ -f packaging/opensuse/vaptvupt.changes ] && [ -f packaging/opensuse/_service ]; then - P "openSUSE OBS files: all three present (vaptvupt.spec, vaptvupt.changes, _service)" - # Validate the spec parses - if command -v rpm >/dev/null 2>&1; then - if rpm --specfile packaging/opensuse/vaptvupt.spec >/dev/null 2>&1; then - P "openSUSE vaptvupt.spec: rpm --specfile parses cleanly" - else - F "openSUSE vaptvupt.spec: rpm --specfile rejected it" - fi - SUSE_VER=$(grep -E "^Version:" packaging/opensuse/vaptvupt.spec | awk '{print $2}') - if [ "$SUSE_VER" = "$VERSION" ]; then - P "openSUSE vaptvupt.spec: Version matches include/zupt.h ($VERSION)" - else - F "openSUSE vaptvupt.spec: Version '$SUSE_VER' != include/zupt.h '$VERSION'" - fi - # Name must be vaptvupt, and it must supersede the old zupt package. - if grep -qE "^Name:[[:space:]]+vaptvupt$" packaging/opensuse/vaptvupt.spec; then - P "openSUSE vaptvupt.spec: Name is vaptvupt" - else - F "openSUSE vaptvupt.spec: Name is not vaptvupt" - fi - if grep -qE "^Provides:[[:space:]]+zupt" packaging/opensuse/vaptvupt.spec && \ - grep -qE "^Obsoletes:[[:space:]]+zupt" packaging/opensuse/vaptvupt.spec; then - P "openSUSE vaptvupt.spec: Provides/Obsoletes zupt (clean upgrade)" - else - F "openSUSE vaptvupt.spec: missing Provides/Obsoletes zupt" - fi +if command -v xmllint >/dev/null 2>&1; then + if xmllint --noout packaging/opensuse/_service; then + pass 'openSUSE service XML' else - SKIP "rpm not available — skipping openSUSE spec parse" - fi - # Validate _service is well-formed XML - if command -v python3 >/dev/null 2>&1; then - if python3 -c "import xml.etree.ElementTree as ET; ET.parse('packaging/opensuse/_service')" 2>/dev/null; then - P "openSUSE _service: XML well-formed" - else - F "openSUSE _service: XML parse error" - fi - fi - # _service filename should be vaptvupt now - if grep -qE "vaptvupt" packaging/opensuse/_service; then - P "openSUSE _service: filename is vaptvupt" - else - F "openSUSE _service: filename not updated to vaptvupt" - fi - # .changes: check standard 67-dash separator (openSUSE convention is exactly 67) - SEP_COUNT=$(grep -cE "^-{67}$" packaging/opensuse/vaptvupt.changes) - if [ "$SEP_COUNT" -ge 1 ]; then - P "openSUSE vaptvupt.changes: $SEP_COUNT entries with proper separator" - else - F "openSUSE vaptvupt.changes: missing or wrong separator format" + fail 'openSUSE service XML' fi +elif python3 -c 'import xml.etree.ElementTree as E; E.parse("packaging/opensuse/_service")' 2>/dev/null; then + pass 'openSUSE service XML (Python parser)' else - F "openSUSE OBS files incomplete (need vaptvupt.spec, vaptvupt.changes, _service)" -fi -if [ -f DISTRIBUTION.md ]; then - P "DISTRIBUTION.md present" - for distro in "Arch Linux" "Debian / Ubuntu" "Fedora" "macOS" "NixOS"; do - if grep -q "$distro" DISTRIBUTION.md; then - : - else - F "DISTRIBUTION.md: doesn't mention '$distro'" - fi - done - P "DISTRIBUTION.md: covers all 5 distros" -else - F "DISTRIBUTION.md missing" + fail 'openSUSE service XML' fi -# ─── GitHub Actions CI workflow ─── -if [ -f .github/workflows/ci.yml ]; then - if command -v python3 >/dev/null 2>&1; then - # Write the validator to a temp file rather than inline -c so quoting/ - # indentation can't bite. - cat > /tmp/ci_validate.py << 'PYEOF' -import yaml, sys -try: - with open('.github/workflows/ci.yml') as f: - doc = yaml.safe_load(f) -except Exception as e: - sys.stderr.write(f"YAML_PARSE_ERROR: {e}\n") - sys.exit(1) -jobs = list(doc.get('jobs', {}).keys()) -expected = ['build-and-test', 'strict-warnings', 'sanitizers', - 'dist-reproducibility', 'packaging-syntax', 'release'] -missing = [j for j in expected if j not in jobs] -if missing: - sys.stderr.write(f"MISSING_JOBS: {missing}\n") - sys.exit(1) -print(f"JOBS_OK ({len(jobs)} jobs)") -PYEOF - if python3 /tmp/ci_validate.py 2>/tmp/ci_check.err; then - P "CI workflow: YAML valid + expected jobs present" - else - F "CI workflow: $(cat /tmp/ci_check.err)" - fi - rm -f /tmp/ci_validate.py /tmp/ci_check.err - else - SKIP "python3 unavailable — skipping CI YAML check" - fi +service=packaging/opensuse/_service +spec=packaging/opensuse/zupt.spec +if grep -Fq 'https://github.com/cristiancmoises/zupt.git' "$service" && \ + grep -Fq "refs/tags/v$version" "$service" && \ + grep -Fq 'disable' "$service" && \ + grep -Fq 'disable' "$service" && \ + grep -Fq '3000 bytes) and covers the - # required sections per userPreferences ("plain English. State - # explicitly what the system does NOT protect against.") - SZ=$(wc -c < THREAT_MODEL.md) - if [ "$SZ" -ge 3000 ]; then - P "THREAT_MODEL.md: substantive ($SZ bytes)" - else - F "THREAT_MODEL.md: too short ($SZ bytes, expected >= 3000)" - fi - for section in "What VaptVupt protects against" "What VaptVupt does NOT protect against" "Cryptographic assumptions"; do - if grep -qF "$section" THREAT_MODEL.md; then - : - else - F "THREAT_MODEL.md: missing section '$section'" - fi - done - P "THREAT_MODEL.md: required sections present" +if grep -Fq 'APPIMAGE_RUNTIME_COMPLIANCE_FILE' packaging/build-appimage.sh && \ + grep -Fq 'APPIMAGE_RUNTIME_COMPLIANCE_FILE' packaging/build-gui-appimage.sh && \ + grep -Fq 'AppImage-runtime-compliance.txt' packaging/build-appimage.sh && \ + grep -Fq 'AppImage-runtime-compliance.txt' packaging/build-gui-appimage.sh; then + pass 'AppImage helpers require and bundle external compliance evidence' else - F "THREAT_MODEL.md missing" + fail 'AppImage helper compliance policy is incomplete' fi -echo "" -echo " ───────────────────────────────────────" -echo " packaging syntax: $PASS passed, $FAIL failed" -echo " ───────────────────────────────────────" -[ "$FAIL" = 0 ] || exit 1 +if ! grep -Eiq 'release-appimage|appimagetool|APPIMAGE_RUNTIME_FILE|[.]AppImage' \ + .github/workflows/ci.yml .github/workflows/cross-platform.yml \ + .github/workflows/promote-release.yml; then + pass 'release workflows do not build or promote AppImage assets' +else + fail 'a release workflow still builds or promotes an AppImage asset' +fi + +if grep -Fq 'archive declared-size limit exceeded before extraction' scripts/check-source-only.sh && \ + grep -Fq 'global archive declared-size budget exceeded' scripts/check-source-only.sh && \ + grep -Fq 'SOURCE_AUDIT_ARCHIVE_SECONDS' scripts/check-source-only.sh && \ + grep -Fq 'compressed archive declared size is rejected before extraction' tests/test_source_only.sh; then + pass 'source scanner bounds archive members, expansion and CPU time before extraction' +else + fail 'source scanner archive resource bounds or regressions are incomplete' +fi + +tumbleweed_job=$(sed -n '/^ tumbleweed-rpm:/,/^ gui-rpm-package:/p' \ + .github/workflows/ci.yml) +fedora_gui_job=$(sed -n '/^ gui-rpm-package:/,/^ linux-portable:/p' \ + .github/workflows/ci.yml) +if grep -Fq 'os.chdir(service_dir)' <<<"$tumbleweed_job"; then + pass 'OBS service executor enters its isolated working directory' +else + fail 'OBS service executor does not enter its isolated working directory' +fi + +# These matches intentionally assert the literal Actions variable in YAML. +# shellcheck disable=SC2016 +if grep -Fq 'git config --global --add safe.directory "$GITHUB_WORKSPACE"' \ + <<<"$tumbleweed_job" && \ + grep -Fq 'if rpm -q busybox-gawk >/dev/null 2>&1; then' \ + <<<"$tumbleweed_job" && \ + grep -Fq 'zypper --non-interactive remove busybox-gawk' \ + <<<"$tumbleweed_job" && \ + grep -Fq 'git config --global --add safe.directory "$GITHUB_WORKSPACE"' \ + <<<"$fedora_gui_job"; then + pass 'RPM container jobs trust the exact workspace and replace busybox-gawk' +else + fail 'RPM container workspace trust or busybox-gawk replacement is incomplete' +fi + +# These are literal shell expressions required inside the promotion workflow. +# shellcheck disable=SC2016 +if grep -Fq 'zupt-gui_${VERSION}_all.deb' .github/workflows/promote-release.yml && \ + grep -Fq 'zupt-gui-$VERSION-1.noarch.rpm' .github/workflows/promote-release.yml && \ + grep -Fq 'zupt-gui-$VERSION-1.src.rpm' .github/workflows/promote-release.yml && \ + grep -Fq 'zupt-$VERSION-linux-x86_64.tar.xz' .github/workflows/promote-release.yml && \ + grep -Fq 'zupt-gui-$VERSION-portable.zip' .github/workflows/promote-release.yml && \ + grep -Fq 'zupt >= $VERSION' .github/workflows/promote-release.yml; then + pass 'release promotion allowlists gated CLI and GUI package formats' +else + fail 'release promotion is missing a gated package format or dependency check' +fi + +if ! grep -Eqi 'git[.]securityops[.]co|forgejo|canonical server|GitHub mirror' \ + .github/workflows/promote-release.yml && \ + grep -Fq 'GitHub is the canonical upstream release' \ + .github/workflows/promote-release.yml; then + pass 'GitHub is the sole canonical release target' +else + fail 'release promotion still depends on a non-GitHub canonical forge' +fi + +if grep -Fq 'path: out/*.zip' .github/workflows/cross-platform.yml && \ + ! grep -Fq 'out/*.exe' .github/workflows/cross-platform.yml && \ + grep -Fq 'windows_zip_name=' .github/workflows/promote-release.yml && \ + ! grep -Fq 'windows_exe_name=' .github/workflows/promote-release.yml; then + pass 'Windows release policy promotes the notice-bearing ZIP only' +else + fail 'Windows workflow still permits a bare EXE release asset' +fi + +windows_notices_ok=1 +for notice in MINGW-CRT-COPYING.txt COPYING.MinGW-w64-runtime.txt \ + COPYING.MinGW-w64.txt GCC-COPYING3.txt \ + GCC-RUNTIME-LIBRARY-EXCEPTION.txt; do + grep -Fq "$notice" .github/workflows/cross-platform.yml || \ + windows_notices_ok=0 + grep -Fq "$notice" .github/workflows/promote-release.yml || \ + windows_notices_ok=0 +done +if ((windows_notices_ok)); then + pass 'static Windows bundle preserves MinGW and GCC runtime notices' +else + fail 'static Windows bundle omits MinGW or GCC runtime notices' +fi + +if grep -Eiq 'AppImage.*(not|excluded|outside)' SECURITY.md && \ + grep -Eiq 'AppImage.*(not|excluded|outside)' THREAT_MODEL.md && \ + grep -Eiq 'AppImage.*(not|excluded|outside)' AUDIT.md && \ + grep -Eiq 'AppImage.*(not|excluded|outside)' doc/zupt.1; then + pass 'current security and user documentation records release exclusions' +else + fail 'current documentation still permits an AppImage or bare EXE claim' +fi + +handoff_legal_ok=1 +for legal_file in LICENSE LICENSE-AGPL-3.0 LICENSE-GPL-3.0 LICENSE-COMMERCIAL \ + LICENSE-BSD-2-Clause LICENSE-BSD-3-Clause LICENSE-CC0-1.0 \ + NOTICE THIRD-PARTY-NOTICES.md; do + grep -Fq "$legal_file" scripts/export-opensuse-package.sh || \ + handoff_legal_ok=0 +done +if ((handoff_legal_ok)) && \ + grep -Fq 'handoff legal file is missing or empty' scripts/export-opensuse-package.sh; then + pass 'openSUSE handoff includes and checks complete public legal payload' +else + fail 'openSUSE handoff omits or does not validate a public legal file' +fi + +if grep -Fq 'PYTHON-NOTICE.txt' gui/packaging/windows/build-windows.bat && \ + grep -Fq 'PYINSTALLER-NOTICE.txt' gui/packaging/windows/build-windows.bat && \ + grep -Fq 'QT-NOTICE.txt' gui/packaging/windows/build-windows.bat && \ + grep -Fq 'PYSIDE6-NOTICE.txt' gui/packaging/windows/build-windows.bat && \ + grep -Fq 'PYQT6-NOTICE.txt' gui/packaging/windows/build-windows.bat; then + pass 'downstream Windows GUI helper requires every runtime notice class' +else + fail 'downstream Windows GUI helper permits incomplete runtime notices' +fi + +if grep -Eq '^Name:[[:space:]]+zupt$' "$spec" && \ + grep -Eq '^Source0:[[:space:]]+%\{name\}-%\{version\}\.tar\.gz$' "$spec" && \ + grep -Eq '^License:[[:space:]]+AGPL-3\.0-or-later AND GPL-3\.0-or-later AND BSD-2-Clause AND BSD-3-Clause AND CC0-1\.0$' "$spec" && \ + grep -Eq '^Provides:[[:space:]]+bundled\(vaptvupt-codec\) = 2\.65\.3$' "$spec" && \ + grep -Eq '^Provides:[[:space:]]+vaptvupt = %\{version\}-%\{release\}$' "$spec" && \ + grep -Eq '^Obsoletes:[[:space:]]+vaptvupt < %\{version\}$' "$spec" && \ + grep -Fq 'WITH_SDK=0 WITH_PQBOX=0' "$spec" && \ + grep -Fq 'INSTALL_LEGACY_ALIAS=0' "$spec" && \ + grep -Fq 'INSTALL_LICENSES=0' "$spec" && \ + grep -Fq '%{_bindir}/zupt' "$spec" && \ + ! grep -Fq '%{_bindir}/vaptvupt' "$spec"; then + pass 'openSUSE spec source, license, features and alias policy' +else + fail 'openSUSE spec source, license, features or alias policy' +fi + +if grep -Fq 'LICENSE-BSD-3-Clause' packaging/build-deb.sh && \ + grep -Fq 'LICENSE-CC0-1.0' packaging/build-deb.sh && \ + [[ $(grep -Fc 'LICENSE-BSD-3-Clause' packaging/build-dmg.sh) -ge 2 ]] && \ + [[ $(grep -Fc 'LICENSE-CC0-1.0' packaging/build-dmg.sh) -ge 2 ]] && \ + grep -Fq 'LICENSE-BSD-3-Clause' packaging/build-appimage.sh && \ + grep -Fq 'LICENSE-CC0-1.0' packaging/build-appimage.sh && \ + grep -Fq 'LICENSE-BSD-3-Clause' packaging/build-gui-appimage.sh && \ + grep -Fq 'LICENSE-CC0-1.0' packaging/build-gui-appimage.sh && \ + grep -Fq 'LICENSE-BSD-3-Clause' .github/workflows/cross-platform.yml && \ + grep -Fq 'LICENSE-CC0-1.0' .github/workflows/cross-platform.yml && \ + grep -Fq 'LICENSE-BSD-3-Clause' .github/workflows/promote-release.yml && \ + grep -Fq 'LICENSE-CC0-1.0' .github/workflows/promote-release.yml && \ + grep -Fq 'LICENSE-BSD-3-Clause' packaging/debian/zupt.docs && \ + grep -Fq 'LICENSE-CC0-1.0' packaging/debian/zupt.docs; then + pass 'binary bundle paths preserve BSD-3-Clause and CC0-1.0 texts' +else + fail 'a binary bundle path omits BSD-3-Clause or CC0-1.0 text' +fi + +if grep -Fq 'LICENSE-BSD-3-Clause' gui/packaging/flatpak/dev.zupt.gui.yml && \ + grep -Fq 'LICENSE-CC0-1.0' gui/packaging/flatpak/dev.zupt.gui.yml && \ + grep -Fq 'ZUPT_WINDOWS_RUNTIME_NOTICES_DIR' gui/packaging/windows/build-windows.bat && \ + grep -Fq 'MANIFEST.txt' gui/packaging/windows/build-windows.bat && \ + grep -Fq 'LICENSE-BSD-3-Clause' packaging/windows/zupt-gui.iss && \ + grep -Fq 'LICENSE-CC0-1.0' packaging/windows/zupt-gui.iss && \ + grep -Fq 'RuntimeNoticesDir' packaging/windows/zupt-gui.iss && \ + grep -Fq 'LICENSE-BSD-3-Clause' sdk/Makefile.sdk && \ + grep -Fq 'LICENSE-CC0-1.0' sdk/Makefile.sdk && \ + grep -Fq 'LICENSE-BSD-3-Clause' packaging/homebrew/zupt.rb && \ + grep -Fq 'LICENSE-CC0-1.0' packaging/nix/flake.nix && \ + grep -Fq 'LICENSEDIR' Makefile && \ + grep -Fq 'LICENSE-BSD-3-Clause' Makefile && \ + grep -Fq 'LICENSE-GUI' gui/install.sh; then + pass 'auxiliary bundles install project and external-runtime notices' +else + fail 'an auxiliary bundle omits project or external-runtime notices' +fi + +if command -v rpmspec >/dev/null 2>&1; then + if rpmspec -P "$spec" >/dev/null; then + pass 'openSUSE spec parses with rpmspec' + else + fail 'openSUSE spec rpmspec parse' + fi +else + skip 'rpmspec unavailable' +fi + +if command -v shellcheck >/dev/null 2>&1; then + shell_files=( + packaging/build-deb.sh + packaging/build-rpm.sh + packaging/build-appimage.sh + packaging/build-gui-appimage.sh + packaging/build-gui-deb.sh + packaging/build-gui-rpm.sh + gui/packaging/appimage/build-appimage.sh + packaging/build-dmg.sh + packaging/opensuse/source-audit.sh + scripts/check-source-only.sh + scripts/export-opensuse-package.sh + scripts/test-installed-zupt.sh + tests/test_source_only.sh + tests/test_packaging_syntax.sh + ) + if shellcheck -x "${shell_files[@]}"; then + pass 'ShellCheck tracked shell scripts' + else + fail 'ShellCheck tracked shell scripts' + fi +else + skip 'ShellCheck unavailable' +fi + +for workflow in .github/workflows/*.yml; do + if grep -Eq 'pull_request_target:' "$workflow"; then + fail "$workflow uses pull_request_target" + else + pass "$workflow avoids pull_request_target" + fi + if grep -Eqi 'git[[:space:]]+push|credential\.helper[[:space:]]+store' "$workflow"; then + fail "$workflow contains unsafe publishing command" + else + pass "$workflow avoids direct Git credential mutation" + fi +done + +printf 'SUMMARY: PASS=%d FAIL=%d SKIP=%d\n' "$pass_count" "$fail_count" "$skip_count" +((fail_count == 0)) diff --git a/tests/test_password_prompt_signal.sh b/tests/test_password_prompt_signal.sh new file mode 100755 index 0000000..8c369fc --- /dev/null +++ b/tests/test_password_prompt_signal.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (c) 2026 Cristian Cezar Moisés +# A signal received while a password is read must not leave terminal echo off. + +set -Eeuo pipefail + +bin=${1:-./zupt} +if [[ ! -x $bin ]]; then + printf 'FAIL: ZUPT binary is not executable: %s\n' "$bin" >&2 + exit 1 +fi +case $(uname -s) in + MINGW*|MSYS*|CYGWIN*) + printf '%s\n' \ + 'SKIP: POSIX pseudo-terminal signal restoration test is unavailable on Windows' + exit 0 + ;; +esac +if ! command -v python3 >/dev/null 2>&1; then + printf 'SKIP: password-prompt signal test needs python3 with pty support\n' + exit 0 +fi + +bin=$(cd "$(dirname "$bin")" && pwd -P)/$(basename "$bin") +python3 - "$bin" <<'PY' +import os +import pty +import select +import signal +import subprocess +import tempfile +import termios +import time +import sys + +binary = sys.argv[1] +with tempfile.TemporaryDirectory(prefix="zupt-password-signal-") as work: + source = os.path.join(work, "input.txt") + archive = os.path.join(work, "interrupted.zupt") + with open(source, "w", encoding="utf-8") as stream: + stream.write("terminal restoration regression\n") + + master, slave = pty.openpty() + initial = termios.tcgetattr(slave) + process = subprocess.Popen( + [binary, "compress", "--password-prompt", archive, source], + stdin=slave, + stdout=slave, + stderr=slave, + close_fds=True, + ) + transcript = bytearray() + deadline = time.monotonic() + 10 + try: + while b"Password:" not in transcript and time.monotonic() < deadline: + readable, _, _ = select.select([master], [], [], 0.1) + if readable: + transcript.extend(os.read(master, 4096)) + if process.poll() is not None: + break + if b"Password:" not in transcript: + raise SystemExit("password prompt was not reached") + hidden = termios.tcgetattr(slave) + if hidden[3] & termios.ECHO: + raise SystemExit("terminal echo was not disabled during prompt") + + process.send_signal(signal.SIGINT) + process.wait(timeout=10) + restored = termios.tcgetattr(slave) + if (restored[3] & termios.ECHO) != (initial[3] & termios.ECHO): + raise SystemExit("terminal echo state was not restored after SIGINT") + if not (restored[3] & termios.ECHO): + raise SystemExit("terminal echo is disabled after interrupted prompt") + if os.path.exists(archive): + raise SystemExit("interrupted password prompt left an archive") + finally: + if process.poll() is None: + process.kill() + process.wait() + os.close(master) + os.close(slave) + +print("password prompt signal restoration: PASS") +PY diff --git a/tests/test_password_sources.sh b/tests/test_password_sources.sh new file mode 100644 index 0000000..2261eb6 --- /dev/null +++ b/tests/test_password_sources.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +set -Eeuo pipefail + +binary=${1:-./zupt} +case $binary in + /*) ;; + *) binary=$PWD/${binary#./} ;; +esac + +test_root=$(mktemp -d "${TMPDIR:-/tmp}/zupt-password.XXXXXXXX") +cleanup() { + chmod -R u+rwX "$test_root" 2>/dev/null || true + rm -rf -- "$test_root" +} +trap cleanup EXIT HUP INT TERM + +cd "$test_root" +printf 'password source round-trip\n' > 'entrada ação.txt' +printf 'Correct-Horse-Battery-Staple-2026!\n' > 'senha segura.txt' +chmod 600 'senha segura.txt' + +"$binary" compress --pass-file 'senha segura.txt' archive.zupt \ + 'entrada ação.txt' >/dev/null 2>&1 +"$binary" test --pass-file 'senha segura.txt' archive.zupt >/dev/null 2>&1 +mkdir extracted +"$binary" extract --pass-file 'senha segura.txt' -o extracted \ + archive.zupt >/dev/null 2>&1 +cmp 'entrada ação.txt' 'extracted/entrada ação.txt' + +case $(uname -s) in + MINGW*|MSYS*|CYGWIN*) + printf '%s\n' 'SKIP: inherited POSIX descriptor mapping is not portable in MSYS' + ;; + *) + exec 9<'senha segura.txt' + "$binary" test --pass-fd 9 archive.zupt >/dev/null 2>&1 + exec 9<&- + ;; +esac + +printf '\n' > empty-password +if "$binary" test --pass-file empty-password archive.zupt >/dev/null 2>&1; then + printf '%s\n' 'FAIL: accepted an empty password file' >&2 + exit 1 +fi + +printf 'bad\0password\n' > nul-password +if "$binary" test --pass-file nul-password archive.zupt >/dev/null 2>&1; then + printf '%s\n' 'FAIL: accepted a password file containing NUL' >&2 + exit 1 +fi + +if "$binary" test --pass-fd not-a-number archive.zupt >/dev/null 2>&1; then + printf '%s\n' 'FAIL: accepted an invalid password descriptor' >&2 + exit 1 +fi + +prompt_log=$test_root/non-interactive-prompt.log +if command -v timeout >/dev/null 2>&1; then + set +e + timeout 10 "$binary" test --password-prompt archive.zupt \ + "$prompt_log" 2>&1 + prompt_status=$? + set -e +else + set +e + "$binary" test --password-prompt archive.zupt \ + "$prompt_log" 2>&1 + prompt_status=$? + set -e +fi +if ((prompt_status == 124)); then + printf '%s\n' 'FAIL: non-interactive password prompt timed out' >&2 + exit 1 +elif ((prompt_status == 0)); then + printf '%s\n' 'FAIL: non-interactive password prompt unexpectedly succeeded' >&2 + exit 1 +elif ! grep -Fq 'password prompt requires a terminal.' "$prompt_log"; then + printf 'FAIL: non-interactive password prompt returned status %d without a terminal rejection\n' \ + "$prompt_status" >&2 + exit 1 +fi + +case $(uname -s) in + MINGW*|MSYS*|CYGWIN*) + printf '%s\n' 'SKIP: POSIX pseudo-terminal password overflow test is unavailable in MSYS' + ;; + *) + if command -v python3 >/dev/null 2>&1; then + python3 - "$binary" "$test_root" <<'PY' +import errno +import os +import pty +import select +import sys +import time + +binary, root = sys.argv[1:] +archive = os.path.join(root, "prompt-overflow.zupt") +source = os.path.join(root, "entrada ação.txt") +pid, descriptor = pty.fork() +if pid == 0: + os.execv(binary, [binary, "compress", "--password-prompt", archive, source]) + +deadline = time.monotonic() + 20 +output = bytearray() +sent = False +status = None +while time.monotonic() < deadline: + ready, _, _ = select.select([descriptor], [], [], 0.1) + if ready: + try: + chunk = os.read(descriptor, 4096) + except OSError as error: + if error.errno != errno.EIO: + raise + chunk = b"" + output.extend(chunk) + if not sent and b"Password:" in output: + os.write(descriptor, b"A" * 510 + b"\n") + sent = True + waited, status = os.waitpid(pid, os.WNOHANG) + if waited == pid: + break +else: + os.kill(pid, 9) + os.waitpid(pid, 0) + raise SystemExit("password overflow prompt timed out") + +success = status is not None and os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0 +if not sent or success: + raise SystemExit("overlong interactive password was accepted") +if os.path.exists(archive): + raise SystemExit("overlong interactive password published an archive") +PY + else + printf '%s\n' 'SKIP: python3 is unavailable for pseudo-terminal password overflow test' + fi + ;; +esac + +if "$binary" test -p incorrect archive.zupt >/dev/null 2>&1; then + printf '%s\n' 'FAIL: incorrect password unexpectedly succeeded' >&2 + exit 1 +fi + +if "$binary" --version | grep -q 'libvuptsdk=disabled'; then + if "$binary" compress -p secret --kdf argon2id downgrade.zupt \ + 'entrada ação.txt' >/dev/null 2>&1; then + printf '%s\n' 'FAIL: source-only build silently accepted unavailable Argon2id' >&2 + exit 1 + fi +fi + +printf '%s\n' 'PASS: password prompt/file/fd validation and encrypted round-trip' diff --git a/tests/test_path_traversal.sh b/tests/test_path_traversal.sh index 49dfe29..7982673 100755 --- a/tests/test_path_traversal.sh +++ b/tests/test_path_traversal.sh @@ -1,156 +1,312 @@ -#!/bin/bash +#!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés -# Path traversal / Zip Slip regression tests. -# -# Verifies that the v2.2.2 audit fixes for CVE-pattern path traversal -# (Snyk Zip Slip 2018) and symlink-following on extract are working. -# -# Tests construct malicious archives in two ways: -# (A) compress with a relative path then post-mutate the index (manual fuzz) -# (B) try to extract into a directory containing a symlink with the same -# name as an archive entry — should be refused due to O_NOFOLLOW. +# Extraction confinement and atomic-output regression tests. -ZUPT_BIN="$(realpath ./zupt)" -TMPDIR=$(mktemp -d) -trap "rm -rf $TMPDIR" EXIT -cd "$TMPDIR" +set -Eeuo pipefail -PASS=0; FAIL=0 -chk() { - if [ $? -eq 0 ]; then echo " ✓ $1"; PASS=$((PASS+1)) - else echo " ✗ $1"; FAIL=$((FAIL+1)); fi +REPO_ROOT=$(pwd -P) +ZUPT_BIN=${1:-$REPO_ROOT/zupt} +case $ZUPT_BIN in + /*) ;; + *) ZUPT_BIN=$REPO_ROOT/${ZUPT_BIN#./} ;; +esac +TEST_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/zupt-path-traversal.XXXXXX") +cleanup() { + local status=$? + chmod -R u+rwX "$TEST_ROOT" 2>/dev/null || true + rm -rf -- "$TEST_ROOT" + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' HUP INT TERM + +PASS=0 +FAIL=0 +SKIP=0 +pass() { printf ' PASS: %s\n' "$1"; PASS=$((PASS + 1)); } +fail() { printf ' FAIL: %s\n' "$1"; FAIL=$((FAIL + 1)); } +skip() { printf ' SKIP: %s\n' "$1"; SKIP=$((SKIP + 1)); } +WINDOWS_NATIVE=0 +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) WINDOWS_NATIVE=1 ;; +esac + +FIXTURE=$TEST_ROOT/archive-path-fixture +# Compiler and linker flag variables intentionally expand into argument lists, +# matching the Makefile command-line contract. +# shellcheck disable=SC2086 +"${CC:-cc}" ${CPPFLAGS:-} ${CFLAGS:-} -std=c11 -I"$REPO_ROOT/include" \ + "$REPO_ROOT/tests/archive_path_fixture.c" "$REPO_ROOT/src/zupt_xxh.c" \ + ${LDFLAGS:-} ${LDLIBS:-} -o "$FIXTURE" + +make_fixture() { + MSYS2_ARG_CONV_EXCL='--entry=' "$FIXTURE" "$1" "--entry=$2" + # Prove that the archive passed header, trailer, index-block, index checksum, + # decompression, and index parsing before using it as a negative fixture. + "$ZUPT_BIN" list "$1" > "$TEST_ROOT/list.log" 2>&1 + grep -F -- "$2" "$TEST_ROOT/list.log" >/dev/null } -# ─── Property 1: archive with ".." entry must not extract above target ── -# Strategy: compress an innocent file, then patch the archive's index to -# replace the path with "../../escaped.txt". Extract into a subdir; -# verify the file appears nowhere outside the subdir. -echo " [P1. Zip Slip — relative path traversal blocked]" +expect_unsafe_path() { + local label=$1 archive=$2 entry=$3 output=$4 log=$TEST_ROOT/extract.log rc + make_fixture "$archive" "$entry" + mkdir -p "$output" + set +e + "$ZUPT_BIN" extract -o "$output" "$archive" > "$log" 2>&1 + rc=$? + set -e + if ((rc != 0)) && grep -F 'rejected unsafe path' "$log" >/dev/null; then + pass "$label" + else + fail "$label" + fi +} -mkdir input output_safe -echo "secret content" > input/innocent.txt -"$ZUPT_BIN" c slip.zupt input/innocent.txt > /dev/null 2>&1 +cd "$TEST_ROOT" -# Patch the archive: replace "input/innocent.txt" path string with -# "../escape.txt" in the index. We use a python helper because the index -# is varint-prefixed and we need to keep length consistent. -python3 << 'PYEOF' +expect_unsafe_path 'relative .. entry is rejected after a valid index parse' \ + "$TEST_ROOT/relative.zupt" '../escaped.txt' "$TEST_ROOT/relative-out" +[[ ! -e $TEST_ROOT/escaped.txt ]] || fail 'relative traversal wrote outside root' + +ABSOLUTE_TARGET=$TEST_ROOT/absolute-owned +expect_unsafe_path 'absolute entry is rejected after a valid index parse' \ + "$TEST_ROOT/absolute.zupt" "$ABSOLUTE_TARGET" "$TEST_ROOT/absolute-out" +if [[ ! -e $ABSOLUTE_TARGET ]]; then + pass 'absolute target remains absent' +else + fail 'absolute target remains absent' +fi + +for case_data in \ + 'trailing-space|dir/.. ' \ + 'alternate-data-stream|dir/name:stream' \ + 'reserved-device|dir/CON' \ + 'reserved-device-extension|dir/LPT1.txt' \ + 'trailing-dot|dir/file.'; do + label=${case_data%%|*} + entry=${case_data#*|} + expect_unsafe_path "Windows-normalized $label path is rejected" \ + "$TEST_ROOT/$label.zupt" "$entry" "$TEST_ROOT/$label-out" +done + +control_entry=$'safe\033[31mRED\033[0m.txt' +MSYS2_ARG_CONV_EXCL='--entry=' "$FIXTURE" "$TEST_ROOT/control.zupt" \ + "--entry=$control_entry" +set +e +"$ZUPT_BIN" list "$TEST_ROOT/control.zupt" \ + > "$TEST_ROOT/control.log" 2>&1 +control_status=$? +set -e +if ((control_status != 0)) && ! grep -q $'\033' "$TEST_ROOT/control.log"; then + pass 'control-byte archive path is rejected without terminal injection' +else + fail 'control-byte archive path is rejected without terminal injection' +fi + +file_contains_hex_bytes() { + python3 - "$1" "$2" <<'PY' +import pathlib import sys -data = bytearray(open('slip.zupt','rb').read()) -target = b'input/innocent.txt' -replacement = b'../escape.txt' -# Pad replacement to same length so varint length prefix stays valid -pad = b'\x00' * (len(target) - len(replacement)) -i = data.find(target) -if i < 0: - print("ERROR: pattern not in archive") - sys.exit(1) -# Replace the bytes — note this will fail validation below, which is OK, -# we want to see if extract REJECTS the malformed path. -data[i:i+len(target)] = replacement + pad -open('slip_patched.zupt','wb').write(bytes(data)) -PYEOF -# Try to extract — even if the patched archive is corrupt, we want to -# verify that NO file appears at "../escape.txt" relative to output_safe. -cd output_safe -"$ZUPT_BIN" x ../slip_patched.zupt > /dev/null 2>&1 -cd .. +data = pathlib.Path(sys.argv[1]).read_bytes() +needle = bytes.fromhex(sys.argv[2]) +raise SystemExit(0 if needle in data else 1) +PY +} -# The key invariant: nothing escaped to TMPDIR (parent of output_safe) -[ ! -f "$TMPDIR/escape.txt" ] && [ ! -f escape.txt ] -chk "No escape via patched ../escape.txt path" +expect_display_unsafe_hex_path_rejected() { + local label=$1 name=$2 entry_hex=$3 forbidden_hex=$4 + local archive=$TEST_ROOT/$name.zupt log=$TEST_ROOT/$name.log status + MSYS2_ARG_CONV_EXCL='--entry-hex=' \ + "$FIXTURE" "$archive" "--entry-hex=$entry_hex" + if ! file_contains_hex_bytes "$archive" "$entry_hex"; then + printf ' fixture did not preserve the requested path bytes: %s\n' \ + "$entry_hex" >&2 + fail "$label" + return + fi + set +e + "$ZUPT_BIN" list "$archive" > "$log" 2>&1 + status=$? + set -e + if ((status != 0)) && ! file_contains_hex_bytes "$log" "$forbidden_hex"; then + pass "$label" + else + fail "$label" + fi +} -# ─── Property 2: archive with absolute path must not write to that path ── -echo " [P2. Absolute path entries blocked]" +if MSYS2_ARG_CONV_EXCL='--entry-hex=' \ + "$FIXTURE" "$TEST_ROOT/invalid-hex.zupt" '--entry-hex=0' \ + >/dev/null 2>&1 || + MSYS2_ARG_CONV_EXCL='--entry-hex=' \ + "$FIXTURE" "$TEST_ROOT/invalid-hex.zupt" '--entry-hex=GG' \ + >/dev/null 2>&1; then + fail 'archive path fixture rejects malformed hex input' +else + pass 'archive path fixture rejects malformed hex input' +fi -# Construct an archive entry with absolute "/tmp/owned.txt" via patching -echo "innocent" > input2.txt -"$ZUPT_BIN" c abs.zupt input2.txt > /dev/null 2>&1 -python3 << 'PYEOF' -data = bytearray(open('abs.zupt','rb').read()) -target = b'input2.txt' -# Replace with absolute path of equal length -replacement = b'/tmp/owned' # 10 chars vs 10 chars -i = data.find(target) -if i >= 0: - data[i:i+len(target)] = replacement - open('abs_patched.zupt','wb').write(bytes(data)) -PYEOF +expect_display_unsafe_hex_path_rejected \ + 'raw C1 archive path is rejected without terminal injection' \ + raw-c1 736166659b33316d2e747874 9b +expect_display_unsafe_hex_path_rejected \ + 'UTF-8 C1 archive path is rejected without terminal injection' \ + utf8-c1 73616665c29b33316d2e747874 c29b +expect_display_unsafe_hex_path_rejected \ + 'Unicode bidi-control archive path is rejected without display spoofing' \ + bidi 73616665e280ae6578652e747874 e280ae +expect_display_unsafe_hex_path_rejected \ + 'invalid UTF-8 archive path is rejected without raw display' \ + invalid-utf8 73616665c0af2e747874 c0af -mkdir abs_extract -cd abs_extract -"$ZUPT_BIN" x ../abs_patched.zupt > /dev/null 2>&1 -cd .. +make_fixture "$TEST_ROOT/leaf.zupt" 'innocent.txt' +printf '%s\n' DO_NOT_OVERWRITE > "$TEST_ROOT/sentinel" +mkdir "$TEST_ROOT/leaf-out" +if ln -s "$TEST_ROOT/sentinel" "$TEST_ROOT/leaf-out/innocent.txt" \ + 2>/dev/null && [[ -L $TEST_ROOT/leaf-out/innocent.txt ]]; then + if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/leaf-out" "$TEST_ROOT/leaf.zupt" \ + > "$TEST_ROOT/leaf.log" 2>&1 && + [[ $(<"$TEST_ROOT/sentinel") == DO_NOT_OVERWRITE ]]; then + pass 'leaf symlink is refused without changing its target' + else + fail 'leaf symlink is refused without changing its target' + fi +else + skip 'leaf symlink test is unsupported by this runner' +fi -[ ! -f /tmp/owned ] -chk "Absolute /tmp/owned path rejected" +mkdir "$TEST_ROOT/regular-out" +printf '%s\n' EXISTING > "$TEST_ROOT/regular-out/innocent.txt" +if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/regular-out" "$TEST_ROOT/leaf.zupt" \ + > "$TEST_ROOT/regular.log" 2>&1 && + [[ $(<"$TEST_ROOT/regular-out/innocent.txt") == EXISTING ]]; then + pass 'existing regular file is never overwritten' +else + fail 'existing regular file is never overwritten' +fi -# ─── Property 3: symlink at output target is not followed ────────────── -# Pre-place a symlink in output dir pointing to a sentinel file. -# Extract an archive with the same entry name; verify the sentinel is -# unchanged (i.e. extract refused to follow the symlink). -echo " [P3. Symlink at extract target not followed]" +mkdir "$TEST_ROOT/hardlink-out" +printf '%s\n' HARDLINK_SENTINEL > "$TEST_ROOT/hardlink-target" +if ln "$TEST_ROOT/hardlink-target" "$TEST_ROOT/hardlink-out/innocent.txt" 2>/dev/null; then + if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/hardlink-out" "$TEST_ROOT/leaf.zupt" \ + > "$TEST_ROOT/hardlink.log" 2>&1 && + [[ $(<"$TEST_ROOT/hardlink-target") == HARDLINK_SENTINEL ]]; then + pass 'existing hardlink is never overwritten' + else + fail 'existing hardlink is never overwritten' + fi +else + skip 'hardlink test is unsupported by the temporary filesystem' +fi -echo "DO_NOT_OVERWRITE" > sentinel.txt -mkdir symlink_extract -ln -s "$(pwd)/sentinel.txt" symlink_extract/innocent.txt +make_fixture "$TEST_ROOT/parent.zupt" 'nested/file.txt' +mkdir "$TEST_ROOT/parent-out" "$TEST_ROOT/parent-outside" +if ln -s "$TEST_ROOT/parent-outside" "$TEST_ROOT/parent-out/nested" \ + 2>/dev/null && [[ -L $TEST_ROOT/parent-out/nested ]]; then + if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/parent-out" "$TEST_ROOT/parent.zupt" \ + > "$TEST_ROOT/parent.log" 2>&1 && + [[ -z $(find "$TEST_ROOT/parent-outside" -mindepth 1 -print -quit) ]]; then + pass 'intermediate symlink cannot redirect extraction or directory creation' + else + fail 'intermediate symlink cannot redirect extraction or directory creation' + fi +else + skip 'intermediate symlink test is unsupported by this runner' +fi -# Build a fresh non-patched archive with "innocent.txt" -mkdir input3 && echo "evil overwrite content" > input3/innocent.txt -"$ZUPT_BIN" c clean.zupt input3/innocent.txt > /dev/null 2>&1 -# Mutate path "input3/innocent.txt" -> "innocent.txt" so it lands at the symlink -python3 << 'PYEOF' -data = bytearray(open('clean.zupt','rb').read()) -target = b'input3/innocent.txt' -replacement = b'innocent.txt' + (b'\x00' * (len(target) - len(b'innocent.txt'))) -i = data.find(target) -if i >= 0: - data[i:i+len(target)] = replacement - open('clean_patched.zupt','wb').write(bytes(data)) -PYEOF +mkdir "$TEST_ROOT/root-outside" +if ln -s "$TEST_ROOT/root-outside" "$TEST_ROOT/root-link" 2>/dev/null && + [[ -L $TEST_ROOT/root-link ]]; then + if ((WINDOWS_NATIVE)); then + if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/root-link/child" \ + "$TEST_ROOT/leaf.zupt" > "$TEST_ROOT/root-link.log" 2>&1 && + [[ -z $(find "$TEST_ROOT/root-outside" -mindepth 1 -print -quit) ]]; then + pass 'Windows rejects a reparse-point output-root ancestor' + else + fail 'Windows rejects a reparse-point output-root ancestor' + fi + elif "$ZUPT_BIN" extract -o "$TEST_ROOT/root-link/child" \ + "$TEST_ROOT/leaf.zupt" > "$TEST_ROOT/root-link.log" 2>&1 && + [[ $(<"$TEST_ROOT/root-outside/child/innocent.txt") == 'fixture content' ]]; then + pass 'user-selected POSIX output-root symlink is resolved once' + else + fail 'user-selected POSIX output-root symlink is resolved once' + fi +else + skip 'output-root symlink test is unsupported by this runner' +fi -cd symlink_extract -"$ZUPT_BIN" x ../clean_patched.zupt > /dev/null 2>&1 -cd .. +make_fixture "$TEST_ROOT/backslash.zupt" 'back\slash.txt' +mkdir "$TEST_ROOT/backslash-out" +if "$ZUPT_BIN" extract -o "$TEST_ROOT/backslash-out" "$TEST_ROOT/backslash.zupt" \ + > "$TEST_ROOT/backslash.log" 2>&1 && + [[ -f $TEST_ROOT/backslash-out/back/slash.txt ]]; then + pass 'backslash separators are normalized within the extraction root' +else + fail 'backslash separators are normalized within the extraction root' +fi -# Sentinel must be unchanged — symlink follow would have overwritten it -content=$(cat sentinel.txt) -[ "$content" = "DO_NOT_OVERWRITE" ] -chk "Sentinel via symlink not overwritten" +legitimate_entry_hex=73616665206469722f61c3a7c3a36f2df09f98802e747874 +MSYS2_ARG_CONV_EXCL='--entry-hex=' \ + "$FIXTURE" "$TEST_ROOT/legitimate.zupt" \ + "--entry-hex=$legitimate_entry_hex" +mkdir "$TEST_ROOT/legitimate-out" +if file_contains_hex_bytes "$TEST_ROOT/legitimate.zupt" \ + "$legitimate_entry_hex" && + "$ZUPT_BIN" list "$TEST_ROOT/legitimate.zupt" \ + > "$TEST_ROOT/legitimate-list.log" 2>&1 && + file_contains_hex_bytes "$TEST_ROOT/legitimate-list.log" \ + "$legitimate_entry_hex" && + "$ZUPT_BIN" extract -o "$TEST_ROOT/legitimate-out" \ + "$TEST_ROOT/legitimate.zupt" > "$TEST_ROOT/legitimate.log" 2>&1 && + python3 - "$TEST_ROOT/legitimate-out" "$legitimate_entry_hex" <<'PY' +import pathlib +import sys -# ─── Property 4: legitimate paths still extract correctly ───────────── -echo " [P4. Legitimate (safe) paths still extract]" +# All process arguments are ASCII. Decode the exact UTF-8 archive bytes here +# so the native MinGW fixture's narrow-argv transcoding cannot affect the test. +relative_path = bytes.fromhex(sys.argv[2]).decode("utf-8") +extracted = pathlib.Path(sys.argv[1]).joinpath(*relative_path.split("/")) +raise SystemExit(0 if extracted.read_bytes() == b"fixture content\n" else 1) +PY +then + pass 'safe nested BMP and non-BMP UTF-8 path lists and extracts normally' +else + fail 'safe nested BMP and non-BMP UTF-8 path lists and extracts normally' +fi -mkdir legit_input -echo "ok content" > legit_input/normal.txt -"$ZUPT_BIN" c legit.zupt legit_input/normal.txt > /dev/null 2>&1 +mkdir -p "$TEST_ROOT/relative-root/work" +if (cd "$TEST_ROOT/relative-root/work" && + "$ZUPT_BIN" extract -o ../restore "$TEST_ROOT/leaf.zupt" \ + > "$TEST_ROOT/relative-root.log" 2>&1) && + [[ -f $TEST_ROOT/relative-root/restore/innocent.txt ]]; then + pass 'user-selected output root may contain a relative .. component' +else + fail 'user-selected output root may contain a relative .. component' +fi -mkdir legit_extract && cd legit_extract -"$ZUPT_BIN" x ../legit.zupt > /dev/null 2>&1 -cd .. +cp "$TEST_ROOT/leaf.zupt" "$TEST_ROOT/corrupt.zupt" +python3 - "$TEST_ROOT/corrupt.zupt" <<'PY' +import pathlib +import sys -[ -f legit_extract/legit_input/normal.txt ] && \ - [ "$(cat legit_extract/legit_input/normal.txt)" = "ok content" ] -chk "Normal extraction still works" +path = pathlib.Path(sys.argv[1]) +data = bytearray(path.read_bytes()) +# Header (64) + data-block fixed/varint header (17): first payload byte. +data[81] ^= 0x01 +path.write_bytes(data) +PY +mkdir "$TEST_ROOT/corrupt-out" +if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/corrupt-out" "$TEST_ROOT/corrupt.zupt" \ + > "$TEST_ROOT/corrupt.log" 2>&1 && + [[ -z $(find "$TEST_ROOT/corrupt-out" -mindepth 1 -print -quit) ]]; then + pass 'corrupt payload leaves neither a final nor temporary output file' +else + fail 'corrupt payload leaves neither a final nor temporary output file' +fi -# ─── Property 5: deep path (allowed) but parent dir is created ───────── -echo " [P5. Multi-component safe paths still work]" - -mkdir deep && mkdir deep/sub && mkdir deep/sub/sub2 -echo "deep" > deep/sub/sub2/file.txt -"$ZUPT_BIN" c deep.zupt deep/sub/sub2/file.txt > /dev/null 2>&1 - -mkdir deep_extract && cd deep_extract -"$ZUPT_BIN" x ../deep.zupt > /dev/null 2>&1 -cd .. - -[ -f deep_extract/deep/sub/sub2/file.txt ] -chk "Deep nested path extracted" - -echo -echo " ───────────────────────────────────────" -echo " Path-traversal regression: $PASS passed, $FAIL failed" -echo " ───────────────────────────────────────" -[ $FAIL -eq 0 ] +printf '\n Path-confinement regression: %d PASS, %d FAIL, %d SKIP\n' \ + "$PASS" "$FAIL" "$SKIP" +((FAIL == 0)) diff --git a/tests/test_pqbox.sh b/tests/test_pqbox.sh index 8942324..f176673 100755 --- a/tests/test_pqbox.sh +++ b/tests/test_pqbox.sh @@ -1,88 +1,135 @@ #!/bin/bash # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2026 Cristian Cezar Moisés -# -# ZUPT_ENC_PQ_BOX_V1 (--pq-box, vendored libpqvaptvupt) — functional and -# adversarial coverage: keygen file format, byte-exact roundtrips on both -# frame formats, wrong-key and key-type-confusion rejection, envelope and -# data tampering, and cross-mode isolation. +# Functional and adversarial coverage for the optional system libpqvaptvupt. -set -u -P=0; F=0 -ok() { echo " ✓ $1"; P=$((P+1)); } -bad() { echo " ✗ $1"; F=$((F+1)); } -T=$(mktemp -d) -FX=/tmp/bench/fixtures -BIN=./vaptvupt -# Source-only build (WITH_SDK=0) has no libzuptsdk: the SDK-mode paths this -# test exercises are unavailable, so skip cleanly instead of failing. -_sdkck="$(mktemp -d)" -if ! "$BIN" keygen --box -o "$_sdkck/p" >/dev/null 2>&1; then - rm -rf "$_sdkck"; echo " SKIP: built without libzuptsdk (source-only) - SDK-mode test not applicable"; exit 0 +set -Eeuo pipefail + +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P) +zupt=${ZUPT_BIN:-$repo_root/zupt} + +if [[ ! -x $zupt ]]; then + printf ' FAIL: %s not found; build ZUPT first\n' "$zupt" >&2 + exit 1 fi -rm -rf "$_sdkck" +version=$("$zupt" --version 2>&1) +if ! grep -Fq 'libpqvaptvupt=enabled' <<<"$version"; then + echo ' SKIP: system libpqvaptvupt integration is disabled (build with WITH_PQBOX=1)' + exit 0 +fi -echo "pq-box mode (ZUPT_ENC_PQ_BOX_V1)" +tmpdir=$(mktemp -d) +trap 'rm -rf -- "$tmpdir"' EXIT +cd "$tmpdir" -# 1. keygen + file format -$BIN keygen --box -o $T/k.key >/dev/null 2>&1 -[ "$(stat -c%s $T/k.key 2>/dev/null)" = "2441" ] && ok "secret keyfile size (9+2432)" || bad "secret keyfile size" -[ "$(stat -c%s $T/k.key.pub 2>/dev/null)" = "1225" ] && ok "public keyfile size (9+1216)" || bad "public keyfile size" -head -c8 $T/k.key | grep -q "PQVVBOX1" && ok "keyfile magic" || bad "keyfile magic" +passed=0 +failed=0 +pass() { printf ' ✓ %s\n' "$1"; passed=$((passed + 1)); } +fail() { printf ' ✗ %s\n' "$1"; failed=$((failed + 1)); } -# 2. roundtrips: L1 (v1 frame) and L9 (format_v2 + auto-filter), text + binary -for case in "1 text" "9 text" "9 binary"; do - set -- $case; L=$1; fx=$2 - $BIN c -l $L --pq-box $T/k.key.pub $T/a$L$fx.zupt $FX/$fx.dat >/dev/null 2>&1 - rm -rf $T/o$L$fx; mkdir -p $T/o$L$fx - $BIN x --pq-box $T/k.key -o $T/o$L$fx $T/a$L$fx.zupt >/dev/null 2>&1 - Fp=$(find $T/o$L$fx -type f | head -1) - [ -n "$Fp" ] && diff -q "$Fp" $FX/$fx.dat >/dev/null 2>&1 \ - && ok "roundtrip L$L $fx byte-exact" || bad "roundtrip L$L $fx" +echo 'pq-box mode (ZUPT_ENC_PQ_BOX_V1)' + +if "$zupt" keygen --box -o k.key >/dev/null 2>&1 && + [[ -f k.key && -f k.key.pub ]]; then + pass 'pq-box keygen produces private and public key files' +else + fail 'pq-box keygen using system libpqvaptvupt' + exit 1 +fi +if [[ $(wc -c text.dat +dd if=/dev/urandom of=binary.dat bs=65536 count=4 2>/dev/null + +for level in 1 9 9; do + if [[ $level -eq 1 ]]; then + fixture=text.dat + label='L1 text' + elif [[ ! -e a9text.zupt ]]; then + fixture=text.dat + label='L9 text' + else + fixture=binary.dat + label='L9 binary' + fi + archive="a${level}${fixture%.dat}.zupt" + outdir="out-${level}-${fixture%.dat}" + if "$zupt" c -l "$level" --pq-box k.key.pub "$archive" "$fixture" >/dev/null 2>&1; then + mkdir "$outdir" + if "$zupt" x --pq-box k.key -o "$outdir" "$archive" >/dev/null 2>&1 && + cmp -s "$fixture" "$outdir/$fixture"; then + pass "roundtrip $label is byte-exact" + else + fail "roundtrip $label is byte-exact" + fi + else + fail "encrypt $label" + fi done -# 3. wrong key rejected -$BIN keygen --box -o $T/w.key >/dev/null 2>&1 -rm -rf $T/ow; mkdir -p $T/ow -$BIN x --pq-box $T/w.key -o $T/ow $T/a9text.zupt >/dev/null 2>&1 \ - && bad "wrong key accepted" || ok "wrong key rejected" +"$zupt" keygen --box -o wrong.key >/dev/null 2>&1 +mkdir wrong-out +if "$zupt" x --pq-box wrong.key -o wrong-out a9text.zupt >/dev/null 2>&1; then + fail 'wrong pq-box key is rejected' +else + pass 'wrong pq-box key is rejected' +fi -# 4. key-type confusion rejected (pub-as-priv, priv-as-pub, legacy key) -rm -rf $T/oc; mkdir -p $T/oc -$BIN x --pq-box $T/k.key.pub -o $T/oc $T/a9text.zupt >/dev/null 2>&1 \ - && bad "PUBLIC key accepted as secret" || ok "public-as-secret rejected" -$BIN c -l 1 --pq-box $T/k.key $T/cc.zupt $FX/text.dat >/dev/null 2>&1 \ - && bad "SECRET key accepted as public" || ok "secret-as-public rejected" -$BIN keygen -o $T/legacy.key >/dev/null 2>&1 -rm -rf $T/ol; mkdir -p $T/ol -$BIN x --pq-box $T/legacy.key -o $T/ol $T/a9text.zupt >/dev/null 2>&1 \ - && bad "legacy key accepted on box archive" || ok "legacy-key-on-box rejected" +mkdir confusion-out +if "$zupt" x --pq-box k.key.pub -o confusion-out a9text.zupt >/dev/null 2>&1; then + fail 'public key is rejected as a secret key' +else + pass 'public key is rejected as a secret key' +fi +if "$zupt" c -l 1 --pq-box k.key secret-as-public.zupt text.dat >/dev/null 2>&1; then + fail 'secret key is rejected as a public key' +else + pass 'secret key is rejected as a public key' +fi +"$zupt" keygen -o native.key >/dev/null 2>&1 +mkdir native-confusion-out +if "$zupt" x --pq-box native.key -o native-confusion-out a9text.zupt >/dev/null 2>&1; then + fail 'native key is rejected for a pq-box archive' +else + pass 'native key is rejected for a pq-box archive' +fi -# 5. tamper: envelope byte (offset inside the sealed blob) and data region -for spot in 64 -1024; do - cp $T/a9text.zupt $T/t.zupt - python3 - "$T/t.zupt" "$spot" << 'PY' -import sys -p, off = sys.argv[1], int(sys.argv[2]) -d = bytearray(open(p,'rb').read()) -i = off if off >= 0 else len(d)+off -d[i] ^= 0x01 -open(p,'wb').write(d) -PY - rm -rf $T/ot; mkdir -p $T/ot - $BIN x --pq-box $T/k.key -o $T/ot $T/t.zupt >/dev/null 2>&1 \ - && bad "tamper@$spot accepted" || ok "tamper@$spot rejected" +for position in envelope body; do + kind=data + [[ $position == envelope ]] && kind=enc + python3 "$repo_root/tests/archive_surgery.py" flip-payload \ + a9text.zupt "tampered-$position.zupt" --kind "$kind" \ + --require-encrypted + mkdir "tampered-out-$position" + if "$zupt" x --pq-box k.key -o "tampered-out-$position" \ + "tampered-$position.zupt" >/dev/null 2>&1; then + fail "$position tamper is rejected" + else + pass "$position tamper is rejected" + fi done -# 6. cross-mode isolation: box archive demands box key, not password -rm -rf $T/op; mkdir -p $T/op -$BIN x -p somepass -o $T/op $T/a9text.zupt >/dev/null 2>&1 \ - && bad "password accepted on box archive" || ok "password-on-box rejected" +mkdir password-out +if "$zupt" x -p somepass -o password-out a9text.zupt >/dev/null 2>&1; then + fail 'password mode is rejected for a pq-box archive' +else + pass 'password mode is rejected for a pq-box archive' +fi -echo "" -echo " ───────────────────────────────────────" -echo " pq-box: $P passed, $F failed" -echo " ───────────────────────────────────────" -rm -rf $T -exit $([ $F -eq 0 ] && echo 0 || echo 1) +printf '\n pq-box: %d passed, %d failed\n' "$passed" "$failed" +((failed == 0)) diff --git a/tests/test_sdk.sh b/tests/test_sdk.sh index 6aba095..fbe6051 100755 --- a/tests/test_sdk.sh +++ b/tests/test_sdk.sh @@ -1,75 +1,118 @@ #!/bin/bash # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés -# Test zupt SDK-backed PQ encryption (v2.2+) +# Functional and adversarial coverage for the optional system libvuptsdk. +set -Eeuo pipefail -cd "$(dirname "$0")/.." -ZUPT_BIN="$(realpath ./zupt)" -TMPDIR=$(mktemp -d) -trap "rm -rf $TMPDIR" EXIT +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P) +zupt=${ZUPT_BIN:-$repo_root/zupt} -cd "$TMPDIR" -PASS=0; FAIL=0 -chk() { if [ $? -eq 0 ]; then echo " OK: $1"; PASS=$((PASS+1)); else echo " FAIL: $1"; FAIL=$((FAIL+1)); fi; } -chk_neg() { if [ $? -ne 0 ]; then echo " OK: $1"; PASS=$((PASS+1)); else echo " FAIL: $1 (should have failed)"; FAIL=$((FAIL+1)); fi; } +if [[ ! -x $zupt ]]; then + printf ' FAIL: %s not found; build ZUPT first\n' "$zupt" >&2 + exit 1 +fi -# Setup: real keypair -"$ZUPT_BIN" keygen --sdk -o key.priv > /dev/null 2>&1 -[ -f key.priv ] && [ -f key.priv.pub ] -chk "SDK keygen produces both files" +version=$("$zupt" --version 2>&1) +if ! grep -Fq 'libvuptsdk=enabled' <<<"$version"; then + echo ' SKIP: system libvuptsdk integration is disabled (build with WITH_SDK=1)' + exit 0 +fi -# Test data -echo "Hello SDK PQ encryption" > input.txt -dd if=/dev/urandom of=large.bin bs=64K count=4 2>/dev/null +tmpdir=$(mktemp -d) +trap 'rm -rf -- "$tmpdir"' EXIT +cd "$tmpdir" -# Roundtrip small file -"$ZUPT_BIN" c --pq-sdk key.priv.pub small.zupt input.txt > /dev/null 2>&1 -chk "SDK encrypt small" -mkdir -p extract1 && cd extract1 -"$ZUPT_BIN" x --pq-sdk ../key.priv ../small.zupt > /dev/null 2>&1 -chk "SDK decrypt small" -diff -q input.txt ../input.txt > /dev/null 2>&1 -chk "SDK small roundtrip byte-exact" -cd .. +passed=0 +failed=0 +pass() { printf ' OK: %s\n' "$1"; passed=$((passed + 1)); } +fail() { printf ' FAIL: %s\n' "$1"; failed=$((failed + 1)); } -# Roundtrip large file -"$ZUPT_BIN" c --pq-sdk key.priv.pub large.zupt large.bin > /dev/null 2>&1 -chk "SDK encrypt large (256KB)" -mkdir -p extract2 && cd extract2 -"$ZUPT_BIN" x --pq-sdk ../key.priv ../large.zupt > /dev/null 2>&1 -chk "SDK decrypt large" -diff -q large.bin ../large.bin > /dev/null 2>&1 -chk "SDK large roundtrip byte-exact" -cd .. +if "$zupt" keygen --sdk -o key.priv >/dev/null 2>&1 && + [[ -f key.priv && -f key.priv.pub ]]; then + pass 'SDK keygen produces private and public key files' +else + fail 'SDK keygen using system libvuptsdk' + exit 1 +fi -# Wrong key rejected -"$ZUPT_BIN" keygen --sdk -o other.priv > /dev/null 2>&1 -"$ZUPT_BIN" x --pq-sdk other.priv small.zupt > /dev/null 2>&1 -chk_neg "SDK wrong key rejected" +printf 'Hello SDK PQ encryption\n' >input.txt +dd if=/dev/urandom of=large.bin bs=65536 count=4 2>/dev/null + +if "$zupt" c --pq-sdk key.priv.pub small.zupt input.txt >/dev/null 2>&1; then + pass 'SDK encrypts a small file' +else + fail 'SDK encrypts a small file' +fi +mkdir extract1 +if (cd extract1 && "$zupt" x --pq-sdk ../key.priv ../small.zupt >/dev/null 2>&1); then + pass 'SDK decrypts a small file' +else + fail 'SDK decrypts a small file' +fi +if cmp -s input.txt extract1/input.txt; then + pass 'SDK small roundtrip is byte-exact' +else + fail 'SDK small roundtrip is byte-exact' +fi + +if "$zupt" c --pq-sdk key.priv.pub large.zupt large.bin >/dev/null 2>&1; then + pass 'SDK encrypts a 256 KiB file' +else + fail 'SDK encrypts a 256 KiB file' +fi +mkdir extract2 +if (cd extract2 && "$zupt" x --pq-sdk ../key.priv ../large.zupt >/dev/null 2>&1); then + pass 'SDK decrypts a 256 KiB file' +else + fail 'SDK decrypts a 256 KiB file' +fi +if cmp -s large.bin extract2/large.bin; then + pass 'SDK large roundtrip is byte-exact' +else + fail 'SDK large roundtrip is byte-exact' +fi + +"$zupt" keygen --sdk -o other.priv >/dev/null 2>&1 +mkdir wrong-key +if (cd wrong-key && "$zupt" x --pq-sdk ../other.priv ../small.zupt >/dev/null 2>&1); then + fail 'SDK rejects the wrong private key' +else + pass 'SDK rejects the wrong private key' +fi -# Tamper detected. -# F-02 (Zupt 2.2.4): use a deterministic body-region offset, not -# len-50 which occasionally landed in the unauthenticated index -# region. See docs/FINDINGS-2.x.md F-02 for the full analysis. cp small.zupt tampered.zupt -python3 -c " -b = bytearray(open('tampered.zupt','rb').read()) -b[200] ^= 1 -open('tampered.zupt','wb').write(bytes(b)) -" -"$ZUPT_BIN" x --pq-sdk key.priv tampered.zupt > /dev/null 2>&1 -chk_neg "SDK tampered ciphertext rejected" +python3 - <<'PY' +from pathlib import Path -# Legacy v1 compat: legacy --pq still works -"$ZUPT_BIN" keygen -o legacy.key > /dev/null 2>&1 -"$ZUPT_BIN" c --pq legacy.key legacy.zupt input.txt > /dev/null 2>&1 -chk "Legacy --pq still encrypts" -mkdir -p extract3 && cd extract3 -"$ZUPT_BIN" x --pq ../legacy.key ../legacy.zupt > /dev/null 2>&1 -chk "Legacy --pq still decrypts" -cd .. +path = Path("tampered.zupt") +data = bytearray(path.read_bytes()) +if len(data) <= 200: + raise SystemExit("archive too small for deterministic body tamper") +data[200] ^= 1 +path.write_bytes(data) +PY +mkdir tampered +if (cd tampered && "$zupt" x --pq-sdk ../key.priv ../tampered.zupt >/dev/null 2>&1); then + fail 'SDK rejects tampered ciphertext' +else + pass 'SDK rejects tampered ciphertext' +fi -echo -echo " Results: $PASS passed, $FAIL failed ($((PASS+FAIL)) tests)" -[ $FAIL -eq 0 ] +"$zupt" keygen -o native.key >/dev/null 2>&1 +if "$zupt" c --pq native.key native.zupt input.txt >/dev/null 2>&1; then + pass 'native --pq encryption remains available' +else + fail 'native --pq encryption remains available' +fi +mkdir native-out +if (cd native-out && "$zupt" x --pq ../native.key ../native.zupt >/dev/null 2>&1) && + cmp -s input.txt native-out/input.txt; then + pass 'native --pq roundtrip remains byte-exact' +else + fail 'native --pq roundtrip remains byte-exact' +fi + +printf '\n Results: %d passed, %d failed (%d tests)\n' \ + "$passed" "$failed" "$((passed + failed))" +((failed == 0)) diff --git a/tests/test_sha256_shani.c b/tests/test_sha256_shani.c index b850bb0..58b6581 100644 --- a/tests/test_sha256_shani.c +++ b/tests/test_sha256_shani.c @@ -30,6 +30,7 @@ #define HAVE_SHANI_BUILD 1 #endif +#ifdef HAVE_SHANI_BUILD static const uint32_t IV[8] = { 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 @@ -45,6 +46,7 @@ static void bad(const char *m) { printf(" \xE2\x9C\x97 %s\n", m); fail++; } static void hex(const uint8_t *b, int n, char *out) { for (int i = 0; i < n; i++) sprintf(out + i*2, "%02x", b[i]); } +#endif int main(void) { zupt_detect_cpu(&zupt_cpu); diff --git a/tests/test_source_only.sh b/tests/test_source_only.sh new file mode 100755 index 0000000..3ed42fb --- /dev/null +++ b/tests/test_source_only.sh @@ -0,0 +1,428 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later + +set -Eeuo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +SCANNER=$ROOT/scripts/check-source-only.sh +TEST_TMP=$(mktemp -d "${TMPDIR:-/tmp}/zupt-source-only-tests.XXXXXXXX") +PASSED=0 + +cleanup() { + local status=$? + trap - EXIT HUP INT TERM + rm -rf -- "$TEST_TMP" + exit "$status" +} +trap cleanup EXIT HUP INT TERM + +pass() { + PASSED=$((PASSED + 1)) + printf 'ok %d - %s\n' "$PASSED" "$1" +} + +skip() { + PASSED=$((PASSED + 1)) + printf 'ok %d - %s # SKIP\n' "$PASSED" "$1" +} + +expect_pass_tree() { + local name=$1 tree=$2 output=$TEST_TMP/output + if "$SCANNER" --tree "$tree" >"$output" 2>&1 && grep -q '^PASS source-only:' "$output"; then + pass "$name" + else + printf 'not ok - %s\n' "$name" + sed -n '1,120p' "$output" + exit 1 + fi +} + +expect_fail_tree() { + local name=$1 tree=$2 expected=${3:-} output=$TEST_TMP/output + if "$SCANNER" --tree "$tree" >"$output" 2>&1; then + printf 'not ok - %s (scanner unexpectedly passed)\n' "$name" + exit 1 + fi + grep -q '^FAIL ' "$output" || { + printf 'not ok - %s (missing FAIL finding)\n' "$name" + exit 1 + } + grep -q '^FAIL source-only:' "$output" || { + printf 'not ok - %s (missing FAIL summary)\n' "$name" + exit 1 + } + if [[ -n $expected ]] && ! grep -Fq -- "$expected" "$output"; then + printf 'not ok - %s (missing expected path)\n' "$name" + exit 1 + fi + pass "$name" +} + +expect_fail_archive_with_limits() { + local name=$1 archive=$2 expected=$3 + shift 3 + local output=$TEST_TMP/output + if env "$@" "$SCANNER" --archive "$archive" >"$output" 2>&1; then + printf 'not ok - %s (scanner unexpectedly passed)\n' "$name" + exit 1 + fi + if ! grep -Fq -- "$expected" "$output"; then + printf 'not ok - %s (missing expected bounded-archive finding)\n' "$name" + sed -n '1,120p' "$output" + exit 1 + fi + pass "$name" +} + +fresh_tree() { + local name=$1 + mkdir -p "$TEST_TMP/$name" + printf '%s' "$TEST_TMP/$name" +} + +safe=$(fresh_tree safe) +mkdir -p "$safe/src" "$safe/assets" +printf '#include \nint main(void) { return 0; }\n' >"$safe/src/main.c" +printf '.text\n.globl portable_symbol\nportable_symbol:\n ret\n' >"$safe/src/portable.S" +printf '\211PNG\r\n\032\n' >"$safe/assets/icon.png" +printf '\000\000\001\000' >"$safe/assets/icon.ico" +if ln -s src/main.c "$safe/main-link.c" 2>/dev/null && + [[ -L $safe/main-link.c ]]; then + SYMLINKS_SUPPORTED=1 + safe_label='text source, assembly, PNG, ICO, and internal symlink pass' +else + SYMLINKS_SUPPORTED=0 + safe_label='text source, assembly, PNG, and ICO pass (symlink unavailable)' +fi +expect_pass_tree "$safe_label" "$safe" + +tree=$(fresh_tree undeclared-bin) +printf '\001\002\003fixture data\n' >"$tree/vector.bin" +expect_fail_tree 'undeclared .bin data is rejected' "$tree" vector.bin + +tree=$(fresh_tree declared-bin) +mkdir -p "$tree/tests/data" +printf '\001\002\003fixture data\n' >"$tree/tests/data/vector.bin" +manifest=$TEST_TMP/source-data.tsv +printf 'tests/data/vector.bin\ttest vector\tgenerated by test_source_only.sh\tAGPL-3.0-or-later\n' >"$manifest" +if "$SCANNER" --data-manifest "$manifest" --tree "$tree" >"$TEST_TMP/output" 2>&1 && + grep -q '^PASS source-only:' "$TEST_TMP/output"; then + pass 'declared non-executable .bin fixture passes with complete metadata' +else + printf 'not ok - declared non-executable .bin fixture passes\n' + sed -n '1,120p' "$TEST_TMP/output" + exit 1 +fi + +printf '\177ELF\002\001\001\000compiled' >"$tree/tests/data/vector.bin" +if "$SCANNER" --data-manifest "$manifest" --tree "$tree" >"$TEST_TMP/output" 2>&1; then + printf 'not ok - manifest cannot allow executable magic\n' + exit 1 +elif grep -Fq 'tests/data/vector.bin' "$TEST_TMP/output"; then + pass 'data manifest cannot exempt executable magic' +else + printf 'not ok - executable magic path missing from manifest test\n' + exit 1 +fi + +tree=$(fresh_tree elf) +printf '\177ELF\002\001\001\000compiled' >"$tree/renamed.txt" +expect_fail_tree 'ELF renamed as text is rejected' "$tree" renamed.txt + +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) + skip 'control-byte filenames are forbidden by the Windows filesystem' + skip 'raw C1 filenames are forbidden by the Windows filesystem' + skip 'UTF-8 C1 filenames are forbidden by the Windows filesystem' + skip 'bidirectional-control filenames are forbidden by the Windows filesystem' + skip 'printable UTF-8 filename preservation is not exercised on Windows' + ;; + *) + tree=$(fresh_tree control-path) + control_name=$'escape\033[31m.txt' + printf '\177ELF\002\001\001\000compiled' >"$tree/$control_name" + if "$SCANNER" --tree "$tree" >"$TEST_TMP/output" 2>&1; then + printf 'not ok - control-byte path was not rejected\n' + exit 1 + elif grep -q $'\033' "$TEST_TMP/output" || + ! grep -Fq 'escape\x1b[31m.txt' "$TEST_TMP/output"; then + printf 'not ok - control-byte path was not rendered safely\n' + exit 1 + else + pass 'scanner escapes terminal control bytes in reported paths' + fi + + tree=$(fresh_tree raw-c1-path) + control_name=$'raw-\200.txt' + if { printf '\177ELF\002\001\001\000compiled' >"$tree/$control_name"; } 2>/dev/null; then + if "$SCANNER" --tree "$tree" >"$TEST_TMP/output" 2>&1; then + printf 'not ok - raw C1 path was not rejected\n' + exit 1 + elif ! grep -Fq 'raw-\x80.txt' "$TEST_TMP/output" || + LC_ALL=C grep -q $'\200' "$TEST_TMP/output"; then + printf 'not ok - raw C1 path was not rendered safely\n' + exit 1 + else + pass 'scanner escapes invalid raw C1 bytes in reported paths' + fi + else + skip 'raw C1 filenames are forbidden by this filesystem' + fi + + tree=$(fresh_tree utf8-c1-path) + control_name=$'utf8-\302\233.txt' + printf '\177ELF\002\001\001\000compiled' >"$tree/$control_name" + if "$SCANNER" --tree "$tree" >"$TEST_TMP/output" 2>&1; then + printf 'not ok - UTF-8 C1 path was not rejected\n' + exit 1 + elif ! grep -Fq 'utf8-\u009b.txt' "$TEST_TMP/output" || + LC_ALL=C grep -q $'\302\233' "$TEST_TMP/output"; then + printf 'not ok - UTF-8 C1 path was not rendered safely\n' + exit 1 + else + pass 'scanner escapes UTF-8-encoded C1 controls in reported paths' + fi + + tree=$(fresh_tree bidi-path) + control_name=$'report-\342\200\256txt.exe' + printf '\177ELF\002\001\001\000compiled' >"$tree/$control_name" + if "$SCANNER" --tree "$tree" >"$TEST_TMP/output" 2>&1; then + printf 'not ok - bidirectional-control path was not rejected\n' + exit 1 + elif ! grep -Fq 'report-\u202etxt.exe' "$TEST_TMP/output" || + LC_ALL=C grep -q $'\342\200\256' "$TEST_TMP/output"; then + printf 'not ok - bidirectional-control path was not rendered safely\n' + exit 1 + else + pass 'scanner escapes UTF-8 bidirectional controls in reported paths' + fi + + tree=$(fresh_tree printable-utf8-path) + control_name=$'caf\303\251.txt' + printf '\177ELF\002\001\001\000compiled' >"$tree/$control_name" + if "$SCANNER" --tree "$tree" >"$TEST_TMP/output" 2>&1; then + printf 'not ok - printable UTF-8 path was not rejected\n' + exit 1 + elif ! LC_ALL=C grep -Fq -- "$control_name" "$TEST_TMP/output"; then + printf 'not ok - printable UTF-8 path was not preserved\n' + exit 1 + else + pass 'scanner preserves printable UTF-8 in reported paths' + fi + ;; +esac + +tree=$(fresh_tree ar) +printf '!\n' >"$tree/renamed.data" +expect_fail_tree 'ar library renamed as data is rejected' "$tree" renamed.data + +tree=$(fresh_tree thin-ar) +printf '!\n' >"$tree/renamed.data" +expect_fail_tree 'GNU thin archive renamed as data is rejected' "$tree" renamed.data + +tree=$(fresh_tree mz) +printf 'MZnot-source' >"$tree/renamed.data" +expect_fail_tree 'PE/MZ renamed as data is rejected' "$tree" renamed.data + +tree=$(fresh_tree macho) +printf '\376\355\372\317compiled' >"$tree/renamed.data" +expect_fail_tree 'Mach-O renamed as data is rejected' "$tree" renamed.data + +tree=$(fresh_tree coff) +printf '\144\206\001\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000' >"$tree/renamed.data" +expect_fail_tree 'COFF object renamed as data is rejected' "$tree" renamed.data + +tree=$(fresh_tree lfs) +printf 'version https://git-lfs.github.com/spec/v1\noid sha256:0000\nsize 4\n' >"$tree/pointer.c" +expect_fail_tree 'unresolved Git LFS pointer is rejected' "$tree" pointer.c + +tree=$(fresh_tree symlink) +if ((SYMLINKS_SUPPORTED)) && ln -s ../../outside "$tree/escape" 2>/dev/null && + [[ -L $tree/escape ]]; then + expect_fail_tree 'escaping symlink is rejected' "$tree" escape +else + skip 'escaping symlink test is unsupported by this runner' +fi + +tree=$(fresh_tree so-version) +printf 'not actually compiled\n' >"$tree/libexample.so.1" +expect_fail_tree 'versioned shared-library extension is rejected' "$tree" libexample.so.1 + +tree=$(fresh_tree rpm) +printf '\355\253\356\333package' >"$tree/renamed.data" +expect_fail_tree 'RPM magic is rejected without relying on extension' "$tree" renamed.data + +tree=$(fresh_tree deb) +printf '!\ndebian-binary 000000000000000000000000000000000000000000000000000000\n' >"$tree/renamed.data" +expect_fail_tree 'DEB magic is rejected without relying on extension' "$tree" renamed.data + +tree=$(fresh_tree appimage) +printf '\177ELF\002\001\001\000AI\002payload' >"$tree/renamed.data" +expect_fail_tree 'AppImage magic is rejected without relying on extension' "$tree" renamed.data + +tree=$(fresh_tree wasm) +printf '\000asm\001\000\000\000' >"$tree/module.data" +expect_fail_tree 'WebAssembly magic is rejected' "$tree" module.data + +tree=$(fresh_tree class) +printf '\312\376\272\276\000\000\000\075' >"$tree/class.data" +expect_fail_tree 'Java class magic is rejected' "$tree" class.data + +tree=$(fresh_tree pyc) +printf '\247\015\015\012\000\000\000\000\000\000\000\000\000\000\000\000' >"$tree/python.data" +expect_fail_tree 'Python bytecode magic is rejected' "$tree" python.data + +tree=$(fresh_tree nested) +mkdir -p "$tree/input" +printf '\177ELF\002\001\001\000nested' >"$tree/input/payload.txt" +tar -C "$tree/input" -cf "$tree/outer.tar" payload.txt +rm -rf -- "$tree/input" +expect_fail_tree 'compiled content inside an archive is rejected' "$tree" 'outer.tar!payload.txt' + +tree=$(fresh_tree renamed-7z) +printf '\067\172\274\257\047\034malformed' >"$tree/renamed.data" +expect_fail_tree '7z magic is recognized and cannot bypass archive inspection' \ + "$tree" renamed.data + +tree=$(fresh_tree renamed-rar) +printf 'Rar!\032\007\001\000malformed' >"$tree/renamed.data" +expect_fail_tree 'RAR magic is recognized and cannot bypass archive inspection' \ + "$tree" renamed.data + +tree=$(fresh_tree empty-archive) +tar -cf "$tree/empty.tar" --files-from /dev/null +expect_fail_tree 'empty archives are rejected as having no inspectable source' \ + "$tree" empty.tar + +tree=$(fresh_tree member-limit) +mkdir -p "$tree/input" +for member_number in 1 2 3 4; do + printf 'source %s\n' "$member_number" >"$tree/input/$member_number.c" +done +tar -C "$tree/input" -cf "$tree/members.tar" . +expect_fail_archive_with_limits \ + 'archive member count is bounded during preflight listing' \ + "$tree/members.tar" 'archive member limit exceeded' \ + SOURCE_AUDIT_MAX_MEMBERS=3 + +expect_fail_archive_with_limits \ + 'archive member-name output is byte-bounded during preflight listing' \ + "$tree/members.tar" 'archive member-name budget exceeded' \ + SOURCE_AUDIT_MAX_LIST_KIB=0 + +tree=$(fresh_tree expanded-limit) +mkdir -p "$tree/input" +dd if=/dev/zero of="$tree/input/zeros.c" bs=1024 count=2048 2>/dev/null +tar -C "$tree/input" -czf "$tree/compressed-size-bomb.tar.gz" zeros.c +expect_fail_archive_with_limits \ + 'compressed archive declared size is rejected before extraction' \ + "$tree/compressed-size-bomb.tar.gz" \ + 'archive declared-size limit exceeded before extraction' \ + SOURCE_AUDIT_MAX_KIB=1024 + +tree=$(fresh_tree global-expanded-limit) +mkdir -p "$tree/one" "$tree/two" +dd if=/dev/zero of="$tree/one/one.c" bs=700 count=1 2>/dev/null +dd if=/dev/zero of="$tree/two/two.c" bs=700 count=1 2>/dev/null +tar -C "$tree/one" -cf "$tree/one.tar" one.c +tar -C "$tree/two" -cf "$tree/two.tar" two.c +if env SOURCE_AUDIT_MAX_KIB=2 SOURCE_AUDIT_MAX_TOTAL_KIB=1 \ + "$SCANNER" --archive "$tree/one.tar" --archive "$tree/two.tar" \ + >"$TEST_TMP/output" 2>&1; then + printf 'not ok - global archive size budget unexpectedly passed\n' + exit 1 +elif grep -Fq 'global archive declared-size budget exceeded' "$TEST_TMP/output"; then + pass 'global declared-size budget covers multiple archives' +else + printf 'not ok - global archive size budget finding missing\n' + sed -n '1,120p' "$TEST_TMP/output" + exit 1 +fi + +tree=$(fresh_tree archive-symlink) +mkdir -p "$tree/input" +if ((SYMLINKS_SUPPORTED)) && + ln -s ../../outside "$tree/input/escape" 2>/dev/null && + [[ -L $tree/input/escape ]]; then + tar -C "$tree/input" -cf "$tree/escape.tar" escape + rm -rf -- "$tree/input" + expect_fail_tree 'escaping symlink inside an archive is rejected before extraction' "$tree" 'escape.tar!escape' +else + skip 'archive symlink test is unsupported by this runner' +fi + +tree=$(fresh_tree bad-ref) +printf 'SDK_LIB = vendor/vuptsdk/libvuptsdk.so.2\n' >"$tree/Makefile" +expect_fail_tree 'removed vendored library references are rejected' "$tree" Makefile + +archive_src=$(fresh_tree standalone-archive) +printf 'source text\n' >"$archive_src/source.c" +tar -C "$archive_src" -cf "$TEST_TMP/source.tar" source.c +if "$SCANNER" --archive "$TEST_TMP/source.tar" >"$TEST_TMP/output" 2>&1 && + grep -q '^PASS source-only:' "$TEST_TMP/output"; then + pass 'standalone source archive passes' +else + printf 'not ok - standalone source archive passes\n' + sed -n '1,120p' "$TEST_TMP/output" + exit 1 +fi + +if SOURCE_AUDIT_FORCE_WATCHDOG=1 \ + "$SCANNER" --archive "$TEST_TMP/source.tar" >"$TEST_TMP/output" 2>&1 && + grep -q '^PASS source-only:' "$TEST_TMP/output"; then + pass 'portable archive watchdog fallback completes a normal scan' +else + printf 'not ok - portable archive watchdog fallback\n' + sed -n '1,120p' "$TEST_TMP/output" + exit 1 +fi + +repo=$TEST_TMP/repository +mkdir -p "$repo" +git -C "$repo" init -q +git -C "$repo" config user.name 'Source Audit Test' +git -C "$repo" config user.email 'source-audit@example.invalid' +printf 'safe source\n' >"$repo/source.c" +printf '*.o\n' >"$repo/.gitignore" +mkdir -p "$repo/tests" "$repo/scripts" "$repo/packaging/opensuse" +printf 'fixture mentions vendor/vuptsdk/libvuptsdk.so.2\n' >"$repo/tests/test_source_only.sh" +printf '# scanner implementation fixture\n' >"$repo/scripts/check-source-only.sh" +printf '# scanner wrapper fixture\n' >"$repo/packaging/opensuse/source-audit.sh" +git -C "$repo" add source.c .gitignore tests scripts packaging +git -C "$repo" commit -qm 'safe source' +git -C "$repo" tag v1.0.0 +if "$SCANNER" --root "$repo" --tag v1.0.0 >"$TEST_TMP/output" 2>&1 && + grep -q '^PASS source-only:' "$TEST_TMP/output"; then + pass 'tracked, working-tree, HEAD archive, and tag archive pass' +else + printf 'not ok - repository and tag audit pass\n' + sed -n '1,120p' "$TEST_TMP/output" + exit 1 +fi + +printf '\177ELF\002\001\001\000ignored' >"$repo/ignored.o" +if "$SCANNER" --root "$repo" >"$TEST_TMP/output" 2>&1; then + printf 'not ok - ignored working-tree object is rejected\n' + exit 1 +elif grep -Fq ignored.o "$TEST_TMP/output"; then + pass 'ignored working-tree object is rejected' +else + printf 'not ok - ignored object path missing\n' + exit 1 +fi +rm -f -- "$repo/ignored.o" + +printf '\177ELF\002\001\001\000indexed' >"$repo/indexed.txt" +git -C "$repo" add indexed.txt +printf 'safe worktree replacement\n' >"$repo/indexed.txt" +if "$SCANNER" --root "$repo" >"$TEST_TMP/output" 2>&1; then + printf 'not ok - compiled indexed blob is rejected\n' + exit 1 +elif grep -Fq indexed.txt "$TEST_TMP/output"; then + pass 'Git index content is audited independently of the worktree' +else + printf 'not ok - indexed path missing\n' + exit 1 +fi + +printf '1..%d\n' "$PASSED" diff --git a/tests/test_static_analysis.sh b/tests/test_static_analysis.sh index d084506..4c13747 100755 --- a/tests/test_static_analysis.sh +++ b/tests/test_static_analysis.sh @@ -4,10 +4,10 @@ # # Static-analysis regression for v3.0.3. # -# Asserts that our (non-vendored) C source compiles cleanly under: +# Asserts that every first-party src/zupt_*.c translation unit compiles under: # - GCC strict warnings + -Werror -# - GCC -Wconversion + -Wsign-conversion (silenced/false-positive-prone -# warnings; we enable for OUR code only, not vendored vv_*.c) +# - GCC -Wconversion + -Wsign-conversion on the security/I/O subset where +# that warning policy is already clean # - cppcheck warning + performance level # # History: @@ -22,75 +22,90 @@ PASS=0; FAIL=0 P() { echo " ✓ $1"; PASS=$((PASS+1)); } F() { echo " ✗ $1"; FAIL=$((FAIL+1)); } -# Our (non-vendored) C source files. Vendored: vv_*.c, fips202.c, -# zupt_mlkem.c — these have their own upstream style and we don't -# enforce our warning set on them. -OUR_FILES=( - src/zupt_main.c - src/zupt_format.c - src/zupt_dedup.c - src/zupt_disk.c - src/zupt_crypto.c - src/zupt_aes256.c - src/zupt_sha256.c - src/zupt_xxh.c - src/zupt_parallel.c +STATIC_TMP=$(mktemp -d "${TMPDIR:-/tmp}/zupt-static-analysis.XXXXXXXX") || exit 1 +cleanup() { + local status=$? + trap - EXIT HUP INT TERM + rm -rf -- "$STATIC_TMP" + exit "$status" +} +trap cleanup EXIT HUP INT TERM + +# Derive the list so a newly added first-party translation unit cannot silently +# escape this gate. Bundled GPL codec sources use their own warning policy. +OUR_FILES=() +while IFS= read -r -d '' source_file; do + OUR_FILES[${#OUR_FILES[@]}]=$source_file +done < <(find src -maxdepth 1 -type f -name 'zupt_*.c' \ + ! -name 'zupt_sha256_shani.c' -print0 | sort -z) + +# Conversion warnings are intentionally a second, narrower policy. The LZ, +# LZH, Keccak, and ML-KEM implementations use signed loop indices per their +# reviewed algorithms; the strict-Werror and cppcheck passes still cover them. +CONVERSION_FILES=( + src/zupt_main.c src/zupt_format.c src/zupt_dedup.c src/zupt_disk.c + src/zupt_crypto.c src/zupt_crypto_sdk.c src/zupt_crypto_pqbox.c + src/zupt_aes256.c src/zupt_sha256.c src/zupt_xxh.c src/zupt_parallel.c + src/zupt_cpuid.c src/zupt_filetype.c src/zupt_mlock.c src/zupt_predict.c + src/zupt_x25519.c ) # zupt_sha256_shani.c needs -msha -mssse3 -msse4.1 to compile its # intrinsics; checked separately below so the main loop stays flag-clean. SHANI_FILE=src/zupt_sha256_shani.c -# Filter to files that actually exist (architecture-conditional ones) -EXIST=() -for f in "${OUR_FILES[@]}"; do - [ -f "$f" ] && EXIST+=("$f") -done +EXIST=("${OUR_FILES[@]}") echo "Static analysis" # ─── Strict GCC + -Werror ─── -STRICT_CFLAGS="-Wall -Wextra -Wpedantic -Wshadow -Wcast-align -Wstrict-prototypes \ --Wmissing-prototypes -Wnull-dereference -Wformat=2 -Wlogical-op -Wjump-misses-init \ --Wdouble-promotion -Woverlength-strings -Werror -O2 -std=c11 -Iinclude -Isrc" +STRICT_CFLAGS=( + -Wall -Wextra -Wpedantic -Wshadow -Wcast-align -Wstrict-prototypes + -Wmissing-prototypes -Wnull-dereference -Wformat=2 -Wlogical-op + -Wjump-misses-init -Wdouble-promotion -Woverlength-strings -Werror + -O2 -std=c11 -Iinclude -Isrc +) STRICT_FAILS=0 for f in "${EXIST[@]}"; do - if ! gcc $STRICT_CFLAGS -c "$f" -o /dev/null 2>/tmp/sa-strict.log; then + if ! gcc "${STRICT_CFLAGS[@]}" -c "$f" -o /dev/null 2>"$STATIC_TMP/strict.log"; then STRICT_FAILS=$((STRICT_FAILS+1)) F "strict GCC -Werror failed on $f" - head -3 /tmp/sa-strict.log | sed 's/^/ /' + head -3 "$STATIC_TMP/strict.log" | sed 's/^/ /' fi done [ "$STRICT_FAILS" = 0 ] && P "strict GCC -Werror clean on ${#EXIST[@]} files" # ─── -Wconversion + -Wsign-conversion ─── -CONV_CFLAGS="-Wall -Wextra -Wconversion -Wsign-conversion -O2 -std=c11 -Iinclude -Isrc" +CONV_CFLAGS=( + -Wall -Wextra -Wconversion -Wsign-conversion + -O2 -std=c11 -Iinclude -Isrc +) CONV_FAILS=0 -for f in "${EXIST[@]}"; do - n=$(gcc $CONV_CFLAGS -c "$f" -o /dev/null 2>&1 | grep -c "warning:") +for f in "${CONVERSION_FILES[@]}"; do + n=$(gcc "${CONV_CFLAGS[@]}" -c "$f" -o /dev/null 2>&1 | grep -c "warning:") if [ "$n" -gt 0 ]; then CONV_FAILS=$((CONV_FAILS+1)) F "$f: $n -Wconversion warnings" - gcc $CONV_CFLAGS -c "$f" -o /dev/null 2>&1 | grep "warning:" | head -3 | sed 's/^/ /' + gcc "${CONV_CFLAGS[@]}" -c "$f" -o /dev/null 2>&1 | grep "warning:" | head -3 | sed 's/^/ /' fi done -[ "$CONV_FAILS" = 0 ] && P "-Wconversion -Wsign-conversion clean on ${#EXIST[@]} files" +[ "$CONV_FAILS" = 0 ] && P "-Wconversion -Wsign-conversion clean on ${#CONVERSION_FILES[@]} security/I/O files" # ── SHA-NI file (needs -msha -mssse3 -msse4.1 on x86_64) ── if [ -f "$SHANI_FILE" ]; then ARCH_SA=$(uname -m) if [ "$ARCH_SA" = "x86_64" ] || [ "$ARCH_SA" = "i686" ]; then - SA_SHANI="-msha -mssse3 -msse4.1" + SA_SHANI=(-msha -mssse3 -msse4.1) else - SA_SHANI="" + SA_SHANI=() fi - if gcc $STRICT_CFLAGS $SA_SHANI -c "$SHANI_FILE" -o /dev/null 2>/tmp/sa-shani.log; then + if gcc "${STRICT_CFLAGS[@]}" "${SA_SHANI[@]}" -c "$SHANI_FILE" -o /dev/null 2>"$STATIC_TMP/shani.log"; then P "SHA-NI file strict GCC -Werror clean" else F "SHA-NI file fails strict -Werror" - head -5 /tmp/sa-shani.log | sed 's/^/ /' + head -5 "$STATIC_TMP/shani.log" | sed 's/^/ /' fi - if [ "$(gcc $CONV_CFLAGS $SA_SHANI -c "$SHANI_FILE" -o /dev/null 2>&1 | grep -c 'warning:')" = 0 ]; then + if [ "$(gcc "${CONV_CFLAGS[@]}" "${SA_SHANI[@]}" -c "$SHANI_FILE" -o /dev/null 2>&1 | grep -c 'warning:')" = 0 ]; then P "SHA-NI file -Wconversion -Wsign-conversion clean" else F "SHA-NI file has -Wconversion warnings" @@ -99,7 +114,7 @@ fi # ─── cppcheck warning + performance ─── if command -v cppcheck >/dev/null 2>&1; then - SUPP=/tmp/cppcheck-supp-sa.txt + SUPP=$STATIC_TMP/cppcheck-suppressions.txt cat > "$SUPP" </dev/null 2>&1; then *:src/vv_simd.c *:src/vv_xxh64.c *:src/fips202.c -*:src/zupt_mlkem.c missingIncludeSystem EOF n=$(timeout 60 cppcheck --quiet --enable=warning,performance \ @@ -173,6 +187,44 @@ else F "ECHO bit-clear missing the explicit (tcflag_t) cast" fi +# A restore to a device is irreversible. Classify the already-open descriptor +# rather than checking target_path and resolving that mutable name again. +if grep -Fq 'lstat(target_path' src/zupt_disk.c; then + F "disk restore has a path-check/open TOCTOU pattern" +elif grep -Fq 'tgt_fd = open(target_path' src/zupt_disk.c && + grep -Fq 'fstat(tgt_fd, &opened_st)' src/zupt_disk.c; then + P "disk restore classifies the opened target descriptor" +else + F "disk restore descriptor-first target guard is missing" +fi + +# CodeQL #5 reported chmod(dst, mode) after reopening/resolving the SDK save +# path. Key copies must use the core's handle/descriptor-relative atomic +# publisher and apply POSIX permissions to its already-open temporary stream. +if grep -Fq 'chmod(dst, mode)' sdk/src/zuptsdk.c; then + F "SDK key save has a path-based chmod TOCTOU pattern" +elif grep -Fq 'zupt_atomic_output_open(dst, &fo)' sdk/src/zuptsdk.c && + grep -Fq 'fchmod(fileno(fo), mode)' sdk/src/zuptsdk.c && + grep -Fq 'zupt_atomic_output_finish(output, rc == ZUPTSDK_OK)' \ + sdk/src/zuptsdk.c; then + P "SDK key save uses descriptor-relative atomic publication" +else + F "SDK key save atomic publication guard is missing" +fi + +# The SDK regression must not recreate the same check/use pattern while +# inspecting its sentinels and key modes. Open once, then classify/read via +# that descriptor; this also keeps CodeQL evidence free of test-only races. +if grep -Eq '(^|[^[:alnum:]_])(stat|lstat)[[:space:]]*\(' \ + sdk/tests/test_sdk_roundtrip.c; then + F "SDK regression uses path-level stat/lstat before later path operations" +elif grep -Fq 'fstat(fd, &info)' sdk/tests/test_sdk_roundtrip.c && + grep -Fq 'fstat(fd, info)' sdk/tests/test_sdk_roundtrip.c; then + P "SDK regression inspects already-open file descriptors" +else + F "SDK regression descriptor-based inspection guard is missing" +fi + echo "" echo " ───────────────────────────────────────" echo " Static analysis: $PASS passed, $FAIL failed" diff --git a/tests/test_threaded.sh b/tests/test_threaded.sh index 0805c30..340938d 100644 --- a/tests/test_threaded.sh +++ b/tests/test_threaded.sh @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (c) 2025-2026 Cristian Cezar Moisés set +e -ZUPT="./zupt" +ZUPT="${1:-./zupt}" T="/tmp/zupt_mt_$$" PASS=0; FAIL=0; TOTAL=0 mkdir -p "$T" @@ -207,7 +207,8 @@ T4_MS=$(( (T4_END - T4_START) / 1000000 )) echo " N=1: ${T1_MS}ms N=4: ${T4_MS}ms" if [ "$T4_MS" -gt 0 ] && [ "$T1_MS" -gt 0 ]; then - SPEEDUP=$(echo "scale=1; $T1_MS / $T4_MS" | bc 2>/dev/null || echo "?") + SPEEDUP=$(awk -v one="$T1_MS" -v four="$T4_MS" \ + 'BEGIN { if (four > 0) printf "%.1f", one / four; else print "?" }') echo " Speedup: ${SPEEDUP}x" pass "Throughput comparison (N=1: ${T1_MS}ms, N=4: ${T4_MS}ms, ${SPEEDUP}x)" else diff --git a/tests/test_vaptvupt.c b/tests/test_vaptvupt.c index 2c8a104..5866c14 100644 --- a/tests/test_vaptvupt.c +++ b/tests/test_vaptvupt.c @@ -2,7 +2,7 @@ * ZUPT v2.0.0 — VaptVupt Codec Unit Tests * * Tests VaptVupt roundtrip in all 3 modes, incompressible fallback, - * and validates integration with Zupt's XXH64 alias. + * and validates integration with ZUPT's XXH64 alias. * * VAPTVUPT: Integration test suite * Copyright (c) 2026 Cristian Cezar Moisés diff --git a/tests/test_vectors.c b/tests/test_vectors.c index 0021a87..8d00707 100644 --- a/tests/test_vectors.c +++ b/tests/test_vectors.c @@ -1,7 +1,7 @@ /* * SPDX-License-Identifier: AGPL-3.0-or-later * Copyright (c) 2025-2026 Cristian Cezar Moisés - * Zupt — NIST/RFC Cryptographic Test Vectors + * ZUPT — NIST/RFC Cryptographic Test Vectors * Copyright (c) 2026 Cristian Cezar Moisés — AGPL-3.0-or-later * * Tests: SHA-256 (FIPS 180-4), HMAC-SHA256 (RFC 4231), @@ -44,7 +44,7 @@ static void hex2bin(const char *hex, uint8_t *bin, int len) { } int main(void) { - printf("Zupt Cryptographic Test Vectors\n"); + printf("ZUPT Cryptographic Test Vectors\n"); printf("================================\n\n"); /* ═══ SHA-256 (FIPS 180-4) ═══ */ @@ -206,7 +206,7 @@ int main(void) { else { printf(" FAIL: XXH64('') = %016llx\n", (unsigned long long)h); fail++; } } - /* ═══ ML-KEM-768 internal self-test (F-04, Zupt 2.2.4) ═══ */ + /* ═══ ML-KEM-768 internal self-test (F-04, ZUPT 2.2.4) ═══ */ printf("\n-- ML-KEM-768 internal self-test --\n"); { /* zupt_mlkem768_selftest() returns 0 on success, -1 on failure. */ diff --git a/tests/test_vv_decode_slack.sh b/tests/test_vv_decode_slack.sh index fea46b0..8b2ab38 100755 --- a/tests/test_vv_decode_slack.sh +++ b/tests/test_vv_decode_slack.sh @@ -26,8 +26,7 @@ PASS=0; FAIL=0 P() { echo " ✓ $1"; PASS=$((PASS+1)); } F() { echo " ✗ $1"; FAIL=$((FAIL+1)); } -BIN=./vaptvupt -[ -x ./vaptvupt ] || BIN=./zupt +BIN=${1:-${ZUPT_BIN:-./zupt}} [ -x "$BIN" ] || { echo "ERROR: no built binary"; exit 2; } echo "VaptVupt decode over-copy guard" diff --git a/vendor/pqvaptvupt/LICENSE b/vendor/pqvaptvupt/LICENSE deleted file mode 100644 index 623d582..0000000 --- a/vendor/pqvaptvupt/LICENSE +++ /dev/null @@ -1,32 +0,0 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - libpqvaptvupt — post-quantum sealed-box encryption. - Copyright (C) 2026 Cristian Cezar Moisés. All rights reserved. - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as - published by the Free Software Foundation, either version 3 of the - License, or (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - - Full text of AGPL-3.0-or-later available at: - https://www.gnu.org/licenses/agpl-3.0.html - - COMMERCIAL LICENSE: - For use cases that conflict with the AGPL's network-use obligations - (e.g. proprietary SaaS deployment without source release), a - commercial license is available. Contact sac@securityops.co. - - VENDORED SOURCES: - src/vendor/zupt_*.{c,h} are derived from Zupt v2.1.5 - Copyright (C) 2026 Cristian Cezar Moisés. Originally MIT-licensed. - Relicensed under AGPL-3.0-or-later for inclusion in this library - by the same author. diff --git a/vendor/pqvaptvupt/include/pqvaptvupt.h b/vendor/pqvaptvupt/include/pqvaptvupt.h deleted file mode 100644 index a67a3d7..0000000 --- a/vendor/pqvaptvupt/include/pqvaptvupt.h +++ /dev/null @@ -1,178 +0,0 @@ -/* - * libpqvaptvupt — post-quantum sealed-box encryption. - * - * A minimal, libsodium-style sealed-box API backed by a real hybrid - * post-quantum KEM (ML-KEM-768 + X25519) plus AES-256-CTR + HMAC-SHA256 - * Encrypt-then-MAC. The construction matches Zupt v2.1.5+. - * - * Three functions. No state. No streams. - * - * pqvv_keygen(pk, sk) — one-time - * pqvv_seal(pk, pt, pt_len, &ct, &ct_len) — encrypt - * pqvv_open(sk, ct, ct_len, &pt, &pt_len) — decrypt - * - * Identical ergonomic to libsodium's crypto_box_seal / crypto_box_seal_open, - * but with PQ KEM beneath. Migration from libsodium is a sed: - * - * crypto_box_keypair → pqvv_keygen - * crypto_box_seal → pqvv_seal - * crypto_box_seal_open → pqvv_open - * - * Copyright (c) 2026 Cristian Cezar Moisés. - * SPDX-License-Identifier: AGPL-3.0-or-later - * Commercial license: sac@securityops.co - * - * Vendored cryptographic primitives: - * - ML-KEM-768 (FIPS 203) from Zupt v2.1.5 src/zupt_mlkem.c - * - X25519 (RFC 7748) from Zupt v2.1.5 src/zupt_x25519.c - * - SHA-3 / SHAKE-128 / SHAKE-256 from Zupt v2.1.5 src/zupt_keccak.c - * - SHA-256 (FIPS 180-4) from Zupt v2.1.5 src/zupt_sha256.c - * - AES-256-CTR (NIST SP 800-38A) from Zupt v2.1.5 src/zupt_aes256.c - * - HMAC-SHA256 (RFC 2104) from Zupt v2.1.5 src/zupt_crypto.c - * - OS CSPRNG from Zupt v2.1.5 src/zupt_crypto.c - * - * All primitives are verified against NIST/RFC test vectors in tests/. - */ -#ifndef LIBPQVAPTVUPT_H -#define LIBPQVAPTVUPT_H - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/* ───────────────────────────────────────────────────────────────────── - * Version - * ──────────────────────────────────────────────────────────────────── */ -#define PQVV_VERSION_MAJOR 0 -#define PQVV_VERSION_MINOR 6 -#define PQVV_VERSION_PATCH 0 -#define PQVV_VERSION_STRING "0.6.0" - -/* ───────────────────────────────────────────────────────────────────── - * Sizes (compile-time constants for callers that want to stack-allocate) - * ──────────────────────────────────────────────────────────────────── */ -#define PQVV_PUBLICKEYBYTES 1216 /* ML-KEM-768 pk (1184) + X25519 pk (32) */ -#define PQVV_SECRETKEYBYTES 2432 /* ML-KEM-768 sk (2400) + X25519 sk (32) */ -#define PQVV_OVERHEAD 1184 /* per-message overhead (KEM ct + ephemeral pk + nonce + MAC) */ - /* ML-KEM-768 ct (1088) + ephem X25519 pk (32) + nonce (16) + MAC (32) + magic (16) */ - -/* Return codes. Zero on success; negative on error. */ -typedef enum { - PQVV_OK = 0, - PQVV_ERR_NULL = -1, /* NULL pointer in arguments */ - PQVV_ERR_RANGE = -2, /* size out of range */ - PQVV_ERR_AUTH = -3, /* MAC verification failed (tampered / wrong key) */ - PQVV_ERR_CORRUPT = -4, /* ciphertext malformed */ - PQVV_ERR_NOMEM = -5, /* allocation failed */ - PQVV_ERR_INTERNAL = -6, /* unexpected internal error */ -} pqvv_error_t; - -/* ───────────────────────────────────────────────────────────────────── - * API - * ──────────────────────────────────────────────────────────────────── */ - -/** - * Library version string. Same as PQVV_VERSION_STRING. - */ -const char *pqvv_version(void); - -/** - * Generate a fresh hybrid keypair. Public key is PQVV_PUBLICKEYBYTES bytes, - * secret key is PQVV_SECRETKEYBYTES bytes; both layouts are opaque. - * - * Uses the OS CSPRNG for all randomness. Aborts the process if no CSPRNG - * is available (no fallback — predictable keys would destroy security). - * - * @return PQVV_OK on success, PQVV_ERR_NULL if either pointer is NULL. - */ -int pqvv_keygen(uint8_t pk[PQVV_PUBLICKEYBYTES], uint8_t sk[PQVV_SECRETKEYBYTES]); - -/** - * Seal plaintext to a recipient public key. Output is freshly malloc'd - * and the caller owns it (free with free()). - * - * Construction (in order, all binary): - * magic 16 bytes "pqvaptvupt-v1\0\0\0" - * kem_ct 1088 bytes ML-KEM-768 ciphertext (encapsulation) - * ephem_pk 32 bytes ephemeral X25519 public key - * nonce 16 bytes random - * mac 32 bytes HMAC-SHA256 over (magic||kem_ct||ephem_pk||nonce||body) - * body AES-256-CTR(plaintext) with key derived as - * HKDF-SHA256-Extract(salt=nonce, IKM=ml_kem_ss || x25519_ss) - * HKDF-SHA256-Expand(info="pqvv-seal-v1", L=64) → enc_key||mac_key - * - * The recipient's pk encapsulates both ML-KEM-768 ss and X25519 ss; the - * sender contributes its own X25519 ephemeral. Combined entropy goes - * through HKDF; if either KEM is broken later, the other still protects. - * - * @param pk recipient's public key - * @param pt plaintext bytes - * @param pt_len plaintext length - * @param out set to pointer to ciphertext buffer (caller frees) - * @param out_len set to ciphertext length - * @return PQVV_OK on success, negative on error. - */ -int pqvv_seal(const uint8_t pk[PQVV_PUBLICKEYBYTES], - const uint8_t *pt, size_t pt_len, - uint8_t **out, size_t *out_len); - -/** - * Open a sealed message. Verifies the MAC before doing any decryption. - * Output is freshly malloc'd and the caller owns it (free with free()). - * - * @param sk recipient's secret key - * @param ct ciphertext bytes from pqvv_seal - * @param ct_len ciphertext length - * @param out set to pointer to plaintext buffer (caller frees) - * @param out_len set to plaintext length - * @return PQVV_OK on success, PQVV_ERR_AUTH if MAC fails, PQVV_ERR_CORRUPT - * if ciphertext shape is invalid, negative on other error. - */ -int pqvv_open(const uint8_t sk[PQVV_SECRETKEYBYTES], - const uint8_t *ct, size_t ct_len, - uint8_t **out, size_t *out_len); - -/* ───────────────────────────────────────────────────────────────────── - * Primitives (exported for testing against NIST/RFC vectors) - * - * Not the recommended user-facing API — use pqvv_seal / pqvv_open. These - * are exported so the test suite can verify each primitive in isolation - * against the official test vectors. - * ──────────────────────────────────────────────────────────────────── */ - -/** - * SHA-256 (FIPS 180-4). Computes the 32-byte digest of `len` bytes. - */ -void pqvv_sha256(const uint8_t *data, size_t len, uint8_t out[32]); - -/** - * HMAC-SHA-256 (RFC 2104). - */ -void pqvv_hmac_sha256(const uint8_t *key, size_t klen, - const uint8_t *msg, size_t mlen, - uint8_t out[32]); - -/** - * Fill `buf` with `len` cryptographically-strong random bytes from the - * OS CSPRNG. Aborts the process on failure. - */ -void pqvv_random_bytes(uint8_t *buf, size_t len); - -/** - * Constant-time memory equality. Returns 1 if equal, 0 otherwise. - * Use for comparing secrets, MACs, etc. Never use memcmp. - */ -int pqvv_ct_memeq(const void *a, const void *b, size_t n); - -/** - * Zero a memory region in a way the compiler cannot optimize away. - */ -void pqvv_memzero(void *p, size_t n); - -#ifdef __cplusplus -} -#endif -#endif /* LIBPQVAPTVUPT_H */ diff --git a/vendor/zuptsdk/include/vaptvupt.h b/vendor/zuptsdk/include/vaptvupt.h deleted file mode 100644 index 48b6d8d..0000000 --- a/vendor/zuptsdk/include/vaptvupt.h +++ /dev/null @@ -1,472 +0,0 @@ -/* - * VaptVupt Codec — Next-generation lossless compression - * Public API and data structures - * - * SPDX-License-Identifier: GPL-3.0-or-later - * Copyright 2026 Cristian. - * Zero dependencies. Pure C11. - */ -#ifndef VAPTVUPT_H -#define VAPTVUPT_H - -#include -#include "vv_platform.h" -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/* ═══════════════════════════════════════════════════════════════ - * VERSION & CONSTANTS - * ═══════════════════════════════════════════════════════════════ */ - -#define VV_VERSION_MAJOR 0 -#define VV_VERSION_MINOR 1 -#define VV_VERSION_PATCH 0 -#define VV_VERSION_STRING "0.1.0" - -#define VV_MAGIC 0x56560100u /* "VV\x01\x00" */ -#define VV_MAX_BLOCK_SIZE (1u << 20) /* 1 MB per block */ -#define VV_MIN_MATCH 4 -#define VV_MAX_MATCH 65535 -#define VV_MAX_LIT_RUN 65535 -#define VV_MAX_OFFSET (1u << 24) /* 16 MB default window */ - -/* ═══════════════════════════════════════════════════════════════ - * ERROR CODES - * ═══════════════════════════════════════════════════════════════ */ - -typedef enum { - VV_OK = 0, - VV_ERR_IO = -1, - VV_ERR_CORRUPT = -2, - VV_ERR_NOMEM = -3, - VV_ERR_OVERFLOW = -4, - VV_ERR_BAD_MAGIC = -5, - VV_ERR_PARAM = -6, -} vv_error_t; - -/* ═══════════════════════════════════════════════════════════════ - * COMPRESSION MODES - * ═══════════════════════════════════════════════════════════════ */ - -typedef enum { - VV_MODE_ULTRA_FAST = 0, /* Speed priority: greedy parse, no entropy */ - VV_MODE_BALANCED = 1, /* Default: lazy parse + Huffman */ - VV_MODE_EXTREME = 2, /* Ratio priority: optimal parse + Huffman */ -} vv_mode_t; - -/* ═══════════════════════════════════════════════════════════════ - * BLOCK TYPES (2-bit field in block header) - * ═══════════════════════════════════════════════════════════════ */ - -typedef enum { - VV_BLOCK_RAW = 0, /* Uncompressed (stored) */ - VV_BLOCK_COMPRESSED = 1, /* LZ + raw literals */ - VV_BLOCK_RLE = 2, /* Run-length (single byte) */ - VV_BLOCK_ENTROPY = 3, /* LZ + entropy-coded literals (ANS or Huffman) */ -} vv_block_type_t; - -/* Entropy sub-type tags (first byte of entropy section in type-3 blocks) */ -#define VV_ENTROPY_HUFFMAN 0x48 /* 'H' — Huffman (v0.3-v0.4) */ -#define VV_ENTROPY_ANS 0x41 /* 'A' — tANS single-stream (v0.5) */ -#define VV_ENTROPY_ANS4 0x49 /* 'I' — tANS 4-way interleaved (v0.6+) */ -#define VV_ENTROPY_CTX 0x43 /* 'C' — tANS order-1 context model (v0.7+) */ -#define VV_ENTROPY_SEQ 0x53 /* 'S' — sequence coding: ANS on lits+ml+of (v0.8+) */ -#define VV_ENTROPY_SEQ_V2 0x54 /* 'T' — same as 'S' but with min_match=3 - * for binary-data compression parity with - * gzip-9. Shifts ml_base[] down by 1 across - * all 36 codes; every other field unchanged. - * Added in v2.33.0 (decode); encoder in a - * future release. */ - -/* Block header accessors (2-bit type, 1-bit last, 21-bit size) */ -static inline vv_block_type_t vv_bh_type(uint32_t h) { return (vv_block_type_t)(h & 3); } -static inline int vv_bh_last(uint32_t h) { return (h >> 2) & 1; } -static inline uint32_t vv_bh_size(uint32_t h) { return (h >> 3) & 0x1FFFFF; } -static inline uint32_t vv_bh_pack(vv_block_type_t t, int last, uint32_t sz) { - return (uint32_t)t | ((uint32_t)last << 2) | (sz << 3); -} - -/* ═══════════════════════════════════════════════════════════════ - * TOKEN TYPES (in the sequence stream) - * - * Each token is: [type:2][litlen:6] [optional litlen ext] - * [literal bytes] - * [matchlen ext] [offset bytes] - * - * The decoder reads a compact token byte, copies literals, - * then copies a match. This is LZ4-like for speed. - * ═══════════════════════════════════════════════════════════════ */ - -/* Token byte layout: - * Bits 7-4: literal_length (0-14, 15=extended) - * Bits 3-0: match_length - VV_MIN_MATCH (0-14, 15=extended) - * - * Followed by: - * [extended literal length varint, if litlen==15] - * [literal bytes] - * [offset: 2 bytes LE (or 3 bytes if high bit set)] - * [extended match length varint, if matchlen==15] - */ - -/* ═══════════════════════════════════════════════════════════════ - * ON-DISK STRUCTURES - * ═══════════════════════════════════════════════════════════════ */ - -#pragma pack(push, 1) - -/* Frame header: 16 bytes */ -typedef struct { - uint32_t magic; /* VV_MAGIC */ - uint8_t version; /* Format version (1) */ - uint8_t flags; /* bit0: has_checksum, bit1: has_dict */ - uint8_t mode_hint; /* Compression mode used (informational) */ - uint8_t window_log; /* Window size = 1 << window_log */ - uint64_t content_size; /* Uncompressed size (0 = unknown) */ -} vv_frame_header_t; - -/* Block header: 4 bytes */ -typedef struct { - /* Bits 0-1: block_type (vv_block_type_t) */ - /* Bit 2: last_block flag */ - /* Bits 3-23: decompressed_size (max 1 MB) */ - /* Bits 24-31: reserved */ - uint32_t packed; -} vv_block_header_t; - -/* Frame footer: 12 bytes */ -typedef struct { - uint64_t checksum; /* XXH64 of decompressed content */ - uint32_t footer_magic; /* 0x56564E44 = "VVND" */ -} vv_frame_footer_t; - -#pragma pack(pop) - -/* Block header accessors defined above with block type enum */ - -/* ═══════════════════════════════════════════════════════════════ - * MATCHER STATE - * ═══════════════════════════════════════════════════════════════ */ - -#define VV_HC_BITS 18 -#define VV_HC_SIZE (1u << VV_HC_BITS) - -typedef struct { - int32_t table[VV_HC_SIZE]; /* Hash → most recent position */ - int32_t *chain; /* Chain array (window_size entries) */ - uint32_t window_size; - uint32_t chain_depth; /* Max chain traversal (level-dependent) */ -} vv_matcher_t; - -/* ═══════════════════════════════════════════════════════════════ - * HUFFMAN TABLES (entropy coding) - * - * 256-symbol alphabet. Max code length 12 bits. - * Decode table: 4096 entries × 2 bytes = 8 KB (fits in L1). - * ═══════════════════════════════════════════════════════════════ */ - -#define VV_HUF_MAX_BITS 12 -#define VV_HUF_TABLE_SIZE (1 << VV_HUF_MAX_BITS) - -typedef struct { - uint8_t lengths[256]; /* Code lengths per symbol */ - uint16_t codes[256]; /* Canonical codes (for encoding) */ - /* Decode table: entry = (symbol << 8) | num_bits */ - uint16_t decode[VV_HUF_TABLE_SIZE]; -} vv_huffman_t; - -/* ═══════════════════════════════════════════════════════════════ - * ENCODER/DECODER OPTIONS - * ═══════════════════════════════════════════════════════════════ */ - -typedef struct { - vv_mode_t mode; - uint8_t window_log; /* 0 = auto (20 for balanced, 24 for extreme) */ - int checksum; /* 1 = compute XXH64 */ - int verbose; - int format_v2; /* 1 = produce 'T' tag blocks (min_match=3) for - * better real-binary ratio. Requires decoder - * v2.33.0+. Default 0 for back-compat. */ - int compat_v246_5_decoder; - /* 1 = suppress lit_fmt=4 (4-stream Huffman) in - * SEQ block encode race. Required when - * output must be readable by v2.46.5 or - * older decoders. Default 0 (lit_fmt=4 - * enabled, requires v2.47+ decoder). */ -} vv_options_t; - -static inline void vv_default_options(vv_options_t *o) { - o->mode = VV_MODE_BALANCED; - o->window_log = 0; - o->checksum = 1; - o->verbose = 0; - o->format_v2 = 0; - o->compat_v246_5_decoder = 0; -} - -/* ═══════════════════════════════════════════════════════════════ - * PUBLIC API — ONE-SHOT - * ═══════════════════════════════════════════════════════════════ */ - -/* Compress src[0..src_len-1] into dst[0..dst_cap-1]. - * Returns compressed size, or negative error code. */ -int64_t vv_compress(const uint8_t *src, size_t src_len, - uint8_t *dst, size_t dst_cap, - const vv_options_t *opts); - -/* Decompress src[0..src_len-1] into dst[0..dst_cap-1]. - * Returns decompressed size, or negative error code. */ -int64_t vv_decompress(const uint8_t *src, size_t src_len, - uint8_t *dst, size_t dst_cap); - -/* Flags for vv_decompress_flags (bitmask) */ -#define VV_DECOMPRESS_DEFAULT 0x0 -#define VV_DECOMPRESS_SKIP_CHECKSUM 0x1 /* Skip XXH64 footer verification. - * - * Use when the caller has its own - * integrity protection (e.g. AES-GCM - * wrapping the compressed data, as in - * Zupt backups). On RAW/random-data - * inputs where XXH64 dominates decode - * time, this flag delivers a ~2× speedup. - * - * SAFETY: only set when another layer - * already detects tampering/corruption. - * Without any integrity check, silent - * data corruption can go undetected. */ - -/* Decompress with flags. Returns decompressed size, or negative error code. - * Equivalent to vv_decompress() when flags == VV_DECOMPRESS_DEFAULT. */ -int64_t vv_decompress_flags(const uint8_t *src, size_t src_len, - uint8_t *dst, size_t dst_cap, - uint32_t flags); - -/* Compute upper bound on compressed size for src_len input bytes. */ -size_t vv_compress_bound(size_t src_len); - -/* ═══════════════════════════════════════════════════════════════ - * MULTI-THREADED COMPRESSION - * - * Compresses large inputs in parallel by splitting into independent - * frames (each a valid .vv frame on its own — concatenated output - * is a valid .vv file that vv_decompress handles natively as a - * multi-frame stream). - * - * Requires the library to be built with VV_ENABLE_THREADS (and - * linked with -lpthread on POSIX). If threads are not available, - * the function falls back to sequential single-threaded encoding, - * producing bit-identical output to vv_compress. - * - * Tradeoff: multi-frame output is ~0.5-2% larger than a single - * vv_compress frame because cross-frame match history is lost. Use - * for inputs ≥ 4 MB where parallel speedup outweighs the ratio cost. - * ═══════════════════════════════════════════════════════════════ */ - -/* Compress src in parallel using up to nthreads worker threads. - * If nthreads is 0, uses the number of online CPUs (or 1 if that - * cannot be determined). If the library was built without threading, - * this acts exactly like vv_compress (nthreads is ignored). - * - * chunk_size controls the frame split size — must be ≥ 1 MB for - * reasonable compression ratio. If 0, defaults to 4 MB. - * - * Returns compressed size on success, negative error code on failure. */ -int64_t vv_compress_mt(const uint8_t *src, size_t src_len, - uint8_t *dst, size_t dst_cap, - const vv_options_t *opts, - unsigned int nthreads, - size_t chunk_size); - -/* Frame info extracted from the first 16 bytes of a compressed stream. - * Populated by vv_get_frame_info(). */ -typedef struct { - uint8_t version; /* Format version */ - uint8_t has_checksum; /* Non-zero if frame has XXH64 footer */ - uint8_t mode_hint; /* Compression mode used (informational) */ - uint8_t window_log; /* Window size = 1 << window_log */ - uint64_t content_size; /* Uncompressed size if known (0 = unknown) */ -} vv_frame_info_t; - -/* Parse the first 16 bytes of a compressed stream to extract frame - * metadata. Requires src_len >= 16. Useful for pre-allocating the - * output buffer when content_size is known (e.g., streams produced - * by the one-shot vv_compress API always carry content_size). - * - * Returns VV_OK on success, negative error code on bad magic / - * unsupported version / too-short input. */ -int vv_get_frame_info(const uint8_t *src, size_t src_len, - vv_frame_info_t *info); - -/* ═══════════════════════════════════════════════════════════════ - * STREAMING API — for large files, memory-constrained use, or - * when the full input/output isn't known in advance. - * - * Compress: - * ctx = vv_cstream_create(&opts); - * for each chunk: vv_cstream_compress_chunk(ctx, chunk, len, dst, dst_cap, &written, is_last); - * vv_cstream_destroy(ctx); - * - * Decompress: - * ctx = vv_dstream_create(); - * for each incoming block: vv_dstream_decompress_chunk(ctx, src, len, dst, dst_cap, &read, &written); - * vv_dstream_destroy(ctx); - * - * Compression is block-at-a-time: caller accumulates source data in - * chunks of up to VV_MAX_BLOCK_SIZE (1 MB). Each call to - * vv_cstream_compress_chunk emits one compressed block (or the frame - * header on the first call, and the frame footer on the last). - * - * Decompression accepts arbitrary byte chunks and emits decoded bytes - * as blocks complete. Partial blocks are buffered internally. - * ═══════════════════════════════════════════════════════════════ */ - -/* Opaque stream context types */ -typedef struct vv_cstream_s vv_cstream_t; -typedef struct vv_dstream_s vv_dstream_t; - -/* Create a new compression stream context. - * Returns NULL on allocation failure. - * If opts is NULL, uses default options (balanced mode, checksum=1). - * The context holds the matcher state; cross-block rep-match history - * and hash tables are preserved across chunks for optimal ratio. */ -vv_cstream_t *vv_cstream_create(const vv_options_t *opts); - -/* Reset a compression stream for reuse. Clears the matcher state, - * rep-match offsets, checksum accumulator, and emission flag so the - * context can be used to compress a new independent frame. - * Scratch buffers are preserved — this is the fast path for - * per-file compression (e.g., backup tools compressing many small - * files), avoiding per-file allocation cost. - * - * If opts is NULL, reuses the options from the last create/reset. - * If opts is non-NULL, applies new options but window_log cannot - * change (would require re-allocating matcher tables). */ -int vv_cstream_reset(vv_cstream_t *ctx, const vv_options_t *opts); - -/* Compress one chunk of source into dst. chunk_len must be ≤ - * VV_MAX_BLOCK_SIZE (1 MB). Set is_last=1 on the final call to emit - * the frame footer (checksum if enabled). - * - * Writes at most dst_cap bytes to dst; sets *written to the actual - * number of bytes emitted. Caller must ensure dst_cap ≥ - * vv_compress_bound(chunk_len) + 24 (frame header + footer). - * - * On the first call, the frame header is emitted before the first - * block. On the last call, the frame footer (if checksum enabled) is - * emitted after the final block. - * - * Returns VV_OK (0) on success, negative error code on failure. */ -int vv_cstream_compress_chunk(vv_cstream_t *ctx, - const uint8_t *chunk, size_t chunk_len, - uint8_t *dst, size_t dst_cap, - size_t *written, int is_last); - -/* Destroy a compression stream context and free all resources. */ -void vv_cstream_destroy(vv_cstream_t *ctx); - -/* Create a new decompression stream context. - * Returns NULL on allocation failure. */ -vv_dstream_t *vv_dstream_create(void); - -/* Reset a decompression stream for reuse. Clears state so the same - * context can decompress another independent frame. Internal buffer - * is preserved (but emptied), avoiding per-frame allocation cost. */ -int vv_dstream_reset(vv_dstream_t *ctx); - -/* Decompress a chunk of input. src may contain partial or multiple - * blocks; internal buffer holds incomplete blocks until enough input - * is available. - * - * IMPORTANT API CONTRACT: - * - `dst` MUST be the same stable buffer base across all calls for - * a single frame. The decoder tracks its own output position - * inside `dst` and requires it not to move between calls. - * - `dst_cap` MUST be large enough to hold the fully-decoded - * content of the current frame (the decoder does not support - * partial-output-then-resume semantics across a block boundary). - * - `*written` is set to the CUMULATIVE total bytes written into - * `dst` so far, NOT the delta for this call. If you need the - * per-call delta, subtract the previous value. - * - `*consumed` is per-call: how many `src` bytes were processed - * this call. - * - * Writing pattern: - * size_t total_written = 0; - * while (!done) { - * rc = vv_dstream_decompress_chunk(ds, chunk, chunk_len, - * dst, dst_cap, // stable - * &consumed, &written); - * total_written = written; // NOT += written - * ... - * } - * - * Returns VV_OK (0) if more input is needed, 1 if the frame ended - * successfully, or negative error code on failure. */ -int vv_dstream_decompress_chunk(vv_dstream_t *ctx, - const uint8_t *src, size_t src_len, - uint8_t *dst, size_t dst_cap, - size_t *consumed, size_t *written); - -/* Destroy a decompression stream context and free all resources. */ -void vv_dstream_destroy(vv_dstream_t *ctx); - -/* ═══════════════════════════════════════════════════════════════ - * INTERNAL HELPERS (shared across modules) - * ═══════════════════════════════════════════════════════════════ */ - -/* XXH64 hash (simplified, for checksum) */ -uint64_t vv_xxh64(const void *data, size_t len, uint64_t seed); - -/* Streaming XXH64: init + update + finalize for when the input isn't - * contiguous in memory. Must produce the same 64-bit hash as a - * single-shot vv_xxh64() over the concatenated input. */ -typedef struct { - uint64_t v1, v2, v3, v4; - uint64_t total_len; - uint64_t seed; - uint8_t buf[32]; - size_t buf_len; -} vv_xxh64_state_t; - -void vv_xxh64_init(vv_xxh64_state_t *s, uint64_t seed); -void vv_xxh64_update(vv_xxh64_state_t *s, const void *data, size_t len); -uint64_t vv_xxh64_finalize(const vv_xxh64_state_t *s); - -/* Hash function for matcher */ -static inline uint32_t vv_hash4(const uint8_t *p) { - uint32_t v; - memcpy(&v, p, 4); - return (v * 2654435761u) >> (32 - VV_HC_BITS); -} - -/* Read/write little-endian helpers */ -static inline uint16_t vv_read16(const uint8_t *p) { - uint16_t v; memcpy(&v, p, 2); return v; -} -static inline uint32_t vv_read32(const uint8_t *p) { - uint32_t v; memcpy(&v, p, 4); return v; -} -static inline void vv_write16(uint8_t *p, uint16_t v) { - memcpy(p, &v, 2); -} -static inline void vv_write32(uint8_t *p, uint32_t v) { - memcpy(p, &v, 4); -} - -/* ═══════════════════════════════════════════════════════════════ - * SIMD COPY HELPERS (declared here, defined in vv_simd.c) - * ═══════════════════════════════════════════════════════════════ */ - -/* Copy exactly n bytes, may over-read/write by up to 32 bytes. - * Caller must ensure sufficient slack in destination. */ -void vv_copy_fast(uint8_t *dst, const uint8_t *src, size_t n); - -/* Copy match with overlap handling (offset may be < copy length). */ -void vv_copy_match(uint8_t *dst, uint32_t offset, size_t length); - -#ifdef __cplusplus -} -#endif -#endif /* VAPTVUPT_H */ diff --git a/vendor/zuptsdk/include/vaptvupt_api.h b/vendor/zuptsdk/include/vaptvupt_api.h deleted file mode 100644 index f19c85f..0000000 --- a/vendor/zuptsdk/include/vaptvupt_api.h +++ /dev/null @@ -1,43 +0,0 @@ -/* - * VaptVupt — Zupt Integration API - * SPDX-License-Identifier: GPL-3.0-or-later - * Copyright 2026 Cristian. - * - * ZUPT-COMPAT: This is the API that Zupt calls. It wraps the internal - * VaptVupt API with sensible defaults for backup workloads: - * - Checksum always enabled (data integrity is critical for backups) - * - Adaptive window selection (auto-detect optimal wlog per file) - * - Level maps to mode: 1=fast, 5=balanced, 9=extreme - * - * Usage: - * size_t bound = vvz_compress_bound(src_len); - * uint8_t *dst = malloc(bound); - * int64_t csz = vvz_compress(src, src_len, dst, bound, 5); - * int64_t dsz = vvz_decompress(dst, csz, out, out_cap); - */ -#ifndef VAPTVUPT_API_H -#define VAPTVUPT_API_H - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/* Compress src into dst. Returns compressed size or negative error code. - * level: 1 = fast (max speed), 5 = balanced (default), 9 = extreme (max ratio) */ -int64_t vvz_compress(const uint8_t *src, size_t src_len, - uint8_t *dst, size_t dst_cap, int level); - -/* Decompress src into dst. Returns decompressed size or negative error code. */ -int64_t vvz_decompress(const uint8_t *src, size_t src_len, - uint8_t *dst, size_t dst_cap); - -/* Upper bound on compressed size for a given input length. */ -size_t vvz_compress_bound(size_t src_len); - -#ifdef __cplusplus -} -#endif -#endif /* VAPTVUPT_API_H */ diff --git a/vendor/zuptsdk/include/vv_ans.h b/vendor/zuptsdk/include/vv_ans.h deleted file mode 100644 index 86f1f03..0000000 --- a/vendor/zuptsdk/include/vv_ans.h +++ /dev/null @@ -1,145 +0,0 @@ -/* - * SPDX-License-Identifier: GPL-3.0-or-later - * - * VaptVupt — tANS Entropy Codec (v2: sparse header + 4-way interleaved) - * - * Standalone: define VV_ANS_STANDALONE to use without VaptVupt. - * ZUPT-COMPAT: this header has zero VaptVupt dependencies when standalone. - * - * v0.6 changes: - * - Adaptive sparse/dense header (Item 1): 3× smaller on typical data - * - 4-way interleaved encode/decode (Item 2): ~2.5× faster decode - */ -#ifndef VV_ANS_H -#define VV_ANS_H - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -#define VVA_TABLE_LOG 12 -#define VVA_TABLE_SIZE (1 << VVA_TABLE_LOG) /* 4096 */ -#define VVA_MAX_SYMBOL 256 - -/* Header format discriminators */ -#define VVA_HDR_SINGLE 0x01 /* Single symbol: 0-bit encoding */ -#define VVA_HDR_SPARSE 0x02 /* ≤32 active symbols: (sym,freq) pairs */ -#define VVA_HDR_DENSE 0x03 /* >32 active symbols: max_sym + freq array */ -/* ZUPT-COMPAT: v0.5 legacy format detected by first byte being 0x00-0xFF - * without matching any HDR_* code — fall back to old read path. */ -#define VVA_HDR_LEGACY 0x00 /* v0.5 format: [max_sym] [2B×(max_sym+1)] */ - -#ifdef VV_ANS_STANDALONE -typedef enum { - VVA_OK = 0, - VVA_ERR_IO = -1, - VVA_ERR_CORRUPT = -2, - VVA_ERR_NOMEM = -3, - VVA_ERR_OVERFLOW = -4, - VVA_ERR_PARAM = -6, -} vva_error_t; -#else -#include "vaptvupt.h" -typedef vv_error_t vva_error_t; -#define VVA_OK VV_OK -#define VVA_ERR_CORRUPT VV_ERR_CORRUPT -#define VVA_ERR_NOMEM VV_ERR_NOMEM -#define VVA_ERR_OVERFLOW VV_ERR_OVERFLOW -#define VVA_ERR_PARAM VV_ERR_PARAM -#endif - -typedef struct { - uint8_t symbol; - uint8_t nbits; - uint16_t baseline; -} vva_dec_entry_t; - -/* ═══ Public API ═══ */ - -/* Single-stream encode/decode (tag 'A', backward compat with v0.5) */ -vva_error_t vva_encode(const uint8_t *src, size_t src_len, - uint8_t *dst, size_t dst_cap, size_t *dst_len); - -vva_error_t vva_decode(const uint8_t *src, size_t src_len, - uint8_t *dst, size_t dst_cap, - size_t num_literals, size_t *src_consumed); - -/* 4-way interleaved encode/decode (tag 'I', v0.6+) */ -vva_error_t vva_encode4(const uint8_t *src, size_t src_len, - uint8_t *dst, size_t dst_cap, size_t *dst_len); - -vva_error_t vva_decode4(const uint8_t *src, size_t src_len, - uint8_t *dst, size_t dst_cap, - size_t num_literals, size_t *src_consumed); - -/* Order-1 context model encode/decode (tag 'C', v0.7+) - * Uses 256 ANS tables — one per previous byte. Contexts with too few - * observations inherit from the global table. 4 MB decode memory. */ -vva_error_t vva_encode_ctx(const uint8_t *src, size_t src_len, - uint8_t *dst, size_t dst_cap, size_t *dst_len); - -vva_error_t vva_decode_ctx(const uint8_t *src, size_t src_len, - uint8_t *dst, size_t dst_cap, - size_t num_literals, size_t *src_consumed); - -/* ═══ Sequence coding (tag 'S', v0.8+) ═══ - * ZUPT-COMPAT: available when VV_ANS_STANDALONE is defined. - * - * Encodes an LZ token stream using 3 ANS tables: literals, match-length - * codes (36 symbols), and offset codes (24 symbols). Replaces raw varint - * storage of match metadata, saving 8-15% on typical data. - * - * Input token format (from LZ engine): - * [token: litlen:4|matchlen:4] [litlen_ext] [literal_bytes] [2B offset LE] [matchlen_ext] - * Output: [3 table headers] [4B seq_count] [4B lit_count] [ANS bitstream] - */ - -#define VVA_ML_CODES 36 /* Match length code count */ -#define VVA_OF_CODES 27 /* Offset code count: 3 rep + 24 explicit */ -#define VVA_LL_CODES 36 /* Literal-run length code count (covers 0-65536+) */ - -vva_error_t vva_encode_sequences(const uint8_t *tokens, size_t tok_len, - uint8_t *dst, size_t dst_cap, size_t *dst_len, - int off_bytes); - -/* Format-v2 variant: encodes match-length codes using ml_base_v2 - * (min_match=3). Used for 'T' tag blocks. Added v2.34.0. */ -vva_error_t vva_encode_sequences_v2(const uint8_t *tokens, size_t tok_len, - uint8_t *dst, size_t dst_cap, size_t *dst_len, - int off_bytes); - -/* Sprint 105 Phase C: variants accepting disable_huf4 flag. - * disable_huf4=1 suppresses lit_fmt=4 (4-stream Huffman) selection - * for v2.46.5 and older decoder compatibility. */ -vva_error_t vva_encode_sequences_compat(const uint8_t *tokens, size_t tok_len, - uint8_t *dst, size_t dst_cap, size_t *dst_len, - int off_bytes, int disable_huf4); -vva_error_t vva_encode_sequences_v2_compat(const uint8_t *tokens, size_t tok_len, - uint8_t *dst, size_t dst_cap, size_t *dst_len, - int off_bytes, int disable_huf4); - -vva_error_t vva_decode_sequences(const uint8_t *src, size_t src_len, - uint8_t *dst, size_t dst_cap, size_t *dst_len, - const uint8_t *dst_base); - -/* Format-v2 variant: same wire payload as vva_decode_sequences but - * interprets match-length codes with a table shifted down by 1 - * (min_match=3 instead of 4). Produced by tag 'T' (VV_ENTROPY_SEQ_V2) - * blocks; closes the ~10% binary-compression gap vs gzip-9. Added - * v2.33.0. */ -vva_error_t vva_decode_sequences_v2(const uint8_t *src, size_t src_len, - uint8_t *dst, size_t dst_cap, size_t *dst_len, - const uint8_t *dst_base); - -static inline size_t vva_bound(size_t src_len) { - /* Context model header can be up to ~10KB, seq coding adds 3 table headers */ - return 12288 + (src_len * 15 + 7) / 8 + 16; -} - -#ifdef __cplusplus -} -#endif -#endif /* VV_ANS_H */ diff --git a/vendor/zuptsdk/include/vv_huffman.h b/vendor/zuptsdk/include/vv_huffman.h deleted file mode 100644 index dafdd1e..0000000 --- a/vendor/zuptsdk/include/vv_huffman.h +++ /dev/null @@ -1,171 +0,0 @@ -/* - * SPDX-License-Identifier: GPL-3.0-or-later - * - * VaptVupt — Canonical Huffman Codec - * - * Standalone header: can be used independently with VV_HUFFMAN_STANDALONE. - * Designed for embedding in Zupt or any other LZ codec. - * - * API: - * vvh_encode() — compress raw literals into Huffman bitstream - * vvh_decode() — decompress Huffman bitstream back to raw literals - * - * Format: - * [1B max_symbol] [packed nibble code lengths] [LSB-first bitstream] - * - * Performance targets: - * Encode: ≥ 150 MB/s Decode: ≥ 800 MB/s (x86-64, -O2) - */ -#ifndef VV_HUFFMAN_H -#define VV_HUFFMAN_H - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/* ═══════════════════════════════════════════════════════════════ - * CONSTANTS - * ═══════════════════════════════════════════════════════════════ */ - -#define VVH_SYMBOLS 256 -#define VVH_MAX_CODE_LEN 15 -#define VVH_DECODE_BITS 12 -#define VVH_DECODE_SIZE (1 << VVH_DECODE_BITS) /* 4096 entries */ - -/* ═══════════════════════════════════════════════════════════════ - * ERROR CODES (compatible with vv_error_t when not standalone) - * ═══════════════════════════════════════════════════════════════ */ - -#ifdef VV_HUFFMAN_STANDALONE -typedef enum { - VVH_OK = 0, - VVH_ERR_CORRUPT = -2, - VVH_ERR_NOMEM = -3, - VVH_ERR_OVERFLOW= -4, -} vvh_error_t; -#else -#include "vaptvupt.h" -typedef vv_error_t vvh_error_t; -#define VVH_OK VV_OK -#define VVH_ERR_CORRUPT VV_ERR_CORRUPT -#define VVH_ERR_NOMEM VV_ERR_NOMEM -#define VVH_ERR_OVERFLOW VV_ERR_OVERFLOW -#endif - -/* ═══════════════════════════════════════════════════════════════ - * ENCODE TABLE (used by encoder only) - * ═══════════════════════════════════════════════════════════════ */ - -typedef struct { - uint8_t lengths[VVH_SYMBOLS]; /* Code length per symbol (0 = absent) */ - uint16_t codes[VVH_SYMBOLS]; /* Bit-reversed canonical codes (LSB-first) */ -} vvh_enc_table_t; - -/* ═══════════════════════════════════════════════════════════════ - * DECODE TABLE (used by decoder only) - * - * 12-bit lookup: 4096 entries × 4 bytes = 16 KB (L1-resident). - * Entry: bits [7:0] = symbol, bits [11:8] = code length. - * Symbols with code length > 12 use a slow path. - * ═══════════════════════════════════════════════════════════════ */ - -typedef struct { - uint32_t table[VVH_DECODE_SIZE]; /* Fast lookup (codes ≤ 12 bits) */ - /* Slow table for codes 13-15 bits (max 256 entries) */ - uint16_t slow_code[VVH_SYMBOLS]; /* Bit-reversed code */ - uint8_t slow_len[VVH_SYMBOLS]; /* Code length */ - uint8_t slow_sym[VVH_SYMBOLS]; /* Symbol value */ - int slow_count; /* Number of slow-path symbols */ -} vvh_dec_table_t; - -/* ═══════════════════════════════════════════════════════════════ - * PUBLIC API - * ═══════════════════════════════════════════════════════════════ */ - -/* - * Encode raw literal bytes into Huffman bitstream. - * - * src[0..src_len-1] — raw literal bytes - * dst[0..dst_cap-1] — output buffer (header + bitstream) - * *dst_len — on success, set to actual compressed size - * - * Returns VVH_OK on success, or VVH_ERR_OVERFLOW if dst too small. - * If compressed size >= src_len, returns VVH_ERR_OVERFLOW (incompressible). - */ -vvh_error_t vvh_encode(const uint8_t *src, size_t src_len, - uint8_t *dst, size_t dst_cap, size_t *dst_len); - -/* - * Decode Huffman bitstream back to raw literal bytes. - * - * src[0..src_len-1] — compressed data (header + bitstream) - * dst[0..dst_cap-1] — output buffer for decoded literals - * num_literals — expected number of decoded symbols - * *src_consumed — on success, bytes consumed from src - * - * Returns VVH_OK on success, or error code. - */ -vvh_error_t vvh_decode(const uint8_t *src, size_t src_len, - uint8_t *dst, size_t dst_cap, - size_t num_literals, size_t *src_consumed); - -/* - * 4-stream interleaved Huffman encode (Sprint 103, Phase A). - * - * Encodes src into 4 round-robin bitstreams sharing a single Huffman - * code table. The output format is: - * - * [code-length header (existing format)] - * [3B stream1_size] [3B stream2_size] [3B stream3_size] - * [stream0_bitstream] [stream1_bitstream] - * [stream2_bitstream] [stream3_bitstream] - * - * Activation guard: requires src_len >= 1024. Below this threshold, - * single-stream vvh_encode wins on overhead and this function returns - * VVH_ERR_OVERFLOW. - * - * NOTE (Phase A): Production decoder support arrives in Phase B. - * This sprint adds only the encoder + a test-only inverse decoder - * (in tests/test_huffman4.c) for round-trip verification. - * - * Returns VVH_OK on success. - * Returns VVH_ERR_OVERFLOW if src_len < 1024, dst too small, or output - * not smaller than input. - */ -vvh_error_t vvh_encode4(const uint8_t *src, size_t src_len, - uint8_t *dst, size_t dst_cap, size_t *dst_len); - -/* - * 4-stream interleaved Huffman decode (Sprint 104, Phase B). - * - * Inverse of vvh_encode4. Decodes the 4-stream wire format produced - * by vvh_encode4. Runs 4 independent decoders in parallel using a - * single shared decode table. - * - * src[0..src_len-1] — compressed data (header + stream-sizes + 4 streams) - * dst[0..dst_cap-1] — output buffer for decoded literals - * num_literals — expected number of decoded symbols - * *src_consumed — on success, bytes consumed from src - * - * Returns VVH_OK on success, VVH_ERR_CORRUPT on malformed input, - * VVH_ERR_OVERFLOW if dst is too small, VVH_ERR_NOMEM on alloc failure. - */ -vvh_error_t vvh_decode4(const uint8_t *src, size_t src_len, - uint8_t *dst, size_t dst_cap, - size_t num_literals, size_t *src_consumed); - -/* - * Upper bound on compressed size for src_len literal bytes. - */ -static inline size_t vvh_bound(size_t src_len) { - /* header (129 max) + bitstream (15 bits/symbol worst case) + slack */ - return 129 + (src_len * 15 + 7) / 8 + 8; -} - -#ifdef __cplusplus -} -#endif -#endif /* VV_HUFFMAN_H */ diff --git a/vendor/zuptsdk/include/vv_platform.h b/vendor/zuptsdk/include/vv_platform.h deleted file mode 100644 index 45590f2..0000000 --- a/vendor/zuptsdk/include/vv_platform.h +++ /dev/null @@ -1,139 +0,0 @@ -/* - * VaptVupt — Cross-platform portability macros - * - * Provides unified abstractions for compiler intrinsics used throughout - * the codebase. Supports GCC, Clang, MSVC, and Intel compilers. - * - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -#ifndef VV_PLATFORM_H -#define VV_PLATFORM_H - -#include -#include - -/* ─── Branch prediction hints ─── */ -#if defined(__GNUC__) || defined(__clang__) - #define VV_LIKELY(x) __builtin_expect(!!(x), 1) - #define VV_UNLIKELY(x) __builtin_expect(!!(x), 0) -#else - #define VV_LIKELY(x) (x) - #define VV_UNLIKELY(x) (x) -#endif - -/* ─── Prefetch hint ─── */ -#if defined(__GNUC__) || defined(__clang__) - #define VV_PREFETCH(p) __builtin_prefetch((p), 0, 1) - #define VV_PREFETCH_RW(p) __builtin_prefetch((p), 1, 1) -#elif defined(_MSC_VER) && (defined(_M_X64) || defined(_M_IX86)) - #include - #define VV_PREFETCH(p) _mm_prefetch((const char*)(p), _MM_HINT_T1) - #define VV_PREFETCH_RW(p) _mm_prefetch((const char*)(p), _MM_HINT_T1) -#else - #define VV_PREFETCH(p) ((void)0) - #define VV_PREFETCH_RW(p) ((void)0) -#endif - -/* ─── Always-inline / never-inline ─── */ -#if defined(__GNUC__) || defined(__clang__) - #define VV_ALWAYS_INLINE static inline __attribute__((always_inline)) - #define VV_NOINLINE __attribute__((noinline)) -#elif defined(_MSC_VER) - #define VV_ALWAYS_INLINE static __forceinline - #define VV_NOINLINE __declspec(noinline) -#else - #define VV_ALWAYS_INLINE static inline - #define VV_NOINLINE -#endif - -/* ─── Unused parameter suppression ─── */ -#if defined(__GNUC__) || defined(__clang__) - #define VV_UNUSED __attribute__((unused)) -#else - #define VV_UNUSED -#endif - -/* ─── Portable unaligned load/store via memcpy (compiler optimizes to single instr) ─── */ -static inline uint16_t vv_load16(const void *p) { - uint16_t v; memcpy(&v, p, 2); return v; -} -static inline uint32_t vv_load32(const void *p) { - uint32_t v; memcpy(&v, p, 4); return v; -} -static inline uint64_t vv_load64(const void *p) { - uint64_t v; memcpy(&v, p, 8); return v; -} -static inline void vv_store16(void *p, uint16_t v) { memcpy(p, &v, 2); } -static inline void vv_store32(void *p, uint32_t v) { memcpy(p, &v, 4); } -static inline void vv_store64(void *p, uint64_t v) { memcpy(p, &v, 8); } - -/* ─── Count trailing zeros (for hash/match optimization) ─── */ -#if defined(__GNUC__) || defined(__clang__) - static inline int vv_ctz32(uint32_t x) { return __builtin_ctz(x); } - static inline int vv_ctz64(uint64_t x) { return __builtin_ctzll(x); } -#elif defined(_MSC_VER) - #include - static inline int vv_ctz32(uint32_t x) { - unsigned long idx; _BitScanForward(&idx, x); return (int)idx; - } - static inline int vv_ctz64(uint64_t x) { - #if defined(_M_X64) || defined(_M_ARM64) - unsigned long idx; _BitScanForward64(&idx, x); return (int)idx; - #else - uint32_t lo = (uint32_t)x; - if (lo) return vv_ctz32(lo); - return 32 + vv_ctz32((uint32_t)(x >> 32)); - #endif - } -#else - static inline int vv_ctz32(uint32_t x) { - int n = 0; while (!(x & 1)) { x >>= 1; n++; } return n; - } - static inline int vv_ctz64(uint64_t x) { - int n = 0; while (!(x & 1)) { x >>= 1; n++; } return n; - } -#endif - -/* ─── SIMD capability detection macros ─── */ -#if defined(__AVX2__) - #define VV_HAS_AVX2 1 -#else - #define VV_HAS_AVX2 0 -#endif - -#if defined(__SSE2__) || defined(_M_X64) || (defined(_M_IX86_FP) && _M_IX86_FP >= 2) - #define VV_HAS_SSE2 1 -#else - #define VV_HAS_SSE2 0 -#endif - -#if defined(__aarch64__) && defined(__ARM_NEON) - #define VV_HAS_NEON 1 -#else - #define VV_HAS_NEON 0 -#endif - -/* Sprint 117: explicit no_sanitize annotation for hardened builds. - * - * Several hot paths use intentional unsigned modular arithmetic: - * - Knuth multiplicative hashes in the LZ matcher - * - xxh64 round mixers (multiplication, left-shift) - * - Post-decrement loop guards (uint32_t depth-- > 0) - * - * C11 §6.2.5p9 defines unsigned overflow as wraparound, so these are - * NOT undefined behavior — but `-fsanitize=integer` and the related - * `-fsanitize=shift-base` flags warn anyway, breaking hardened-build - * deployments. Apply this attribute to the affected functions to - * silence the false positives without disabling the checks globally. - * - * The annotation is clang-only (gcc has no equivalent and does not - * accept -fsanitize=integer in the first place). */ -#if defined(__clang__) && (__clang_major__ >= 4) -# define VV_NO_SANITIZE_INTEGER \ - __attribute__((no_sanitize("unsigned-integer-overflow", "shift", "shift-base", "shift-exponent"))) -#else -# define VV_NO_SANITIZE_INTEGER -#endif - -#endif /* VV_PLATFORM_H */ diff --git a/vendor/zuptsdk/include/zsdk_aes256_gcm_siv.h b/vendor/zuptsdk/include/zsdk_aes256_gcm_siv.h deleted file mode 100644 index 9762969..0000000 --- a/vendor/zuptsdk/include/zsdk_aes256_gcm_siv.h +++ /dev/null @@ -1,39 +0,0 @@ -/* - * AES-256-GCM-SIV (RFC 8452) - * SPDX-License-Identifier: AGPL-3.0-or-later - * - * Nonce-misuse-resistant AEAD. Nonce reuse degrades to deterministic - * encryption (same plaintext+key+nonce -> same ciphertext) rather than - * the catastrophic XOR-of-plaintexts of GCM/CTR. - */ -#ifndef ZSDK_AES256_GCM_SIV_H -#define ZSDK_AES256_GCM_SIV_H - -#ifdef __cplusplus -extern "C" { -#endif - -#include -#include - -#define ZSDK_AES256_GCM_SIV_KEYBYTES 32 -#define ZSDK_AES256_GCM_SIV_NONCEBYTES 12 -#define ZSDK_AES256_GCM_SIV_TAGBYTES 16 - -void zsdk_aes256_gcm_siv_encrypt(uint8_t *out, - const uint8_t *plaintext, size_t pt_len, - const uint8_t *aad, size_t aad_len, - const uint8_t key[32], - const uint8_t nonce[12]); - -int zsdk_aes256_gcm_siv_decrypt(uint8_t *out, - const uint8_t *ciphertext, size_t ct_len, - const uint8_t *aad, size_t aad_len, - const uint8_t key[32], - const uint8_t nonce[12]); - - -#ifdef __cplusplus -} -#endif -#endif diff --git a/vendor/zuptsdk/include/zsdk_aes256_siv.h b/vendor/zuptsdk/include/zsdk_aes256_siv.h deleted file mode 100644 index 7f83dd7..0000000 --- a/vendor/zuptsdk/include/zsdk_aes256_siv.h +++ /dev/null @@ -1,39 +0,0 @@ -/* - * AES-256-SIV (RFC 5297) via OpenSSL EVP - * SPDX-License-Identifier: AGPL-3.0-or-later - * - * Provides nonce-misuse-resistant AEAD via SIV mode (S2V + CTR). - * Uses OpenSSL's audited implementation. Note: SIV uses a 64-byte key - * (two 32-byte halves) rather than a 32-byte key. - */ -#ifndef ZSDK_AES256_SIV_H -#define ZSDK_AES256_SIV_H - -#ifdef __cplusplus -extern "C" { -#endif - -#include -#include - -#define ZSDK_AES256_SIV_KEYBYTES 64 /* AES-256-SIV uses double key */ -#define ZSDK_AES256_SIV_NONCEBYTES 16 /* Optional, can be variable */ -#define ZSDK_AES256_SIV_TAGBYTES 16 - -void zsdk_aes256_siv_encrypt(uint8_t *out, - const uint8_t *plaintext, size_t pt_len, - const uint8_t *aad, size_t aad_len, - const uint8_t key[64], - const uint8_t nonce[16]); - -int zsdk_aes256_siv_decrypt(uint8_t *out, - const uint8_t *ciphertext, size_t ct_len, - const uint8_t *aad, size_t aad_len, - const uint8_t key[64], - const uint8_t nonce[16]); - - -#ifdef __cplusplus -} -#endif -#endif diff --git a/vendor/zuptsdk/include/zsdk_argon2id.h b/vendor/zuptsdk/include/zsdk_argon2id.h deleted file mode 100644 index 29923b3..0000000 --- a/vendor/zuptsdk/include/zsdk_argon2id.h +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Argon2id (RFC 9106) - * Copyright (c) 2026 Cristian Cezar Moisés - * SPDX-License-Identifier: AGPL-3.0-or-later - * - * Memory-hard password hashing function. Reference implementation - * (single-lane focus), verified against RFC 9106 §5 test vectors. - */ -#ifndef ZSDK_ARGON2ID_H -#define ZSDK_ARGON2ID_H - -#ifdef __cplusplus -extern "C" { -#endif - -#include -#include - -/* Returns 0 on success, -1 on parameter validation failure or alloc fail. - * - * passwd, passwd_len the password (any length) - * salt, salt_len random salt (>= 8 bytes recommended; >= 16 standard) - * memory_kib memory cost in KiB (>= 8 * lanes; we require >= 19456) - * iterations time cost (>= 1; we require >= 2) - * lanes parallelism (>= 1, <= 4 here) - * out, out_len output buffer (>= 4 bytes; typically 32) - */ -int zsdk_argon2id(const uint8_t *passwd, size_t passwd_len, - const uint8_t *salt, size_t salt_len, - uint32_t memory_kib, - uint32_t iterations, - uint32_t lanes, - uint8_t *out, size_t out_len); - - -#ifdef __cplusplus -} -#endif -#endif diff --git a/vendor/zuptsdk/include/zsdk_blake2b.h b/vendor/zuptsdk/include/zsdk_blake2b.h deleted file mode 100644 index dcc83b3..0000000 --- a/vendor/zuptsdk/include/zsdk_blake2b.h +++ /dev/null @@ -1,46 +0,0 @@ -/* - * BLAKE2b (RFC 7693) - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -#ifndef ZSDK_BLAKE2B_H -#define ZSDK_BLAKE2B_H - -#ifdef __cplusplus -extern "C" { -#endif - -#include -#include - -#define ZSDK_BLAKE2B_BLOCKBYTES 128 -#define ZSDK_BLAKE2B_OUTBYTES 64 - -typedef struct { - uint64_t h[8]; - uint64_t t[2]; - uint64_t f[2]; - uint8_t buf[ZSDK_BLAKE2B_BLOCKBYTES]; - size_t buflen; - size_t outlen; -} zsdk_blake2b_state; - -int zsdk_blake2b_init(zsdk_blake2b_state *s, size_t outlen); -int zsdk_blake2b_init_key(zsdk_blake2b_state *s, size_t outlen, - const void *key, size_t keylen); -int zsdk_blake2b_update(zsdk_blake2b_state *s, const void *in, size_t inlen); -int zsdk_blake2b_final(zsdk_blake2b_state *s, void *out, size_t outlen); - -/* One-shot. */ -int zsdk_blake2b(void *out, size_t outlen, - const void *in, size_t inlen, - const void *key, size_t keylen); - -/* Argon2's "long hash" H' producing arbitrary length output. */ -int zsdk_blake2b_long(uint8_t *out, size_t outlen, - const uint8_t *in, size_t inlen); - - -#ifdef __cplusplus -} -#endif -#endif diff --git a/vendor/zuptsdk/include/zsdk_hkdf.h b/vendor/zuptsdk/include/zsdk_hkdf.h deleted file mode 100644 index 2a72d67..0000000 --- a/vendor/zuptsdk/include/zsdk_hkdf.h +++ /dev/null @@ -1,41 +0,0 @@ -/* - * HKDF-SHA3-256 (RFC 5869, with SHA3-256 as the hash) - * SPDX-License-Identifier: AGPL-3.0-or-later - * - * SHA3-256 is preferred over SHA-256 here because Keccak's sponge - * construction has stronger structural properties (no length-extension, - * indifferentiable from a random oracle in the standard model). - */ -#ifndef ZSDK_HKDF_H -#define ZSDK_HKDF_H - -#ifdef __cplusplus -extern "C" { -#endif - -#include -#include - -#define ZSDK_HKDF_HASHLEN 32 /* SHA3-256 output size */ - -/* HKDF-Extract: PRK = HMAC-SHA3-256(salt, IKM) */ -void zsdk_hkdf_extract(uint8_t prk[32], - const uint8_t *salt, size_t salt_len, - const uint8_t *ikm, size_t ikm_len); - -/* HKDF-Expand: produces `out_len` bytes (out_len <= 255 * 32). */ -int zsdk_hkdf_expand(uint8_t *out, size_t out_len, - const uint8_t prk[32], - const uint8_t *info, size_t info_len); - -/* Convenience: extract+expand in one call. */ -int zsdk_hkdf(uint8_t *out, size_t out_len, - const uint8_t *salt, size_t salt_len, - const uint8_t *ikm, size_t ikm_len, - const uint8_t *info, size_t info_len); - - -#ifdef __cplusplus -} -#endif -#endif diff --git a/vendor/zuptsdk/include/zsdk_xchacha20_poly1305.h b/vendor/zuptsdk/include/zsdk_xchacha20_poly1305.h deleted file mode 100644 index c1d7628..0000000 --- a/vendor/zuptsdk/include/zsdk_xchacha20_poly1305.h +++ /dev/null @@ -1,57 +0,0 @@ -/* - * XChaCha20-Poly1305 AEAD - * Copyright (c) 2026 Cristian Cezar Moisés - * SPDX-License-Identifier: AGPL-3.0-or-later - * - * Implements: - * - ChaCha20 (RFC 8439) - * - HChaCha20 (draft-irtf-cfrg-xchacha-03 §2.2) - * - XChaCha20 (draft-irtf-cfrg-xchacha-03 §2.3) - * - Poly1305 (RFC 8439 §2.5) - * - XChaCha20-Poly1305 AEAD (draft-irtf-cfrg-xchacha-03 §2.4) - * - * Constant-time implementation: no secret-dependent branches or memory - * accesses. Verified against RFC 8439 test vectors and Wycheproof corpus. - */ - -#ifndef ZUPTSDK_XCHACHA20_POLY1305_H -#define ZUPTSDK_XCHACHA20_POLY1305_H - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -#define ZSDK_XCHACHA20_POLY1305_KEYBYTES 32 -#define ZSDK_XCHACHA20_POLY1305_NONCEBYTES 24 -#define ZSDK_XCHACHA20_POLY1305_TAGBYTES 16 - -/* Encrypt: ciphertext_len = plaintext_len; tag is 16 bytes appended. - * out buffer size must be >= plaintext_len + 16. */ -void zsdk_xchacha20_poly1305_encrypt( - uint8_t *out, /* [out] ciphertext || tag */ - const uint8_t *plaintext, - size_t plaintext_len, - const uint8_t *aad, - size_t aad_len, - const uint8_t key[32], - const uint8_t nonce[24]); - -/* Decrypt: returns 0 on success, -1 on tag mismatch (out untouched). - * out buffer size must be >= ciphertext_len - 16. */ -int zsdk_xchacha20_poly1305_decrypt( - uint8_t *out, /* [out] plaintext */ - const uint8_t *ciphertext, /* ciphertext || tag */ - size_t ciphertext_len, /* includes 16-byte tag */ - const uint8_t *aad, - size_t aad_len, - const uint8_t key[32], - const uint8_t nonce[24]); - -#ifdef __cplusplus -} -#endif - -#endif diff --git a/vendor/zuptsdk/include/zupt.h b/vendor/zuptsdk/include/zupt.h deleted file mode 100644 index a97cd6d..0000000 --- a/vendor/zuptsdk/include/zupt.h +++ /dev/null @@ -1,401 +0,0 @@ -/* - * Zupt — Backup-oriented compression with AES-256 encryption - * Copyright (c) 2026 Cristian Cezar Moisés - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -#ifndef ZUPT_H -#define ZUPT_H - -/* Feature test macros — must precede all system includes. - * _DEFAULT_SOURCE gives us lstat() on glibc without -D_GNU_SOURCE. */ -#if !defined(_DEFAULT_SOURCE) && !defined(_GNU_SOURCE) - #define _DEFAULT_SOURCE 1 -#endif - -#include -#include -#include - -#ifdef _WIN32 - #include - #include - #define ZUPT_PATH_SEP '\\' - #define zupt_mkdir(p) _mkdir(p) -#else - #include - #include - #include - #include - #define ZUPT_PATH_SEP '/' - #define zupt_mkdir(p) mkdir(p, 0755) -#endif - -#define ZUPT_VERSION_STRING "2.2.3" -#define ZUPT_FORMAT_MAJOR 1 -#define ZUPT_FORMAT_MINOR 4 - -#define ZUPT_MAGIC_0 0x5A -#define ZUPT_MAGIC_1 0x55 -#define ZUPT_MAGIC_2 0x50 -#define ZUPT_MAGIC_3 0x54 -#define ZUPT_MAGIC_4 0x1A -#define ZUPT_MAGIC_5 0x00 -#define ZUPT_BLOCK_MAGIC_0 0xBB -#define ZUPT_BLOCK_MAGIC_1 0x01 - -#define ZUPT_MAX_PATH 4096 -#define ZUPT_MAX_FILES 2000000 -#define ZUPT_DEFAULT_BLOCK_SZ (4 * 1024 * 1024) -#define ZUPT_MIN_BLOCK_SZ (64 * 1024) -#define ZUPT_MAX_BLOCK_SZ (256 * 1024 * 1024) - -/* Global flags */ -#define ZUPT_FLAG_ENCRYPTED (1u << 0) -#define ZUPT_FLAG_CKSUM_XXH64 (0u << 5) -#define ZUPT_FLAG_SOLID (1u << 1) -#define ZUPT_FLAG_MULTITHREADED (1u << 2) /* Informational: archive was produced with MT */ -#define ZUPT_FLAG_PQ_HYBRID (1u << 3) /* Post-quantum hybrid encryption */ -#define ZUPT_FLAG_FORMAT_STABLE (1u << 4) /* v1.0: format frozen */ -#define ZUPT_FLAG_DEDUP (1u << 7) /* Block-level deduplication enabled */ -#define ZUPT_FLAG_AAD_SEQ (1u << 8) /* MAC binds block_seq as AAD (anti-reorder) */ - -/* Encryption types (stored in encryption header block) */ -#define ZUPT_ENC_PBKDF2 0x01 /* Password-based: PBKDF2 → AES-256-CTR + HMAC */ -#define ZUPT_ENC_PQ_HYBRID 0x02 /* ML-KEM-768 + X25519 hybrid KEM (legacy XOR+SHA3) */ -#define ZUPT_ENC_PQ_SDK_V2 0x03 /* libzuptsdk v2 header: HKDF combiner + commitment + HPKE binding */ -#define ZUPT_ENC_PW_ARGON2 0x04 /* Password-based via libzuptsdk: Argon2id + XChaCha20-Poly1305 */ - -/* Block types */ -#define ZUPT_BLOCK_DATA 0x00 -#define ZUPT_BLOCK_INDEX 0x02 -#define ZUPT_BLOCK_ENC_HEADER 0x03 -#define ZUPT_BLOCK_DEDUP_REF 0x04 /* Dedup reference: payload = 8B offset of original block */ - -/* Block flags */ -#define ZUPT_BFLAG_ENCRYPTED (1u << 0) - -/* Codec IDs */ -#define ZUPT_CODEC_STORE 0x0000 -#define ZUPT_CODEC_ZUPT_LZ 0x0008 -#define ZUPT_CODEC_ZUPT_LZH 0x0009 /* LZ77 + Huffman */ -#define ZUPT_CODEC_ZUPT_LZHP 0x000A /* LZ77 + Huffman + Byte Prediction (default) */ -#define ZUPT_CODEC_VAPTVUPT 0x0010 /* VAPTVUPT: VaptVupt LZ + ANS entropy codec */ -#define ZUPT_CODEC_AUTO 0xFFFF /* Auto-detect: VaptVupt if AVX2, else LZHP */ - -/* Crypto */ -#define ZUPT_SALT_SIZE 32 -#define ZUPT_NONCE_SIZE 16 -#define ZUPT_HMAC_SIZE 32 -#define ZUPT_AES_KEY_SIZE 32 -#define ZUPT_KDF_ITERATIONS 600000 - -typedef enum { - ZUPT_OK = 0, ZUPT_ERR_IO = -1, ZUPT_ERR_CORRUPT = -2, - ZUPT_ERR_BAD_MAGIC = -3, ZUPT_ERR_BAD_VERSION = -4, - ZUPT_ERR_BAD_CHECKSUM = -5, ZUPT_ERR_NOMEM = -6, - ZUPT_ERR_OVERFLOW = -7, ZUPT_ERR_INVALID = -8, - ZUPT_ERR_NOT_FOUND = -9, ZUPT_ERR_UNSUPPORTED = -10, - ZUPT_ERR_AUTH_FAIL = -11, -} zupt_error_t; - -/* ─── On-disk (packed LE) ─── */ -#pragma pack(push, 1) -typedef struct { - uint8_t magic[6]; - uint8_t version_major, version_minor; - uint32_t global_flags; - uint64_t creation_time; - uint8_t archive_id[16]; - uint64_t encryption_header_off; - uint64_t comment_offset; - uint8_t reserved[12]; -} zupt_archive_header_t; /* 64 bytes */ - -typedef struct { - uint64_t index_offset; - uint64_t total_blocks; - uint64_t archive_checksum; - uint8_t footer_magic[4]; /* "ZEND" */ - uint32_t footer_version; -} zupt_footer_t; /* 32 bytes */ -#pragma pack(pop) - -/* ─── In-memory ─── */ -typedef struct { - char path[ZUPT_MAX_PATH]; - uint64_t uncompressed_size, compressed_size; - uint64_t modification_time, content_hash; - uint64_t first_block_offset; - uint32_t block_count, attributes; -} zupt_index_entry_t; - -typedef struct { - uint8_t block_type; uint16_t codec_id, block_flags; - uint64_t uncompressed_size, compressed_size, checksum; - uint8_t *payload; -} zupt_block_t; - -/* Buffer canary for keyring overflow detection */ -#define ZUPT_CANARY 0xDEADCAFEBABEFACEULL - -typedef struct { - uint64_t canary_head; /* Must equal ZUPT_CANARY */ - uint8_t enc_key[ZUPT_AES_KEY_SIZE]; - uint8_t mac_key[ZUPT_HMAC_SIZE]; - uint8_t salt[ZUPT_SALT_SIZE]; - uint8_t base_nonce[ZUPT_NONCE_SIZE]; - uint32_t iterations; - int active; - uint64_t canary_tail; /* Must equal ZUPT_CANARY */ -} zupt_keyring_t; - -/* Check keyring canaries — abort on buffer overflow */ -static inline void zupt_keyring_init(zupt_keyring_t *kr) { - volatile uint8_t *p = (volatile uint8_t *)kr; - for (size_t i = 0; i < sizeof(*kr); i++) p[i] = 0; - kr->canary_head = ZUPT_CANARY; - kr->canary_tail = ZUPT_CANARY; -} -static inline void zupt_keyring_check(const zupt_keyring_t *kr) { - if (kr->canary_head != ZUPT_CANARY || kr->canary_tail != ZUPT_CANARY) { - fprintf(stderr, "FATAL: keyring buffer overflow detected (canary corrupted)\n"); - /* Use exit(127) instead of abort() to avoid needing */ - _exit(127); - } -} - -typedef struct { - char **paths, **arc_paths; - int count, capacity; -} zupt_filelist_t; - -typedef struct { - int level; uint32_t block_size; uint16_t codec_id; - int verbose, encrypt, quiet, solid, threads; - int pq_mode; /* 1 = post-quantum hybrid KEM mode */ - int sdk_mode; /* 1 = use libzuptsdk-backed v3 crypto (HKDF combiner + commitment + HPKE) */ - int dedup; /* 1 = block-level deduplication enabled */ - char password[256]; - char keyfile[ZUPT_MAX_PATH]; /* Path to .zupt-key file */ - zupt_keyring_t keyring; -} zupt_options_t; - -/* ═══════════════════════════════════════════════════════════════════ - * PORTABLE LITTLE-ENDIAN SERIALIZATION - * - * All multi-byte fields in the on-disk format are stored as LE. - * These helpers ensure correct behaviour on both LE and BE hosts. - * ═══════════════════════════════════════════════════════════════════ */ - -static inline void zupt_le16_put(uint8_t *p, uint16_t v) { - p[0] = (uint8_t)(v & 0xFF); - p[1] = (uint8_t)((v >> 8) & 0xFF); -} -static inline void zupt_le32_put(uint8_t *p, uint32_t v) { - p[0] = (uint8_t)(v & 0xFF); - p[1] = (uint8_t)((v >> 8) & 0xFF); - p[2] = (uint8_t)((v >> 16) & 0xFF); - p[3] = (uint8_t)((v >> 24) & 0xFF); -} -static inline void zupt_le64_put(uint8_t *p, uint64_t v) { - for (int i = 0; i < 8; i++) { p[i] = (uint8_t)(v & 0xFF); v >>= 8; } -} -static inline uint16_t zupt_le16_get(const uint8_t *p) { - return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8)); -} -static inline uint32_t zupt_le32_get(const uint8_t *p) { - return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | - ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); -} -static inline uint64_t zupt_le64_get(const uint8_t *p) { - uint64_t v = 0; - for (int i = 7; i >= 0; i--) v = (v << 8) | p[i]; - return v; -} - -/* ═══════════════════════════════════════════════════════════════════ - * SECURE MEMORY WIPE (resists dead-store elimination by compilers) - * ═══════════════════════════════════════════════════════════════════ */ - -/* FRAMA-C: Secure memory wipe — resists dead-store elimination */ -/*@ requires \valid((uint8_t *)ptr + (0..len-1)); - @ assigns ((uint8_t *)ptr)[0..len-1]; - @ ensures \forall integer i; 0 <= i < len ==> ((uint8_t *)ptr)[i] == 0; -*/ -static inline void zupt_secure_wipe(void *ptr, size_t len) { -#if defined(_WIN32) - SecureZeroMemory(ptr, len); -#elif (defined(__GLIBC__) && (__GLIBC__ > 2 || (__GLIBC__ == 2 && __GLIBC_MINOR__ >= 25))) - extern void explicit_bzero(void *, size_t); - explicit_bzero(ptr, len); -#elif defined(__FreeBSD__) || defined(__OpenBSD__) - extern void explicit_bzero(void *, size_t); - explicit_bzero(ptr, len); -#else - volatile uint8_t *vp = (volatile uint8_t *)ptr; - for (size_t i = 0; i < len; i++) vp[i] = 0; -#endif -} - -/* ═══════════════════════════════════════════════════════════════════ - * REGULAR-FILE CHECK (skip symlinks, devices, FIFOs, sockets) - * ═══════════════════════════════════════════════════════════════════ */ - -static inline int zupt_is_regular_file(const char *path) { -#ifdef _WIN32 - DWORD attr = GetFileAttributesA(path); - if (attr == INVALID_FILE_ATTRIBUTES) return 0; - return !(attr & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_DEVICE | - FILE_ATTRIBUTE_REPARSE_POINT)); -#else - struct stat st; - if (lstat(path, &st) != 0) return 0; - return S_ISREG(st.st_mode); -#endif -} - -/* ─── Solid-mode compression ─── */ -zupt_error_t zupt_compress_solid(const char *out, const char **arc, const char **disk, int n, zupt_options_t *opts); - -/* ─── SHA-256 ─── */ -typedef struct { uint32_t state[8]; uint64_t count; uint8_t buf[64]; } zupt_sha256_ctx; -void zupt_sha256_init(zupt_sha256_ctx *c); -void zupt_sha256_update(zupt_sha256_ctx *c, const uint8_t *d, size_t n); -void zupt_sha256_final(zupt_sha256_ctx *c, uint8_t h[32]); -void zupt_sha256(const uint8_t *d, size_t n, uint8_t h[32]); - -/* ─── AES-256 ─── */ -typedef struct { uint32_t rk[60]; } zupt_aes256_ctx; -void zupt_aes256_init(zupt_aes256_ctx *c, const uint8_t key[32]); -void zupt_aes256_encrypt_block(const zupt_aes256_ctx *c, const uint8_t in[16], uint8_t out[16]); - -/* ─── Crypto ops ─── */ -void zupt_hmac_sha256(const uint8_t *key, size_t klen, const uint8_t *data, size_t dlen, uint8_t mac[32]); -void zupt_pbkdf2_sha256(const uint8_t *pw, size_t pwlen, const uint8_t *salt, size_t slen, uint32_t iter, uint8_t *out, size_t olen); -void zupt_aes256_ctr(const uint8_t key[32], const uint8_t nonce[16], const uint8_t *in, uint8_t *out, size_t len); -void zupt_derive_keys(zupt_keyring_t *kr, const char *pw, const uint8_t salt[32], const uint8_t nonce[16], uint32_t iter); -uint8_t *zupt_encrypt_buffer(const zupt_keyring_t *kr, const uint8_t *plain, size_t plen, uint64_t seq, size_t *olen); -uint8_t *zupt_decrypt_buffer(const zupt_keyring_t *kr, const uint8_t *pkg, size_t pkglen, uint64_t seq, size_t *olen); -void zupt_random_bytes(uint8_t *buf, size_t len); - -/* ─── Memory locking for key material ─── */ -int zupt_mlock_keys(void *ptr, size_t len); -void zupt_munlock_keys(void *ptr, size_t len); - -/* ─── Adaptive compression: file type detection ─── */ -/* Returns: -1=store (incompressible), 0=default, 5=medium, 9=max */ -int zupt_detect_filetype(const uint8_t *header, size_t header_len); - -/* ─── XXH64 ─── */ -uint64_t zupt_xxh64(const void *data, size_t len, uint64_t seed); - -/* ─── LZ ─── */ -size_t zupt_lz_compress(const uint8_t *src, size_t slen, uint8_t *dst, size_t dcap, int level); -size_t zupt_lz_decompress(const uint8_t *src, size_t slen, uint8_t *dst, size_t dlen); -size_t zupt_lz_bound(size_t slen); - -/* ─── LZH (LZ77 + Huffman) ─── */ -size_t zupt_lzh_compress(const uint8_t *src, size_t slen, uint8_t *dst, size_t dcap, int level); -size_t zupt_lzh_decompress(const uint8_t *src, size_t slen, uint8_t *dst, size_t dlen); -size_t zupt_lzh_bound(size_t slen); - -/* ─── Byte Prediction (order-1 context transform) ─── */ -void zupt_predict_build(const uint8_t *data, size_t len, uint8_t prediction[256]); -void zupt_predict_encode(const uint8_t *in, uint8_t *out, size_t len, const uint8_t pred[256]); -void zupt_predict_decode(const uint8_t *in, uint8_t *out, size_t len, const uint8_t pred[256]); -float zupt_predict_benefit(const uint8_t *data, size_t len); - -/* ─── Format I/O ─── */ -int zupt_write_varint(FILE *f, uint64_t v); -int zupt_read_varint(FILE *f, uint64_t *v); -int zupt_encode_varint(uint8_t *b, uint64_t v); -int zupt_decode_varint(const uint8_t *b, size_t blen, uint64_t *v); - -void zupt_filelist_init(zupt_filelist_t *fl); -void zupt_filelist_free(zupt_filelist_t *fl); -void zupt_filelist_add(zupt_filelist_t *fl, const char *disk_path, const char *arc_path); -void zupt_collect_files(zupt_filelist_t *fl, const char *path, const char *base); - -zupt_error_t zupt_compress_files(const char *out, const char **arc, const char **disk, int n, zupt_options_t *opts); -zupt_error_t zupt_extract_archive(const char *arc, const char *dir, zupt_options_t *opts); -zupt_error_t zupt_list_archive(const char *arc, zupt_options_t *opts); -zupt_error_t zupt_test_archive(const char *arc, zupt_options_t *opts); - -/* ─── Hybrid PQ KEM (ML-KEM-768 + X25519) ─── */ -int zupt_hybrid_keygen(const char *keyfile); -int zupt_hybrid_export_pubkey(const char *privfile, const char *pubfile); -int zupt_hybrid_encrypt_init(zupt_keyring_t *kr, const char *pubkeyfile, - uint8_t *enc_hdr, size_t *enc_hdr_len); -int zupt_hybrid_decrypt_init(zupt_keyring_t *kr, const char *privkeyfile, - const uint8_t *enc_hdr, size_t enc_hdr_len); - -/* ─── SDK-backed crypto (zupt v2.2+, libzuptsdk under the hood) ─── */ -int zupt_sdk_hybrid_keygen(const char *privkeyfile, const char *pubkeyfile); -int zupt_sdk_hybrid_encrypt_init(zupt_keyring_t *kr, const char *pubkeyfile, - uint8_t *enc_hdr, size_t *enc_hdr_len); -int zupt_sdk_hybrid_decrypt_init(zupt_keyring_t *kr, const char *privkeyfile, - const uint8_t *enc_hdr, size_t enc_hdr_len); -int zupt_sdk_password_encrypt_init(zupt_keyring_t *kr, const char *password, - uint8_t *enc_hdr, size_t *enc_hdr_len); -int zupt_sdk_password_decrypt_init(zupt_keyring_t *kr, const char *password, - const uint8_t *enc_hdr, size_t enc_hdr_len); - -const char *zupt_strerror(zupt_error_t e); -const char *zupt_codec_name(uint16_t id); -void zupt_default_options(zupt_options_t *o); -void zupt_format_size(uint64_t bytes, char *buf, size_t cap); - -/* Resolve ZUPT_CODEC_AUTO to a concrete codec based on hardware. - * On x86_64 with AVX2: VaptVupt (fast ANS+SIMD decode). - * On all other arches: Zupt-LZHP (no SIMD dependency). - * Decompression of ALL codecs works on ALL architectures. */ -uint16_t zupt_resolve_auto_codec(void); - -/* ─── Full-Disk Backup/Restore ─── */ -#define ZUPT_FLAG_DISK_IMAGE (1u << 6) /* Archive contains a raw disk/partition image */ - -/* Compress a raw block device or file as a disk image. - * Reads source in block_size chunks, detects zero/sparse regions, - * compresses non-zero blocks. Supports encryption + PQ. */ -zupt_error_t zupt_disk_backup(const char *output_path, const char *source_path, - zupt_options_t *opts); - -/* Restore a disk image archive to a block device or file. - * Writes blocks sequentially, restoring sparse regions as zeros. */ -zupt_error_t zupt_disk_restore(const char *archive_path, const char *target_path, - zupt_options_t *opts); - -/* ─── Internal Block I/O (used by format + disk modules) ─── */ -zupt_error_t read_block(FILE *f, zupt_block_t *b); -zupt_error_t read_enc_header(FILE *f, zupt_archive_header_t *hdr, zupt_options_t *opts); -zupt_error_t decompress_block(const zupt_block_t *b, const zupt_keyring_t *kr, - uint64_t block_seq, uint8_t **out, size_t *olen); -zupt_error_t write_enc_header(FILE *out, zupt_archive_header_t *hdr, - zupt_options_t *opts); -int zupt_w8(FILE *f, uint8_t v); -int zupt_w16le(FILE *f, uint16_t v); -int zupt_w64le(FILE *f, uint64_t v); - -/* ─── Block-Level Deduplication ─── */ -#define ZUPT_DEDUP_MAX_ENTRIES (2 * 1024 * 1024) /* 2M entries, ~48MB RAM */ - -typedef struct zupt_dedup_ctx zupt_dedup_ctx_t; - -zupt_dedup_ctx_t *zupt_dedup_init(void); -void zupt_dedup_free(zupt_dedup_ctx_t *ctx); -int zupt_dedup_lookup(zupt_dedup_ctx_t *ctx, uint64_t fingerprint, - uint64_t *ref_offset, uint32_t *ref_size); -int zupt_dedup_insert(zupt_dedup_ctx_t *ctx, uint64_t fingerprint, - uint64_t block_offset, uint32_t block_size); -void zupt_dedup_record_hit(zupt_dedup_ctx_t *ctx, uint64_t saved_bytes); -void zupt_dedup_record_block(zupt_dedup_ctx_t *ctx); -void zupt_dedup_stats(const zupt_dedup_ctx_t *ctx, - uint64_t *blocks_seen, uint64_t *blocks_deduped, - uint64_t *bytes_saved); -int zupt_dedup_write_ref(FILE *out, uint64_t ref_offset, - uint32_t orig_size, uint64_t orig_checksum); - -/* ─── Archive Info (read-only metadata inspection) ─── */ -zupt_error_t zupt_archive_info(const char *path); - -#endif /* ZUPT_H */ diff --git a/vendor/zuptsdk/include/zupt_acsl.h b/vendor/zuptsdk/include/zupt_acsl.h deleted file mode 100644 index 812498c..0000000 --- a/vendor/zuptsdk/include/zupt_acsl.h +++ /dev/null @@ -1,43 +0,0 @@ -/* - * SPDX-License-Identifier: AGPL-3.0-or-later - * Copyright (c) 2026 Cristian Cezar Moisés - * - * Zupt — ACSL Custom Predicates for Frama-C/WP - * - * Usage: frama-c -wp -wp-rte -wp-model Typed+Cast - * -cpp-extra-args="-Iinclude -Isrc" src/zupt_crypto.c - */ -#ifndef ZUPT_ACSL_H -#define ZUPT_ACSL_H - -#ifdef __FRAMAC__ -#include - -/*@ predicate ValidBuffer{L}(uint8_t *p, size_t n) = - @ \valid_read(p + (0..n-1)) && - @ \initialized(p + (0..n-1)); - @ - @ predicate ValidWriteBuffer{L}(uint8_t *p, size_t n) = - @ \valid(p + (0..n-1)); - @ - @ predicate Separated2(uint8_t *a, size_t an, - @ uint8_t *b, size_t bn) = - @ \separated(a + (0..an-1), b + (0..bn-1)); - @ - @ predicate KeyWiped{L}(uint8_t *k, size_t n) = - @ \forall integer i; 0 <= i < n ==> \at(k[i],L) == 0; - @ - @ predicate ValidKey{L}(uint8_t *k, size_t n) = - @ ValidBuffer{L}(k, n) && n == 32; - @ - @ predicate ConstantTimeCompare{L}(uint8_t *a, uint8_t *b, - @ size_t n) = - @ \forall integer i; 0 <= i < n ==> - @ \initialized(\at(a+i,L)) && \initialized(\at(b+i,L)); - @ - @ predicate MACValid{L}(uint8_t *mac) = - @ ValidBuffer{L}(mac, 32); -*/ -#endif /* __FRAMAC__ */ - -#endif /* ZUPT_ACSL_H */ diff --git a/vendor/zuptsdk/include/zupt_cpuid.h b/vendor/zuptsdk/include/zupt_cpuid.h deleted file mode 100644 index 6297b2c..0000000 --- a/vendor/zuptsdk/include/zupt_cpuid.h +++ /dev/null @@ -1,33 +0,0 @@ -/* - * SPDX-License-Identifier: AGPL-3.0-or-later - * Copyright (c) 2026 Cristian Cezar Moisés - * - * Zupt — CPU Feature Detection - * Copyright (c) 2026 Cristian Cezar Moisés — AGPL-3.0-or-later - */ -#ifndef ZUPT_CPUID_H -#define ZUPT_CPUID_H - -#include - -typedef struct { - int has_aesni; /* CPUID.01H:ECX[25] — AES-NI instructions */ - int has_avx; /* AVX (VEX-encoded SSE) — requires CPUID + OS XSAVE */ - int has_pclmul; /* CPUID.01H:ECX[1] — CLMUL (carry-less multiply) */ - int has_avx2; /* CPUID.07H:EBX[5] — AVX2 (256-bit SIMD) */ - int has_sse41; /* CPUID.01H:ECX[19] — SSE4.1 */ -} zupt_cpu_features_t; - -/*@ assigns f->has_aesni, f->has_avx, f->has_pclmul, f->has_avx2, f->has_sse41; - @ ensures f->has_aesni == 0 || f->has_aesni == 1; - @ ensures f->has_avx == 0 || f->has_avx == 1; - @ ensures f->has_pclmul == 0 || f->has_pclmul == 1; - @ ensures f->has_avx2 == 0 || f->has_avx2 == 1; - @ ensures f->has_sse41 == 0 || f->has_sse41 == 1; -*/ -void zupt_detect_cpu(zupt_cpu_features_t *f); - -/* Global instance — set once at program start */ -extern zupt_cpu_features_t zupt_cpu; - -#endif /* ZUPT_CPUID_H */ diff --git a/vendor/zuptsdk/include/zupt_jasmin.h b/vendor/zuptsdk/include/zupt_jasmin.h deleted file mode 100644 index e6bbc5b..0000000 --- a/vendor/zuptsdk/include/zupt_jasmin.h +++ /dev/null @@ -1,65 +0,0 @@ -/* - * SPDX-License-Identifier: AGPL-3.0-or-later - * Copyright (c) 2026 Cristian Cezar Moisés - * - * Zupt — Jasmin Verified Crypto Declarations - * Copyright (c) 2026 Cristian Cezar Moisés — AGPL-3.0-or-later - * - * Extern declarations for Jasmin-compiled assembly functions. - * These replace C fallbacks when built with -DZUPT_USE_JASMIN. - * - * Calling convention: System V AMD64 ABI. - * Pointer args passed in RDI, RSI, RDX, RCX, R8, R9. - * - * v2.0.0: All 4 Jasmin functions wired and active. - */ -#ifndef ZUPT_JASMIN_H -#define ZUPT_JASMIN_H - -#ifdef ZUPT_USE_JASMIN -#include - -/* JASMIN-VERIFIED: CT MAC comparison (4×u64 XOR accumulation). - * Returns 0 if all 32 bytes match, nonzero if any differ. - * Replaces XOR loop in zupt_decrypt_buffer(). */ -extern uint64_t zupt_mac_verify_ct(const void *expected, const void *actual); - -/* JASMIN-VERIFIED: CT conditional select (4×u64 masked select). - * if cond==0: copies a→out. if cond!=0: copies b→out. - * Replaces cmov in zupt_mlkem768_decaps(). */ -extern void zupt_ct_select_32(void *out, const void *a, - const void *b, uint64_t cond); - -/* JASMIN-VERIFIED: CT conditional swap (4×u64 masked XOR swap). - * if cond==0: no-op. if cond==1: swaps a↔b in place. - * Replaces fe_cswap in zupt_x25519.c. - * NOTE: Requires 4×u64 field element layout (donna64). */ -extern void zupt_fe_cswap(void *a, void *b, uint64_t cond); - -/* JASMIN-VERIFIED: AES-256 single-block encrypt via AES-NI. - * out = AES-256-ECB(key, ctr) XOR in. - * FIX v2.0.0: Stack offset bug resolved — round keys at correct - * 16-byte aligned offsets. Requires AES-NI (checked via CPUID). - * - * Args (System V ABI): - * out_ptr (RDI): destination for 16-byte result - * in_blk (RSI): pointer to 16-byte plaintext block - * key (RDX): pointer to 32-byte AES-256 key (two u128) - * ctr_blk (RCX): pointer to 16-byte counter block - */ -extern void zupt_aes256_blk(void *out, const void *in, - const void *key, const void *ctr); - -/* JASMIN-VERIFIED: AES-256-CTR 4-block pipeline via AES-NI. - * Processes nblocks×16 bytes with 4-way interleaving. - * Counter is updated in-place (big-endian increment in bytes [8..15]). - * Requires AES-NI. Falls back to zupt_aes256_blk for remaining 1-3 blocks. - * - * Args: out(RDI), in(RSI), key(RDX), ctr(RCX), nblocks(R8) - */ -extern void zupt_aes256_ctr4(void *out, const void *in, - const void *key, void *ctr, - uint64_t nblocks); - -#endif /* ZUPT_USE_JASMIN */ -#endif /* ZUPT_JASMIN_H */ diff --git a/vendor/zuptsdk/include/zupt_keccak.h b/vendor/zuptsdk/include/zupt_keccak.h deleted file mode 100644 index 56ba0e9..0000000 --- a/vendor/zuptsdk/include/zupt_keccak.h +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Zupt — Backup-oriented compression with AES-256 encryption - * Copyright (c) 2026 Cristian Cezar Moisés - * SPDX-License-Identifier: AGPL-3.0-or-later - * - * Keccak-f[1600] sponge: SHA3-256, SHA3-512, SHAKE-128, SHAKE-256 - * Required by ML-KEM-768 (FIPS 203). - * Pure C11, zero dependencies, no dynamic allocation. - */ -#ifndef ZUPT_KECCAK_H -#define ZUPT_KECCAK_H - -#include -#include - -/* Sponge state: 25 × 64-bit lanes = 200 bytes */ -typedef struct { - uint64_t st[25]; - uint8_t buf[200]; /* absorption buffer */ - size_t rate; /* rate in bytes */ - size_t pt; /* position in buf */ - uint8_t dsuf; /* domain suffix: 0x06 for SHA3, 0x1F for SHAKE */ -} zupt_keccak_ctx; - -/* SHA3-256: 32-byte output */ -void zupt_sha3_256(const uint8_t *data, size_t len, uint8_t out[32]); - -/* SHA3-512: 64-byte output */ -void zupt_sha3_512(const uint8_t *data, size_t len, uint8_t out[64]); - -/* SHAKE-128: extendable output */ -void zupt_shake128(const uint8_t *data, size_t dlen, uint8_t *out, size_t olen); - -/* SHAKE-256: extendable output */ -void zupt_shake256(const uint8_t *data, size_t dlen, uint8_t *out, size_t olen); - -/* Incremental SHAKE-128 for ML-KEM sampling */ -void zupt_shake128_init(zupt_keccak_ctx *ctx); -void zupt_shake128_absorb(zupt_keccak_ctx *ctx, const uint8_t *data, size_t len); -void zupt_shake128_finalize(zupt_keccak_ctx *ctx); -void zupt_shake128_squeeze(zupt_keccak_ctx *ctx, uint8_t *out, size_t len); - -/* Incremental SHAKE-256 */ -void zupt_shake256_init(zupt_keccak_ctx *ctx); -void zupt_shake256_absorb(zupt_keccak_ctx *ctx, const uint8_t *data, size_t len); -void zupt_shake256_finalize(zupt_keccak_ctx *ctx); -void zupt_shake256_squeeze(zupt_keccak_ctx *ctx, uint8_t *out, size_t len); - -#endif diff --git a/vendor/zuptsdk/include/zupt_mlkem.h b/vendor/zuptsdk/include/zupt_mlkem.h deleted file mode 100644 index d928916..0000000 --- a/vendor/zuptsdk/include/zupt_mlkem.h +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Zupt — Backup-oriented compression with AES-256 encryption - * Copyright (c) 2026 Cristian Cezar Moisés - * SPDX-License-Identifier: AGPL-3.0-or-later - * - * ML-KEM-768 (FIPS 203, formerly CRYSTALS-Kyber). - * Post-quantum key encapsulation mechanism. - * - * Parameters (ML-KEM-768): - * k = 3, η₁ = 2, η₂ = 2, d_u = 10, d_v = 4 - * Public key: 1184 bytes - * Secret key: 2400 bytes - * Ciphertext: 1088 bytes - * Shared secret: 32 bytes - * - * SECURITY NOTE: This implementation must undergo independent review - * before deployment in high-assurance contexts. It targets correctness - * against NIST test vectors and constant-time operation. - */ -#ifndef ZUPT_MLKEM_H -#define ZUPT_MLKEM_H - -#include - -#define MLKEM_K 3 -#define MLKEM_N 256 -#define MLKEM_Q 3329 -#define MLKEM_ETA1 2 -#define MLKEM_ETA2 2 -#define MLKEM_DU 10 -#define MLKEM_DV 4 - -#define MLKEM_PUBLICKEYBYTES 1184 -#define MLKEM_SECRETKEYBYTES 2400 -#define MLKEM_CIPHERTEXTBYTES 1088 -#define MLKEM_SSBYTES 32 - -/* KeyGen: generate public/secret keypair. - * pk: output public key (1184 bytes) - * sk: output secret key (2400 bytes) - * Returns 0 on success. */ -int zupt_mlkem768_keygen(uint8_t pk[MLKEM_PUBLICKEYBYTES], - uint8_t sk[MLKEM_SECRETKEYBYTES]); - -/* Encapsulate: produce ciphertext and shared secret from public key. - * ct: output ciphertext (1088 bytes) - * ss: output shared secret (32 bytes) - * pk: input public key (1184 bytes) - * Returns 0 on success. */ -int zupt_mlkem768_encaps(uint8_t ct[MLKEM_CIPHERTEXTBYTES], - uint8_t ss[MLKEM_SSBYTES], - const uint8_t pk[MLKEM_PUBLICKEYBYTES]); - -/* Decapsulate: recover shared secret from ciphertext and secret key. - * ss: output shared secret (32 bytes) - * ct: input ciphertext (1088 bytes) - * sk: input secret key (2400 bytes) - * Returns 0 on success. - * CT-REQUIRED: Implicit rejection — invalid ciphertext produces a - * pseudorandom shared secret (no distinguishable failure). */ -int zupt_mlkem768_decaps(uint8_t ss[MLKEM_SSBYTES], - const uint8_t ct[MLKEM_CIPHERTEXTBYTES], - const uint8_t sk[MLKEM_SECRETKEYBYTES]); - -#endif diff --git a/vendor/zuptsdk/include/zupt_x25519.h b/vendor/zuptsdk/include/zupt_x25519.h deleted file mode 100644 index ea62a95..0000000 --- a/vendor/zuptsdk/include/zupt_x25519.h +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Zupt — Backup-oriented compression with AES-256 encryption - * Copyright (c) 2026 Cristian Cezar Moisés - * SPDX-License-Identifier: AGPL-3.0-or-later - * - * X25519 Diffie-Hellman key agreement (RFC 7748). - * Montgomery ladder — constant-time by construction. - */ -#ifndef ZUPT_X25519_H -#define ZUPT_X25519_H - -#include - -/* X25519(scalar, point) → result. All inputs/outputs are 32 bytes. - * CT-REQUIRED: Montgomery ladder is inherently constant-time. */ -void zupt_x25519(uint8_t out[32], const uint8_t scalar[32], const uint8_t point[32]); - -/* X25519 with the standard basepoint (9). - * Used for keygen: public = X25519(private, basepoint). */ -void zupt_x25519_base(uint8_t out[32], const uint8_t scalar[32]); - -#endif diff --git a/vendor/zuptsdk/include/zuptsdk.h b/vendor/zuptsdk/include/zuptsdk.h deleted file mode 100644 index f30ea6c..0000000 --- a/vendor/zuptsdk/include/zuptsdk.h +++ /dev/null @@ -1,605 +0,0 @@ -/* - * libzuptsdk — Public C ABI for the Zupt backup compression library - * - * Copyright (c) 2026 Cristian Cezar Moisés - * SPDX-License-Identifier: AGPL-3.0-or-later - * - * Repository: https://git.securityops.co/cristiancmoises/zupt - * Website: https://zupt.securityops.co - * Contact: zupt@riseup.net - * - * -------------------------------------------------------------------------- - * STABILITY GUARANTEE - * -------------------------------------------------------------------------- - * Every symbol declared in this header is part of the stable v1.0 ABI and - * is gated behind the linker version tag ZUPTSDK_1.0. New symbols may be - * added in minor versions (1.1, 1.2, ...) under new tags (ZUPTSDK_1.1, ...). - * Existing symbols will never change signature within v1.x. Breaking - * changes require a major version bump (libzuptsdk.so.2). - * - * No symbol prefixed with anything other than `zuptsdk_` or `ZUPTSDK_` is - * part of this ABI. Do not link against internal `zupt_*` symbols even if - * they appear in the static archive — they will disappear without notice. - * - * -------------------------------------------------------------------------- - * THREAD SAFETY - * -------------------------------------------------------------------------- - * Every function that takes a `zuptsdk_ctx_t *` operates only on that - * context's state and on caller-provided buffers. Concurrent calls on - * DISTINCT contexts are safe (MT-Safe). Concurrent calls on the SAME - * context are NOT safe (MT-Unsafe-Same-Context) unless explicitly - * documented otherwise. - * - * -------------------------------------------------------------------------- - * MEMORY OWNERSHIP - * -------------------------------------------------------------------------- - * Every function documents ownership using these conventions in the param - * comments: - * [in] caller owns, library reads only - * [out] caller owns, library writes - * [in,out] caller owns, library reads and writes - * [transfers] ownership moves caller -> library (or library -> caller) - * [borrowed] pointer valid only for the duration of the call - * - * Any function that returns a heap-allocated value via an output pointer - * documents the corresponding zuptsdk_*_destroy() or zuptsdk_free() call - * the caller must invoke. Calling free() on libc-allocated memory from a - * different allocator is undefined; always use the documented destroyer. - * - * -------------------------------------------------------------------------- - * ERROR HANDLING - * -------------------------------------------------------------------------- - * Functions return `int` where 0 == ZUPTSDK_OK and negative values are - * `zuptsdk_error_t` codes. Use zuptsdk_strerror() for a static description - * and zuptsdk_last_error_detail(ctx) for a thread-local detailed message - * including filename, line number, and underlying errno where applicable. - * - * The library never calls abort(), exit(), or _exit(). It never writes to - * stdout or stderr unless the caller explicitly enables logging via - * zuptsdk_ctx_set_log_callback(). - * - * -------------------------------------------------------------------------- - * SECURE MEMORY - * -------------------------------------------------------------------------- - * Inputs and outputs containing secret material (passwords, raw keys, - * decrypted plaintext keys) MUST be passed via `zuptsdk_secure_buffer_t` - * to ensure mlock()-backed storage and explicit_bzero() on destroy. - * Passing such material via plain `const uint8_t *` is allowed for - * convenience but the library cannot guarantee zeroization of caller - * memory in that case. - */ - -#ifndef ZUPTSDK_H -#define ZUPTSDK_H - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/* ════════════════════════════════════════════════════════════════════════ - * VERSION - * ════════════════════════════════════════════════════════════════════════ */ - -#define ZUPTSDK_VERSION_MAJOR 1 -#define ZUPTSDK_VERSION_MINOR 0 -#define ZUPTSDK_VERSION_PATCH 0 -#define ZUPTSDK_VERSION_STRING "1.0.0" - -/* Compile-time version check helper (negative if header older than required) */ -#define ZUPTSDK_VERSION_AT_LEAST(maj, min, pat) \ - ((ZUPTSDK_VERSION_MAJOR > (maj)) || \ - (ZUPTSDK_VERSION_MAJOR == (maj) && ZUPTSDK_VERSION_MINOR > (min)) || \ - (ZUPTSDK_VERSION_MAJOR == (maj) && ZUPTSDK_VERSION_MINOR == (min) && \ - ZUPTSDK_VERSION_PATCH >= (pat))) - -/** - * Return the runtime version string of the linked library, e.g. "1.0.0". - * The returned pointer is to static storage and must NOT be freed. - * - * Use this with the compile-time ZUPTSDK_VERSION_STRING to detect mismatch - * between header and library at runtime. - */ -const char *zuptsdk_version_string(void); - -/** - * Verify that the linked library is at least the requested version. - * Returns 0 if compatible, ZUPTSDK_ERR_VERSION_MISMATCH otherwise. - * Call this once at startup before any other zuptsdk_* function. - */ -int zuptsdk_version_check(int major, int minor, int patch); - -/* ════════════════════════════════════════════════════════════════════════ - * ERRORS - * ════════════════════════════════════════════════════════════════════════ */ - -typedef enum { - ZUPTSDK_OK = 0, - ZUPTSDK_ERR_INVALID_ARG = -1, /* NULL pointer, bad size, bad enum value */ - ZUPTSDK_ERR_NO_MEMORY = -2, /* malloc/calloc/realloc returned NULL */ - ZUPTSDK_ERR_IO = -3, /* read/write error; see errno detail */ - ZUPTSDK_ERR_BAD_ARCHIVE = -4, /* Magic mismatch or truncated header */ - ZUPTSDK_ERR_BAD_PASSWORD = -5, /* MAC verification failed */ - ZUPTSDK_ERR_BAD_KEY = -6, /* PQ key file malformed or wrong type */ - ZUPTSDK_ERR_BAD_MAC = -7, /* HMAC mismatch — archive corrupted or tampered */ - ZUPTSDK_ERR_BAD_VERSION = -8, /* Archive format version not supported */ - ZUPTSDK_ERR_BAD_CHECKSUM = -9, /* Block checksum mismatch */ - ZUPTSDK_ERR_BUFFER_TOO_SMALL = -10, /* Output buffer insufficient */ - ZUPTSDK_ERR_NOT_ENCRYPTED = -11, /* Tried to decrypt unencrypted archive */ - ZUPTSDK_ERR_PASSWORD_REQUIRED = -12, /* Archive needs password but none supplied */ - ZUPTSDK_ERR_PQ_KEY_REQUIRED = -13, /* Archive needs PQ key but none supplied */ - ZUPTSDK_ERR_UNSUPPORTED = -14, /* Feature not supported on this platform */ - ZUPTSDK_ERR_VERSION_MISMATCH = -15, /* Library older than requested */ - ZUPTSDK_ERR_PATH_TRAVERSAL = -16, /* "../" or absolute path in archive */ - ZUPTSDK_ERR_TOO_LARGE = -17, /* Decompressed size exceeds limit */ - ZUPTSDK_ERR_CRYPTO_FAIL = -18, /* Underlying crypto primitive failed */ - ZUPTSDK_ERR_CANCELLED = -19, /* Caller cancelled via progress callback */ - ZUPTSDK_ERR_INTERNAL = -99 /* Bug in library — please report */ -} zuptsdk_error_t; - -/** - * Static error description for a zuptsdk_error_t value. - * Returned pointer is static and must not be freed. Always non-NULL. - */ -const char *zuptsdk_strerror(int err); - -/** - * Thread-local detailed error message from the most recent failed call. - * The string includes file:line of the failure point and underlying errno - * description where applicable. Returned pointer is to thread-local - * storage, valid until the next failed zuptsdk_* call on this thread. - * Returns "" if no error has been recorded on this thread. - */ -const char *zuptsdk_last_error_detail(void); - -/* ════════════════════════════════════════════════════════════════════════ - * OPAQUE TYPES (forward declarations only — no struct layout exposed) - * ════════════════════════════════════════════════════════════════════════ */ - -typedef struct zuptsdk_ctx zuptsdk_ctx_t; -typedef struct zuptsdk_options zuptsdk_options_t; -typedef struct zuptsdk_archive_info zuptsdk_archive_info_t; -typedef struct zuptsdk_secure_buf zuptsdk_secure_buf_t; -typedef struct zuptsdk_keypair zuptsdk_keypair_t; -typedef struct zuptsdk_pubkey zuptsdk_pubkey_t; -typedef struct zuptsdk_privkey zuptsdk_privkey_t; - -/* ════════════════════════════════════════════════════════════════════════ - * ENUMS - * ════════════════════════════════════════════════════════════════════════ */ - -typedef enum { - ZUPTSDK_CODEC_AUTO = 0, /* Hardware-adaptive (VaptVupt on AVX2, LZHP otherwise) */ - ZUPTSDK_CODEC_VAPTVUPT = 1, /* VaptVupt LZ + ANS entropy */ - ZUPTSDK_CODEC_LZHP = 2, /* LZ77 + Huffman + Byte Prediction */ - ZUPTSDK_CODEC_LZH = 3, /* LZ77 + Huffman */ - ZUPTSDK_CODEC_LZ = 4, /* LZ77 only */ - ZUPTSDK_CODEC_STORE = 5 /* No compression */ -} zuptsdk_codec_t; - -typedef enum { - ZUPTSDK_ENC_NONE = 0, /* No encryption */ - ZUPTSDK_ENC_PASSWORD = 1, /* PBKDF2 → AES-256-CTR + HMAC-SHA256 */ - ZUPTSDK_ENC_PQ_HYBRID = 2 /* ML-KEM-768 + X25519 hybrid KEM */ -} zuptsdk_encryption_t; - -typedef enum { - ZUPTSDK_LOG_ERROR = 0, - ZUPTSDK_LOG_WARN = 1, - ZUPTSDK_LOG_INFO = 2, - ZUPTSDK_LOG_DEBUG = 3 -} zuptsdk_log_level_t; - -/* ════════════════════════════════════════════════════════════════════════ - * CALLBACKS - * ════════════════════════════════════════════════════════════════════════ */ - -/** - * Streaming read callback. Library calls this to obtain input bytes. - * @param userdata [in] opaque pointer supplied at stream init - * @param buf [out] destination buffer - * @param max_bytes max bytes to read into buf - * @return Number of bytes actually read (0 == EOF, < 0 == error). - */ -typedef int64_t (*zuptsdk_read_fn)(void *userdata, uint8_t *buf, size_t max_bytes); - -/** - * Streaming write callback. Library calls this to deliver output bytes. - * @param userdata [in] opaque pointer supplied at stream init - * @param buf [in] data to write - * @param bytes number of bytes in buf - * @return Number of bytes actually written (must equal `bytes` on success). - */ -typedef int64_t (*zuptsdk_write_fn)(void *userdata, const uint8_t *buf, size_t bytes); - -/** - * Progress callback. Library invokes periodically during long operations. - * Return non-zero to cancel the operation; the in-flight call will then - * return ZUPTSDK_ERR_CANCELLED. - * @param userdata [in] opaque pointer set via zuptsdk_ctx_set_progress_callback - * @param processed bytes processed so far - * @param total total bytes (0 if unknown) - * @return 0 to continue, non-zero to cancel. - */ -typedef int (*zuptsdk_progress_fn)(void *userdata, uint64_t processed, uint64_t total); - -/** - * Log callback. Receives diagnostic messages from the library. - * Set via zuptsdk_ctx_set_log_callback(). NULL means no logging (default). - * The string is null-terminated and valid only for the duration of the call. - */ -typedef void (*zuptsdk_log_fn)(void *userdata, zuptsdk_log_level_t level, const char *msg); - -/** - * Custom allocator hooks. Set globally via zuptsdk_set_allocator(). - * If any function is NULL, libc malloc/free/realloc is used. - * realloc_fn must accept (NULL, n) as malloc(n) and (p, 0) as free(p). - */ -typedef struct { - void *(*malloc_fn)(void *userdata, size_t size); - void (*free_fn)(void *userdata, void *ptr); - void *(*realloc_fn)(void *userdata, void *ptr, size_t size); - void *userdata; -} zuptsdk_allocator_t; - -/* ════════════════════════════════════════════════════════════════════════ - * GLOBAL CONFIG - * ════════════════════════════════════════════════════════════════════════ */ - -/** - * Install a custom allocator. Must be called before any other zuptsdk_* - * function. Calling after contexts have been created is undefined. - * Pass NULL to revert to libc allocator (only valid before first use). - * - * @param alloc [in,borrowed] allocator hooks; copied internally - * @return ZUPTSDK_OK or ZUPTSDK_ERR_INVALID_ARG - */ -int zuptsdk_set_allocator(const zuptsdk_allocator_t *alloc); - -/* ════════════════════════════════════════════════════════════════════════ - * CONTEXT - * ════════════════════════════════════════════════════════════════════════ */ - -/** - * Create a new SDK context. Each context holds its own thread pool, - * progress callback, log callback, and error state. Contexts are - * cheap to create — a few KB plus the configured thread count. - * - * @param ctx_out [out,transfers] pointer to receive new context - * @return ZUPTSDK_OK on success, ZUPTSDK_ERR_NO_MEMORY on alloc failure. - * On error, *ctx_out is set to NULL. - */ -int zuptsdk_ctx_create(zuptsdk_ctx_t **ctx_out); - -/** - * Destroy a context. Frees all owned resources including thread pool. - * Safe to call with NULL. After this call, the pointer is invalid. - */ -void zuptsdk_ctx_destroy(zuptsdk_ctx_t *ctx); - -/** - * Set worker thread count. 0 == auto (one per CPU). Default is auto. - * Returns ZUPTSDK_ERR_INVALID_ARG if ctx is NULL or threads > 256. - */ -int zuptsdk_ctx_set_threads(zuptsdk_ctx_t *ctx, int threads); - -/** - * Set progress callback for long-running operations on this context. - * Pass NULL fn to clear. userdata is opaque to the library. - */ -int zuptsdk_ctx_set_progress_callback(zuptsdk_ctx_t *ctx, - zuptsdk_progress_fn fn, - void *userdata); - -/** - * Set log callback for diagnostic messages on this context. - * Pass NULL fn to disable logging (default). - */ -int zuptsdk_ctx_set_log_callback(zuptsdk_ctx_t *ctx, - zuptsdk_log_fn fn, - zuptsdk_log_level_t min_level, - void *userdata); - -/* ════════════════════════════════════════════════════════════════════════ - * OPTIONS - * ════════════════════════════════════════════════════════════════════════ */ - -/** - * Create a default-initialized options bag for compress/encrypt operations. - * Defaults: codec=AUTO, level=7, no encryption, no dedup, no solid mode. - */ -int zuptsdk_options_create(zuptsdk_options_t **opts_out); -void zuptsdk_options_destroy(zuptsdk_options_t *opts); - -int zuptsdk_options_set_codec(zuptsdk_options_t *opts, zuptsdk_codec_t codec); -int zuptsdk_options_set_level(zuptsdk_options_t *opts, int level /* 1..9 */); -int zuptsdk_options_set_dedup(zuptsdk_options_t *opts, int enabled); -int zuptsdk_options_set_solid(zuptsdk_options_t *opts, int enabled); -int zuptsdk_options_set_block_size(zuptsdk_options_t *opts, size_t bytes); - -/** - * Maximum decompressed output size. Decompression aborts with - * ZUPTSDK_ERR_TOO_LARGE if exceeded. 0 == unlimited (NOT recommended - * for untrusted input — zip-bomb attack vector). Default: 16 GiB. - */ -int zuptsdk_options_set_max_decompressed(zuptsdk_options_t *opts, - uint64_t max_bytes); - -/* ════════════════════════════════════════════════════════════════════════ - * SECURE BUFFERS (for passwords and key material) - * ════════════════════════════════════════════════════════════════════════ */ - -/** - * Allocate a secure buffer: backing memory is mlock()ed (locked into RAM, - * never swapped to disk) and explicit_bzero()ed on destroy. - * - * @param size requested size in bytes (1..65536) - * @param buf_out [out,transfers] receives buffer handle - * @return ZUPTSDK_OK on success. - */ -int zuptsdk_secure_buf_create(size_t size, zuptsdk_secure_buf_t **buf_out); - -/** - * Destroy a secure buffer. Memory is zeroed and unlocked before free. - * Safe to call with NULL. - */ -void zuptsdk_secure_buf_destroy(zuptsdk_secure_buf_t *buf); - -/** - * Get raw pointer to the secure buffer's storage. Pointer is valid until - * zuptsdk_secure_buf_destroy() is called. Caller may read or write up to - * the buffer's size. - * - * @param buf [in] - * @param data_out [out,borrowed] receives pointer to storage - * @param size_out [out] receives buffer size - */ -int zuptsdk_secure_buf_get(zuptsdk_secure_buf_t *buf, - uint8_t **data_out, size_t *size_out); - -/** - * Convenience: copy data into a new secure buffer. - * Useful when migrating an existing plain buffer to secure storage. - */ -int zuptsdk_secure_buf_from_data(const uint8_t *data, size_t size, - zuptsdk_secure_buf_t **buf_out); - -/* ════════════════════════════════════════════════════════════════════════ - * KEYS (PQ hybrid: ML-KEM-768 + X25519) - * ════════════════════════════════════════════════════════════════════════ */ - -/** - * Generate a fresh hybrid keypair. Uses the system CSPRNG. - * - * @param ctx [in] - * @param kp_out [out,transfers] receives new keypair - * @return ZUPTSDK_OK on success, ZUPTSDK_ERR_CRYPTO_FAIL on RNG failure. - */ -int zuptsdk_keypair_generate(zuptsdk_ctx_t *ctx, zuptsdk_keypair_t **kp_out); - -void zuptsdk_keypair_destroy(zuptsdk_keypair_t *kp); - -/** - * Save private key to a file. The file is written with mode 0600 on POSIX. - * Recommended extension: ".key". - */ -int zuptsdk_keypair_save_private(const zuptsdk_keypair_t *kp, const char *path); - -/** - * Save public key to a file. World-readable. - * Recommended extension: ".pub" or "_public.key". - */ -int zuptsdk_keypair_save_public(const zuptsdk_keypair_t *kp, const char *path); - -/** - * Load a private key from a file. - * @param path [in] - * @param key_out [out,transfers] - */ -int zuptsdk_privkey_load(const char *path, zuptsdk_privkey_t **key_out); -void zuptsdk_privkey_destroy(zuptsdk_privkey_t *key); - -/** - * Load a public key from a file. - */ -int zuptsdk_pubkey_load(const char *path, zuptsdk_pubkey_t **key_out); -void zuptsdk_pubkey_destroy(zuptsdk_pubkey_t *key); - -/** - * Derive public key from private key (no I/O). - */ -int zuptsdk_privkey_get_public(const zuptsdk_privkey_t *priv, - zuptsdk_pubkey_t **pub_out); - -/* ════════════════════════════════════════════════════════════════════════ - * COMPRESS / DECOMPRESS — buffer mode (for small archives) - * ════════════════════════════════════════════════════════════════════════ */ - -/** - * Compress an in-memory file list into a single archive buffer. - * - * @param ctx [in] - * @param opts [in,borrowed] compression and encryption options - * @param file_paths [in] array of filesystem paths to add - * @param file_count number of paths in file_paths - * @param password [in,nullable] password as a secure buffer; NULL for no pw - * @param recipient_pk [in,nullable] PQ public key for encryption; NULL for no PQ - * @param archive_out [out,transfers] receives malloc'd archive bytes; - * caller must free with zuptsdk_free() - * @param archive_sz [out] size of returned archive - * @return ZUPTSDK_OK on success. - */ -int zuptsdk_compress_files(zuptsdk_ctx_t *ctx, - const zuptsdk_options_t *opts, - const char *const *file_paths, - size_t file_count, - zuptsdk_secure_buf_t *password, - const zuptsdk_pubkey_t *recipient_pk, - uint8_t **archive_out, - size_t *archive_sz); - -/** - * Compress a single in-memory data buffer. Useful for SDK consumers that - * have data in memory and want a self-contained archive. - * - * @param logical_name [in] name to record inside the archive (e.g. "data.bin") - */ -int zuptsdk_compress_buffer(zuptsdk_ctx_t *ctx, - const zuptsdk_options_t *opts, - const char *logical_name, - const uint8_t *data, size_t data_sz, - zuptsdk_secure_buf_t *password, - const zuptsdk_pubkey_t *recipient_pk, - uint8_t **archive_out, - size_t *archive_sz); - -/** - * Extract an archive into a directory. - * - * @param dest_dir [in] target directory; created if missing - * @param password [in,nullable] - * @param recipient_sk [in,nullable] PQ private key - */ -int zuptsdk_extract_to_dir(zuptsdk_ctx_t *ctx, - const uint8_t *archive, size_t archive_sz, - const char *dest_dir, - zuptsdk_secure_buf_t *password, - const zuptsdk_privkey_t *recipient_sk); - -/** - * Extract a single-file archive (one created with zuptsdk_compress_buffer) - * back into a memory buffer. - * - * @param data_out [out,transfers] caller frees with zuptsdk_free() - * @param data_sz [out] - */ -int zuptsdk_extract_buffer(zuptsdk_ctx_t *ctx, - const uint8_t *archive, size_t archive_sz, - zuptsdk_secure_buf_t *password, - const zuptsdk_privkey_t *recipient_sk, - uint8_t **data_out, size_t *data_sz); - -/* ════════════════════════════════════════════════════════════════════════ - * COMPRESS / DECOMPRESS — streaming mode (for large archives) - * ════════════════════════════════════════════════════════════════════════ */ - -/** - * Compress from a read callback to a write callback. Streaming version - * with no archive size limit — suitable for piping to network sockets, - * encrypted volumes, or any backend with a write_fn. - * - * @param input [in] read callback supplying source bytes - * @param input_ud [in] userdata passed to read callback - * @param input_name [in] logical filename to record in archive - * @param input_total total bytes to read; 0 if unknown - * @param output [in] write callback receiving archive bytes - * @param output_ud [in] userdata passed to write callback - */ -int zuptsdk_compress_stream(zuptsdk_ctx_t *ctx, - const zuptsdk_options_t *opts, - zuptsdk_read_fn input, void *input_ud, - const char *input_name, uint64_t input_total, - zuptsdk_write_fn output, void *output_ud, - zuptsdk_secure_buf_t *password, - const zuptsdk_pubkey_t *recipient_pk); - -/** - * Decompress an archive read from a callback, writing extracted single-file - * content to a write callback. - */ -int zuptsdk_decompress_stream(zuptsdk_ctx_t *ctx, - zuptsdk_read_fn input, void *input_ud, - zuptsdk_write_fn output, void *output_ud, - zuptsdk_secure_buf_t *password, - const zuptsdk_privkey_t *recipient_sk); - -/* ════════════════════════════════════════════════════════════════════════ - * VERIFY / INFO - * ════════════════════════════════════════════════════════════════════════ */ - -/** - * Verify all block checksums and (if encrypted) HMAC of an archive. - * No data is written to disk. Returns ZUPTSDK_OK if every block validates. - */ -int zuptsdk_verify(zuptsdk_ctx_t *ctx, - const uint8_t *archive, size_t archive_sz, - zuptsdk_secure_buf_t *password, - const zuptsdk_privkey_t *recipient_sk); - -/** - * Read archive metadata without password or key. Returns header info only; - * does not decrypt block contents. - * - * @param info_out [out,transfers] receives info object; - * caller must zuptsdk_archive_info_destroy() - */ -int zuptsdk_archive_info_read(zuptsdk_ctx_t *ctx, - const uint8_t *archive, size_t archive_sz, - zuptsdk_archive_info_t **info_out); - -void zuptsdk_archive_info_destroy(zuptsdk_archive_info_t *info); - -/* Getters — opaque struct, all fields accessed via these functions. */ -int zuptsdk_archive_info_format_major(const zuptsdk_archive_info_t *info); -int zuptsdk_archive_info_format_minor(const zuptsdk_archive_info_t *info); -const char *zuptsdk_archive_info_uuid(const zuptsdk_archive_info_t *info); -int64_t zuptsdk_archive_info_created_unix(const zuptsdk_archive_info_t *info); -uint64_t zuptsdk_archive_info_size(const zuptsdk_archive_info_t *info); -uint32_t zuptsdk_archive_info_block_count(const zuptsdk_archive_info_t *info); -int zuptsdk_archive_info_is_encrypted(const zuptsdk_archive_info_t *info); -int zuptsdk_archive_info_is_pq_hybrid(const zuptsdk_archive_info_t *info); -int zuptsdk_archive_info_is_solid(const zuptsdk_archive_info_t *info); -int zuptsdk_archive_info_is_dedup(const zuptsdk_archive_info_t *info); -int zuptsdk_archive_info_is_disk_image(const zuptsdk_archive_info_t *info); - -/* ════════════════════════════════════════════════════════════════════════ - * DISK BACKUP / RESTORE - * ════════════════════════════════════════════════════════════════════════ */ - -/** - * Backup a block device or disk image file to an archive. - * REQUIRES root/admin privileges to read raw block devices on most OSes. - */ -int zuptsdk_disk_backup(zuptsdk_ctx_t *ctx, - const zuptsdk_options_t *opts, - const char *source_device_or_image, - const char *output_archive_path, - zuptsdk_secure_buf_t *password, - const zuptsdk_pubkey_t *recipient_pk); - -/** - * Restore a disk backup archive to a block device or image file. - * DESTRUCTIVE: target is overwritten without confirmation. - */ -int zuptsdk_disk_restore(zuptsdk_ctx_t *ctx, - const char *archive_path, - const char *target_device_or_image, - zuptsdk_secure_buf_t *password, - const zuptsdk_privkey_t *recipient_sk); - -/* ════════════════════════════════════════════════════════════════════════ - * MISC - * ════════════════════════════════════════════════════════════════════════ */ - -/** - * Free memory returned by the library via [transfers] output pointers. - * Safe to call with NULL. - * - * Always use this — never free() — for SDK-allocated memory, since the - * library may have been built with a custom allocator. - */ -void zuptsdk_free(void *ptr); - -/** - * Best-effort secure zero of a buffer. Resistant to dead-store elimination - * by the optimizer. Use for caller-managed sensitive memory. - */ -void zuptsdk_secure_zero(void *buf, size_t bytes); - -#ifdef __cplusplus -} /* extern "C" */ -#endif - -#endif /* ZUPTSDK_H */ diff --git a/vendor/zuptsdk/include/zuptsdk.hpp b/vendor/zuptsdk/include/zuptsdk.hpp deleted file mode 100644 index 45d789d..0000000 --- a/vendor/zuptsdk/include/zuptsdk.hpp +++ /dev/null @@ -1,230 +0,0 @@ -// libzuptsdk C++17 header — RAII wrappers, exception-based error handling -// SPDX-License-Identifier: AGPL-3.0-or-later -#ifndef ZUPTSDK_HPP -#define ZUPTSDK_HPP - -#include "zuptsdk.h" -#include -#include -#include -#include -#include -#include - -namespace zuptsdk { - -class Error : public std::runtime_error { - int code_; -public: - Error(int code, const std::string& msg) : std::runtime_error(msg), code_(code) {} - int code() const noexcept { return code_; } -}; - -inline void check(int rc) { - if (rc != ZUPTSDK_OK) { - const char* detail = zuptsdk_last_error_detail(); - throw Error(rc, detail && *detail ? detail : zuptsdk_strerror(rc)); - } -} - -// RAII wrapper for SDK-allocated buffers (must be freed via zuptsdk_free) -class Buffer { - uint8_t* data_; - std::size_t size_; -public: - Buffer() : data_(nullptr), size_(0) {} - Buffer(uint8_t* data, std::size_t size) : data_(data), size_(size) {} - ~Buffer() { if (data_) zuptsdk_free(data_); } - Buffer(const Buffer&) = delete; - Buffer& operator=(const Buffer&) = delete; - Buffer(Buffer&& o) noexcept : data_(o.data_), size_(o.size_) { o.data_ = nullptr; o.size_ = 0; } - Buffer& operator=(Buffer&& o) noexcept { - if (data_) zuptsdk_free(data_); - data_ = o.data_; size_ = o.size_; o.data_ = nullptr; o.size_ = 0; - return *this; - } - const uint8_t* data() const noexcept { return data_; } - uint8_t* data() noexcept { return data_; } - std::size_t size() const noexcept { return size_; } - std::vector to_vector() const { return {data_, data_ + size_}; } - uint8_t** out_ptr() noexcept { return &data_; } - std::size_t* out_size() noexcept { return &size_; } -}; - -class Context { - zuptsdk_ctx_t* ctx_; -public: - Context() : ctx_(nullptr) { check(zuptsdk_ctx_create(&ctx_)); } - ~Context() { if (ctx_) zuptsdk_ctx_destroy(ctx_); } - Context(const Context&) = delete; - Context& operator=(const Context&) = delete; - zuptsdk_ctx_t* raw() const noexcept { return ctx_; } -}; - -class Pubkey { - zuptsdk_pubkey_t* pk_; -public: - Pubkey() : pk_(nullptr) {} - explicit Pubkey(zuptsdk_pubkey_t* pk) : pk_(pk) {} - ~Pubkey() { if (pk_) zuptsdk_pubkey_destroy(pk_); } - Pubkey(const Pubkey&) = delete; - Pubkey& operator=(const Pubkey&) = delete; - Pubkey(Pubkey&& o) noexcept : pk_(o.pk_) { o.pk_ = nullptr; } - static Pubkey load(const std::string& path) { - zuptsdk_pubkey_t* pk = nullptr; - check(zuptsdk_pubkey_load(path.c_str(), &pk)); - return Pubkey(pk); - } - zuptsdk_pubkey_t* raw() const noexcept { return pk_; } - std::array fingerprint() const { - std::array fp{}; - check(zuptsdk_pubkey_fingerprint(pk_, fp.data())); - return fp; - } -}; - -class Privkey { - zuptsdk_privkey_t* sk_; -public: - Privkey() : sk_(nullptr) {} - explicit Privkey(zuptsdk_privkey_t* sk) : sk_(sk) {} - ~Privkey() { if (sk_) zuptsdk_privkey_destroy(sk_); } - Privkey(const Privkey&) = delete; - Privkey& operator=(const Privkey&) = delete; - Privkey(Privkey&& o) noexcept : sk_(o.sk_) { o.sk_ = nullptr; } - static Privkey load(const std::string& path) { - zuptsdk_privkey_t* sk = nullptr; - check(zuptsdk_privkey_load(path.c_str(), &sk)); - return Privkey(sk); - } - zuptsdk_privkey_t* raw() const noexcept { return sk_; } -}; - -class Keypair { - zuptsdk_keypair_t* kp_; -public: - explicit Keypair(Context& ctx) : kp_(nullptr) { - check(zuptsdk_keypair_generate(ctx.raw(), &kp_)); - } - ~Keypair() { if (kp_) zuptsdk_keypair_destroy(kp_); } - Keypair(const Keypair&) = delete; - Keypair& operator=(const Keypair&) = delete; - void save_public(const std::string& path) const { - check(zuptsdk_keypair_save_public(kp_, path.c_str())); - } - void save_private(const std::string& path) const { - check(zuptsdk_keypair_save_private(kp_, path.c_str())); - } -}; - -// Result type for encryption operations -struct EncryptResult { - Buffer header; - Buffer ciphertext; -}; - -inline EncryptResult encrypt_pq(Context& ctx, const Pubkey& pk, - const uint8_t* pt, std::size_t pt_sz, - const uint8_t* aad = nullptr, std::size_t aad_sz = 0) { - EncryptResult r; - check(zuptsdk_encrypt_pq(ctx.raw(), pk.raw(), pt, pt_sz, aad, aad_sz, - r.header.out_ptr(), r.header.out_size(), - r.ciphertext.out_ptr(), r.ciphertext.out_size())); - return r; -} - -inline EncryptResult encrypt_pq_v2(Context& ctx, const Pubkey& pk, - int aead_id, bool forward_secret, - const uint8_t* pt, std::size_t pt_sz, - const uint8_t* aad = nullptr, std::size_t aad_sz = 0) { - EncryptResult r; - check(zuptsdk_encrypt_pq_v2(ctx.raw(), pk.raw(), aead_id, forward_secret ? 1 : 0, - pt, pt_sz, aad, aad_sz, - r.header.out_ptr(), r.header.out_size(), - r.ciphertext.out_ptr(), r.ciphertext.out_size())); - return r; -} - -inline Buffer decrypt_pq(Context& ctx, const Privkey& sk, - const uint8_t* hdr, std::size_t hdr_sz, - const uint8_t* ct, std::size_t ct_sz, - const uint8_t* aad = nullptr, std::size_t aad_sz = 0) { - Buffer r; - check(zuptsdk_decrypt_pq(ctx.raw(), sk.raw(), hdr, hdr_sz, ct, ct_sz, aad, aad_sz, - r.out_ptr(), r.out_size())); - return r; -} - -inline Buffer decrypt_pq_v2(Context& ctx, const Privkey& sk, - const uint8_t* hdr, std::size_t hdr_sz, - const uint8_t* ct, std::size_t ct_sz, - const uint8_t* aad = nullptr, std::size_t aad_sz = 0) { - Buffer r; - check(zuptsdk_decrypt_pq_v2(ctx.raw(), sk.raw(), hdr, hdr_sz, ct, ct_sz, aad, aad_sz, - r.out_ptr(), r.out_size())); - return r; -} - -// Streaming -class StreamEncrypter { - zuptsdk_stream_state_t* st_; - Buffer header_; -public: - StreamEncrypter(Context& ctx, const Pubkey& pk, - const uint8_t* aad = nullptr, std::size_t aad_sz = 0) : st_(nullptr) { - check(zuptsdk_stream_pq_init_encrypt(ctx.raw(), pk.raw(), aad, aad_sz, - header_.out_ptr(), header_.out_size(), &st_)); - } - ~StreamEncrypter() { if (st_) zuptsdk_stream_state_destroy(st_); } - StreamEncrypter(const StreamEncrypter&) = delete; - StreamEncrypter& operator=(const StreamEncrypter&) = delete; - const Buffer& header() const noexcept { return header_; } - - std::vector encrypt_chunk(const uint8_t* pt, std::size_t pt_sz, bool final_chunk) { - std::vector out(pt_sz + 21); - std::size_t out_sz = 0; - check(zuptsdk_stream_chunk_encrypt(st_, - final_chunk ? ZUPTSDK_CHUNK_FINAL : ZUPTSDK_CHUNK_MESSAGE, - pt, pt_sz, nullptr, 0, out.data(), out.size(), &out_sz)); - out.resize(out_sz); - return out; - } -}; - -class StreamDecrypter { - zuptsdk_stream_state_t* st_; - bool finished_ = false; -public: - StreamDecrypter(Context& ctx, const Privkey& sk, - const uint8_t* hdr, std::size_t hdr_sz, - const uint8_t* aad = nullptr, std::size_t aad_sz = 0) : st_(nullptr) { - check(zuptsdk_stream_pq_init_decrypt(ctx.raw(), sk.raw(), hdr, hdr_sz, - aad, aad_sz, &st_)); - } - ~StreamDecrypter() { if (st_) zuptsdk_stream_state_destroy(st_); } - StreamDecrypter(const StreamDecrypter&) = delete; - StreamDecrypter& operator=(const StreamDecrypter&) = delete; - - struct ChunkResult { - std::vector data; - bool final_chunk; - }; - ChunkResult decrypt_chunk(const uint8_t* in, std::size_t in_sz) { - std::vector out(in_sz); // upper bound - std::size_t out_sz = 0; - zuptsdk_chunk_tag_t tag; - check(zuptsdk_stream_chunk_decrypt(st_, in, in_sz, nullptr, 0, - out.data(), out.size(), &out_sz, &tag)); - out.resize(out_sz); - bool fin = (tag == ZUPTSDK_CHUNK_FINAL); - if (fin) finished_ = true; - return { std::move(out), fin }; - } - bool finished() const noexcept { return finished_; } -}; - -inline std::string version() { return zuptsdk_version_string(); } - -} // namespace zuptsdk - -#endif // ZUPTSDK_HPP diff --git a/vendor/zuptsdk/include/zuptsdk_easy.h b/vendor/zuptsdk/include/zuptsdk_easy.h deleted file mode 100644 index ff6d666..0000000 --- a/vendor/zuptsdk/include/zuptsdk_easy.h +++ /dev/null @@ -1,89 +0,0 @@ -/* - * zuptsdk easy.h — high-level API for drop-in encryption. - * SPDX-License-Identifier: AGPL-3.0-or-later - * - * Goal: 3 lines of code to encrypt/decrypt anything in any language. - * No context management, no parameter tuning, secure defaults. - */ -#ifndef ZUPTSDK_EASY_H -#define ZUPTSDK_EASY_H - -#include "zuptsdk.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* ─── String/buffer encryption (PQ pubkey) ─── */ - -/** Encrypt with recipient pubkey file path. Returns alloc'd combined - * blob (header || ciphertext) ready to store/transmit. */ -int zuptsdk_easy_encrypt(const char *recipient_pubkey_path, - const uint8_t *plaintext, size_t plaintext_sz, - uint8_t **blob_out, size_t *blob_sz); - -/** Decrypt blob with recipient privkey file path. */ -int zuptsdk_easy_decrypt(const char *recipient_privkey_path, - const uint8_t *blob, size_t blob_sz, - uint8_t **plaintext_out, size_t *plaintext_sz); - -/* ─── Password-based encryption ─── */ - -/** Encrypt with password (Argon2id, MODERATE preset by default). */ -int zuptsdk_easy_encrypt_password(const char *password, - const uint8_t *plaintext, size_t plaintext_sz, - uint8_t **blob_out, size_t *blob_sz); - -int zuptsdk_easy_decrypt_password(const char *password, - const uint8_t *blob, size_t blob_sz, - uint8_t **plaintext_out, size_t *plaintext_sz); - -/* ─── Field-level encryption (for DB columns, JSON fields) ─── */ - -/** Encrypt small fields with a 32-byte key. Returns base64-encoded - * string (alloc'd, NUL-terminated, free with zuptsdk_free). - * Suitable for DB columns, JSON fields, env vars. */ -int zuptsdk_easy_encrypt_field(const uint8_t key[32], - const char *plaintext, - char **b64_out); - -int zuptsdk_easy_decrypt_field(const uint8_t key[32], - const char *b64_input, - char **plaintext_out); - -/* ─── File encryption with progress ─── */ - -typedef void (*zuptsdk_easy_progress_t)(uint64_t bytes_done, - uint64_t bytes_total, - void *userdata); - -int zuptsdk_easy_encrypt_file(const char *recipient_pubkey_path, - const char *input_path, - const char *output_path, - zuptsdk_easy_progress_t cb, void *userdata); - -int zuptsdk_easy_decrypt_file(const char *recipient_privkey_path, - const char *input_path, - const char *output_path, - zuptsdk_easy_progress_t cb, void *userdata); - -/* ─── Keypair generation ─── */ - -/** Generate keypair and save to two paths. Convenience wrapper. */ -int zuptsdk_easy_keygen(const char *pubkey_out_path, - const char *privkey_out_path); - -/** Derive a deterministic 32-byte key from a password via Argon2id. - * For field encryption, derive key once at startup, reuse for many fields. */ -int zuptsdk_easy_derive_key(const char *password, - const uint8_t salt[16], - uint8_t key_out[32]); - -/* ─── Random salt generation ─── */ -int zuptsdk_easy_random_salt(uint8_t out[16]); - -#ifdef __cplusplus -} -#endif - -#endif diff --git a/vendor/zuptsdk/include/zuptsdk_metrics.h b/vendor/zuptsdk/include/zuptsdk_metrics.h deleted file mode 100644 index 66695a9..0000000 --- a/vendor/zuptsdk/include/zuptsdk_metrics.h +++ /dev/null @@ -1,57 +0,0 @@ -/* zuptsdk observability — metrics & structured logging hooks - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -#ifndef ZUPTSDK_METRICS_H -#define ZUPTSDK_METRICS_H - -#include "zuptsdk.h" - -#ifdef __cplusplus -extern "C" { -#endif - -typedef struct { - uint64_t encrypt_pq_count; - uint64_t decrypt_pq_count; - uint64_t encrypt_password_count; - uint64_t decrypt_password_count; - uint64_t encrypt_field_count; - uint64_t decrypt_field_count; - uint64_t encrypt_failures; - uint64_t decrypt_failures; - uint64_t mac_failures; - uint64_t commitment_failures; - uint64_t fault_detections; - uint64_t bytes_encrypted; - uint64_t bytes_decrypted; - uint64_t total_latency_ns; -} zuptsdk_metrics_t; - -/** Get a snapshot of accumulated metrics (thread-safe, atomic read). */ -void zuptsdk_metrics_snapshot(zuptsdk_metrics_t *out); - -/** Reset all counters to zero. */ -void zuptsdk_metrics_reset(void); - -/** Render snapshot in Prometheus exposition format to a buffer. - * Returns bytes written, or -1 if buf too small. - * If out is NULL, returns required size. */ -int zuptsdk_metrics_render_prometheus(char *buf, size_t buf_sz); - -/** Structured log callback for ops. Called on each encrypt/decrypt with - * outcome and timing. Set to NULL to disable. - * @param op "encrypt_pq" / "decrypt_pq" / "encrypt_password" / etc. - * @param rc error code (0 = OK) - * @param bytes plaintext bytes processed - * @param duration_ns elapsed time - */ -typedef void (*zuptsdk_op_log_t)(const char *op, int rc, size_t bytes, - uint64_t duration_ns, void *userdata); - -void zuptsdk_set_op_log(zuptsdk_op_log_t cb, void *userdata); - -#ifdef __cplusplus -} -#endif - -#endif