diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 730731b..a04c599 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,7 @@ jobs: 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 \ @@ -121,6 +122,8 @@ jobs: 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" @@ -466,6 +469,12 @@ jobs: 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) diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml index 0c10766..fc21650 100644 --- a/.github/workflows/cross-platform.yml +++ b/.github/workflows/cross-platform.yml @@ -86,20 +86,33 @@ jobs: 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-😀.bin" + 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 - grep -F 'café.txt' "$test_root/list.txt" - grep -F 'ação-安全.txt' "$test_root/list.txt" - grep -F 'emoji-😀.bin' "$test_root/list.txt" "$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 diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml index 9104b14..0dc0ec4 100644 --- a/.github/workflows/promote-release.yml +++ b/.github/workflows/promote-release.yml @@ -11,7 +11,7 @@ on: required: true type: number tag: - description: Existing annotated release tag, for example v5.2.5 + description: Existing annotated release tag, for example v5.2.8 required: true type: string @@ -389,11 +389,17 @@ jobs: [[ $(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 '%{ARCH}' "$srpm") == src ]] + [[ $(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 @@ -406,12 +412,18 @@ jobs: [[ $(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 '%{ARCH}' "$gui_srpm") == src ]] + [[ $(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' diff --git a/AUDIT.md b/AUDIT.md index 8ddb599..76b87d3 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -1,12 +1,12 @@ -# ZUPT 5.2.5 audit guide and finding history +# 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.5 scope +## 5.2.8 scope The baseline scope is the source-only CLI and its bundled source codec: @@ -28,7 +28,7 @@ output. ## Source-only review -The 5.2.5 baseline retains the source-only boundary introduced in 5.2.2, which +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 @@ -42,10 +42,10 @@ 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.5 +scripts/check-source-only.sh --tag v5.2.8 # generated source archive -scripts/check-source-only.sh --archive /path/to/zupt-5.2.5.tar.gz +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, @@ -80,6 +80,7 @@ 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: @@ -92,6 +93,7 @@ Relevant review layers include: | 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 | @@ -109,7 +111,7 @@ without 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.5 result, or evidence that release assets +not independent certification, a 5.2.8 result, or evidence that release assets were published. | Gate | Result | Recorded evidence | @@ -141,12 +143,129 @@ A separate local openSUSE Tumbleweed reproduction resolved the explicit 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.5. +the skipped native jobs into passes or transfer any result to 5.2.8. -The exact 5.2.5 candidate must repeat the required suite. Native Windows and -macOS gates, hosted GitHub CI and release promotion, authenticated OBS -validation, and resolution of the openSUSE automatic `debugsource` rpmlint -`no-binary` finding remain pending until recorded otherwise. +## 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 ... . Release assets referenced by the AUR, -Homebrew, Guix, or generic RPM recipes must exist in the canonical GitHub -release at their recorded URL before those recipes are published. +. 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: @@ -191,8 +270,8 @@ bash tests/test_source_only.sh For a tag or an existing source archive: ~~~sh -bash scripts/check-source-only.sh --tag v5.2.5 -bash scripts/check-source-only.sh --archive /path/to/zupt-5.2.5.tar.gz +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 @@ -296,6 +375,7 @@ 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 @@ -328,7 +408,7 @@ 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.5 results. Post-tag CI integration failures prevented +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 @@ -336,14 +416,26 @@ 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.5 evidence. The exact 5.2.5 candidate -must repeat all required gates; -native Windows and macOS, hosted GitHub CI/release promotion, authenticated OBS, -and resolution of the openSUSE automatic `debugsource` rpmlint `no-binary` -finding remain pending until recorded otherwise. Unexecuted gates are `SKIP`, -never `PASS`. +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 ``` Where the compiler supports them, run the sanitizer target separately: @@ -296,7 +343,7 @@ 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.5. The immutable 5.2.3 candidate was not promoted because its source-policy +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 @@ -306,11 +353,39 @@ 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.5. The exact 5.2.5 candidate must repeat the -required suite. Native Windows and macOS, hosted GitHub CI/release promotion, -authenticated OBS, and the openSUSE automatic `debugsource` rpmlint `no-binary` -finding remain pending until recorded otherwise. An unavailable or unexecuted -environment remains `SKIP`, never `PASS`. +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 diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 97321f0..025f76d 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -159,7 +159,7 @@ grant attached to their unchanged Git blobs, are recorded in ## AppImage type-2 runtime -No AppImage is a promised or promoted 5.2.5 release asset. The upstream +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 @@ -171,7 +171,7 @@ 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.5 upstream release gates. +not covered by the 5.2.8 upstream release gates. ## Reporting attribution issues diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 4e7c222..edce9f9 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -1,4 +1,4 @@ -# ZUPT 5.2.5 threat model +# 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 @@ -17,7 +17,7 @@ plausibly deniable. ## Baseline considered here -The upstream baseline is built from the 5.2.5 source with: +The upstream baseline is built from the 5.2.8 source with: ```sh make WITH_SDK=0 WITH_PQBOX=0 @@ -88,6 +88,8 @@ 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 @@ -99,6 +101,13 @@ 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, @@ -175,22 +184,39 @@ 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. -The Windows handle-relative boundary in 5.2.5 covers normal local Win32 paths. +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 before it opens a destructive destination. -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 supported Linux, macOS, -and FreeBSD interfaces, a raw block-device target is rejected before writing if -its capacity is unknown or smaller than the image. These controls reduce source -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. +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: @@ -250,9 +276,9 @@ 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.5; bare Linux and Windows executables are also excluded. +promoted for 5.2.8; bare Linux and Windows executables are also excluded. -For 5.2.5, that gated artifact scope covers the CLI files plus the exact GUI +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 @@ -265,7 +291,7 @@ 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.5 evidence. The immutable 5.2.3 candidate was not +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 @@ -275,10 +301,27 @@ 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.5 evidence. Hosted GitHub CI and release promotion, native -Windows/macOS, authenticated OBS, and the openSUSE automatic `debugsource` -rpmlint `no-binary` finding remain pending until an exact 5.2.5 candidate -records them. +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 @@ -305,7 +348,7 @@ These are historical facts about earlier releases, retained to support recovery: combinations remain unclaimed. Historical test counts in the changelog describe those releases. They do not -automatically become 5.2.5 results; current outcomes belong in the release +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. @@ -316,4 +359,4 @@ 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. -Document version: 5.2.5, 2026-08-31. +Document version: 5.2.8, 2026-08-31. diff --git a/doc/zupt-gui.1 b/doc/zupt-gui.1 index 29dcbd4..cecb070 100644 --- a/doc/zupt-gui.1 +++ b/doc/zupt-gui.1 @@ -1,6 +1,6 @@ .\" SPDX-License-Identifier: AGPL-3.0-or-later .\" Copyright (c) 2025-2026 Cristian Cezar Moisés -.TH ZUPT-GUI 1 "2026-08-31" "ZUPT 5.2.5" "User Commands" +.TH ZUPT-GUI 1 "2026-08-31" "ZUPT 5.2.8" "User Commands" .SH NAME zupt-gui \- Qt interface for the ZUPT backup utility .SH SYNOPSIS @@ -47,7 +47,7 @@ or only when libvuptsdk or libpqvaptvupt is independently reported enabled. These two optional integrations are detected separately. .PP -The gated 5.2.5 GUI release set is limited to the architecture-independent DEB, +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 diff --git a/doc/zupt.1 b/doc/zupt.1 index df66131..d871e25 100644 --- a/doc/zupt.1 +++ b/doc/zupt.1 @@ -1,6 +1,6 @@ .\" 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.5" "User Commands" +.TH ZUPT 1 "2026-08-31" "ZUPT 5.2.8" "User Commands" . .SH NAME zupt \- source-built backup compression and authenticated-encryption utility @@ -89,7 +89,7 @@ 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.5; neither are AppDir/Flatpak bundles, GUI platform installers, or bare +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 @@ -477,7 +477,7 @@ 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.5. Cross-compilation and Wine results are not native +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. . @@ -577,7 +577,7 @@ and the encrypted-dedup linear AAD sequence published through 5.2.1. The narrow compatibility fixture is an actual v5.2.1 password-encrypted DATA/DATA/REF/DATA disk archive stored as hexadecimal text with source and hash provenance. The candidate lists, tests, extracts, and restores that fixture -byte-exact. The exact 5.2.5 candidate must repeat the gate. It does not imply +byte-exact. The exact 5.2.8 candidate must repeat the gate. It does not imply that a 5.2.1 reader accepts every new 5.2.2 archive or that every historical encrypted mode was retested. diff --git a/gui/README.md b/gui/README.md index 9c7a139..378fda5 100644 --- a/gui/README.md +++ b/gui/README.md @@ -1,6 +1,6 @@ # ZUPT GUI -The ZUPT GUI is a Python/Qt front end for the ZUPT 5.2.5 command-line +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. @@ -83,10 +83,10 @@ operating systems and must be tested on the target system. Release pages provide only these GUI artifacts after their separate package and installed off-screen GUI/CLI integration gates pass: -- `zupt-gui_5.2.5_all.deb`; -- `zupt-gui-5.2.5-1.noarch.rpm`; -- `zupt-gui-5.2.5-1.src.rpm`; -- `zupt-gui-5.2.5-portable.zip`. +- `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, @@ -97,7 +97,7 @@ 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.5 release gates. +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 @@ -110,7 +110,7 @@ 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.5 release asset. The promoted Windows +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 diff --git a/gui/packaging/deb/control b/gui/packaging/deb/control index be5b85b..65765c6 100644 --- a/gui/packaging/deb/control +++ b/gui/packaging/deb/control @@ -1,9 +1,9 @@ Package: zupt-gui -Version: 5.2.5 +Version: 5.2.8 Section: utils Priority: optional Architecture: all -Depends: python3 (>= 3.9), python3-pyqt6 | python3-pyside6.qtwidgets, zupt (= 5.2.5) +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: Qt graphical interface for the ZUPT backup utility diff --git a/gui/packaging/windows/build-windows.bat b/gui/packaging/windows/build-windows.bat index 53e3f9b..36c23d6 100644 --- a/gui/packaging/windows/build-windows.bat +++ b/gui/packaging/windows/build-windows.bat @@ -13,7 +13,7 @@ rem runtime files embedded by this local build. setlocal EnableExtensions for %%I in ("%~dp0\..\..\..") do set "REPO_ROOT=%%~fI" set "VERSION=%~1" -if not defined VERSION set "VERSION=5.2.5" +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%" diff --git a/include/zupt.h b/include/zupt.h index d844746..4136770 100644 --- a/include/zupt.h +++ b/include/zupt.h @@ -120,7 +120,10 @@ static inline int zupt_win_mkdir_utf8(const char *path) { /* 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. */ -#define ZUPT_VERSION_STRING "5.2.5" +/* 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.65.3" diff --git a/install.sh b/install.sh index e56044c..d7e22d5 100644 --- a/install.sh +++ b/install.sh @@ -6,7 +6,7 @@ set -Eeuo pipefail umask 077 -VERSION=${VERSION:-5.2.5} +VERSION=${VERSION:-5.2.8} PREFIX=${PREFIX:-/usr/local} echo "🔧 Installing ZUPT..." diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index 73ef256..5cec646 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -11,7 +11,7 @@ # Test locally with `makepkg -s` after the release archive is published. pkgname=zupt -pkgver=5.2.5 +pkgver=5.2.8 pkgrel=1 pkgdesc='Pure-C11 post-quantum backup compression utility (AES-256-CTR + HMAC-SHA256 + ML-KEM-768 + X25519)' arch=('x86_64') @@ -22,8 +22,8 @@ makedepends=('gcc' 'git' 'make') checkdepends=('python') source=("${pkgname}-${pkgver}.tar.gz::https://github.com/cristiancmoises/zupt/releases/download/v${pkgver}/${pkgname}-${pkgver}.tar.gz") -# Byte-reproducible upstream v5.2.5 source archive. -sha256sums=('7b86d34a418a2bce24396610bd7c23705f58d27873e610728083bd57ebb69ba4') +# Byte-reproducible upstream v5.2.8 source archive. +sha256sums=('378b9506211545b9594cf0d38ac8955d9b1cac34eb6b379ae0ec26b84edb65f7') build() { cd "${pkgname}-${pkgver}" diff --git a/packaging/build-gui-rpm.sh b/packaging/build-gui-rpm.sh index 372ae69..b916472 100755 --- a/packaging/build-gui-rpm.sh +++ b/packaging/build-gui-rpm.sh @@ -124,20 +124,49 @@ mapfile -t source_rpms < <(find "$top/SRPMS" -type f -name "zupt-gui-$version-*. [[ ${#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[@]}" -rpm -qpl "${main_rpms[0]}" >"$work/contents.txt" +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_rpms[0]}" | cpio -idm --quiet) +(cd -- "$extract" && rpm2cpio "$main_rpm" | cpio -idm --quiet) PYTHONDONTWRITEBYTECODE=1 python3 - </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 diff --git a/packaging/debian/changelog b/packaging/debian/changelog index 21c900a..cbed093 100644 --- a/packaging/debian/changelog +++ b/packaging/debian/changelog @@ -1,3 +1,37 @@ +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 diff --git a/packaging/guix/zupt.scm b/packaging/guix/zupt.scm index 042a2e0..ec60fa5 100644 --- a/packaging/guix/zupt.scm +++ b/packaging/guix/zupt.scm @@ -62,7 +62,7 @@ xcb-util-renderutil xcb-util-wm xcb-util-cursor libinput-minimal mtdev libevdev eudev)) -(define %zupt-version "5.2.5") +(define %zupt-version "5.2.8") (define %zupt-source (origin @@ -72,7 +72,7 @@ "/releases/download/v" %zupt-version "/zupt-" %zupt-version ".tar.gz")) (sha256 - (base32 "194vnvmmggc3h1r11rkkg395hpvh4dybs43674jcwawa855d71kv")))) + (base32 "1xv5vd7bh9pcw2d3fszb6jn1r6sxjp48mlzh9icvji8m4439b2rp")))) (define-public zupt (package diff --git a/packaging/homebrew/zupt.rb b/packaging/homebrew/zupt.rb index 609f1c1..5b2164e 100644 --- a/packaging/homebrew/zupt.rb +++ b/packaging/homebrew/zupt.rb @@ -22,9 +22,9 @@ 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.5/zupt-5.2.5.tar.gz" - version "5.2.5" - sha256 "7b86d34a418a2bce24396610bd7c23705f58d27873e610728083bd57ebb69ba4" + 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 diff --git a/packaging/nix/flake.nix b/packaging/nix/flake.nix index 8b283ad..aba67e8 100644 --- a/packaging/nix/flake.nix +++ b/packaging/nix/flake.nix @@ -9,7 +9,7 @@ # nix flake check # lint the flake # # To consume from another flake: -# inputs.zupt.url = "github:cristiancmoises/zupt/v5.2.5"; +# inputs.zupt.url = "github:cristiancmoises/zupt/v5.2.8"; # ...packages.x86_64-linux.default = inputs.zupt.packages.x86_64-linux.zupt; # # `make dist` has its own reproducibility gate. This development flake has no @@ -30,7 +30,7 @@ zupt = pkgs.stdenv.mkDerivation { pname = "zupt"; - version = "5.2.5"; + version = "5.2.8"; # When publishing, replace this with `fetchurl` against the # release tarball. For local development the flake assumes it diff --git a/packaging/opensuse/README.md b/packaging/opensuse/README.md index 8123840..cc2a092 100644 --- a/packaging/opensuse/README.md +++ b/packaging/opensuse/README.md @@ -1,4 +1,4 @@ -# ZUPT 5.2.5 for openSUSE Build Service +# ZUPT 5.2.8 for openSUSE Build Service 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 @@ -10,14 +10,14 @@ 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 upstream changes to -Cabelo. +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. ## Files and source policy | File | Purpose | |---|---| -| `_service` | Fetch the immutable `v5.2.5` tag and create `Source0` at build time. | +| `_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. | @@ -30,11 +30,11 @@ https://github.com/cristiancmoises/zupt.git ``` `obs_scm` stores an `.obscpio` plus `.obsinfo`. The `tar` and `recompress` -services reconstruct `zupt-5.2.5.tar.gz` inside the build environment, which +services reconstruct `zupt-5.2.8.tar.gz` inside the build environment, which matches `Source0` in the spec. This source policy does not prohibit separately built release-page packages. -The upstream 5.2.5 gates may publish the CLI source tarball, DEB, binary RPM, +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 @@ -139,7 +139,7 @@ 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.5.tar.gz` next to the spec and use a disposable RPM build tree: +`zupt-5.2.8.tar.gz` next to the spec and use a disposable RPM build tree: ```sh rpm_top=$(mktemp -d) @@ -167,7 +167,7 @@ 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.5, native Windows or macOS success, hosted +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 @@ -193,10 +193,67 @@ 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 transfers -automatically to v5.2.5. The exact v5.2.5 candidate must repeat every applicable -gate, and the automatic openSUSE `debugsource` rpmlint `no-binary` finding -remains unresolved and unsuppressed. +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 @@ -235,11 +292,11 @@ 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.5` tag. Exact-tag hosted, + 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.5`. Verify the reported ZIP and + `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. @@ -251,7 +308,7 @@ gate. ``` 4. From the extracted handoff root, run - `packaging/opensuse/source-audit.sh --archive /path/to/zupt-5.2.5.tar.gz`. + `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. diff --git a/packaging/opensuse/_service b/packaging/opensuse/_service index 8f1be66..7fc42da 100644 --- a/packaging/opensuse/_service +++ b/packaging/opensuse/_service @@ -4,7 +4,7 @@ https://github.com/cristiancmoises/zupt.git git - refs/tags/v5.2.5 + refs/tags/v5.2.8 @PARENT_TAG@ ^v(.*)$ \1 diff --git a/packaging/opensuse/zupt.changes b/packaging/opensuse/zupt.changes index 33acb3b..476816b 100644 --- a/packaging/opensuse/zupt.changes +++ b/packaging/opensuse/zupt.changes @@ -1,3 +1,37 @@ +------------------------------------------------------------------- +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 diff --git a/packaging/opensuse/zupt.spec b/packaging/opensuse/zupt.spec index 770eeaa..f4a6573 100644 --- a/packaging/opensuse/zupt.spec +++ b/packaging/opensuse/zupt.spec @@ -18,7 +18,7 @@ # Name: zupt -Version: 5.2.5 +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 diff --git a/packaging/portable/README.txt b/packaging/portable/README.txt index 925622f..05b06ae 100644 --- a/packaging/portable/README.txt +++ b/packaging/portable/README.txt @@ -27,7 +27,7 @@ Requirements ------------ 1. Python 3.9 or newer. 2. PySide6 6.5 or newer, or a compatible PyQt6 package. - 3. ZUPT 5.2.5, installed as `zupt` on PATH or placed beside the launcher + 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. @@ -46,7 +46,7 @@ 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.5 or place its command beside + * "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. diff --git a/packaging/rpm/zupt.spec b/packaging/rpm/zupt.spec index 394abb5..cdefc2a 100644 --- a/packaging/rpm/zupt.spec +++ b/packaging/rpm/zupt.spec @@ -20,7 +20,7 @@ # installed smoke test. Name: zupt -Version: 5.2.5 +Version: 5.2.8 Release: 1%{?dist} Summary: Backup compression with authenticated and post-quantum encryption @@ -101,6 +101,26 @@ comments. Plain archives use non-cryptographic checksums. %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. diff --git a/scripts/check-source-only.sh b/scripts/check-source-only.sh index 5d5e2f3..62c933f 100755 --- a/scripts/check-source-only.sh +++ b/scripts/check-source-only.sh @@ -13,6 +13,9 @@ HAVE_EXTERNAL_TARGET=0 declare -a TAGS=() declare -a ARCHIVES=() declare -a TREES=() +TAG_COUNT=0 +ARCHIVE_COUNT=0 +TREE_COUNT=0 FAILURES=0 SCANNED=0 @@ -92,17 +95,20 @@ while (($#)); do --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 ;; @@ -127,7 +133,7 @@ while (($#)); do esac done -if ((HAVE_EXTERNAL_TARGET)) && ((ROOT_REQUESTED == 0)) && ((${#TAGS[@]} == 0)); then +if ((HAVE_EXTERNAL_TARGET)) && ((ROOT_REQUESTED == 0)) && ((TAG_COUNT == 0)); then REPOSITORY_AUDIT=0 fi @@ -159,6 +165,10 @@ safe_path_for_output() { 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" @@ -179,6 +189,7 @@ safe_path_for_output() { 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} @@ -186,8 +197,10 @@ safe_path_for_output() { 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) || @@ -200,10 +213,13 @@ safe_path_for_output() { 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) || @@ -268,7 +284,9 @@ path_stays_below_root() { [[ $candidate != /* && $candidate != //* ]] || return 1 [[ ! $candidate =~ ^[[:alpha:]]: ]] || return 1 read -r -a components <<< "$candidate" - for component in "${components[@]}"; do + # 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 ''|.) ;; ..) @@ -821,38 +839,44 @@ if ((REPOSITORY_AUDIT)); then else fail_path git-archive-HEAD HEAD 'repository has no commit' fi - 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" + 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 git-tag "$target" 'revision does not resolve to a commit' + fail_path standalone-tree "$target" 'tree does not exist' fi done fi -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 - -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 +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" 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/vv_encoder.c b/src/vv_encoder.c index 06d9bda..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 diff --git a/src/zupt_disk.c b/src/zupt_disk.c index b7a2570..d6bc090 100644 --- a/src/zupt_disk.c +++ b/src/zupt_disk.c @@ -1220,23 +1220,35 @@ zupt_error_t zupt_disk_restore(const char *archive_path, const char *target_path #else int tgt_fd = -1; int is_block_dev = 0; - struct stat target_st; - - if (lstat(target_path, &target_st) == 0) { - if (S_ISLNK(target_st.st_mode)) { - fprintf(stderr, "Error: refusing a symbolic-link restore target.\n"); - fclose(f); - return ZUPT_ERR_INVALID; - } - if (S_ISREG(target_st.st_mode)) { - if (target_st.st_dev == archive_identity.device && - target_st.st_ino == archive_identity.inode) { + /* 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 (target_st.st_nlink != 1) { + if (opened_st.st_nlink != 1) { fprintf(stderr, "Error: refusing a multiply-linked restore target.\n"); fclose(f); @@ -1244,59 +1256,46 @@ zupt_error_t zupt_disk_restore(const char *archive_path, const char *target_path } target_atomic = zupt_atomic_output_open(target_path, &target_stream); - } else if (S_ISBLK(target_st.st_mode) || - S_ISCHR(target_st.st_mode)) { - 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 || - opened_st.st_dev != target_st.st_dev || - opened_st.st_ino != target_st.st_ino || - !(S_ISBLK(opened_st.st_mode) || - S_ISCHR(opened_st.st_mode))) { + } 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; - errno = EAGAIN; + } 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 { - 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 - } + 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); @@ -1304,8 +1303,12 @@ zupt_error_t zupt_disk_restore(const char *archive_path, const char *target_path } } 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 inspect target '%s': %s\n", + fprintf(stderr, "Error: Cannot open target '%s': %s\n", target_path, strerror(errno)); fclose(f); return ZUPT_ERR_IO; diff --git a/src/zupt_format.c b/src/zupt_format.c index ef54a6c..f127d98 100644 --- a/src/zupt_format.c +++ b/src/zupt_format.c @@ -4221,7 +4221,7 @@ 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; diff --git a/src/zupt_main.c b/src/zupt_main.c index f2b5b3b..a0d4a00 100644 --- a/src/zupt_main.c +++ b/src/zupt_main.c @@ -25,7 +25,9 @@ #ifdef _WIN32 #include #include + #include #else + #include #include #include #endif @@ -100,9 +102,11 @@ static int zupt_create_private_temp_directory(char *output, size_t capacity) { } return 0; #else - static const char pattern[] = "/tmp/zupt-bench-XXXXXX"; - if (sizeof(pattern) > capacity) return 0; - memcpy(output, pattern, sizeof(pattern)); + 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); @@ -114,7 +118,97 @@ static int zupt_create_private_temp_directory(char *output, size_t capacity) { } #ifdef _WIN32 -static int zupt_remove_tree_wide(const wchar_t *directory) { +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)); @@ -125,6 +219,8 @@ static int zupt_remove_tree_wide(const wchar_t *directory) { 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) { @@ -143,23 +239,217 @@ static int zupt_remove_tree_wide(const wchar_t *directory) { child[directory_length] = L'\\'; memcpy(child + directory_length + 1u, data.cFileName, (name_length + 1u) * sizeof(*child)); - if ((data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) { - if ((data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { - if (!RemoveDirectoryW(child)) failed = 1; - } else if (zupt_remove_tree_wide(child) != 0) { - failed = 1; - } - } else { - SetFileAttributesW(child, FILE_ATTRIBUTE_NORMAL); - if (!DeleteFileW(child)) failed = 1; + 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 (GetLastError() != ERROR_FILE_NOT_FOUND) { + } else if (search_error != ERROR_FILE_NOT_FOUND) { failed = 1; } - if (!RemoveDirectoryW(directory)) 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 @@ -169,41 +459,43 @@ static int zupt_remove_temp_tree(const char *directory) { #ifdef _WIN32 wchar_t *wide = zupt_win_utf8_to_wide_alloc(directory); if (!wide) return -1; - int result = zupt_remove_tree_wide(wide); + 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 - DIR *stream = opendir(directory); - if (!stream) return errno == ENOENT ? 0 : -1; - int failed = 0; - struct dirent *entry; - while ((entry = readdir(stream)) != NULL) { - if (strcmp(entry->d_name, ".") == 0 || - strcmp(entry->d_name, "..") == 0) - continue; - size_t needed = strlen(directory) + strlen(entry->d_name) + 2u; - char *child = (char *)malloc(needed); - if (!child) { - failed = 1; - continue; - } - if (!zupt_join_temp_path(child, needed, directory, entry->d_name)) { - free(child); - failed = 1; - continue; - } - struct stat info; - if (lstat(child, &info) != 0) { - failed = 1; - } else if (S_ISDIR(info.st_mode)) { - if (zupt_remove_temp_tree(child) != 0) failed = 1; - } else if (unlink(child) != 0) { - failed = 1; - } - free(child); - } - if (closedir(stream) != 0) failed = 1; - if (rmdir(directory) != 0) failed = 1; + 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 } @@ -429,7 +721,11 @@ static int prompt_password(const char *prompt, char *buf, size_t cap) { if (!buf || cap < 2) return 0; buf[0] = '\0'; #ifdef _WIN32 - if (!_isatty(_fileno(stdin))) { + 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; } @@ -445,6 +741,11 @@ static int prompt_password(const char *prompt, char *buf, size_t cap) { 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 == 0 || c == 0xe0) { (void)_getch(); diff --git a/tests/archive_path_fixture.c b/tests/archive_path_fixture.c index 05aa54e..e298df0 100644 --- a/tests/archive_path_fixture.c +++ b/tests/archive_path_fixture.c @@ -42,6 +42,31 @@ static size_t put_varint(uint8_t *out, uint64_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) { @@ -66,11 +91,24 @@ static int write_block(FILE *stream, uint8_t type, const uint8_t *payload, int main(int argc, char **argv) { static const uint8_t content[] = "fixture content\n"; - const char *entry = argc == 3 && strncmp(argv[2], "--entry=", 8) == 0 - ? argv[2] + 8 : NULL; - if (!entry || argv[1][0] == '\0' || entry[0] == '\0' || - strlen(entry) >= ZUPT_MAX_PATH) { - fprintf(stderr, "usage: %s ARCHIVE --entry=ENTRY_PATH\n", argv[0]); + 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; } @@ -106,7 +144,6 @@ int main(int argc, char **argv) { uint8_t index[ZUPT_MAX_PATH + 128]; size_t index_size = 0; - size_t path_size = strlen(entry); index_size += put_varint(index + index_size, 1); index_size += put_varint(index + index_size, path_size); memcpy(index + index_size, entry, path_size); 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_benchmark_temp_safety.sh b/tests/test_benchmark_temp_safety.sh index 470f545..954b5fb 100755 --- a/tests/test_benchmark_temp_safety.sh +++ b/tests/test_benchmark_temp_safety.sh @@ -3,6 +3,11 @@ 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 @@ -11,9 +16,36 @@ fail() { 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*) - printf 'SKIP: historical POSIX /tmp symlink benchmark test is not native on Windows\n' + "$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 @@ -46,4 +78,50 @@ if [[ -d $old_directory ]]; then mv "$old_directory" "$tmp/historical-remnant" fi -printf 'private benchmark workspace: PASS\n' +# 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_key_files.sh b/tests/test_key_files.sh index ec4be02..8c232ff 100644 --- a/tests/test_key_files.sh +++ b/tests/test_key_files.sh @@ -41,17 +41,76 @@ file_mode() { 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 - 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 + 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 diff --git a/tests/test_password_sources.sh b/tests/test_password_sources.sh index 014ad1c..2261eb6 100644 --- a/tests/test_password_sources.sh +++ b/tests/test_password_sources.sh @@ -56,9 +56,30 @@ if "$binary" test --pass-fd not-a-number archive.zupt >/dev/null 2>&1; then exit 1 fi -if "$binary" test --password-prompt archive.zupt /dev/null 2>&1; then +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 diff --git a/tests/test_path_traversal.sh b/tests/test_path_traversal.sh index 4eedac8..7982673 100755 --- a/tests/test_path_traversal.sh +++ b/tests/test_path_traversal.sh @@ -104,33 +104,62 @@ else fail 'control-byte archive path is rejected without terminal injection' fi -expect_display_unsafe_path_rejected() { - local label=$1 name=$2 entry=$3 +file_contains_hex_bytes() { + python3 - "$1" "$2" <<'PY' +import pathlib +import sys + +data = pathlib.Path(sys.argv[1]).read_bytes() +needle = bytes.fromhex(sys.argv[2]) +raise SystemExit(0 if needle in data else 1) +PY +} + +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=' "$FIXTURE" "$archive" "--entry=$entry" + 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)) && ! LC_ALL=C grep -Fq -- "$entry" "$log"; then + if ((status != 0)) && ! file_contains_hex_bytes "$log" "$forbidden_hex"; then pass "$label" else fail "$label" fi } -expect_display_unsafe_path_rejected \ +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 + +expect_display_unsafe_hex_path_rejected \ 'raw C1 archive path is rejected without terminal injection' \ - raw-c1 $'safe\23331m.txt' -expect_display_unsafe_path_rejected \ + raw-c1 736166659b33316d2e747874 9b +expect_display_unsafe_hex_path_rejected \ 'UTF-8 C1 archive path is rejected without terminal injection' \ - utf8-c1 $'safe\302\23331m.txt' -expect_display_unsafe_path_rejected \ + utf8-c1 73616665c29b33316d2e747874 c29b +expect_display_unsafe_hex_path_rejected \ 'Unicode bidi-control archive path is rejected without display spoofing' \ - bidi $'safe\342\200\256exe.txt' -expect_display_unsafe_path_rejected \ + bidi 73616665e280ae6578652e747874 e280ae +expect_display_unsafe_hex_path_rejected \ 'invalid UTF-8 archive path is rejected without raw display' \ - invalid-utf8 $'safe\300\257.txt' + invalid-utf8 73616665c0af2e747874 c0af make_fixture "$TEST_ROOT/leaf.zupt" 'innocent.txt' printf '%s\n' DO_NOT_OVERWRITE > "$TEST_ROOT/sentinel" @@ -219,14 +248,33 @@ else fail 'backslash separators are normalized within the extraction root' fi -make_fixture "$TEST_ROOT/legitimate.zupt" 'safe dir/ação.txt' +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 "$ZUPT_BIN" extract -o "$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 && - [[ $(<"$TEST_ROOT/legitimate-out/safe dir/ação.txt") == 'fixture content' ]]; then - pass 'safe nested UTF-8 path extracts normally' + python3 - "$TEST_ROOT/legitimate-out" "$legitimate_entry_hex" <<'PY' +import pathlib +import sys + +# 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 UTF-8 path extracts normally' + fail 'safe nested BMP and non-BMP UTF-8 path lists and extracts normally' fi mkdir -p "$TEST_ROOT/relative-root/work" 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 index 9243386..3ed42fb 100755 --- a/tests/test_source_only.sh +++ b/tests/test_source_only.sh @@ -154,16 +154,19 @@ case "$(uname -s)" in tree=$(fresh_tree raw-c1-path) control_name=$'raw-\200.txt' - printf '\177ELF\002\001\001\000compiled' >"$tree/$control_name" - 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 + 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 - pass 'scanner escapes invalid raw C1 bytes in reported paths' + skip 'raw C1 filenames are forbidden by this filesystem' fi tree=$(fresh_tree utf8-c1-path) diff --git a/tests/test_static_analysis.sh b/tests/test_static_analysis.sh index 49af6ba..4c13747 100755 --- a/tests/test_static_analysis.sh +++ b/tests/test_static_analysis.sh @@ -187,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"