diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 6d20f54..223ae44 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.2.2 +current_version = 1.3.0 commit = False tag = False diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..830c414 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Keep upstream license bytes and CRLF intact for manifest verification. +7z/**/License.txt -text whitespace=cr-at-eol,-blank-at-eof diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e7e7890 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +name: CI + +on: + push: + branches: [main, 'release/**'] + pull_request: + branches: [main, 'release/**'] + workflow_dispatch: + workflow_call: + inputs: + ref: + description: Exact commit to build for a release + required: true + type: string + +permissions: + contents: read + +# Keep PR/push checks independent. Reusable release builds use their exact SHA +# and must not be cancelled: publication consumes artifacts from that same run. +concurrency: + group: ci-${{ github.workflow }}-${{ github.event_name }}-${{ inputs.ref || github.ref }} + cancel-in-progress: ${{ !inputs.ref }} + +jobs: + build: + name: Build (${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - target: windows-x64 + runner: windows-2025 + architecture: x64 + executable: dist/complex-unzip-tool-v2.exe + - target: macos-x64 + runner: macos-15-intel + architecture: x64 + executable: dist/complex-unzip-tool-v2 + - target: macos-arm64 + runner: macos-15 + architecture: arm64 + executable: dist/complex-unzip-tool-v2 + - target: linux-x64 + runner: ubuntu-24.04 + architecture: x64 + executable: dist/complex-unzip-tool-v2 + - target: linux-arm64 + runner: ubuntu-24.04-arm + architecture: arm64 + executable: dist/complex-unzip-tool-v2 + env: + PYTHONUTF8: '1' + POETRY_NO_INTERACTION: '1' + POETRY_VIRTUALENVS_IN_PROJECT: 'true' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.ref || github.sha }} + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.11' + architecture: ${{ matrix.architecture }} + - name: Install locked dependencies + run: | + python -m pip install poetry==2.5.1 + poetry install --with dev + - name: Verify version and bundled engines + run: | + poetry run python -m scripts.release version + poetry run python -m scripts.release verify-engines + - name: Run tests + run: | + node --check scripts/publish_release.cjs + poetry run python -m compileall -q complex_unzip_tool_v2 scripts + poetry run pytest -q + - name: Smoke test source CLI + run: poetry run python -m scripts.smoke_test + - name: Build standalone executable + run: poetry run build + - name: Smoke test standalone executable + env: + EXECUTABLE: ${{ matrix.executable }} + shell: bash + run: poetry run python -m scripts.smoke_test --executable "$EXECUTABLE" + - name: Package executable and notices + env: + TARGET: ${{ matrix.target }} + shell: bash + run: poetry run python -m scripts.release package "$TARGET" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: package-${{ matrix.target }} + path: artifacts/* + if-no-files-found: error + retention-days: 14 + compression-level: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b4bc7b7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,92 @@ +name: Release + +on: + pull_request_target: + branches: [main] + types: [closed] + +permissions: + contents: read + +jobs: + prepare: + if: >- + github.event.pull_request.merged == true && + github.event.pull_request.base.ref == 'main' && + startsWith(github.event.pull_request.head.ref, 'release/') && + contains(github.event.pull_request.labels.*.name, 'release') + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + tag: ${{ steps.version.outputs.tag }} + sha: ${{ github.event.pull_request.merge_commit_sha }} + steps: + # Only merged code is checked out; never execute an unmerged PR head here. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.11' + - name: Read consistent project version + id: version + env: + RELEASE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + shell: bash + run: | + version="$(python -m scripts.release version)" + python -m scripts.release verify-notes + tag="v$version" + if git show-ref --verify --quiet "refs/tags/$tag"; then + existing="$(git rev-parse "$tag^{commit}")" + if [[ "$existing" != "$RELEASE_SHA" ]]; then + echo "::error::Tag $tag already exists at another commit. Bump the project version." + exit 1 + fi + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + build: + needs: prepare + uses: ./.github/workflows/ci.yml + with: + ref: ${{ needs.prepare.outputs.sha }} + + publish: + needs: [prepare, build] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + concurrency: + group: release-${{ needs.prepare.outputs.tag }} + cancel-in-progress: false + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.prepare.outputs.sha }} + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.11' + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: package-* + path: release-assets + merge-multiple: true + - name: Verify complete release and checksums + run: python -m scripts.release verify-assets release-assets + - name: Publish complete draft + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + RELEASE_SHA: ${{ needs.prepare.outputs.sha }} + with: + script: | + const publish = require(process.env.GITHUB_WORKSPACE + '/scripts/publish_release.cjs'); + await publish({github, context, core, + tag: process.env.RELEASE_TAG, + sha: process.env.RELEASE_SHA, + assetDir: 'release-assets'}); diff --git a/.gitignore b/.gitignore index b3780e7..88ec07f 100644 --- a/.gitignore +++ b/.gitignore @@ -163,6 +163,8 @@ cython_debug/ .vscode/ # Project specific +artifacts/ +release-assets/ output.txt passwords.txt demo/ diff --git a/7z/7z.dll b/7z/7z.dll deleted file mode 100644 index cffd321..0000000 Binary files a/7z/7z.dll and /dev/null differ diff --git a/7z/7z.exe b/7z/7z.exe deleted file mode 100644 index 3cf79d1..0000000 Binary files a/7z/7z.exe and /dev/null differ diff --git a/7z/README.md b/7z/README.md new file mode 100644 index 0000000..fcb417e --- /dev/null +++ b/7z/README.md @@ -0,0 +1,33 @@ +# Bundled 7-Zip + +Version **26.03**, released **2026-09-03**. Files are unmodified binaries from the +[official download page](https://www.7-zip.org/download.html) and +[upstream release](https://github.com/ip7z/7zip/releases/tag/26.03). + +| Platform | Engine | Required companion | +| --- | --- | --- | +| Windows x64 | `windows-x64/7z.exe` | `windows-x64/7z.dll` | +| macOS Intel / Apple Silicon | `macos/7zz` (universal) | None | +| Linux x64 | `linux-x64/7zzs` (static) | None | +| Linux ARM64 | `linux-arm64/7zzs` (static) | None | + +All platform assets live in subdirectories; only this README and `manifest.json` +are stored at the root of `7z/`. Each engine directory contains its upstream +`License.txt`. These notices include +the GNU LGPL, BSD licenses, and unRAR restriction; they must accompany redistributed +engines. Corresponding source is available in `7z2603-src.tar.xz` on the linked +upstream release. Keep executable permissions on the Unix engines. + +`manifest.json` records each original download URL, its SHA-256 digest from the +GitHub release API, and SHA-256 digests of the bundled files. To update: + +1. Check the official release for all supported platforms. +2. Download the Windows x64 installer and macOS/Linux console tarballs; verify + their digests against the official release metadata. +3. Extract the Windows installer with 7-Zip (no installation is needed), retaining + `7z.exe`, `7z.dll`, and `License.txt` in `windows-x64/`. Do not substitute `7za`, + which supports fewer formats. +4. Copy macOS `7zz` and Linux `7zzs`, each with its own `License.txt`, preserving + mode bits. Refresh the manifest and version references in the documentation. +5. Run the test suite, native archive smoke checks, and standalone builds on the + supported platforms. The application never downloads an engine at runtime. diff --git a/7z/linux-arm64/7zzs b/7z/linux-arm64/7zzs new file mode 100755 index 0000000..149ba66 Binary files /dev/null and b/7z/linux-arm64/7zzs differ diff --git a/7z/linux-arm64/License.txt b/7z/linux-arm64/License.txt new file mode 100644 index 0000000..aad6b7a --- /dev/null +++ b/7z/linux-arm64/License.txt @@ -0,0 +1,144 @@ + 7-Zip for Linux and macOS + ~~~~~~~~~~~~~~~~~~~~~~~~~ + License for use and distribution + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + 7-Zip Copyright (C) 1999-2026 Igor Pavlov. + + The licenses for 7zz and 7zzs files are: + + - The "GNU LGPL" as main license for most of the code + - The "GNU LGPL" with "unRAR license restriction" for some code + - The "BSD 3-clause License" for some code + - The "BSD 2-clause License" for some code + + Redistributions in binary form must reproduce related license information from this file. + + Note: + You can use 7-Zip on any computer, including a computer in a commercial + organization. You don't need to register or pay for 7-Zip. + + +GNU LGPL information +-------------------- + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You can receive a copy of the GNU Lesser General Public License from + http://www.gnu.org/ + + + + +BSD 3-clause License in 7-Zip code +---------------------------------- + + The "BSD 3-clause License" is used for the following code in 7z.dll + 1) LZFSE data decompression. + That code was derived from the code in the "LZFSE compression library" developed by Apple Inc, + that also uses the "BSD 3-clause License". + 2) ZSTD data decompression. + that code was developed using original zstd decoder code as reference code. + The original zstd decoder code was developed by Facebook Inc, + that also uses the "BSD 3-clause License". + + Copyright (c) 2015-2016, Apple Inc. All rights reserved. + Copyright (c) Facebook, Inc. All rights reserved. + Copyright (c) 2023-2026 Igor Pavlov. + +Text of the "BSD 3-clause License" +---------------------------------- + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + + + + +BSD 2-clause License in 7-Zip code +---------------------------------- + + The "BSD 2-clause License" is used for the XXH64 code in 7-Zip. + + XXH64 code in 7-Zip was derived from the original XXH64 code developed by Yann Collet. + + Copyright (c) 2012-2021 Yann Collet. + Copyright (c) 2023-2026 Igor Pavlov. + +Text of the "BSD 2-clause License" +---------------------------------- + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + + + + +unRAR license restriction +------------------------- + +The decompression engine for RAR archives was developed using source +code of unRAR program. +All copyrights to original unRAR code are owned by Alexander Roshal. + +The license for original unRAR code has the following restriction: + + The unRAR sources cannot be used to re-create the RAR compression algorithm, + which is proprietary. Distribution of modified unRAR sources in separate form + or as a part of other software is permitted, provided that it is clearly + stated in the documentation and source comments that the code may + not be used to develop a RAR (WinRAR) compatible archiver. + +-- diff --git a/7z/linux-x64/7zzs b/7z/linux-x64/7zzs new file mode 100755 index 0000000..af14734 Binary files /dev/null and b/7z/linux-x64/7zzs differ diff --git a/7z/linux-x64/License.txt b/7z/linux-x64/License.txt new file mode 100644 index 0000000..aad6b7a --- /dev/null +++ b/7z/linux-x64/License.txt @@ -0,0 +1,144 @@ + 7-Zip for Linux and macOS + ~~~~~~~~~~~~~~~~~~~~~~~~~ + License for use and distribution + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + 7-Zip Copyright (C) 1999-2026 Igor Pavlov. + + The licenses for 7zz and 7zzs files are: + + - The "GNU LGPL" as main license for most of the code + - The "GNU LGPL" with "unRAR license restriction" for some code + - The "BSD 3-clause License" for some code + - The "BSD 2-clause License" for some code + + Redistributions in binary form must reproduce related license information from this file. + + Note: + You can use 7-Zip on any computer, including a computer in a commercial + organization. You don't need to register or pay for 7-Zip. + + +GNU LGPL information +-------------------- + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You can receive a copy of the GNU Lesser General Public License from + http://www.gnu.org/ + + + + +BSD 3-clause License in 7-Zip code +---------------------------------- + + The "BSD 3-clause License" is used for the following code in 7z.dll + 1) LZFSE data decompression. + That code was derived from the code in the "LZFSE compression library" developed by Apple Inc, + that also uses the "BSD 3-clause License". + 2) ZSTD data decompression. + that code was developed using original zstd decoder code as reference code. + The original zstd decoder code was developed by Facebook Inc, + that also uses the "BSD 3-clause License". + + Copyright (c) 2015-2016, Apple Inc. All rights reserved. + Copyright (c) Facebook, Inc. All rights reserved. + Copyright (c) 2023-2026 Igor Pavlov. + +Text of the "BSD 3-clause License" +---------------------------------- + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + + + + +BSD 2-clause License in 7-Zip code +---------------------------------- + + The "BSD 2-clause License" is used for the XXH64 code in 7-Zip. + + XXH64 code in 7-Zip was derived from the original XXH64 code developed by Yann Collet. + + Copyright (c) 2012-2021 Yann Collet. + Copyright (c) 2023-2026 Igor Pavlov. + +Text of the "BSD 2-clause License" +---------------------------------- + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + + + + +unRAR license restriction +------------------------- + +The decompression engine for RAR archives was developed using source +code of unRAR program. +All copyrights to original unRAR code are owned by Alexander Roshal. + +The license for original unRAR code has the following restriction: + + The unRAR sources cannot be used to re-create the RAR compression algorithm, + which is proprietary. Distribution of modified unRAR sources in separate form + or as a part of other software is permitted, provided that it is clearly + stated in the documentation and source comments that the code may + not be used to develop a RAR (WinRAR) compatible archiver. + +-- diff --git a/7z/macos/7zz b/7z/macos/7zz new file mode 100755 index 0000000..bf9e6cf Binary files /dev/null and b/7z/macos/7zz differ diff --git a/7z/macos/License.txt b/7z/macos/License.txt new file mode 100644 index 0000000..aad6b7a --- /dev/null +++ b/7z/macos/License.txt @@ -0,0 +1,144 @@ + 7-Zip for Linux and macOS + ~~~~~~~~~~~~~~~~~~~~~~~~~ + License for use and distribution + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + 7-Zip Copyright (C) 1999-2026 Igor Pavlov. + + The licenses for 7zz and 7zzs files are: + + - The "GNU LGPL" as main license for most of the code + - The "GNU LGPL" with "unRAR license restriction" for some code + - The "BSD 3-clause License" for some code + - The "BSD 2-clause License" for some code + + Redistributions in binary form must reproduce related license information from this file. + + Note: + You can use 7-Zip on any computer, including a computer in a commercial + organization. You don't need to register or pay for 7-Zip. + + +GNU LGPL information +-------------------- + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You can receive a copy of the GNU Lesser General Public License from + http://www.gnu.org/ + + + + +BSD 3-clause License in 7-Zip code +---------------------------------- + + The "BSD 3-clause License" is used for the following code in 7z.dll + 1) LZFSE data decompression. + That code was derived from the code in the "LZFSE compression library" developed by Apple Inc, + that also uses the "BSD 3-clause License". + 2) ZSTD data decompression. + that code was developed using original zstd decoder code as reference code. + The original zstd decoder code was developed by Facebook Inc, + that also uses the "BSD 3-clause License". + + Copyright (c) 2015-2016, Apple Inc. All rights reserved. + Copyright (c) Facebook, Inc. All rights reserved. + Copyright (c) 2023-2026 Igor Pavlov. + +Text of the "BSD 3-clause License" +---------------------------------- + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + + + + +BSD 2-clause License in 7-Zip code +---------------------------------- + + The "BSD 2-clause License" is used for the XXH64 code in 7-Zip. + + XXH64 code in 7-Zip was derived from the original XXH64 code developed by Yann Collet. + + Copyright (c) 2012-2021 Yann Collet. + Copyright (c) 2023-2026 Igor Pavlov. + +Text of the "BSD 2-clause License" +---------------------------------- + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + + + + +unRAR license restriction +------------------------- + +The decompression engine for RAR archives was developed using source +code of unRAR program. +All copyrights to original unRAR code are owned by Alexander Roshal. + +The license for original unRAR code has the following restriction: + + The unRAR sources cannot be used to re-create the RAR compression algorithm, + which is proprietary. Distribution of modified unRAR sources in separate form + or as a part of other software is permitted, provided that it is clearly + stated in the documentation and source comments that the code may + not be used to develop a RAR (WinRAR) compatible archiver. + +-- diff --git a/7z/manifest.json b/7z/manifest.json new file mode 100644 index 0000000..c19ec49 --- /dev/null +++ b/7z/manifest.json @@ -0,0 +1,40 @@ +{ + "version": "26.03", + "released": "2026-09-03", + "source": "https://github.com/ip7z/7zip/releases/tag/26.03", + "packages": [ + { + "url": "https://github.com/ip7z/7zip/releases/download/26.03/7z2603-linux-arm64.tar.xz", + "sha256": "2389ba20e4d8295e8709c20b6263b69bd1ec4972fe38a04ad7a1badbf595b996", + "files": { + "linux-arm64/7zzs": "277907bc627633ec344757fe47699856cbb6e37f75cbc310d37d62cfacdd73b2", + "linux-arm64/License.txt": "1790374e5352329cedb46ee3808930a88e9ca2f08b82b10fcf5cf605d2c301b1" + } + }, + { + "url": "https://github.com/ip7z/7zip/releases/download/26.03/7z2603-linux-x64.tar.xz", + "sha256": "dc99eff5008f1ab79bd7084c68513701547a808a89502bf4133683535ab3c695", + "files": { + "linux-x64/7zzs": "eab4c8d7f193e3d6d3237370bbcaa879a160a3f1dc82202207e27baeab79b6ac", + "linux-x64/License.txt": "1790374e5352329cedb46ee3808930a88e9ca2f08b82b10fcf5cf605d2c301b1" + } + }, + { + "url": "https://github.com/ip7z/7zip/releases/download/26.03/7z2603-mac.tar.xz", + "sha256": "5ca87677072c59f5602e5c49baa27d4694bacd2259b4e507f0094249d4281480", + "files": { + "macos/7zz": "74b0910e50ea44d9760a57fada2192cfd530ba8bffbe7b47c412a464b796cabf", + "macos/License.txt": "1790374e5352329cedb46ee3808930a88e9ca2f08b82b10fcf5cf605d2c301b1" + } + }, + { + "url": "https://github.com/ip7z/7zip/releases/download/26.03/7z2603-x64.exe", + "sha256": "0859c524b8a63551848f0c246abddcb1d0b7b656b0fbfe879f8d85e61a9e6edd", + "files": { + "windows-x64/7z.exe": "6ee3c0ed0b27663c1b948ae85a7c0bb073aed1498983182f3f0df1f6a8c30b2f", + "windows-x64/7z.dll": "65e4c1f855f9ef6e8f0f5df8e3f27d9eb5f07311408639da0a1ca0b8f4871b0d", + "windows-x64/License.txt": "519ac0a4bded9c18ea02e0afb71f663d8c47373bd9facd3ac96a79f51d77765d" + } + } + ] +} diff --git a/7z/windows-x64/7z.dll b/7z/windows-x64/7z.dll new file mode 100644 index 0000000..ad4718b Binary files /dev/null and b/7z/windows-x64/7z.dll differ diff --git a/7z/windows-x64/7z.exe b/7z/windows-x64/7z.exe new file mode 100644 index 0000000..3ad9b2f Binary files /dev/null and b/7z/windows-x64/7z.exe differ diff --git a/7z/License.txt b/7z/windows-x64/License.txt similarity index 74% rename from 7z/License.txt rename to 7z/windows-x64/License.txt index 5142f3f..70f1df4 100644 --- a/7z/License.txt +++ b/7z/windows-x64/License.txt @@ -1,125 +1,146 @@ - 7-Zip Extra - ~~~~~~~~~~~ - License for use and distribution - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - Copyright (C) 1999-2024 Igor Pavlov. - - 7-Zip Copyright (C) 1999-2024 Igor Pavlov. - - The licenses for files are: - - - 7za.exe: - - The "GNU LGPL" as main license for most of the code - - The "BSD 3-clause License" for some code - - The "BSD 2-clause License" for some code - - All other files: the "GNU LGPL". - - Redistributions in binary form must reproduce related license information from this file. - - Note: - You can use 7-Zip Extra on any computer, including a computer in a commercial - organization. You don't need to register or pay for 7-Zip. - - It is allowed to digitally sign DLL and EXE files included into this package - with arbitrary signatures of third parties. - - -GNU LGPL information --------------------- - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You can receive a copy of the GNU Lesser General Public License from - http://www.gnu.org/ - - - -BSD 3-clause License in 7-Zip code ----------------------------------- - - The "BSD 3-clause License" is used for the following code in 7za.exe - - ZSTD data decompression. - that code was developed using original zstd decoder code as reference code. - The original zstd decoder code was developed by Facebook Inc, - that also uses the "BSD 3-clause License". - - Copyright (c) Facebook, Inc. All rights reserved. - Copyright (c) 2023-2024 Igor Pavlov. - -Text of the "BSD 3-clause License" ----------------------------------- - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its contributors may - be used to endorse or promote products derived from this software without - specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - ---- - - - - -BSD 2-clause License in 7-Zip code ----------------------------------- - - The "BSD 2-clause License" is used for the XXH64 code in 7za.exe. - - XXH64 code in 7-Zip was derived from the original XXH64 code developed by Yann Collet. - - Copyright (c) 2012-2021 Yann Collet. - Copyright (c) 2023-2024 Igor Pavlov. - -Text of the "BSD 2-clause License" ----------------------------------- - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - ---- + 7-Zip + ~~~~~ + License for use and distribution + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + 7-Zip Copyright (C) 1999-2026 Igor Pavlov. + + The licenses for files are: + + - 7z.dll: + - The "GNU LGPL" as main license for most of the code + - The "GNU LGPL" with "unRAR license restriction" for some code + - The "BSD 3-clause License" for some code + - The "BSD 2-clause License" for some code + - All other files: the "GNU LGPL". + + Redistributions in binary form must reproduce related license information from this file. + + Note: + You can use 7-Zip on any computer, including a computer in a commercial + organization. You don't need to register or pay for 7-Zip. + + +GNU LGPL information +-------------------- + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You can receive a copy of the GNU Lesser General Public License from + http://www.gnu.org/ + + + + +BSD 3-clause License in 7-Zip code +---------------------------------- + + The "BSD 3-clause License" is used for the following code in 7z.dll + 1) LZFSE data decompression. + That code was derived from the code in the "LZFSE compression library" developed by Apple Inc, + that also uses the "BSD 3-clause License". + 2) ZSTD data decompression. + that code was developed using original zstd decoder code as reference code. + The original zstd decoder code was developed by Facebook Inc, + that also uses the "BSD 3-clause License". + + Copyright (c) 2015-2016, Apple Inc. All rights reserved. + Copyright (c) Facebook, Inc. All rights reserved. + Copyright (c) 2023-2026 Igor Pavlov. + +Text of the "BSD 3-clause License" +---------------------------------- + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + + + + +BSD 2-clause License in 7-Zip code +---------------------------------- + + The "BSD 2-clause License" is used for the XXH64 code in 7-Zip. + + XXH64 code in 7-Zip was derived from the original XXH64 code developed by Yann Collet. + + Copyright (c) 2012-2021 Yann Collet. + Copyright (c) 2023-2026 Igor Pavlov. + +Text of the "BSD 2-clause License" +---------------------------------- + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + + + + +unRAR license restriction +------------------------- + +The decompression engine for RAR archives was developed using source +code of unRAR program. +All copyrights to original unRAR code are owned by Alexander Roshal. + +The license for original unRAR code has the following restriction: + + The unRAR sources cannot be used to re-create the RAR compression algorithm, + which is proprietary. Distribution of modified unRAR sources in separate form + or as a part of other software is permitted, provided that it is clearly + stated in the documentation and source comments that the code may + not be used to develop a RAR (WinRAR) compatible archiver. + +-- diff --git a/AGENTS.md b/AGENTS.md index 8d27de6..7bb476e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,8 @@ This document defines how AI agents (and humans using them) should operate in th - Package/Deps: Poetry (`pyproject.toml`, `poetry.lock`) - Tests: `pytest` in `tests/` - Bundling: scripts in `scripts/` and `7z/` binaries bundled for archive ops -- Default dev shell: Windows PowerShell +- Supported platforms: Windows x64, macOS Intel/Apple Silicon, Linux x64/ARM64 +- Dev shells: PowerShell on Windows; bash/zsh on macOS/Linux ## Project Summary A command-line tool to unzip/extract various archive formats, including nested and password-protected archives, with cloaked file detection. @@ -24,12 +25,12 @@ A command-line tool to unzip/extract various archive formats, including nested a ## Goals - Keep the app stable and easy to maintain. - Prefer minimal, well-scoped changes with tests. -- Follow a clear workflow so changes are reproducible locally on Windows. +- Follow a clear workflow so changes are reproducible locally on each supported platform. - **When making changes, ensure all existing behavior is preserved unless intentionally modified.** ## Ground Rules - Do not exfiltrate secrets or make network calls unless explicitly required. -- Assume local execution on Windows with PowerShell; keep commands compatible. +- Keep code portable across supported platforms; use PowerShell-compatible commands on Windows and native shell commands on macOS/Linux. - Respect the existing structure under `complex_unzip_tool_v2/` and `scripts/`. - Use `pytest` for tests; add or update tests when changing behavior. - Keep public APIs stable unless the change is intentional and documented. @@ -81,7 +82,7 @@ poetry run main --help poetry run build ``` -Note: The project bundles `7z/7z.exe`; code paths may assume this local binary. +The project bundles 7-Zip 26.03 for each supported platform. Use `modules/seven_zip_runtime.py` for engine selection; see `7z/README.md` for paths, checksums, and licenses. Build on the target OS/architecture with `poetry run build`. ## Repository Facts - Entry points: `complex_unzip_tool_v2/__main__.py`, `complex_unzip_tool_v2/main.py`. @@ -113,6 +114,14 @@ Note: The project bundles `7z/7z.exe`; code paths may assume this local binary. ## Spec-driven development (OpenSpec) This repo uses **OpenSpec**. Active specs live in `openspec/specs/`, proposed changes in `openspec/changes/`, and project conventions in `openspec/project.md`. For non-trivial features or behavior changes, create/advance an OpenSpec change (via the `openspec-*` skills) instead of ad-hoc edits, then archive it once implemented. Small bugfixes can skip this, but still follow TDD. +## CI and releases + +- `.github/workflows/ci.yml` tests and builds Windows x64, macOS x64/ARM64, and Linux x64/ARM64. `scripts/smoke_test.py` verifies real native source/standalone extraction using temporary fixtures. +- `.github/workflows/release.yml` publishes only after a `release/*` PR carrying the `release` label merges into `main`. Build and tag the exact merge commit. +- Versions in `pyproject.toml`, package `__version__`, and `.bumpversion.cfg` must match and be bumped before release. `scripts/release.py` validates versions and packages assets/checksums. +- Release bodies come verbatim from `ReleaseNotes/RELEASE_NOTES_vX.Y.Z.md` in the merge commit. Missing or empty notes block publication; do not fall back to generated GitHub notes or another version. +- Only the publication job has write permissions. Never move an existing version tag or overwrite a published release; `scripts/publish_release.cjs` can resume a matching draft. + ## Quality Gates (Definition of Done) - Build/Run: CLI `--help` works without errors. - Tests: all tests pass locally; new behavior is covered by tests. @@ -125,7 +134,7 @@ This repo uses **OpenSpec**. Active specs live in `openspec/specs/`, proposed ch - Put tests in `tests/`, named `test_*.py`. - Cover happy path and at least one edge case (e.g., missing password, invalid archive, cloaked file detection). - Prefer small, deterministic examples; avoid large fixtures unless needed. -- Tests do **not** require the real `7z.exe`: mock the subprocess/extraction calls with `monkeypatch` and use the `tmp_path` fixture for filesystem effects. Pure helpers (regex / grouping / uncloaking / path normalization) are tested directly. See `tests/test_archive_utils.py` for the mocking pattern. +- Unit tests do **not** execute the real 7-Zip engine: mock the subprocess/extraction calls with `monkeypatch` and use the `tmp_path` fixture for filesystem effects. Pure helpers (regex / grouping / uncloaking / path normalization) are tested directly. See `tests/test_archive_utils.py` for the mocking pattern. ## Coding Conventions - Keep functions small; prefer pure helpers in `modules/` when feasible. @@ -137,7 +146,7 @@ This repo uses **OpenSpec**. Active specs live in `openspec/specs/`, proposed ch ## Passwords handling - Password discovery sources (in this order): 1) Target directory: `passwords.txt` located in the directory you pass to the CLI. - 2) Tool root directory: `passwords.txt` at the repository root (next to `AGENTS.md`). + 2) Tool directory: `passwords.txt` beside `sys.executable` in frozen builds; repository root (next to `AGENTS.md`) in source runs. Never use CWD or `_MEIPASS` for the global book. - File format: one password per line; blank lines are ignored. @@ -149,6 +158,18 @@ This repo uses **OpenSpec**. Active specs live in `openspec/specs/`, proposed ch - Saving behavior: - When new passwords are learned during a run, they are saved to the local `passwords.txt` in UTF-8. - Save only occurs when there are actual changes. + - Save to the same tool-directory path used for loading; target-directory books are read-only sources unless they are the same file. If saving raises `OSError`, warn without interrupting rename-history finalization or CLI completion. Builds must not embed `passwords.txt`. + +## Single-file multipart inputs +- `file_utils.read_dir` includes matching sibling volumes when a file is selected, using the same directory, exact archive name, and split convention. It must not pull in unrelated archives, subdirectories, or another split convention with the same base name. +- Discovery previews the configured uncloaking rules without renaming files. Step 4 still owns all renames and recovery history. If sibling scanning raises `OSError`, retain explicitly selected inputs. +- Reuse the existing group extraction and cleanup paths: clean all discovered parts on success; retain them all on extraction/password failure. +- Regression coverage: `tests/test_multipart_file_input.py`; real source/frozen coverage: `scripts/smoke_test.py`. + +## Redirected Windows output +- Configure redirected stdout/stderr as UTF-8 before the first Rich output. Preserve interactive terminal streams and wrappers without `reconfigure`. +- Keep the PyInstaller `X utf8` option. The frozen CLI pauses only when both stdin and stdout are interactive Windows terminals. +- Smoke tests deliberately use legacy GBK Python stream settings on Windows and validate UTF-8 output bytes. ## Renaming/Uncloaking rules The tool normalizes “cloaked” filenames before grouping/extracting. This is Step 4 in the CLI (“Uncloaking file extensions”). @@ -268,7 +289,7 @@ The tool treats every file as a potential archive by default. During nested extr - Smoke: running against directories containing non‑archive files (e.g., .mp4) should not produce “corrupted archive” messages for those files. ## Common Local Paths -- 7-Zip: `./7z/7z.exe` +- 7-Zip: `./7z/windows-x64/7z.exe` + `7z.dll` (Windows), `./7z/macos/7zz`, `./7z/linux-x64/7zzs`, or `./7z/linux-arm64/7zzs`; each platform directory also contains its `License.txt`. - Config: `complex_unzip_tool_v2/config/cloaked_file_rules.json` - CLI Entrypoint: `complex_unzip_tool_v2/__main__.py` @@ -281,12 +302,12 @@ The tool treats every file as a potential archive by default. During nested extr ## Troubleshooting - Poetry not found: install Poetry or run via system Python if necessary. - Windows path issues: use raw strings or `pathlib` to avoid backslash escapes. -- 7z missing: ensure `./7z/7z.exe` exists; code should refer to the bundled binary. +- 7-Zip missing: restore the bundled engine for the host platform (and the DLL on Windows). Unix engines require executable permissions. The CLI validates the engine before extraction changes input files. ## Scope for Agents - Keep PRs atomic and under ~300 lines of changes when possible. - Avoid introducing new dependencies unless clearly justified and added to `pyproject.toml`. -- Do not modify bundled binaries under `7z/`. +- Do not modify bundled binaries under `7z/` except for an explicitly requested upstream upgrade. Preserve official bytes and licenses, and update `7z/manifest.json`. --- diff --git a/CLAUDE.md b/CLAUDE.md index 24a1729..58ea829 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ This repository follows **Test-Driven Development**. For every feature or bugfix 3. Write the minimum code to make it pass. 4. Refactor with the test green. -Tests do **not** require the real `7z.exe` — the extraction engine's subprocess calls are mocked with `monkeypatch` and filesystem effects use the `tmp_path` fixture. Pure helpers (regex/grouping/uncloaking/path-normalization) are tested directly. Prefer this style: small, deterministic, no real archives. +Unit tests do **not** execute the real 7-Zip engine — the extraction engine's subprocess calls are mocked with `monkeypatch` and filesystem effects use the `tmp_path` fixture. Pure helpers (regex/grouping/uncloaking/path-normalization) are tested directly. Prefer this style: small, deterministic, no real archives. ## Commands @@ -29,13 +29,13 @@ poetry run mypy complex_unzip_tool_v2/ # type check (strict: disallow_u poetry run main "C:\path\to\archives" # run the tool (alias: poetry run cuz) poetry run main --help # CLI smoke test -poetry run build # build standalone exe -> dist/ +poetry run build # build native standalone -> dist/ poetry run bump-patch | bump-minor | bump-major # version bump (bump2version) ``` **Quality gates before committing:** `pytest -q` green, `flake8` clean, `mypy` clean, `black` applied, `--help` works. -Platform is **Windows-only** (bundles `./7z/7z.exe`); the default shell is PowerShell. +Supported platforms: **Windows x64, macOS Intel/Apple Silicon, Linux x64/ARM64**. Native 7-Zip 26.03 engines are bundled; `modules/seven_zip_runtime.py` selects the correct one for source and frozen execution. See `7z/README.md` for paths, checksums, and licenses. Build on the target OS/architecture; Windows uses PowerShell and macOS/Linux use a native shell. ## Architecture diff --git a/README.en.md b/README.en.md index d714ee0..f38c024 100644 --- a/README.en.md +++ b/README.en.md @@ -3,12 +3,14 @@ ![GitHub Release](https://img.shields.io/github/v/release/rozx/Complex-unzip-tool-v2) [![Python](https://img.shields.io/badge/python-3.11+-green.svg)](https://python.org) [![License](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE) -[![Platform](https://img.shields.io/badge/platform-Windows-lightgrey.svg)](https://github.com/rozx/Complex-unzip-tool-v2) +[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/rozx/Complex-unzip-tool-v2) 🌐 [中文](README.md) | **English** **One-click extraction for disguised ("cloaked") archives downloaded from cloud drives — built for 百度网盘 / Baidu Netdisk.** +**v1.3.0** adds macOS / Linux support, bundles 7-Zip 26.03, and fixes password-book locations, single-file multipart cleanup, and redirected Windows output. See the [1.3.0 release notes](ReleaseNotes/RELEASE_NOTES_v1.3.0.md). + --- ## 🤔 What problem does it solve? @@ -23,11 +25,28 @@ This tool automatically **restores the real filenames (uncloaks)**, **regroups** ## 🚀 Quick Start -1. **Download** the latest `complex-unzip-tool-v2.exe` from the **[Releases](https://github.com/rozx/Complex-unzip-tool-v2/releases)** page — no installation needed. -2. **Drag & drop** your archive files or folders onto the `.exe`. +1. **Download** the package for your OS and architecture from **[Releases](https://github.com/rozx/Complex-unzip-tool-v2/releases)**. On Windows, extract the `.zip` to get `complex-unzip-tool-v2.exe`. +2. **Drag & drop** your archive files or folders onto the `.exe` on Windows. 3. **Done** — it uncloaks, groups, and extracts everything automatically. -> 🖱️ Drag & drop is the easiest way to use it. +On macOS / Linux, extract the matching `.tar.gz` and run the program in a terminal. This example uses an Apple Silicon Mac; substitute the package name for your platform: + +```bash +tar -xzf complex-unzip-tool-v2-v1.3.0-macos-arm64.tar.gz +./complex-unzip-tool-v2 "$HOME/Downloads/Archives" +``` + +| Platform | v1.3.0 package | Bundled 7-Zip 26.03 | +| --- | --- | --- | +| Windows x64 | `complex-unzip-tool-v2-v1.3.0-windows-x64.zip` | `7z.exe` + `7z.dll` | +| macOS Intel | `complex-unzip-tool-v2-v1.3.0-macos-x64.tar.gz` | Universal `7zz` | +| macOS Apple Silicon | `complex-unzip-tool-v2-v1.3.0-macos-arm64.tar.gz` | Universal `7zz` | +| Linux x64 | `complex-unzip-tool-v2-v1.3.0-linux-x64.tar.gz` | Static `7zzs` | +| Linux ARM64 | `complex-unzip-tool-v2-v1.3.0-linux-arm64.tar.gz` | Static `7zzs` | + +Packages include Python and 7-Zip, so no separate installation or runtime engine download is required. Each package includes documentation and licenses; the release also includes `SHA256SUMS`. See [7z/README.md](7z/README.md) for upstream sources, checksums, and licenses. + +The macOS application has separate Intel and Apple Silicon builds. Linux executables are built on Ubuntu 24.04 and require compatible system libraries. macOS signing/notarization and Windows publisher signing are not configured. See Development below to run from source or build locally. --- @@ -39,11 +58,13 @@ This tool automatically **restores the real filenames (uncloaks)**, **regroups** # Extract every archive inside a folder complex-unzip-tool-v2.exe "D:\Downloads\Archives" -# Extract specific files (multipart parts can be listed together) -complex-unzip-tool-v2.exe "D:\file.zip" "D:\movie.7z.001" "D:\movie.7z.002" +# Extract specific files (the primary volume discovers matching sibling parts) +complex-unzip-tool-v2.exe "D:\file.zip" "D:\movie.7z.001" ``` -Extracted contents are written to an `unzipped/` folder. On success, original archives are moved to the **Recycle Bin** (recoverable). +Extracted contents are written to an `unzipped/` folder. On success, original archives are moved to the system **Recycle Bin / Trash** (recoverable). Linux needs an available desktop trash directory; originals are retained if recycling fails. + +Passing a single volume collects matching parts from the same directory and naming convention by their uncloaked names, including cloaked continuation parts. All parts are cleaned up on success and retained on extraction or password failure. On Windows, output redirected to a file or pipe uses UTF-8 and the program exits without waiting for Enter. ### Passwords @@ -52,7 +73,7 @@ Many netdisk archives are password-protected. Put your passwords in a `passwords **Two locations are supported and merged automatically:** 1. **Target directory** — place `passwords.txt` in the folder you pass to the tool (or next to the file you pass). Best for passwords specific to that batch of files. -2. **Tool directory** — place `passwords.txt` next to the `.exe` (its working directory) as a global password book used for every run. +2. **Tool directory** — place `passwords.txt` next to the executable (`.exe` on Windows; `complex-unzip-tool-v2` on macOS / Linux). Source runs use the project root. This location is independent of the working directory and drag-and-drop launch behavior. **File format** (one password per line; blank lines ignored; duplicates removed automatically): @@ -63,8 +84,11 @@ mypassword ``` - 📝 **Auto-learn**: passwords cracked during a run are written back to the tool-directory `passwords.txt` for reuse next time. +- The target-directory password book is read only, unless it is also the tool-directory book. If the tool directory is not writable, the program warns and completes extraction cleanup; move it to a writable directory to save new passwords. Builds do not embed personal password books. - 🈶 **Encoding-aware**: auto-detects UTF-8 / GBK / GB2312 / Big5 / UTF-16 (with BOM), so Chinese passwords work without mojibake. +When upgrading, check whether an older version saved passwords in a launch directory such as Desktop or Downloads. Merge any entries you want to reuse into `passwords.txt` beside the new executable. + ### Options | Option | Description | @@ -83,14 +107,14 @@ mypassword - 📦 **Multipart support** — `.001/.002`, `.part1/.part2`, `.rar/.r00`, `.zip/.z01`, and more; finds and regroups scattered parts. - 🔐 **Smart passwords** — tries a password book automatically and caches successful ones. - 🏗️ **Nested extraction** — recursively extracts archives inside archives. -- 🖱️ **Drag & drop + standalone** — single self-contained `.exe` (Python + 7-Zip bundled), no install. +- 🖱️ **Standalone** — build a single executable with Python and 7-Zip for each platform; Windows also supports dropping files onto the `.exe`. - 🌐 **Bilingual UI** — clear progress output in English and 中文. --- ## 🛠️ Development -Requires Python 3.11+ and [Poetry](https://python-poetry.org/). Windows only (bundles `7z/7z.exe`). +Requires Python 3.11+ and [Poetry](https://python-poetry.org/). Supports the platforms and architectures listed above. ```powershell git clone https://github.com/rozx/Complex-unzip-tool-v2.git @@ -99,11 +123,51 @@ poetry install poetry run main "D:\path\to\archives" # run the tool (alias: poetry run cuz) poetry run pytest -q # run tests -poetry run build # build the standalone .exe -> dist/ +poetry run build # build for the current platform -> dist/ +``` + +On macOS / Linux: + +```bash +poetry run main "$HOME/Downloads/Archives" +poetry run pytest -q +poetry run build +./dist/complex-unzip-tool-v2 --help ``` +Build on the target OS and architecture. Windows produces an `.exe`; macOS / Linux produce an executable without an extension. Only the native engine is packaged. The macOS application targets the current Python architecture while its bundled 7-Zip is universal. Distribution signing and notarization are not configured. + +If copying a source checkout loses Unix execute permissions, run `chmod +x` on the matching engine: `7z/macos/7zz`, `7z/linux-x64/7zzs`, or `7z/linux-arm64/7zzs`. Missing or non-executable engines are reported before the CLI modifies input files. + See [AGENTS.md](AGENTS.md) and [CLAUDE.md](CLAUDE.md) for architecture, conventions, and the extraction pipeline. +### GitHub CI and automatic releases + +[CI](.github/workflows/ci.yml) runs on pushes and PRs targeting `main` or `release/**`. Each of the five platforms runs the complete test suite, engine checksum validation, real archive smoke tests, a standalone build, and standalone smoke tests. Downloadable packages remain in Actions artifacts for 14 days. + +New commits cancel older CI runs for the same branch or PR event. Release builds use a separate concurrency group and are not cancelled by ordinary CI. Release merges still run both the `main` push checks and the release builds so publication uses only artifacts from its own workflow run. + +[Release](.github/workflows/release.yml) publishes only when all of these conditions hold: + +1. The PR source branch matches `release/*`, for example `release/v1.3`. +2. The PR has the `release` label at merge time. +3. The PR is merged into `main`. + +Before merging, check that `pyproject.toml`, package `__version__`, and `.bumpversion.cfg` agree on an unpublished stable `X.Y.Z`. Use `poetry run bump-minor`, `poetry run bump-patch`, or the other bump commands when preparing subsequent versions. The branch name controls eligibility; the project version supplies the tag, such as `v1.3.0`. Include the matching notes in the same PR; the 1.3.0 notes are in [ReleaseNotes/RELEASE_NOTES_v1.3.0.md](ReleaseNotes/RELEASE_NOTES_v1.3.0.md). Missing or empty notes fail before platform builds start. + +Check the version and notes locally before committing: + +```powershell +poetry run python -m scripts.release version +poetry run python -m scripts.release verify-notes +``` + +The release workflow rebuilds all platforms from the PR's exact merge commit. After every build succeeds, it creates a GitHub Release using the exact contents of `ReleaseNotes/RELEASE_NOTES_vX.Y.Z.md` as its body, and uploads the five platform packages listed above plus `SHA256SUMS`. + +The workflow must first exist on `main`; subsequent eligible merges trigger publication. Add the label before merging. Closing an unmerged PR, adding the label after merge, or pushing a tag alone does not publish. The built-in `GITHUB_TOKEN` is sufficient; only the final publishing job has `contents: write`. + +Use Actions to rerun failed jobs. Interrupted uploads leave a draft that can be resumed for the same version and merge commit; its body is updated from that commit's matching release notes file. Publication waits until all remote assets pass checksum verification. Published releases are never overwritten, and a tag or draft belonging to another commit causes a clear failure. + --- ## 🤝 Contributing diff --git a/README.md b/README.md index 480910f..d7e95fc 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,14 @@ ![GitHub Release](https://img.shields.io/github/v/release/rozx/Complex-unzip-tool-v2) [![Python](https://img.shields.io/badge/python-3.11+-green.svg)](https://python.org) [![License](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE) -[![Platform](https://img.shields.io/badge/platform-Windows-lightgrey.svg)](https://github.com/rozx/Complex-unzip-tool-v2) +[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/rozx/Complex-unzip-tool-v2) 🌐 **中文** | [English](README.en.md) **一键解压从网盘下载的"伪装"压缩包 —— 专为百度网盘等网盘场景打造。** +**v1.3.0**:新增 macOS / Linux 支持,内置 7-Zip 26.03,修复密码本位置、单文件分卷清理和 Windows 重定向输出。详见 [1.3.0 发布说明](ReleaseNotes/RELEASE_NOTES_v1.3.0.md)。 + --- ## 🤔 解决什么问题? @@ -23,11 +25,28 @@ ## 🚀 快速开始 -1. **下载**:从 **[Releases](https://github.com/rozx/Complex-unzip-tool-v2/releases)** 页面下载最新的 `complex-unzip-tool-v2.exe`,无需安装。 -2. **拖拽**:将压缩包文件或文件夹拖拽到 `.exe` 上。 +1. **下载**:从 **[Releases](https://github.com/rozx/Complex-unzip-tool-v2/releases)** 页面选择对应系统和架构的发布包。Windows 解开 `.zip` 后得到 `complex-unzip-tool-v2.exe`。 +2. **拖拽**:在 Windows 上,将压缩包文件或文件夹拖拽到 `.exe` 上。 3. **完成**:工具会自动解伪装、分组并解压所有内容。 -> 🖱️ 拖拽是最简单的使用方式。 +macOS / Linux 下载对应的 `.tar.gz`,解开后在终端运行。以下以 Apple Silicon Mac 为例,其他平台请替换包名: + +```bash +tar -xzf complex-unzip-tool-v2-v1.3.0-macos-arm64.tar.gz +./complex-unzip-tool-v2 "$HOME/Downloads/Archives" +``` + +| 平台 | v1.3.0 发布包 | 内置 7-Zip 26.03 | +| --- | --- | --- | +| Windows x64 | `complex-unzip-tool-v2-v1.3.0-windows-x64.zip` | `7z.exe` + `7z.dll` | +| macOS Intel | `complex-unzip-tool-v2-v1.3.0-macos-x64.tar.gz` | 通用二进制 `7zz` | +| macOS Apple Silicon | `complex-unzip-tool-v2-v1.3.0-macos-arm64.tar.gz` | 通用二进制 `7zz` | +| Linux x64 | `complex-unzip-tool-v2-v1.3.0-linux-x64.tar.gz` | 静态二进制 `7zzs` | +| Linux ARM64 | `complex-unzip-tool-v2-v1.3.0-linux-arm64.tar.gz` | 静态二进制 `7zzs` | + +发布包已包含 Python 和 7-Zip,无需另行安装,运行时不联网下载引擎。每个包附带说明和许可证,Release 另附 `SHA256SUMS`。二进制来源、校验值和许可证见 [7z/README.md](7z/README.md)。 + +macOS 的应用分别按 Intel 和 Apple Silicon 架构构建。Linux 独立包在 Ubuntu 24.04 上构建,需要兼容的系统库;macOS 签名及公证、Windows 发行者签名尚未配置。也可按下方「开发」步骤从源码运行或自行构建。 --- @@ -39,11 +58,13 @@ # 解压文件夹内的所有压缩包 complex-unzip-tool-v2.exe "D:\Downloads\Archives" -# 解压指定文件(分卷可一起列出) -complex-unzip-tool-v2.exe "D:\file.zip" "D:\movie.7z.001" "D:\movie.7z.002" +# 解压指定文件(只传主卷即可自动收集同目录的同组分卷) +complex-unzip-tool-v2.exe "D:\file.zip" "D:\movie.7z.001" ``` -解压结果输出到 `unzipped/` 文件夹。成功后原始压缩包会被移到**回收站**(可恢复)。 +解压结果输出到 `unzipped/` 文件夹。成功后原始压缩包会被移到系统的**回收站 / 废纸篓**(可恢复)。Linux 需要可用的桌面废纸篓目录;回收失败时保留原文件。 + +只传入一个分卷文件时,会按还原后的文件名收集同目录、同一命名格式的同组分卷,包括带伪装字符的续卷;成功后统一清理,解压或密码失败时全部保留。Windows 下重定向到文件或管道的输出使用 UTF-8,完成后无需按回车退出。 ### 密码 @@ -52,7 +73,7 @@ complex-unzip-tool-v2.exe "D:\file.zip" "D:\movie.7z.001" "D:\movie.7z.002" **支持两个位置,二者会自动合并:** 1. **目标目录(待解压文件夹)** —— 把 `passwords.txt` 放在你传给工具的文件夹里,或与待解压文件同级目录。适合「这批文件专用」的密码。 -2. **工具目录** —— 把 `passwords.txt` 放在 `.exe` 同目录(即运行目录),作为常用密码本,对所有任务生效。 +2. **工具目录** —— 把 `passwords.txt` 放在可执行文件旁(Windows 为 `.exe`;macOS / Linux 为 `complex-unzip-tool-v2`)。源码运行时使用项目根目录。该位置不受当前工作目录或拖放启动方式影响。 **文件格式**(每行一个密码,空行忽略,自动去重): @@ -63,8 +84,11 @@ mypassword ``` - 📝 **自动记忆**:运行中新破解出的密码会自动写回工具目录的 `passwords.txt`,下次直接复用。 +- 目标目录的密码本仅作为读取来源(与工具目录相同时除外)。工具目录不可写时会提示保存失败,仍会完成解压收尾;请将程序放到可写目录以保存新密码。构建不会将个人密码本内嵌到程序中。 - 🈶 **编码自适应**:自动识别 UTF-8 / GBK / GB2312 / Big5 / UTF-16(含 BOM),中文密码无需担心乱码。 +从旧版本升级时,如果密码本曾被写到桌面、下载目录等启动目录,请将需要长期使用的条目合并到新程序旁的 `passwords.txt`。 + ### 命令行选项 | 选项 | 说明 | @@ -83,14 +107,14 @@ mypassword - 📦 **多分卷支持** —— 支持 `.001/.002`、`.part1/.part2`、`.rar/.r00`、`.zip/.z01` 等格式,自动查找并重组散落的分卷。 - 🔐 **智能密码** —— 自动尝试密码本并缓存成功密码。 - 🏗️ **嵌套解压** —— 递归解压压缩包中的压缩包。 -- 🖱️ **拖拽即用、独立运行** —— 单文件 `.exe`(内置 Python 与 7-Zip),无需安装。 +- 🖱️ **独立运行** —— 各平台可构建内置 Python 与 7-Zip 的单文件程序;Windows 还支持拖拽到 `.exe`。 - 🌐 **中英双语界面** —— 清晰的中英文进度输出。 --- ## 🛠️ 开发 -需要 Python 3.11+ 与 [Poetry](https://python-poetry.org/),仅支持 Windows(内置 `7z/7z.exe`)。 +需要 Python 3.11+ 与 [Poetry](https://python-poetry.org/)。支持上表所列平台及架构。 ```powershell git clone https://github.com/rozx/Complex-unzip-tool-v2.git @@ -99,11 +123,51 @@ poetry install poetry run main "D:\path\to\archives" # 运行(别名 cuz) poetry run pytest -q # 运行测试 -poetry run build # 构建独立 exe -> dist/ +poetry run build # 构建当前平台的独立程序 -> dist/ +``` + +macOS / Linux 运行示例: + +```bash +poetry run main "$HOME/Downloads/Archives" +poetry run pytest -q +poetry run build +./dist/complex-unzip-tool-v2 --help ``` +构建需在目标操作系统和架构上进行。Windows 输出 `.exe`,macOS / Linux 输出无扩展名的可执行文件;构建只包含当前平台的引擎。macOS 的应用本身按当前 Python 架构构建,内置 7-Zip 为通用二进制。当前构建不配置发行者签名或公证。 + +从源码运行时,若 Unix 引擎的执行权限在复制过程中丢失,可对相应文件执行 `chmod +x 7z/macos/7zz`、`chmod +x 7z/linux-x64/7zzs` 或 `chmod +x 7z/linux-arm64/7zzs`。引擎缺失或不可执行时,工具会在修改输入文件前报错。 + 架构、约定与解压流程详见 [AGENTS.md](AGENTS.md) 与 [CLAUDE.md](CLAUDE.md)。 +### GitHub CI 与自动发布 + +[CI](.github/workflows/ci.yml) 在推送或提交 PR 到 `main`、`release/**` 时运行。五个平台分别执行完整测试、原生引擎校验、真实压缩包冒烟测试、独立程序构建及构建产物冒烟测试。每个平台的发布包在 Actions artifacts 中保留 14 天。 + +同一分支或 PR 的新提交会取消对应事件的旧 CI。发布构建使用独立并发组,不会被普通 CI 取消。发布合并仍分别执行 `main` 推送检查和发布构建,以确保发布仅使用本次发布工作流生成的产物。 + +[Release](.github/workflows/release.yml) 仅在以下条件同时满足时自动发布: + +1. PR 的来源分支为 `release/*`,例如 `release/v1.3`。 +2. PR 在合并时带有 `release` 标签。 +3. PR 被合并到 `main`。 + +合并前,确认 `pyproject.toml`、包内 `__version__` 和 `.bumpversion.cfg` 使用一致且尚未发布的 `X.Y.Z`。准备后续版本时,可通过 `poetry run bump-minor`、`poetry run bump-patch` 等命令升版。分支名只控制触发,发布标签取自项目版本,例如 `v1.3.0`。同一 PR 必须包含对应的发布说明;1.3.0 的说明位于 [ReleaseNotes/RELEASE_NOTES_v1.3.0.md](ReleaseNotes/RELEASE_NOTES_v1.3.0.md)。文件缺失或为空时,会在平台构建前报错。 + +提交前可本地检查版本和发布说明: + +```powershell +poetry run python -m scripts.release version +poetry run python -m scripts.release verify-notes +``` + +发布流程会从该 PR 的确切合并提交重新构建全部平台;全部成功后创建 GitHub Release,以 `ReleaseNotes/RELEASE_NOTES_vX.Y.Z.md` 的原文作为正文,并上传上方列出的五个平台发布包和 `SHA256SUMS`。 + +工作流需先存在于 `main`,之后符合条件的合并才会触发自动发布。标签应在合并前添加;仅关闭 PR、合并后补标签或直接推送 tag 都不会发布。使用内置 `GITHUB_TOKEN`,仅最终发布任务拥有 `contents: write`。 + +失败时可在 Actions 中重新运行失败任务。上传中断会保留草稿;同版本、同合并提交的草稿可以继续上传,并以该提交中对应版本的说明文件更新正文。校验全部远端文件后才公开发布;已公开的 Release 不会覆盖,指向其他提交的已有 tag 或草稿会报错。 + --- ## 🤝 参与贡献 diff --git a/RelaseNotes/RELEASE_NOTES_v1.1.10.md b/ReleaseNotes/RELEASE_NOTES_v1.1.10.md similarity index 100% rename from RelaseNotes/RELEASE_NOTES_v1.1.10.md rename to ReleaseNotes/RELEASE_NOTES_v1.1.10.md diff --git a/RelaseNotes/RELEASE_NOTES_v1.1.11.md b/ReleaseNotes/RELEASE_NOTES_v1.1.11.md similarity index 100% rename from RelaseNotes/RELEASE_NOTES_v1.1.11.md rename to ReleaseNotes/RELEASE_NOTES_v1.1.11.md diff --git a/RelaseNotes/RELEASE_NOTES_v1.1.7.md b/ReleaseNotes/RELEASE_NOTES_v1.1.7.md similarity index 100% rename from RelaseNotes/RELEASE_NOTES_v1.1.7.md rename to ReleaseNotes/RELEASE_NOTES_v1.1.7.md diff --git a/RelaseNotes/RELEASE_NOTES_v1.1.8.md b/ReleaseNotes/RELEASE_NOTES_v1.1.8.md similarity index 100% rename from RelaseNotes/RELEASE_NOTES_v1.1.8.md rename to ReleaseNotes/RELEASE_NOTES_v1.1.8.md diff --git a/RelaseNotes/RELEASE_NOTES_v1.2.0.md b/ReleaseNotes/RELEASE_NOTES_v1.2.0.md similarity index 100% rename from RelaseNotes/RELEASE_NOTES_v1.2.0.md rename to ReleaseNotes/RELEASE_NOTES_v1.2.0.md diff --git a/RelaseNotes/RELEASE_NOTES_v1.2.1.md b/ReleaseNotes/RELEASE_NOTES_v1.2.1.md similarity index 100% rename from RelaseNotes/RELEASE_NOTES_v1.2.1.md rename to ReleaseNotes/RELEASE_NOTES_v1.2.1.md diff --git a/RelaseNotes/RELEASE_NOTES_v1.2.2.md b/ReleaseNotes/RELEASE_NOTES_v1.2.2.md similarity index 100% rename from RelaseNotes/RELEASE_NOTES_v1.2.2.md rename to ReleaseNotes/RELEASE_NOTES_v1.2.2.md diff --git a/ReleaseNotes/RELEASE_NOTES_v1.3.0.md b/ReleaseNotes/RELEASE_NOTES_v1.3.0.md new file mode 100644 index 0000000..cd2932f --- /dev/null +++ b/ReleaseNotes/RELEASE_NOTES_v1.3.0.md @@ -0,0 +1,81 @@ +# Release Notes v1.3.0 / 发布说明 v1.3.0 + +Version 1.3.0 adds native macOS and Linux support, updates the bundled 7-Zip to **26.03** on all platforms, and fixes password-book lookup, multipart cleanup, and redirected Windows output. It also introduces GitHub Actions builds and releases for five platform targets. + +1.3.0 新增 macOS 和 Linux 原生支持,将各平台内置的 7-Zip 更新至 **26.03**,并修复密码本路径、分卷清理和 Windows 输出重定向问题。同时新增 GitHub Actions,为五个平台构建程序并自动发布。 + +## Platform Support / 平台支持 + +Choose the package for your operating system and CPU architecture. Each package contains a standalone executable with Python and the native 7-Zip engine, plus documentation and licenses. No separate Python or 7-Zip installation is needed, and no engine is downloaded at runtime. + +请按操作系统和 CPU 架构选择发布包。每个包包含内置 Python 和原生 7-Zip 引擎的独立程序,以及说明文档和许可证。无需另行安装 Python 或 7-Zip,运行时也不会联网下载引擎。 + +| Platform / 平台 | Package / 发布包 | +| --- | --- | +| Windows x64 | `complex-unzip-tool-v2-v1.3.0-windows-x64.zip` | +| macOS Intel | `complex-unzip-tool-v2-v1.3.0-macos-x64.tar.gz` | +| macOS Apple Silicon | `complex-unzip-tool-v2-v1.3.0-macos-arm64.tar.gz` | +| Linux x64 | `complex-unzip-tool-v2-v1.3.0-linux-x64.tar.gz` | +| Linux ARM64 | `complex-unzip-tool-v2-v1.3.0-linux-arm64.tar.gz` | + +- Windows includes the updated `7z.exe` and `7z.dll`; macOS uses universal `7zz`; Linux uses the matching static `7zzs`. The macOS application itself has separate Intel and Apple Silicon builds. +- Missing or unusable engines are reported before the CLI modifies input files. `--help` and `--version` remain available. +- macOS and Linux programs exit without an extra Enter prompt. Windows only pauses when both input and output are interactive terminals. + +- Windows 内置更新后的 `7z.exe` 和 `7z.dll`;macOS 使用通用二进制 `7zz`;Linux 使用对应架构的静态 `7zzs`。macOS 应用本身仍分别提供 Intel 和 Apple Silicon 构建。 +- 引擎缺失或不可用时,会在修改输入文件前报错;`--help` 和 `--version` 仍可使用。 +- macOS 和 Linux 程序结束后直接退出;Windows 仅在输入、输出均为交互式终端时等待回车。 + +## Bug Fixes / 错误修复 + +### Password books follow the tool directory / 密码本固定使用工具目录 — [#19](https://github.com/rozx/Complex-unzip-tool-v2/issues/19) + +The global `passwords.txt` is now loaded and saved beside the executable, or at the project root when running from source. Launching from another working directory or dragging an archive onto the Windows executable no longer changes that location. The target-directory password book remains an additional read-only source, unless it is the same file as the global book. + +If the global book cannot be saved, the CLI reports the path and write-permission problem and continues its final cleanup. Builds no longer embed a personal `passwords.txt` inside the executable. + +全局 `passwords.txt` 现在固定在可执行文件旁读写;从源码运行时使用项目根目录。从其他工作目录启动,或将压缩包拖到 Windows 程序上,都不会改变这个位置。目标目录的密码本仍作为额外的只读来源,除非它与全局密码本是同一个文件。 + +全局密码本无法保存时,CLI 会提示路径和写入权限问题,并继续完成收尾。构建程序也不再内嵌个人 `passwords.txt`。 + +### Single-file inputs include matching volumes / 单文件输入自动收集同组分卷 — [#18](https://github.com/rozx/Complex-unzip-tool-v2/issues/18) + +Passing only `movie.7z.001` now discovers its matching sibling volumes before extraction, including cloaked names such as `movie.7z.002删除`. A cloaked primary is also recognized. Successful extraction cleans up the entire discovered set instead of leaving `.002` and later parts behind. Discovery is limited to the same directory, archive name, and split convention; unrelated archives and subdirectories are excluded. Extraction or password failure still preserves all source parts. + +只传入 `movie.7z.001` 时,现在会先收集同组分卷再解压,包括 `movie.7z.002删除` 等伪装续卷;传入伪装主卷同样可以识别。成功后统一清理已收集的整组源文件,不再遗留 `.002` 等续卷。收集范围限定为同目录、同名且采用同一分卷格式的文件,不包含无关档案和子目录。解压或密码失败时,仍会保留全部源分卷。 + +### Redirected Windows output uses UTF-8 / Windows 重定向输出使用 UTF-8 — [#17](https://github.com/rozx/Complex-unzip-tool-v2/issues/17) + +Windows stdout and stderr redirected to a file or pipe are configured as UTF-8 before the first banner. Chinese text and emoji no longer trigger the reported GBK `UnicodeEncodeError`. Standalone builds also enable Python's UTF-8 mode, without depending on environment variables. Redirected output no longer waits for Enter at exit. + +Windows 的 stdout 和 stderr 被重定向到文件或管道时,会在首次输出前设置为 UTF-8。中文和 emoji 不再触发此前的 GBK `UnicodeEncodeError`。独立构建同时启用 Python UTF-8 模式,无需依赖环境变量;输出重定向时,结束后也不会等待回车。 + +## Build and Release Automation / 构建与自动发布 + +- GitHub CI runs tests, engine checksum checks, real archive smoke tests, and standalone builds for all five platform targets. +- Automatic publication requires a PR from `release/*`, carrying the `release` label at merge time, to be merged into `main`. The workflow builds the exact merge commit and publishes only after all platform builds and asset checks succeed. +- Release descriptions come verbatim from `ReleaseNotes/RELEASE_NOTES_vX.Y.Z.md`. Missing or empty notes stop the release before platform builds begin. +- Releases include the five packages and `SHA256SUMS`. Interrupted uploads can resume a matching draft; published releases are not overwritten. + +- GitHub CI 为五个平台执行测试、引擎校验、真实压缩包冒烟测试和独立程序构建。 +- 自动发布要求来源分支为 `release/*`,PR 在合并时带有 `release` 标签,并合并到 `main`。工作流使用确切的合并提交构建,所有平台构建及产物校验成功后才发布。 +- 发布说明直接采用 `ReleaseNotes/RELEASE_NOTES_vX.Y.Z.md` 的原文。文件缺失或为空时,会在平台构建开始前中止发布。 +- Release 包含五个平台的发布包和 `SHA256SUMS`。上传中断后可以继续匹配的草稿,已公开的版本不会被覆盖。 + +## Upgrade Notes / 升级须知 + +- **Windows packaging:** extract the `.zip` first, then run or drag files onto `complex-unzip-tool-v2.exe`. Keep your global `passwords.txt` beside the new executable. +- **Password migration:** if an older version wrote passwords into a launch directory such as Desktop or Downloads, move or merge those entries into the tool-directory book. A book beside the input archive is still read for that input, but learned passwords are saved to the tool directory. +- **macOS / Linux:** extract the matching `.tar.gz` and run `./complex-unzip-tool-v2 "archive directory"` in a terminal. Preserve executable permissions. +- **Compatibility:** Linux standalone packages are built on Ubuntu 24.04 and require compatible system libraries; the static 7-Zip engine does not make the entire application static. Windows publisher signing and macOS signing/notarization are not configured. Linux recycling requires a usable desktop trash directory; originals remain if recycling fails. + +- **Windows 发布包:** 先解开 `.zip`,再运行 `complex-unzip-tool-v2.exe` 或将文件拖到程序上。全局 `passwords.txt` 请保留在新程序旁。 +- **密码本迁移:** 如果旧版本曾把密码写到桌面、下载目录等启动目录,请将这些条目移入或合并到工具目录的密码本。输入档案旁的密码本仍可用于该任务,但新学到的密码会写到工具目录。 +- **macOS / Linux:** 解开对应的 `.tar.gz` 后,在终端运行 `./complex-unzip-tool-v2 "待解压目录"`,并保留执行权限。 +- **兼容性:** Linux 独立包在 Ubuntu 24.04 上构建,需要兼容的系统库;内置静态 7-Zip 不代表整个应用静态链接。Windows 发行者签名、macOS 签名及公证尚未配置。Linux 回收文件需要可用的桌面废纸篓目录;回收失败时保留原文件。 + +## Validation / 验证 + +The **300-test** suite passes locally on macOS ARM64. Source and standalone smoke tests passed with nested ZIPs, encrypted 7z archives, Unicode filenames/passwords, and both directory and single-file multipart inputs. Regression coverage includes GBK-encoded redirected streams, password-save failures, and source retention on extraction/password failure. Native builds for all five platforms are enforced by the release CI. + +**300 项测试**已在本地 macOS ARM64 上通过。源码与独立程序均通过了嵌套 ZIP、加密 7z、中文文件名及密码、目录输入和单文件分卷输入的冒烟验证。回归测试覆盖 GBK 重定向输出、密码本保存失败,以及解压或密码失败时的源文件保留。五个平台的原生构建由发布 CI 检查。 diff --git a/complex_unzip_tool_v2/__init__.py b/complex_unzip_tool_v2/__init__.py index f7c3dea..4ac1650 100644 --- a/complex_unzip_tool_v2/__init__.py +++ b/complex_unzip_tool_v2/__init__.py @@ -1,5 +1,5 @@ """Complex Unzip Tool v2 - A powerful archive extraction utility.""" -__version__ = "1.2.2" +__version__ = "1.3.0" __author__ = "Rozx" __email__ = "lisida900710@gmail.com" diff --git a/complex_unzip_tool_v2/classes/PasswordBook.py b/complex_unzip_tool_v2/classes/PasswordBook.py index 07afe91..104f9ea 100644 --- a/complex_unzip_tool_v2/classes/PasswordBook.py +++ b/complex_unzip_tool_v2/classes/PasswordBook.py @@ -1,11 +1,21 @@ +import sys +from pathlib import Path + + class PasswordBook: - def __init__(self): + def __init__(self) -> None: self.local_entries: list[str] = [] self.dest_entries: list[str] = [] self._has_changes: bool = False # Track if there are unsaved changes - # load passwords from local file - self.load_passwords("passwords.txt", True) + # Portable builds use the real executable directory, never _MEIPASS or CWD. + tool_root = ( + Path(sys.executable).absolute().parent + if getattr(sys, "frozen", False) + else Path(__file__).resolve().parents[2] + ) + self.password_file = tool_root / "passwords.txt" + self.load_passwords(str(self.password_file), True) def load_passwords(self, path: str, is_local: bool = False) -> None: """Load passwords from a file 从文件加载密码""" @@ -63,7 +73,7 @@ def save_passwords(self, force: bool = False) -> None: if not self._has_changes and not force: return # No changes to save - with open("passwords.txt", "w", encoding="utf-8") as f: + with open(self.password_file, "w", encoding="utf-8") as f: for entry in self.local_entries: f.write(f"{entry}\n") diff --git a/complex_unzip_tool_v2/main.py b/complex_unzip_tool_v2/main.py index ed80ea7..fe6dc2a 100644 --- a/complex_unzip_tool_v2/main.py +++ b/complex_unzip_tool_v2/main.py @@ -3,6 +3,7 @@ import os import shutil import sys +import platform import typer from typing import List, Optional, Annotated from . import __version__ @@ -146,12 +147,22 @@ def _maybe_recover_pending_renames(input_root: str) -> None: ) -def _ask_for_user_input_and_exit() -> None: +def _ask_for_user_input_and_exit(exit_code: int = 0) -> None: """Ask for random user input before exiting the application.""" # Only ask for input in standalone builds (PyInstaller frozen executables) - if getattr(sys, "frozen", False): - input("Press Enter to exit... 按回车键退出...") - sys.exit(0) + if ( + getattr(sys, "frozen", False) + and platform.system() == "Windows" + and sys.stdin is not None + and sys.stdin.isatty() + and sys.stdout is not None + and sys.stdout.isatty() + ): + try: + input("Press Enter to exit... 按回车键退出...") + except (EOFError, KeyboardInterrupt): + pass + sys.exit(exit_code) @app.callback(invoke_without_command=True) @@ -180,6 +191,11 @@ def main_callback( # If no command is provided, run the default extract command if ctx.invoked_subcommand is None: if paths: + try: + archive_utils.resolve_seven_zip_path() + except archive_utils.SevenZipNotFoundError as exc: + print_error(str(exc)) + _ask_for_user_input_and_exit(1) # Call extract_files directly instead of extract command extract_files(paths, use_recycle_bin=not permanent_delete) else: @@ -1266,7 +1282,14 @@ def extract_files(paths: List[str], use_recycle_bin: bool = True) -> None: # save user provided passwords only if there are changes if passwordBook.has_unsaved_changes(): print_info("💾 Saving passwords 正在保存密码...") - passwordBook.save_passwords() + try: + passwordBook.save_passwords() + except OSError as exc: + print_warning( + f"Could not save passwords 无法保存密码本: " + f"{passwordBook.password_file} ({exc}). " + "Check write permissions 请检查目录写入权限。" + ) else: print_info("📝 No new passwords to save 没有新密码需要保存") diff --git a/complex_unzip_tool_v2/modules/archive_utils.py b/complex_unzip_tool_v2/modules/archive_utils.py index 7a316d4..b94f1e6 100644 --- a/complex_unzip_tool_v2/modules/archive_utils.py +++ b/complex_unzip_tool_v2/modules/archive_utils.py @@ -1,6 +1,5 @@ import subprocess import os -import sys import shutil import tempfile from typing import List, Dict, Optional, Union, Tuple, Callable @@ -28,6 +27,10 @@ from complex_unzip_tool_v2.modules.file_utils import safe_remove from complex_unzip_tool_v2.modules.utils import sanitize_path, sanitize_filename from complex_unzip_tool_v2.modules.const import PATH_ERROR_KEYWORDS +from complex_unzip_tool_v2.modules.seven_zip_runtime import ( + bundled_engine_path, + resolve_seven_zip_path, +) from complex_unzip_tool_v2.classes.ArchiveTypes import ( ArchiveError, @@ -46,11 +49,8 @@ def _resolve_seven_zip_path(seven_zip_path: Optional[str]) -> str: - """Return a valid path to 7z.exe, raising if it doesn't exist.""" - path = seven_zip_path or _get_default_7z_path() - if not os.path.exists(path): - raise SevenZipNotFoundError(f"7z executable not found at: {path}") - return path + """Return a validated native 7-Zip executable path.""" + return resolve_seven_zip_path(seven_zip_path) def _ensure_archive_exists(archive_path: str) -> None: @@ -173,21 +173,8 @@ def is_valid_archive( def _get_default_7z_path() -> str: - """ - Get the default path to 7z.exe executable. - Works for both development and PyInstaller standalone builds. - """ - if getattr(sys, "frozen", False): - # Running in a PyInstaller bundle - bundle_dir = sys._MEIPASS - seven_zip_path = os.path.join(bundle_dir, "7z", "7z.exe") - else: - # Running in development mode - current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - project_root = os.path.dirname(current_dir) - seven_zip_path = os.path.join(project_root, "7z", "7z.exe") - - return seven_zip_path + """Return the native engine path for source and standalone execution.""" + return str(bundled_engine_path()) def _decode_subprocess_output( diff --git a/complex_unzip_tool_v2/modules/file_utils.py b/complex_unzip_tool_v2/modules/file_utils.py index 8934d74..06958af 100644 --- a/complex_unzip_tool_v2/modules/file_utils.py +++ b/complex_unzip_tool_v2/modules/file_utils.py @@ -133,9 +133,51 @@ def get_archive_base_name(file_path: str) -> tuple[str, str]: return name, ext.lstrip(".") +def _multipart_input_key(filename: str) -> tuple[str, str] | None: + """Identify an exact volume set without merging different split conventions.""" + if re.search(multipart_regex, filename, re.IGNORECASE): + numbered = re.match(r"^(.*)\.\d+$", filename) + if numbered: + return "numbered", os.path.normcase(numbered.group(1)) + rar_part = re.match(r"^(.*)\.part\d+\.rar$", filename, re.IGNORECASE) + if rar_part: + return "rar-part", os.path.normcase(rar_part.group(1)) + # Ambiguous primary names (.zip/.rar/...) only match their own continuations. + for family, suffix in ( + ("rar", r"rar|r\d{2}"), + ("zip", r"zip|z\d{2}"), + ("zipx", r"zipx|zx\d{2}"), + ("arj", r"arj|a\d{2}"), + ("ace", r"ace|c\d{2}"), + ): + match = re.match(rf"^(.*)\.(?:{suffix})$", filename, re.IGNORECASE) + if match: + return family, os.path.normcase(match.group(1)) + return None + + def read_dir(file_paths: list[str]) -> list[str]: """Read directory contents 读取目录内容""" result = [] + detector: CloakedFileDetector | None = None + + def multipart_key(path: str) -> tuple[str, str] | None: + nonlocal detector + key = _multipart_input_key(os.path.basename(path)) + if key is not None: + return key + if detector is None: + rules = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "config", + "cloaked_file_rules.json", + ) + detector = CloakedFileDetector(rules) + # Preview only. Step 4 owns actual renames and their recovery history. + normalized = detector.detect_cloaked_file(path) + return ( + _multipart_input_key(os.path.basename(normalized)) if normalized else None + ) # Use ignored files from constants for path in file_paths: @@ -153,6 +195,22 @@ def read_dir(file_paths: list[str]) -> list[str]: basename = os.path.basename(path) if basename not in IGNORED_FILES: result.append(path) + key = multipart_key(path) if os.path.isfile(path) else None + if key is not None and os.path.isfile(path): + directory = os.path.dirname(path) + try: + with os.scandir(directory or ".") as siblings: + for sibling in siblings: + sibling_path = os.path.join(directory, sibling.name) + if ( + sibling.name not in IGNORED_FILES + and sibling.is_file() + and multipart_key(sibling_path) == key + ): + result.append(sibling_path) + except OSError: + # Discovery is best-effort; keep the explicitly selected file. + pass # make sure the result is unique return list(set(result)) diff --git a/complex_unzip_tool_v2/modules/rich_utils.py b/complex_unzip_tool_v2/modules/rich_utils.py index 7497c2a..a90f708 100644 --- a/complex_unzip_tool_v2/modules/rich_utils.py +++ b/complex_unzip_tool_v2/modules/rich_utils.py @@ -11,9 +11,23 @@ ) from rich.table import Table from typing import List, Any, Optional +import platform +import sys import time from complex_unzip_tool_v2 import __version__ + +def _configure_redirected_output() -> None: + """Keep Windows pipes/logs Unicode-safe before Rich emits the first banner.""" + if platform.system() == "Windows": + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if callable(reconfigure) and not stream.isatty(): + reconfigure(encoding="utf-8", errors="backslashreplace") + + +_configure_redirected_output() + # Initialize console with better width handling and proper encoding support console = Console(width=120, force_terminal=True) diff --git a/complex_unzip_tool_v2/modules/seven_zip_runtime.py b/complex_unzip_tool_v2/modules/seven_zip_runtime.py new file mode 100644 index 0000000..0ada057 --- /dev/null +++ b/complex_unzip_tool_v2/modules/seven_zip_runtime.py @@ -0,0 +1,60 @@ +"""Native bundled 7-Zip selection shared by archive operations and packaging.""" + +import os +import platform +import sys +from pathlib import Path +from typing import Optional + +from complex_unzip_tool_v2.classes.ArchiveTypes import SevenZipNotFoundError + + +def bundled_engine_path(root: Optional[Path] = None) -> Path: + """Locate the host engine in a source checkout or PyInstaller bundle.""" + if root is None: + root = ( + Path(getattr(sys, "_MEIPASS")) + if getattr(sys, "frozen", False) + else Path(__file__).resolve().parents[2] + ) + system = platform.system() + machine = platform.machine().lower() + architecture = { + "amd64": "x64", + "x86_64": "x64", + "arm64": "arm64", + "aarch64": "arm64", + }.get(machine) + if system == "Windows" and architecture == "x64": + return root / "7z" / "windows-x64" / "7z.exe" + if system == "Darwin" and architecture in {"x64", "arm64"}: + return root / "7z" / "macos" / "7zz" + if system == "Linux" and architecture in {"x64", "arm64"}: + return root / "7z" / f"linux-{architecture}" / "7zzs" + raise SevenZipNotFoundError( + f"Unsupported 7-Zip platform: {system}/{machine}. " + "Supported: Windows x64, macOS x64/arm64, Linux x64/arm64." + ) + + +def validate_engine(path: Path, *, bundled: bool = False) -> str: + """Validate an engine before invoking it, without changing permissions.""" + if not path.is_file(): + raise SevenZipNotFoundError(f"7-Zip executable not found at: {path}") + if platform.system() != "Windows" and not os.access(path, os.X_OK): + raise SevenZipNotFoundError( + f"7-Zip executable lacks execute permission: {path}. " + "Restore its executable permission (chmod +x)." + ) + if bundled and platform.system() == "Windows": + dll = path.with_suffix(".dll") + if not dll.is_file(): + raise SevenZipNotFoundError(f"Required 7-Zip library not found at: {dll}") + return str(path) + + +def resolve_seven_zip_path(seven_zip_path: Optional[str] = None) -> str: + """Return an explicit engine file or the validated native bundled engine.""" + if seven_zip_path: + return validate_engine(Path(seven_zip_path)) + return validate_engine(bundled_engine_path(), bundled=True) diff --git a/openspec/changes/archive/2026-09-24-add-cross-platform-7zip/.openspec.yaml b/openspec/changes/archive/2026-09-24-add-cross-platform-7zip/.openspec.yaml new file mode 100644 index 0000000..75289e4 --- /dev/null +++ b/openspec/changes/archive/2026-09-24-add-cross-platform-7zip/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-24 diff --git a/openspec/changes/archive/2026-09-24-add-cross-platform-7zip/design.md b/openspec/changes/archive/2026-09-24-add-cross-platform-7zip/design.md new file mode 100644 index 0000000..338a734 --- /dev/null +++ b/openspec/changes/archive/2026-09-24-add-cross-platform-7zip/design.md @@ -0,0 +1,34 @@ +# Design + +## Context + +All listing/extraction calls in `archive_utils.py` already share `_resolve_seven_zip_path`. Its default resolver and `scripts/build.py` separately hard-code `7z/7z.exe`. Existing archive commands use argument lists and portable filesystem APIs. Path sanitization operates only on relative fallback output paths, so it does not need replacing for this upgrade. + +## Goals / Non-Goals + +Goals: share native engine selection between runtime and build, preserve explicit path overrides and extraction APIs, and ship offline executables. + +Non-goals: automatic downloading at runtime, arbitrary PATH fallback, 32-bit support, Windows ARM builds, cross-compilation, changing archive grouping/deletion behavior, or a GUI. + +## Decisions + +- A small runtime module owns OS/architecture selection and bundle-root resolution. Windows x64 retains `7z/7z.exe` plus `7z.dll`; macOS uses the official universal `7z/macos/7zz`; Linux uses the official static `7zzs` in `7z/linux-x64/` or `7z/linux-arm64/`. Recognize x86_64/AMD64 and aarch64/arm64 aliases. Reject unsupported combinations with the existing domain exception. +- Source roots derive from the module location, frozen roots from `sys._MEIPASS`; neither depends on current working directory. Explicit executable file paths still take priority. Require a regular executable file on POSIX and the companion DLL for bundled Windows use. +- CLI validation runs at the entry callback before extraction changes files. Direct extraction APIs keep their existing validation behavior. Help and version do not require the engine. Existing isolated pipeline tests remain independent of native binaries. +- The standalone builder reuses the same mapping, verifies required assets before cleaning old build output, includes native executables/DLLs as PyInstaller binaries, and includes upstream license texts as data. Build through the active Python interpreter. Only Windows uses `.exe` and the `.ico` icon. Build each artifact on its destination OS/architecture. +- Keep the Windows interactive standalone pause only when stdin is a terminal. POSIX standalone invocations and redirected stdin exit normally. +- Preserve upstream files unchanged. Record official URLs, release-asset SHA-256 values, and bundled-file hashes in `7z/` alongside redistribution notices. No installer execution is needed to obtain the Windows EXE/DLL; extract the official installer with the native console engine. + +## Risks / Trade-offs + +- More binary assets increase checkout size; native builds include only their own engine. +- macOS signing/quarantine policies still apply; distribution signing is outside this change. Validate the local PyInstaller executable. +- Linux desktop trash services vary; retain existing safe deletion behavior and failure handling. +- Windows cannot run natively on the current macOS host; use deterministic platform-selection/build tests and record this limit. Exercise both Linux binaries in Docker when available. + +## Plan review + +- Verified all archive subprocess paths use the shared resolver; explicit overrides flow through listing, extraction, and nested extraction. +- Verified bundled-root lookup must remain compatible with `_MEIPASS` and must not follow CWD. +- Closed gaps: build currently silently omits missing assets; require all native files and notices before deleting prior artifacts. CLI currently starts rename recovery before engine use; preflight at the callback prevents mutation when the engine is unavailable. Frozen exit currently always reads stdin; restrict the pause to Windows TTYs. +- Existing source retention and output normalization specs remain unchanged. diff --git a/openspec/changes/archive/2026-09-24-add-cross-platform-7zip/proposal.md b/openspec/changes/archive/2026-09-24-add-cross-platform-7zip/proposal.md new file mode 100644 index 0000000..4bf5eae --- /dev/null +++ b/openspec/changes/archive/2026-09-24-add-cross-platform-7zip/proposal.md @@ -0,0 +1,26 @@ +# Proposal + +## Why + +The archive engine and standalone builder hard-code Windows executables. Official 7-Zip now supplies native macOS and Linux console binaries, and the bundled Windows engine needs updating to the current official release. + +## What Changes + +- Bundle official 7-Zip 26.03 for Windows x64, macOS Intel/Apple Silicon, and Linux x64/ARM64, with licenses and verifiable provenance. +- Select the native bundled engine consistently in source runs and PyInstaller builds, retaining explicit executable-path overrides. +- Build standalone command-line executables on each supported host platform and document their usage. +- Fail clearly before CLI extraction if the bundled engine is missing, not executable, or the platform is unsupported. + +## Capabilities + +### New Capabilities + +- `cross-platform-runtime`: Offline native engine selection, distribution, and standalone builds across supported operating systems. + +### Modified Capabilities + +None. Existing extraction, password, rename recovery, and source-retention contracts remain unchanged. + +## Impact + +`archive_utils.py`, a shared runtime helper, CLI startup validation, `scripts/build.py`, bundled `7z/` files, regression tests, and bilingual/project documentation. No new application dependencies or runtime downloads. Windows x64 keeps `7z/7z.exe` and `7z/7z.dll`. diff --git a/openspec/changes/archive/2026-09-24-add-cross-platform-7zip/specs/cross-platform-runtime/spec.md b/openspec/changes/archive/2026-09-24-add-cross-platform-7zip/specs/cross-platform-runtime/spec.md new file mode 100644 index 0000000..0a1db2d --- /dev/null +++ b/openspec/changes/archive/2026-09-24-add-cross-platform-7zip/specs/cross-platform-runtime/spec.md @@ -0,0 +1,49 @@ +# Spec Delta + +## Purpose + +Provide offline archive extraction and standalone command-line distribution using native 7-Zip engines on supported Windows, macOS, and Linux systems. + +## ADDED Requirements + +### Requirement: Native offline archive engine +The system SHALL bundle official 7-Zip 26.03 for Windows x64, macOS x64/ARM64, and Linux x64/ARM64 and automatically select the compatible engine without network access or a system installation. Existing explicit executable-path overrides SHALL remain supported. + +#### Scenario: Source and standalone execution +- **WHEN** the application lists or extracts an archive on a supported platform from any working directory +- **THEN** it SHALL use the matching bundled native engine in both source and standalone execution +- **AND** password, multipart, nested extraction, and source retention behavior SHALL be preserved + +#### Scenario: Explicit executable override +- **WHEN** a caller supplies an executable file path +- **THEN** listing and extraction SHALL use that file instead of the bundled default + +### Requirement: Engine errors precede CLI file mutations +The CLI SHALL report an actionable error and exit unsuccessfully before modifying input files when its bundled engine is missing, lacks execute permission, lacks required companion files, or the platform is unsupported. Help and version SHALL remain available without an engine. + +#### Scenario: Missing or unusable engine +- **WHEN** extraction is requested with an unavailable engine +- **THEN** input names and contents SHALL remain unchanged +- **AND** the CLI SHALL identify the engine or platform problem + +### Requirement: Platform-native standalone distribution +The build command SHALL produce a standalone console program for the host platform, including only its native engine and required redistribution notices. It SHALL validate required assets before removing previous build output. + +#### Scenario: Building on macOS or Linux +- **WHEN** the build command runs on a supported macOS or Linux host +- **THEN** it SHALL produce `dist/complex-unzip-tool-v2` with an executable bundled engine + +#### Scenario: Building on Windows +- **WHEN** the build command runs on Windows x64 +- **THEN** it SHALL produce `dist/complex-unzip-tool-v2.exe` with the updated engine and its DLL + +#### Scenario: Incomplete build inputs +- **WHEN** a required engine file or license is missing +- **THEN** the build SHALL fail clearly without deleting existing output + +### Requirement: Terminal-compatible exit +Standalone programs SHALL pause on exit only for interactive Windows terminals. + +#### Scenario: POSIX or redirected invocation +- **WHEN** a macOS/Linux standalone command completes or Windows stdin is redirected +- **THEN** the application SHALL exit without requesting an additional Enter key diff --git a/openspec/changes/archive/2026-09-24-add-cross-platform-7zip/tasks.md b/openspec/changes/archive/2026-09-24-add-cross-platform-7zip/tasks.md new file mode 100644 index 0000000..bcc2241 --- /dev/null +++ b/openspec/changes/archive/2026-09-24-add-cross-platform-7zip/tasks.md @@ -0,0 +1,18 @@ +# Tasks + +## 1. Runtime and bundled engine + +- [x] 1.1 Add native engine selection using TDD at the archive listing/extraction boundary; verify supported OS/architecture mappings, frozen roots, explicit overrides, and unavailable engines. +- [x] 1.2 Replace Windows assets and add macOS/Linux engines from official 26.03 packages; verify upstream SHA-256 digests, bundled-file hashes, architecture, and licenses. +- [x] 1.3 Add CLI preflight and platform-aware exit behavior with failing-then-passing CLI tests; verify input preservation and help/version availability. + +## 2. Build and documentation + +- [x] 2.1 Use native runtime assets in the standalone builder with failing-then-passing build tests; verify native binary selection, notices, output names, and missing-asset behavior. +- [x] 2.2 Update both READMEs and project guidance; verify platform matrix and source/build commands match implementation. + +## 3. Validation and closure + +- [x] 3.1 Run the full existing suite, CLI smoke, formatting/lint/type checks; distinguish pre-existing failures from regressions in a validation record. +- [x] 3.2 Perform real archive/password/multipart smoke checks on macOS and available Linux containers and build/run the native macOS executable; record native Windows validation limits. +- [x] 3.3 Validate OpenSpec, sync the new capability, and archive the completed change. diff --git a/openspec/changes/archive/2026-09-24-add-cross-platform-7zip/validation.md b/openspec/changes/archive/2026-09-24-add-cross-platform-7zip/validation.md new file mode 100644 index 0000000..5036dfc --- /dev/null +++ b/openspec/changes/archive/2026-09-24-add-cross-platform-7zip/validation.md @@ -0,0 +1,34 @@ +# Validation — 2026-09-24 + +## Automated checks + +- macOS ARM64 / Python 3.11.14: **234 passed** (`poetry run pytest -q`). +- Linux ARM64 / Python 3.12.12 container: **234 passed**. +- Linux x64 / Python 3.11 container under Docker emulation: **234 passed**. +- New regressions were observed failing before implementation: native engine dispatch/permissions (7 failures), CLI preflight/exit behavior (4 failures), and platform packaging/asset validation (7 failures). +- Two existing tests used Windows-only path fixtures and failed on the initial macOS baseline (205 passed, 2 failed). They now use native paths while preserving their grouping/basename assertions. +- New runtime helper, builder, and new test files pass Black and flake8. The runtime helper and builder pass mypy with existing dependency errors excluded (`--follow-imports=silent`). +- Whole-project flake8 retains the same 193 baseline findings; whole-project mypy has 137 errors versus 138 before this change. Comparison ignoring shifted line numbers found no new diagnostics. Existing unrelated formatting/type/lint debt was not reformatted or repaired. +- CLI `--help`, Python compilation, `git diff --check`, and strict OpenSpec change validation pass. + +## Native engine and standalone checks + +Real 7-Zip smoke checks passed on macOS ARM64, Linux ARM64, and Linux x64 (Docker emulation): + +- Listing and extracting ZIPs with Chinese filenames and spaces in paths. +- Encrypted 7z archives with a Unicode password and encrypted headers; wrong passwords produce the password domain error. +- Split 7z `.001`/`.002+` extraction with byte-for-byte output verification. +- Recursive 7z → ZIP extraction and regular-file rejection. +- Source CLI help, version, and extraction into `unzipped/` using disposable synthetic inputs. + +`poetry run build` produced the macOS ARM64 standalone executable in `dist/complex-unzip-tool-v2` (about 12.6 MiB). Its help, version, and real extraction were verified from another working directory with stdin redirected, proving bundled-root lookup and normal POSIX exit behavior. Running the PyInstaller bootloader required execution outside the Codex filesystem sandbox because the sandbox blocks its sync semaphore. + +Official release packages were checked against GitHub release-asset SHA-256 metadata before extraction. Every bundled engine and license matches `7z/manifest.json`. Unix engines retain mode `0755`; `file` confirms the macOS universal Intel/ARM64 binary and Linux static binaries. + +## Limits and reproduction + +- Windows native execution/build and Intel macOS execution were not available on this host. Windows/Intel platform selection and packaging have deterministic regression coverage; the Windows EXE/DLL are unchanged files from the official x64 installer. +- Linux engine, source CLI, and full suites were exercised in both architectures; Linux standalone builds were not executed. Packaging input selection is covered for both architectures. +- Linux suites used a read-only checkout mount and existing pure Python test/runtime dependencies. The containers had networking disabled during tests and archive smoke checks. +- The host's pre-existing global Poetry launcher points to a missing interpreter. A project-local ignored `.venv` was created, Poetry installed there, and locked project dependencies installed with `.venv/bin/poetry install`. Local commands can use `.venv/bin/poetry run ...`. +- No repository dependency versions were changed, release uploaded, remote push made, or Windows installation performed. diff --git a/openspec/changes/archive/2026-09-24-add-github-release-ci/.openspec.yaml b/openspec/changes/archive/2026-09-24-add-github-release-ci/.openspec.yaml new file mode 100644 index 0000000..75289e4 --- /dev/null +++ b/openspec/changes/archive/2026-09-24-add-github-release-ci/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-24 diff --git a/openspec/changes/archive/2026-09-24-add-github-release-ci/design.md b/openspec/changes/archive/2026-09-24-add-github-release-ci/design.md new file mode 100644 index 0000000..918c843 --- /dev/null +++ b/openspec/changes/archive/2026-09-24-add-github-release-ci/design.md @@ -0,0 +1,33 @@ +# Design + +## Context + +`scripts/build.py` builds a native standalone executable, and the repository has 234 passing tests but no workflows. Versions are duplicated in `pyproject.toml`, the package initializer, and `.bumpversion.cfg`. Tags already use `vX.Y.Z`; the current `1.2.2` tag exists. Source branches such as `release/v1.3` are not necessarily complete semantic versions. + +## Goals / Non-Goals + +Provide one build pipeline reused by ordinary CI and release publication. Keep extraction logic unchanged. Do not auto-bump versions, derive versions from branch suffixes, sign/notarize binaries, publish packages to registries, or push repository changes during implementation. + +## Decisions + +- CI runs on PRs and pushes targeting `main` or `release/**`, and can be called by the release workflow. Five explicit hosted runners use Python 3.11: Windows x64 (`windows-2025`), macOS x64 (`macos-15-intel`), macOS ARM64 (`macos-15`), Linux x64 (`ubuntu-24.04`), and Linux ARM64 (`ubuntu-24.04-arm`). Pin official actions to release commit SHAs and install locked dependencies with Poetry. +- Release uses `pull_request_target: closed` on `main`, gated by `merged`, the `release/` head prefix, and the `release` label from that event. It checks out only `merge_commit_sha`, never an unmerged PR head or a moving branch. This supports merged fork PRs too, while the ordinary CI token remains read-only. Only the final publishing job receives `contents: write`; checkout does not persist credentials. +- Validate all three version declarations and accept stable `X.Y.Z` versions. Require the exact version's nonempty UTF-8 notes file during prepare, before platform builds; the publisher reads that file again and applies it both to new releases and resumed drafts. Rename the historical misspelled `RelaseNotes/` directory to `ReleaseNotes/`, preserving file contents. Branch names are a release gate only. Maintainers bump the version in the release PR using the existing bump commands. +- Every matrix job validates bundled-file hashes, runs pytest and source smoke, builds, then runs the same real ZIP/nested/password smoke against the standalone CLI from a temporary working directory. Outputs are Windows ZIP and Unix tar.gz files, preserving executable bits, with project/upstream licenses, README files, and 7-Zip provenance. A sidecar hash accompanies each artifact. +- Publishing downloads only this workflow run's five build artifacts, verifies their exact versioned names and checksums, and writes `SHA256SUMS`. A GitHub draft gets the verbatim body from `ReleaseNotes/RELEASE_NOTES_vX.Y.Z.md` in the merge commit, the exact merged commit as target, and all six public assets. It becomes public only after upload and remote digest verification. A rerun can resume a draft for the same commit. Published releases remain untouched; an existing tag pointing elsewhere or a mismatched draft fails. Publication concurrency is grouped by version without cancelling an active publisher. +- The new workflow must be present on `main` before subsequent eligible merges can trigger the base-branch release workflow. Labels must be applied before merging. No separate tag push or manual event publishes a release. + +## Plan review + +- Verified native platform mapping and `_MEIPASS` lookup already exist; each build must use a native runner, not pretend the universal 7-Zip binary makes the Python application universal. +- Closed gap: directly uploading Unix executables as Actions artifacts loses executable permissions. Archive them as tar.gz before upload. +- Closed gap: `github.sha` on a target PR event is not the release's immutable identity. Pass `merge_commit_sha` through metadata, every checkout, and publication. +- Closed gap: versions currently describe an already released tag. Keep version bump explicit and reject overwrite instead of guessing a new version. +- Closed gap: whole-project lint/type checks have pre-existing failures. CI gates the passing tests, compilation, real engine checks, builds, and smoke tests; it does not pretend existing lint debt is clean. +- Closed gap: PyInstaller ignores `PYTHONUTF8` from the environment. Bake `X utf8` into the executable so Chinese output remains encodable when Windows CI redirects stdout. The generated build configuration has a regression assertion for this runtime option. + +## Risks / Trade-offs + +- Five native jobs cost runner time; reuse one pipeline so CI/release checks do not drift. +- Linux standalone compatibility inherits the Ubuntu 24.04 build environment; document that baseline. macOS/Windows outputs remain unsigned. +- Live GitHub event/permissions behavior and unavailable local platforms can only be proven after pushing. Validate workflows with actionlint and exercise helpers locally without publishing. diff --git a/openspec/changes/archive/2026-09-24-add-github-release-ci/proposal.md b/openspec/changes/archive/2026-09-24-add-github-release-ci/proposal.md new file mode 100644 index 0000000..6fe5dca --- /dev/null +++ b/openspec/changes/archive/2026-09-24-add-github-release-ci/proposal.md @@ -0,0 +1,27 @@ +# Proposal + +## Why + +The new native platforms are only verified locally. Maintainers need automatic tests, native standalone builds, and a complete GitHub release when a labeled release PR merges. + +## What Changes + +- Add reusable CI for Windows x64, macOS x64/ARM64, and Linux x64/ARM64, with tests, real archive smoke checks, standalone builds, and downloadable archives. +- Publish only after a `release/*` PR with the `release` label merges into `main`. +- Derive a `vX.Y.Z` tag from the consistent project version, bind all artifacts to the merge commit, and publish only when all five builds succeed. +- Read release bodies from the matching `ReleaseNotes/RELEASE_NOTES_vX.Y.Z.md` in the merge commit, requiring a nonempty file. +- Include licenses and checksums; resume an interrupted draft without changing published releases or moving existing tags. + +## Capabilities + +### New Capabilities + +- `github-release-ci`: Automated multi-platform validation and merge-triggered GitHub release publication. + +### Modified Capabilities + +None. Native runtime behavior remains governed by `cross-platform-runtime`. + +## Impact + +GitHub Actions workflows, small release/package and smoke helpers under `scripts/`, regression tests, and bilingual contributor documentation. No application dependency changes or release version bump. Workflows become active after being pushed; no live release is created during implementation. diff --git a/openspec/changes/archive/2026-09-24-add-github-release-ci/specs/github-release-ci/spec.md b/openspec/changes/archive/2026-09-24-add-github-release-ci/specs/github-release-ci/spec.md new file mode 100644 index 0000000..121f5e4 --- /dev/null +++ b/openspec/changes/archive/2026-09-24-add-github-release-ci/specs/github-release-ci/spec.md @@ -0,0 +1,54 @@ +# Spec Delta + +## Purpose + +Automatically validate native builds and publish complete, versioned downloads when maintainers merge an explicitly labeled release pull request. + +## ADDED Requirements + +### Requirement: Five native build targets +CI SHALL test and build Windows x64, macOS x64/ARM64, and Linux x64/ARM64 using native hosted runners. Each build SHALL verify the bundled engines, the existing test suite, and real source/standalone archive extraction before uploading an artifact. + +#### Scenario: A platform fails +- **WHEN** any platform's tests, build, or smoke check fails +- **THEN** the run SHALL fail and SHALL NOT publish a release + +### Requirement: Release trigger is an eligible merged PR +Publication SHALL occur only after a PR from `release/*` merges into `main` with the `release` label present at merge time. Every release build and tag SHALL correspond to that PR's merge commit. + +#### Scenario: Eligible merge +- **WHEN** a labeled `release/v1.3` PR merges into `main` +- **THEN** the release pipeline SHALL build all five targets from that merge commit + +#### Scenario: Non-release event +- **WHEN** a PR is closed without merging, lacks the label, uses another source branch, or targets another base branch +- **THEN** no release SHALL be published + +### Requirement: Consistent version and complete downloadable assets +The release SHALL use `vX.Y.Z` from the consistent project version declarations, include five platform archives and a checksum index, preserve Unix execute permissions, and include redistribution notices. Missing assets, inconsistent versions, or checksum mismatches SHALL block publication. + +#### Scenario: All builds succeed +- **WHEN** all five verified platform archives are available for one version +- **THEN** the release SHALL publish them together with release notes and `SHA256SUMS` + +### Requirement: Safe publication retries +An interrupted draft for the same version and merge commit SHALL be resumable. Published releases SHALL remain unchanged, and existing tags SHALL never move to another commit. + +#### Scenario: Conflicting version +- **WHEN** a version's existing tag or draft belongs to a different commit +- **THEN** publication SHALL fail without replacing that tag or release + +#### Scenario: Retry after upload failure +- **WHEN** publication is rerun for its own unpublished draft and identical merge commit +- **THEN** the complete asset set SHALL be uploaded and verified before the draft becomes public + +### Requirement: Version-specific repository release notes +The release body SHALL use the exact Markdown contents of `ReleaseNotes/RELEASE_NOTES_vX.Y.Z.md` from the same merge commit being built. Missing or empty notes SHALL fail before platform builds begin. GitHub-generated notes and files for other versions SHALL NOT be used as fallbacks. + +#### Scenario: Matching release notes exist +- **WHEN** the project version is `1.3.0` and `ReleaseNotes/RELEASE_NOTES_v1.3.0.md` is nonempty +- **THEN** a new release or resumed draft SHALL publish that file's contents as its body + +#### Scenario: Release notes are missing or empty +- **WHEN** the exact version's release notes file is missing or contains only whitespace +- **THEN** the release workflow SHALL fail during prepare without building or creating a release diff --git a/openspec/changes/archive/2026-09-24-add-github-release-ci/tasks.md b/openspec/changes/archive/2026-09-24-add-github-release-ci/tasks.md new file mode 100644 index 0000000..e0c73fb --- /dev/null +++ b/openspec/changes/archive/2026-09-24-add-github-release-ci/tasks.md @@ -0,0 +1,18 @@ +# Tasks + +## 1. Build helpers + +- [x] 1.1 Implement version validation, archive packaging, and complete release checksum validation with failing-then-passing pytest coverage. +- [x] 1.2 Add a reusable real archive smoke command and verify both source and standalone runs locally. + +## 2. GitHub workflows + +- [x] 2.1 Add five-platform CI and reuse it for the immutable release merge commit; validate YAML and expressions with actionlint. +- [x] 2.2 Implement the labeled merge trigger and bounded draft publication; exercise trigger, conflict, retry, and upload-failure cases without remote writes. + +- [x] 2.3 Read exact-version repository notes, reject missing/empty files in preflight, and verify body preservation for new releases and draft retries. + +## 3. Validation and documentation + +- [x] 3.1 Document version preparation, release trigger, outputs, and retry behavior in both READMEs; verify commands and artifact names match implementation. +- [x] 3.2 Run full tests and local build/package/smoke checks, record unavailable native runner validation, and archive the validated OpenSpec change. diff --git a/openspec/changes/archive/2026-09-24-add-github-release-ci/validation.md b/openspec/changes/archive/2026-09-24-add-github-release-ci/validation.md new file mode 100644 index 0000000..05e5634 --- /dev/null +++ b/openspec/changes/archive/2026-09-24-add-github-release-ci/validation.md @@ -0,0 +1,36 @@ +# Validation — 2026-09-24 + +## Release notes follow-up + +- Release notes now come verbatim from `ReleaseNotes/RELEASE_NOTES_vX.Y.Z.md` in the merge commit. Prepare rejects missing or empty files before the build matrix starts; the publisher enforces the same requirement for new drafts and retries, with no generated-note fallback. +- Renamed `RelaseNotes/` to `ReleaseNotes/`; SHA-256 comparison verified all seven historical files remained byte-identical. +- Regression tests first failed for the missing notes reader and GitHub-generated/missing draft bodies. After implementation, both macOS and Linux ARM64 passed the full **256-test** suite; actionlint, JavaScript syntax, focused formatting/lint/type checks, and strict spec validation passed. + +## Original CI validation + +- `actionlint .github/workflows/ci.yml .github/workflows/release.yml`: passed. +- `pytest -q`: **251 passed** on macOS ARM64 and a Linux ARM64 container. +- New release helper tests were written before implementation and failed for the missing module, then passed. Seven mocked GitHub API cases failed before the publisher existed, then passed: new release, draft retry, conflicting tag, conflicting draft, published release, upload failure, and remote digest mismatch. +- The generated standalone spec initially failed four assertions for its missing UTF-8 runtime option; adding the documented PyInstaller option made them pass. +- Evaluated the actual release job condition with synthetic merged/labeled, wrong-source, wrong-base, unmerged, and missing-label payloads: all five behaved as intended. Actual GitHub event delivery remains untested until the workflow is on `main`. +- Black, flake8, and mypy checks for the new Python helpers/tests passed. JavaScript syntax checking and `git diff --check` passed. Existing unrelated whole-project lint/type debt is unchanged in scope. +- Real source and frozen CLI smoke passed on macOS ARM64 and Linux ARM64: help/version with redirected stdin, Chinese filenames, nested ZIPs, encrypted 7z with a Unicode password, wrong-password rejection, split-volume 7z, byte-identical outputs, and successful source cleanup within temporary fixtures. +- Both native standalone builds and release packaging completed. Unix archive tests verified executable mode preservation; package tests verified license/doc inclusion and SHA-256 sidecars. Complete-asset validation tests rejected missing, modified, and extra files before publishing. +- Linux used a writable copy in a disposable container and installed the native PyInstaller 6.15.0 wheel; it did not modify the mounted source checkout. macOS frozen execution required leaving the Codex sandbox because PyInstaller uses a system semaphore blocked there. +- Windows x64, Intel macOS, and Linux x64 native Actions builds are configured but were not run on GitHub. Windows/Intel/Linux target selection has existing deterministic coverage. No live release/tag/API mutation, push, or version bump was performed. + +## Release behavior and prerequisites + +The trigger is `pull_request_target: closed` targeting `main`, guarded by merged state, the `release/` source prefix, and the `release` label. All build checkouts and publication target the payload's exact merge SHA. The workflow needs to exist on `main` before eligible future merges. An existing version tag at another commit fails before the five builds begin. The repository currently declares the already released `1.2.2`, so a release PR must bump the three version declarations before merging. + +Only the publication job gets `contents: write`. It verifies all five archives, stages a draft, verifies remote asset digests, and then publishes. Published releases are left unchanged; rerunning an already published version stops with a clear error. Matching drafts can be resumed. + +## Authoritative references checked + +- [GitHub workflow events](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target) +- [Hosted runner labels](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) +- [Release API and latest-version semantics](https://docs.github.com/en/rest/releases/releases) +- [Release asset digests](https://docs.github.com/en/rest/releases/assets) +- [PyInstaller interpreter options](https://pyinstaller.org/en/stable/spec-files.html#specifying-python-interpreter-options) + +Official action release tags and commit SHAs were resolved through the GitHub API: checkout v7.0.1, setup-python v7.0.0, upload-artifact v7.0.1, download-artifact v8.0.1, and github-script v9.0.0. Workflow pins use the resolved commit SHAs. diff --git a/openspec/changes/add-rename-history/.openspec.yaml b/openspec/changes/archive/2026-09-24-add-rename-history/.openspec.yaml similarity index 100% rename from openspec/changes/add-rename-history/.openspec.yaml rename to openspec/changes/archive/2026-09-24-add-rename-history/.openspec.yaml diff --git a/openspec/changes/add-rename-history/design.md b/openspec/changes/archive/2026-09-24-add-rename-history/design.md similarity index 100% rename from openspec/changes/add-rename-history/design.md rename to openspec/changes/archive/2026-09-24-add-rename-history/design.md diff --git a/openspec/changes/add-rename-history/proposal.md b/openspec/changes/archive/2026-09-24-add-rename-history/proposal.md similarity index 100% rename from openspec/changes/add-rename-history/proposal.md rename to openspec/changes/archive/2026-09-24-add-rename-history/proposal.md diff --git a/openspec/changes/add-rename-history/specs/rename-history/spec.md b/openspec/changes/archive/2026-09-24-add-rename-history/specs/rename-history/spec.md similarity index 96% rename from openspec/changes/add-rename-history/specs/rename-history/spec.md rename to openspec/changes/archive/2026-09-24-add-rename-history/specs/rename-history/spec.md index 7f2dc1e..b093f8b 100644 --- a/openspec/changes/add-rename-history/specs/rename-history/spec.md +++ b/openspec/changes/archive/2026-09-24-add-rename-history/specs/rename-history/spec.md @@ -1,3 +1,7 @@ +## Purpose + +Preserve the recognizability of source archive files by tracking uncloaking renames, restoring names when originals are retained, and supporting recovery after interrupted runs. + ## ADDED Requirements ### Requirement: Track every successful uncloak rename diff --git a/openspec/changes/add-rename-history/tasks.md b/openspec/changes/archive/2026-09-24-add-rename-history/tasks.md similarity index 93% rename from openspec/changes/add-rename-history/tasks.md rename to openspec/changes/archive/2026-09-24-add-rename-history/tasks.md index fbbbaa1..e6aa33e 100644 --- a/openspec/changes/add-rename-history/tasks.md +++ b/openspec/changes/archive/2026-09-24-add-rename-history/tasks.md @@ -5,7 +5,7 @@ - [x] 1.3 Implement `bind(renamed_path: str, group_key: str)` — find entry by `renamed` path and set its `group`; persist. - [x] 1.4 Implement `revert_group(group_key: str) -> tuple[int, list[tuple[str, str]]]` — for every entry bound to that group, attempt `os.rename(renamed → original)`; skip missing renamed files; use collision-safe fallback when original already exists; remove successfully-reverted entries; persist after each entry; return `(count, sample)` for UI. - [x] 1.5 Implement `clear_group(group_key: str)` — drop all entries bound to that group; persist. -- [x] 1.6 Implement `revert_unbound() -> int` — revert any entries whose `group is None` at finalize time. +- [x] 1.6 Implement `revert_unbound() -> tuple[int, list[tuple[str, str]]]` — revert any entries whose `group is None` at finalize time. - [x] 1.7 Implement `finalize()` — if no entries remain, delete the JSON file; otherwise leave it for next-run recovery. - [x] 1.8 Implement `RenameHistory.load_pending(input_root: str) -> RenameHistory | None` classmethod — return a populated instance if a leftover file exists with non-empty entries; return None otherwise. - [x] 1.9 Implement atomic JSON write helper (`_persist`): write `.tmp` then `os.replace`. Use `json.dump` with stable key order. @@ -52,5 +52,5 @@ ## 6. Validation - [x] 6.1 Run `poetry run pytest -q` — all tests pass. -- [ ] 6.2 Run the tool against a real cloaked input dir, observe rename happens, force a failure (e.g. wrong password / Ctrl+C), confirm files are reverted and the JSON file is properly cleaned up or preserved as designed. -- [ ] 6.3 Run `openspec verify --change add-rename-history --json` and resolve any issues. +- [x] 6.2 Run the tool against a real cloaked input dir, observe rename happens, force a failure (e.g. wrong password / Ctrl+C), confirm files are reverted and the JSON file is properly cleaned up or preserved as designed. +- [x] 6.3 Verify implementation/spec/test coverage and run `openspec validate add-rename-history --strict`; record remaining non-blocking differences in `validation.md`. The installed CLI has no `verify` subcommand. diff --git a/openspec/changes/archive/2026-09-24-add-rename-history/validation.md b/openspec/changes/archive/2026-09-24-add-rename-history/validation.md new file mode 100644 index 0000000..abd0152 --- /dev/null +++ b/openspec/changes/archive/2026-09-24-add-rename-history/validation.md @@ -0,0 +1,33 @@ +# Verification — 2026-09-24 + +## Completeness and correctness + +All implementation tasks are represented in the existing code. The previously unchecked live verification was completed before archiving. The obsolete `openspec verify` command in task 6.3 was replaced with the available strict validator plus implementation/spec/test inspection. + +The six requirements and sixteen scenarios were synced without removal to `openspec/specs/rename-history/spec.md`, with a concrete Purpose section. + +| Requirement | Implementation evidence | +| --- | --- | +| Record every successful uncloak rename | Both successful rename branches in `CloakedFileDetector.uncloak_file` call `history.record`; `RenameHistory.record` persists original/renamed paths and the run timestamp. | +| Bind renames to groups | `main.extract_files` binds each group's files immediately after Step 5. Unbound entries are reverted before finalization. | +| Revert when originals are preserved | `_reconcile_rename_history` mirrors the source-deletion gate and is called for retained/error paths in both extraction phases. `revert_group` handles missing sources and destination collisions. | +| Clear history for successful groups | `_reconcile_rename_history` clears group entries on the success/deletion path. | +| Persist across crashes | `_persist` writes JSON using a sibling temporary file and `os.replace`; successful per-file reversions persist eagerly. | +| Recover at startup | `_maybe_recover_pending_renames` loads pending entries, reports root mismatch, defaults to no, and reverts after explicit acceptance. | + +## Checks performed + +- Rename history and main integration tests: **32 passed**. +- Full test suite: **256 passed**. +- Strict change validation and strict main-spec validation: passed. +- Real native 7-Zip password-failure smoke: created an encrypted split archive, cloaked its primary volume as `sample.7z.001删除`, and ran the CLI without the password, selecting skip. The normal detector renamed the volume, extraction failed, the original cloaked filename and SHA-256 were restored, and the history file was removed. +- Real crash-recovery smoke: a child process ran the normal uncloak helper, persisted its history, and terminated with `os._exit(23)`. The parent verified the renamed file and JSON entry, then ran the CLI with recovery accepted and password extraction skipped. The original name/content were restored and the history file was cleaned. +- Both live cases used disposable synthetic inputs in a temporary directory and the bundled native macOS engine. No user archives were touched. + +## Non-blocking difference + +`RenameHistory.revert_group` safely drops entries whose renamed file no longer exists, but does so silently. The missing-source scenario and design D6 request an informational log. The safety behavior is implemented and tested; the log remains a follow-up. No runtime implementation was changed during this sync/archive operation. + +## Scope + +This operation synchronizes and archives the existing change, adds verification evidence, corrects the documented return type of `revert_unbound`, and updates the verification command. It does not change extraction behavior or claim the missing-source log is implemented. diff --git a/openspec/project.md b/openspec/project.md index 9a1d4de..54affba 100644 --- a/openspec/project.md +++ b/openspec/project.md @@ -2,7 +2,7 @@ ## Purpose -Complex Unzip Tool v2 is a robust and intelligent command-line archive extraction utility designed for Windows. It handles complex archive scenarios including password-protected files, multipart archives, nested structures, and cloaked/obfuscated filenames. The tool provides automated extraction with smart features like drag-and-drop support, intelligent password management, and multilingual support (English/Chinese). +Complex Unzip Tool v2 is a robust and intelligent command-line archive extraction utility for Windows x64, macOS Intel/Apple Silicon, and Linux x64/ARM64. It handles complex archive scenarios including password-protected files, multipart archives, nested structures, and cloaked/obfuscated filenames. The tool provides automated extraction with smart features like drag-and-drop support, intelligent password management, and multilingual support (English/Chinese). Inspired by: https://github.com/TR-Supowe/Complex-Unzip-Tool @@ -14,7 +14,7 @@ Inspired by: https://github.com/TR-Supowe/Complex-Unzip-Tool - **Typer**: CLI framework (version ^0.17.3) - **Rich**: Rich terminal output and progress display (version ^14.1.0) - **send2trash**: Safe file deletion to recycle bin (version ^1.8.3) -- **7-Zip**: Archive extraction engine (bundled as `7z/7z.exe` and `7z/7z.dll`) +- **7-Zip**: Archive extraction engine (26.03; native assets and licenses under `7z/`, selected by `modules/seven_zip_runtime.py`) ### Development Tools - **pytest**: Testing framework (version ^7.4.0) @@ -233,15 +233,15 @@ poetry run pytest -v ## Important Constraints ### Technical Constraints -- **Platform**: Windows-only (uses `7z/7z.exe` bundled binary) +- **Platform**: Windows x64, macOS Intel/Apple Silicon, Linux x64/ARM64 - **Python Version**: 3.11+ required for development -- **Shell**: Default dev shell is Windows PowerShell -- **File System**: Uses Windows path conventions (backslashes) +- **Shell**: PowerShell on Windows; bash/zsh on macOS/Linux +- **File System**: Use host-native paths via pathlib/os.path - **Encoding**: Multiple encoding support for password files (Chinese character support) ### Business Constraints - **No Network Calls**: Do not exfiltrate secrets or make network calls unless explicitly required -- **Local Execution**: Assume local execution on Windows +- **Local Execution**: Offline execution on supported platforms - **Backward Compatibility**: Preserve existing behavior unless intentionally modified - **Stability**: Keep the app stable and easy to maintain - **Minimal Changes**: Prefer minimal, well-scoped changes with tests @@ -255,16 +255,16 @@ poetry run pytest -v ### Development Constraints - **PR Size**: Keep PRs atomic and under ~300 lines when possible - **Dependencies**: Avoid introducing new dependencies unless clearly justified -- **Bundled Binaries**: Do not modify bundled binaries under `7z/` +- **Bundled Binaries**: Upgrade only when explicitly requested; preserve upstream bytes, platform licenses, and provenance in `7z/manifest.json` - **Public APIs**: Keep public APIs stable unless change is intentional and documented ## External Dependencies ### Runtime Dependencies -- **7-Zip**: Bundled as `7z/7z.exe` and `7z/7z.dll` (required for archive operations) - - Location: `./7z/7z.exe` (relative to project root) - - License: Must include `7z/License.txt` in distribution - - Version: Bundled binary (managed separately from Python dependencies) +- **7-Zip**: Native bundled engine for the host platform (required for archive operations) + - Locations: `7z/windows-x64/7z.exe` + `7z.dll`, `7z/macos/7zz`, `7z/linux-x64/7zzs`, `7z/linux-arm64/7zzs` + - License: Must include the `License.txt` next to the selected engine in distribution + - Version: 26.03 (managed separately from Python dependencies) ### Python Dependencies (via Poetry) - **typer** (^0.17.3): CLI framework for command-line interface @@ -300,7 +300,7 @@ poetry run pytest -v ### Entry Points - **CLI Entry**: `complex_unzip_tool_v2/__main__.py` and `complex_unzip_tool_v2/main.py` - **Poetry Scripts**: `main`, `cuz`, `build`, `bump`, `bump-patch`, `bump-minor`, `bump-major` -- **Standalone Executable**: `dist/complex-unzip-tool-v2.exe` (after build) +- **Standalone Executable**: `dist/complex-unzip-tool-v2.exe` on Windows; `dist/complex-unzip-tool-v2` on macOS/Linux (build on the target OS/architecture) ### Testing Infrastructure - **Test Runner**: pytest @@ -310,7 +310,7 @@ poetry run pytest -v ### Documentation - **README.md**: User-facing documentation (bilingual: English/Chinese) - **AGENTS.md**: AI agent guidelines and project conventions -- **Release Notes**: `RelaseNotes/` directory with version-specific notes +- **Release Notes**: `ReleaseNotes/` directory with version-specific notes - **OpenSpec**: `openspec/` directory for spec-driven development ## Quick Reference @@ -337,7 +337,7 @@ poetry run mypy complex_unzip_tool_v2/ ``` ### Key File Locations -- **7-Zip Binary**: `./7z/7z.exe` +- **7-Zip Binary**: See `7z/README.md` for the native platform paths - **Config**: `complex_unzip_tool_v2/config/cloaked_file_rules.json` - **CLI Entry**: `complex_unzip_tool_v2/__main__.py` - **Main Logic**: `complex_unzip_tool_v2/main.py` diff --git a/openspec/specs/cross-platform-runtime/spec.md b/openspec/specs/cross-platform-runtime/spec.md new file mode 100644 index 0000000..d1f0605 --- /dev/null +++ b/openspec/specs/cross-platform-runtime/spec.md @@ -0,0 +1,54 @@ +# cross-platform-runtime Specification + +## Purpose +Provide offline archive extraction and standalone command-line distribution using native 7-Zip engines on supported Windows, macOS, and Linux systems. + +## Requirements + +### Requirement: Native offline archive engine +The system SHALL bundle official 7-Zip 26.03 for Windows x64, macOS x64/ARM64, and Linux x64/ARM64 and automatically select the compatible engine without network access or a system installation. Existing explicit executable-path overrides SHALL remain supported. + +#### Scenario: Source and standalone execution +- **WHEN** the application lists or extracts an archive on a supported platform from any working directory +- **THEN** it SHALL use the matching bundled native engine in both source and standalone execution +- **AND** password, multipart, nested extraction, and source retention behavior SHALL be preserved + +#### Scenario: Explicit executable override +- **WHEN** a caller supplies an executable file path +- **THEN** listing and extraction SHALL use that file instead of the bundled default + +### Requirement: Engine errors precede CLI file mutations +The CLI SHALL report an actionable error and exit unsuccessfully before modifying input files when its bundled engine is missing, lacks execute permission, lacks required companion files, or the platform is unsupported. Help and version SHALL remain available without an engine. + +#### Scenario: Missing or unusable engine +- **WHEN** extraction is requested with an unavailable engine +- **THEN** input names and contents SHALL remain unchanged +- **AND** the CLI SHALL identify the engine or platform problem +- **AND** an interactive frozen Windows invocation SHALL pause so the error remains readable, then exit unsuccessfully + +### Requirement: Platform-native standalone distribution +The build command SHALL produce a standalone console program for the host platform, including only its native engine and required redistribution notices. It SHALL validate required assets before removing previous build output. + +#### Scenario: Building on macOS or Linux +- **WHEN** the build command runs on a supported macOS or Linux host +- **THEN** it SHALL produce `dist/complex-unzip-tool-v2` with an executable bundled engine + +#### Scenario: Building on Windows +- **WHEN** the build command runs on Windows x64 +- **THEN** it SHALL produce `dist/complex-unzip-tool-v2.exe` with the updated engine and its DLL + +#### Scenario: Incomplete build inputs +- **WHEN** a required engine file or license is missing +- **THEN** the build SHALL fail clearly without deleting existing output + +### Requirement: Terminal-compatible exit +Standalone programs SHALL pause on exit only for interactive Windows terminals. + +#### Scenario: POSIX or redirected invocation +- **WHEN** a macOS/Linux standalone command completes or Windows stdin or stdout is redirected +- **THEN** the application SHALL exit without requesting an additional Enter key + +#### Scenario: Windows output uses a legacy code page +- **WHEN** Windows stdout or stderr is redirected to a pipe or file under a legacy encoding such as GBK +- **THEN** the CLI SHALL emit UTF-8 output without an encoding error for Chinese text or emoji +- **AND** both source and standalone invocations SHALL support this behavior without requiring Python environment variables diff --git a/openspec/specs/github-release-ci/spec.md b/openspec/specs/github-release-ci/spec.md new file mode 100644 index 0000000..71ffd9f --- /dev/null +++ b/openspec/specs/github-release-ci/spec.md @@ -0,0 +1,58 @@ +# github-release-ci Specification + +## Purpose +Automatically validate native builds and publish complete, versioned downloads when maintainers merge an explicitly labeled release pull request. + +## Requirements + +### Requirement: Five native build targets +CI SHALL test and build Windows x64, macOS x64/ARM64, and Linux x64/ARM64 using native hosted runners. Each build SHALL verify the bundled engines, the existing test suite, and real source/standalone archive extraction before uploading an artifact. + +#### Scenario: A platform fails +- **WHEN** any platform's tests, build, or smoke check fails +- **THEN** the run SHALL fail and SHALL NOT publish a release + +#### Scenario: A newer commit supersedes ordinary CI +- **WHEN** another push or PR update starts checks for the same event and ref +- **THEN** the older ordinary CI run SHALL be cancelled +- **AND** reusable release builds SHALL remain isolated from ordinary CI cancellation and publish only artifacts from their own workflow run + +### Requirement: Release trigger is an eligible merged PR +Publication SHALL occur only after a PR from `release/*` merges into `main` with the `release` label present at merge time. Every release build and tag SHALL correspond to that PR's merge commit. + +#### Scenario: Eligible merge +- **WHEN** a labeled `release/v1.3` PR merges into `main` +- **THEN** the release pipeline SHALL build all five targets from that merge commit + +#### Scenario: Non-release event +- **WHEN** a PR is closed without merging, lacks the label, uses another source branch, or targets another base branch +- **THEN** no release SHALL be published + +### Requirement: Consistent version and complete downloadable assets +The release SHALL use `vX.Y.Z` from the consistent project version declarations, include five platform archives and a checksum index, preserve Unix execute permissions, and include redistribution notices. Missing assets, inconsistent versions, or checksum mismatches SHALL block publication. + +#### Scenario: All builds succeed +- **WHEN** all five verified platform archives are available for one version +- **THEN** the release SHALL publish them together with release notes and `SHA256SUMS` + +### Requirement: Safe publication retries +An interrupted draft for the same version and merge commit SHALL be resumable. Published releases SHALL remain unchanged, and existing tags SHALL never move to another commit. + +#### Scenario: Conflicting version +- **WHEN** a version's existing tag or draft belongs to a different commit +- **THEN** publication SHALL fail without replacing that tag or release + +#### Scenario: Retry after upload failure +- **WHEN** publication is rerun for its own unpublished draft and identical merge commit +- **THEN** the complete asset set SHALL be uploaded and verified before the draft becomes public + +### Requirement: Version-specific repository release notes +The release body SHALL use the exact Markdown contents of `ReleaseNotes/RELEASE_NOTES_vX.Y.Z.md` from the same merge commit being built. Missing or empty notes SHALL fail before platform builds begin. GitHub-generated notes and files for other versions SHALL NOT be used as fallbacks. + +#### Scenario: Matching release notes exist +- **WHEN** the project version is `1.3.0` and `ReleaseNotes/RELEASE_NOTES_v1.3.0.md` is nonempty +- **THEN** a new release or resumed draft SHALL publish that file's contents as its body + +#### Scenario: Release notes are missing or empty +- **WHEN** the exact version's release notes file is missing or contains only whitespace +- **THEN** the release workflow SHALL fail during prepare without building or creating a release diff --git a/openspec/specs/rename-history/spec.md b/openspec/specs/rename-history/spec.md new file mode 100644 index 0000000..c132657 --- /dev/null +++ b/openspec/specs/rename-history/spec.md @@ -0,0 +1,96 @@ +# Rename History Specification + +## Purpose +Preserve the recognizability of source archive files by tracking uncloaking renames, restoring names when originals are retained, and supporting recovery after interrupted runs. + +## Requirements + +### Requirement: Track every successful uncloak rename +The system SHALL record every in-place rename performed by the cloaked-file detector during a run, capturing the original path, the renamed path, and a stable identifier for the run. + +#### Scenario: Single cloaked file renamed +- **WHEN** the detector renames `1525.zip.001.bin` to `1525.7z.001` during Step 4 +- **THEN** the rename history SHALL contain an entry with `original=1525.zip.001.bin` and `renamed=1525.7z.001` + +#### Scenario: Duplicate-collision rename +- **WHEN** the detector encounters a collision target and renames the cloaked file to a `__duplicate_` variant via `_build_collision_path` +- **THEN** the rename history SHALL contain an entry mapping the original cloaked name to the duplicate variant + +#### Scenario: Failed rename is not recorded +- **WHEN** `os.rename` raises an exception during uncloaking +- **THEN** the rename history SHALL NOT contain an entry for that file + +### Requirement: Bind tracked renames to archive groups +The system SHALL associate every tracked rename with the `ArchiveGroup` whose file list contains its `renamed` path after Step 5 grouping completes. + +#### Scenario: Bind after grouping +- **WHEN** Step 5 produces an `ArchiveGroup` with name `dir-1525` containing files including `1525.7z.001` +- **AND** the rename history contains an entry with `renamed=1525.7z.001` +- **THEN** that entry SHALL be bound to group key `dir-1525` + +#### Scenario: Renamed file not present in any group +- **WHEN** binding completes and a tracked entry's `renamed` path appears in no group's file list +- **THEN** the entry SHALL remain unbound and SHALL be reverted at end-of-run + +### Requirement: Revert renames when group originals are preserved +The system SHALL revert all rename history entries bound to a group whenever that group's outcome would have caused the original archive files to be preserved (i.e. the same condition under which `_should_delete_original_archives` returns False, or any failure path that bypasses the deletion step entirely). + +#### Scenario: Group fails with password skip +- **WHEN** Step 7 or Step 8 finishes a group with `password_failed_archives` non-empty (originals preserved) +- **THEN** the system SHALL rename every bound file from `renamed` back to `original` +- **AND** SHALL emit a warning indicating how many renames were reverted for that group + +#### Scenario: Group raises an exception +- **WHEN** Step 7 or Step 8 catches an exception for a group and falls into the `finally` cleanup that retains the source parts +- **THEN** the system SHALL revert all renames bound to that group + +#### Scenario: Revert encounters missing renamed file +- **WHEN** revert is asked to rename `renamed → original` but `renamed` no longer exists on disk +- **THEN** the system SHALL skip that entry, log an info message, and continue with remaining entries + +#### Scenario: Revert encounters existing original +- **WHEN** revert is asked to rename `renamed → original` but `original` already exists on disk +- **THEN** the system SHALL pick a unique fallback path using the same collision-suffix scheme as the cloaked detector and complete the rename without raising + +### Requirement: Drop history entries for successful groups +The system SHALL remove all rename history entries bound to a group whenever that group's outcome causes the original archive files to be deleted (`_should_delete_original_archives` returns True). + +#### Scenario: Group succeeds and originals are deleted +- **WHEN** Step 7 or Step 8 finishes a group successfully and removes the original archive(s) +- **THEN** the system SHALL clear the history entries bound to that group +- **AND** SHALL NOT attempt to revert any of those renames + +### Requirement: Persist history across crashes +The system SHALL persist the rename history to a JSON file in the input root directory, updated eagerly after every record / revert / clear operation, and removed on clean program completion when the history contains no remaining entries. + +#### Scenario: History written on each record +- **WHEN** a new rename is recorded +- **THEN** the file `/.unzip-rename-history.tmp.json` SHALL exist on disk and SHALL contain the entry +- **AND** the write SHALL be atomic (write to temp + `os.replace`) + +#### Scenario: File removed on clean exit +- **WHEN** the run completes with all groups processed and no entries remaining +- **THEN** `.unzip-rename-history.tmp.json` SHALL be deleted + +#### Scenario: File survives crash +- **WHEN** the program is terminated abnormally (SIGINT, exception in unrelated code, OS kill) after at least one rename was recorded +- **THEN** the JSON file SHALL remain on disk reflecting all renames that had been recorded up to the moment of termination + +### Requirement: Recover from leftover history on startup +The system SHALL detect a pre-existing `.unzip-rename-history.tmp.json` in the input root before Step 4 runs, summarize its contents to the user, and prompt for revert with a default of "no". + +#### Scenario: Leftover detected, user accepts +- **WHEN** a leftover history file with non-empty entries is detected at startup +- **AND** the user enters `y` at the recovery prompt +- **THEN** the system SHALL revert each entry whose `renamed` path still exists, applying the same missing-file and clobber-safety rules as in-run revert +- **AND** SHALL delete the history file before continuing +- **AND** SHALL then proceed with the normal Step 1+ flow + +#### Scenario: Leftover detected, user declines +- **WHEN** a leftover history file is detected and the user enters `n` (or accepts the default) +- **THEN** the system SHALL leave the file in place +- **AND** SHALL proceed with the normal flow, where any new records during the run will overwrite the file + +#### Scenario: Leftover with mismatched input root +- **WHEN** a leftover history file is detected but its `input_root` field does not match the current input root +- **THEN** the system SHALL warn the user about the mismatch in the recovery prompt before asking for confirmation diff --git a/pyproject.toml b/pyproject.toml index 346a66e..a71967f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "complex-unzip-tool-v2" -version = "1.2.2" +version = "1.3.0" description = "A powerful tool for extracting complex archive files with password support and multipart handling" authors = ["Rozx "] readme = "README.md" diff --git a/scripts/build.py b/scripts/build.py index 340f2ef..ec4a44b 100644 --- a/scripts/build.py +++ b/scripts/build.py @@ -3,50 +3,59 @@ Build script for creating standalone executable of Complex Unzip Tool v2 """ -import os +import platform import shutil import subprocess import sys from pathlib import Path +from complex_unzip_tool_v2.modules.seven_zip_runtime import ( + SevenZipNotFoundError, + bundled_engine_path, + validate_engine, +) + + def generate_spec_content(project_root: Path, scripts_dir: Path) -> str: """Generate the PyInstaller spec file content dynamically.""" - + # Define paths main_script = scripts_dir / "standalone_main.py" - + # Define data files to include data_files = [] - - # Add 7z binaries if they exist - seven_zip_files = [ - (project_root / "7z" / "7z.exe", "7z"), - (project_root / "7z" / "7z.dll", "7z"), - (project_root / "7z" / "License.txt", "7z"), - ] - - for src_path, dest_dir in seven_zip_files: - if src_path.exists(): - data_files.append(f' (r"{src_path}", "{dest_dir}"),') - - # Add passwords.txt if it exists - passwords_file = project_root / "passwords.txt" - if passwords_file.exists(): - data_files.append(f' (r"{passwords_file}", "."),') - + + # Include only the native engine; PyInstaller preserves executable mode. + engine = bundled_engine_path(project_root) + validate_engine(engine, bundled=True) + destination = engine.parent.relative_to(project_root).as_posix() + binaries = [(str(engine), destination)] + if platform.system() == "Windows": + binaries.append((str(engine.with_suffix(".dll")), destination)) + license_file = engine.parent / "License.txt" + if not license_file.is_file(): + raise SevenZipNotFoundError(f"7-Zip license not found at: {license_file}") + data_files.append(f" ({str(license_file)!r}, {destination!r}),") + # Add config files config_dir = project_root / "complex_unzip_tool_v2" / "config" cloaked_rules_file = config_dir / "cloaked_file_rules.json" if cloaked_rules_file.exists(): - data_files.append(f' (r"{cloaked_rules_file}", "complex_unzip_tool_v2/config"),') - + data_files.append( + f' (r"{cloaked_rules_file}", "complex_unzip_tool_v2/config"),' + ) + data_files_str = "\n".join(data_files) - + # Check for icon file icon_path = project_root / "icons" / "app_icon.ico" - icon_line = f'r"{icon_path}"' if icon_path.exists() else 'None' - - spec_content = f'''# -*- mode: python ; coding: utf-8 -*- + icon_line = ( + repr(str(icon_path)) + if platform.system() == "Windows" and icon_path.exists() + else "None" + ) + + spec_content = f"""# -*- mode: python ; coding: utf-8 -*- # This file is generated automatically by the build script import os @@ -64,7 +73,7 @@ def generate_spec_content(project_root: Path, scripts_dir: Path) -> str: a = Analysis( [main_script], pathex=[project_root], - binaries=[], + binaries={binaries!r}, datas=datas, hiddenimports=[ 'complex_unzip_tool_v2', @@ -96,7 +105,7 @@ def generate_spec_content(project_root: Path, scripts_dir: Path) -> str: a.scripts, a.binaries, a.datas, - [], + [('X utf8', None, 'OPTION')], # Preserve Chinese output in redirected CI logs. name='complex-unzip-tool-v2', debug=False, bootloader_ignore_signals=False, @@ -112,82 +121,83 @@ def generate_spec_content(project_root: Path, scripts_dir: Path) -> str: entitlements_file=None, icon={icon_line}, ) -''' +""" return spec_content -def main(): + +def main() -> int: # Get project root directory (parent of scripts directory) project_root = Path(__file__).parent.parent.absolute() scripts_dir = Path(__file__).parent.absolute() - + + # Validate native assets before removing any previous build output. + try: + spec_content = generate_spec_content(project_root, scripts_dir) + except SevenZipNotFoundError as exc: + print(f"❌ Error: {exc}") + return 1 + # Clean previous builds build_dir = project_root / "build" dist_dir = project_root / "dist" - + print("🧹 Cleaning previous builds...") if build_dir.exists(): shutil.rmtree(build_dir) if dist_dir.exists(): shutil.rmtree(dist_dir) - - # Verify 7z files exist - seven_zip_exe = project_root / "7z" / "7z.exe" - seven_zip_dll = project_root / "7z" / "7z.dll" - - if not seven_zip_exe.exists(): - print(f"❌ Error: 7z.exe not found at {seven_zip_exe}") - return 1 - - if not seven_zip_dll.exists(): - print(f"❌ Error: 7z.dll not found at {seven_zip_dll}") - return 1 - - print("✅ 7z binaries found") - + # Generate spec file dynamically print("📝 Generating PyInstaller spec file...") - spec_content = generate_spec_content(project_root, scripts_dir) spec_file = scripts_dir / "build_standalone_generated.spec" - - with open(spec_file, 'w', encoding='utf-8') as f: + + with open(spec_file, "w", encoding="utf-8") as f: f.write(spec_content) - + print(f"✅ Spec file generated: {spec_file}") - + # Run PyInstaller print("🔨 Building standalone executable...") try: - result = subprocess.run([ - "poetry", "run", "pyinstaller", - "--clean", - "--noconfirm", - str(spec_file) - ], cwd=project_root, check=True) - + subprocess.run( + [ + sys.executable, + "-m", + "PyInstaller", + "--clean", + "--noconfirm", + str(spec_file), + ], + cwd=project_root, + check=True, + ) + print("✅ Build completed successfully!") - + # Check if executable was created - exe_path = dist_dir / "complex-unzip-tool-v2.exe" + suffix = ".exe" if platform.system() == "Windows" else "" + exe_path = dist_dir / f"complex-unzip-tool-v2{suffix}" if exe_path.exists(): size_mb = exe_path.stat().st_size / (1024 * 1024) print(f"📦 Executable created: {exe_path}") print(f"📏 Size: {size_mb:.1f} MB") - print(f"🎯 You can now distribute this single file!") + print("🎯 You can now distribute this single file!") else: print("❌ Error: Executable not found in dist folder") return 1 - + except subprocess.CalledProcessError as e: print(f"❌ Build failed with error code {e.returncode}") return 1 - + finally: # Clean up generated spec file if spec_file.exists(): print("🧹 Cleaning up generated spec file...") spec_file.unlink() - + return 0 + if __name__ == "__main__": sys.exit(main()) diff --git a/scripts/publish_release.cjs b/scripts/publish_release.cjs new file mode 100644 index 0000000..d3b5b96 --- /dev/null +++ b/scripts/publish_release.cjs @@ -0,0 +1,85 @@ +// Called only by the final, write-scoped GitHub Actions job after all builds pass. +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +module.exports = async function publish({github, context, core, tag, sha, assetDir, + notesDir = path.join(__dirname, '..', 'ReleaseNotes')}) { + if (!/^v\d+\.\d+\.\d+$/.test(tag) || !/^[a-f0-9]{40}$/.test(sha)) { + throw new Error('Invalid release tag or merge commit'); + } + const notesPath = path.join(notesDir, `RELEASE_NOTES_${tag}.md`); + const notes = fs.readFileSync(notesPath, 'utf8'); + if (!notes.trim()) throw new Error(`Release notes are empty: ${notesPath}`); + const digest = data => 'sha256:' + crypto.createHash('sha256').update(data).digest('hex'); + const checksums = fs.readFileSync(path.join(assetDir, 'SHA256SUMS')); + const assets = checksums.toString('utf8').trim().split('\n').map(line => { + const match = /^([a-f0-9]{64}) ([A-Za-z0-9._-]+)$/.exec(line); + if (!match) throw new Error('Invalid checksum index'); + const [, hash, name] = match; + const data = fs.readFileSync(path.join(assetDir, name)); + if (digest(data) !== `sha256:${hash}`) throw new Error(`Checksum mismatch: ${name}`); + return {name, data, digest: digest(data)}; + }); + if (assets.length !== 5 || new Set(assets.map(a => a.name)).size !== 5) { + throw new Error('Expected five distinct platform archives'); + } + assets.push({name: 'SHA256SUMS', data: checksums, digest: digest(checksums)}); + const repo = context.repo; + const assertTag = async () => { + let object; + try { + object = (await github.rest.git.getRef({...repo, ref: `tags/${tag}`})).data.object; + } catch (error) { + if (error.status === 404) return; + throw error; + } + while (object.type === 'tag') { + object = (await github.rest.git.getTag({...repo, tag_sha: object.sha})).data.object; + } + if (object.type !== 'commit' || object.sha !== sha) { + throw new Error(`Existing tag ${tag} belongs to a different commit`); + } + }; + await assertTag(); + const releases = await github.paginate(github.rest.repos.listReleases, {...repo, per_page: 100}); + let release = releases.find(item => item.tag_name === tag); + if (release && (!release.draft || release.target_commitish !== sha)) { + throw new Error(`Release ${tag} is already published or belongs to a different commit`); + } + if (!release) { + release = (await github.rest.repos.createRelease({ + ...repo, tag_name: tag, target_commitish: sha, name: tag, + body: notes, draft: true, prerelease: false, + })).data; + } + const releaseArgs = {...repo, release_id: release.id, per_page: 100}; + const existing = await github.paginate(github.rest.repos.listReleaseAssets, releaseArgs); + if (existing.some(item => !assets.some(a => a.name === item.name))) { + throw new Error('Draft contains unexpected assets; leaving it unchanged'); + } + for (const asset of assets) { + const previous = existing.find(item => item.name === asset.name); + if (previous?.digest === asset.digest) continue; + if (previous) { + await github.rest.repos.deleteReleaseAsset({...repo, asset_id: previous.id}); + } + await github.rest.repos.uploadReleaseAsset({ + ...repo, release_id: release.id, name: asset.name, data: asset.data, + headers: {'content-type': 'application/octet-stream'}, + }); + } + const uploaded = await github.paginate(github.rest.repos.listReleaseAssets, releaseArgs); + if (uploaded.length !== assets.length || assets.some(asset => + !uploaded.some(item => item.name === asset.name && item.digest === asset.digest))) { + throw new Error('Uploaded asset checksums differ; keeping the release as a draft'); + } + await assertTag(); + // Let GitHub choose Latest by creation date and semantic version, rather than + // unconditionally promoting a possibly older release that finishes later. + // https://docs.github.com/en/rest/releases/releases#update-a-release + const published = await github.rest.repos.updateRelease({ + ...repo, release_id: release.id, body: notes, draft: false, make_latest: 'legacy', + }); + core.info(`Published ${published.data.html_url}`); +}; diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..3f0fcf1 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,151 @@ +"""Version, native archive packaging, and checksum checks for GitHub releases.""" + +import argparse +import ast +import configparser +import hashlib +import json +import re +import tarfile +import tomllib +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +TARGETS = { + "windows-x64": (".exe", "7z/windows-x64", ".zip"), + "macos-x64": ("", "7z/macos", ".tar.gz"), + "macos-arm64": ("", "7z/macos", ".tar.gz"), + "linux-x64": ("", "7z/linux-x64", ".tar.gz"), + "linux-arm64": ("", "7z/linux-arm64", ".tar.gz"), +} + + +def read_version(root: Path = ROOT) -> str: + """Require one stable version across the project's version declarations.""" + version = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))[ + "tool" + ]["poetry"]["version"] + if not re.fullmatch(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", version): + raise ValueError("Releases require a stable X.Y.Z project version") + module = ast.parse( + (root / "complex_unzip_tool_v2/__init__.py").read_text(encoding="utf-8") + ) + package_versions = [ + ast.literal_eval(node.value) + for node in module.body + if isinstance(node, ast.Assign) + and any(isinstance(t, ast.Name) and t.id == "__version__" for t in node.targets) + ] + config = configparser.ConfigParser() + config.read(root / ".bumpversion.cfg", encoding="utf-8") + if ( + package_versions != [version] + or config["bumpversion"]["current_version"] != version + ): + raise ValueError("Project, package, and bumpversion versions must match") + return str(version) + + +def read_release_notes(root: Path = ROOT) -> str: + """Read the required release notes for the exact project version.""" + path = root / "ReleaseNotes" / f"RELEASE_NOTES_v{read_version(root)}.md" + if not path.is_file(): + raise ValueError(f"Release notes not found: {path}") + notes = path.read_text(encoding="utf-8") + if not notes.strip(): + raise ValueError(f"Release notes are empty: {path}") + return notes + + +def verify_engines(root: Path = ROOT) -> None: + """Check the bundled official files against their recorded hashes.""" + manifest = json.loads((root / "7z/manifest.json").read_text(encoding="utf-8")) + for package_info in manifest["packages"]: + for name, digest in package_info["files"].items(): + if hashlib.sha256((root / "7z" / name).read_bytes()).hexdigest() != digest: + raise ValueError(f"Bundled 7-Zip checksum mismatch: {name}") + + +def package(root: Path, target: str, output: Path) -> Path: + """Archive a tested standalone executable with its redistribution notices.""" + suffix, engine_dir, extension = TARGETS[target] + version = read_version(root) + executable = f"complex-unzip-tool-v2{suffix}" + files = { + executable: root / "dist" / executable, + "LICENSE": root / "LICENSE", + "README.md": root / "README.md", + "README.en.md": root / "README.en.md", + "7-Zip-License.txt": root / engine_dir / "License.txt", + "7-Zip-manifest.json": root / "7z/manifest.json", + } + for path in files.values(): + if not path.is_file(): + raise FileNotFoundError(path) + output.mkdir(parents=True, exist_ok=True) + archive = output / f"complex-unzip-tool-v2-v{version}-{target}{extension}" + if extension == ".zip": + with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zipped: + for name, path in files.items(): + zipped.write(path, name) + else: + with tarfile.open(archive, "w:gz") as tar: + for name, path in files.items(): + tar.add(path, arcname=name) + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + archive.with_name(archive.name + ".sha256").write_text( + f"{digest} {archive.name}\n", encoding="utf-8" + ) + return archive + + +def verify_assets(directory: Path, version: str) -> Path: + """Reject incomplete or mixed releases before producing the public index.""" + names = [ + f"complex-unzip-tool-v2-v{version}-{target}{values[2]}" + for target, values in TARGETS.items() + ] + expected = set(names) | {name + ".sha256" for name in names} + actual = {p.name for p in directory.iterdir()} - {"SHA256SUMS"} + if actual != expected: + raise ValueError("Release must contain exactly five archives and their hashes") + lines = [] + for name in sorted(names): + digest = hashlib.sha256((directory / name).read_bytes()).hexdigest() + line = f"{digest} {name}\n" + if (directory / (name + ".sha256")).read_text(encoding="utf-8") != line: + raise ValueError(f"Release checksum mismatch: {name}") + lines.append(line) + index = directory / "SHA256SUMS" + index.write_text("".join(lines), encoding="utf-8") + return index + + +def main() -> None: + """Run release helpers without network access.""" + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("version") + commands.add_parser("verify-notes") + commands.add_parser("verify-engines") + pack = commands.add_parser("package") + pack.add_argument("target", choices=TARGETS) + pack.add_argument("--output", type=Path, default=ROOT / "artifacts") + verify = commands.add_parser("verify-assets") + verify.add_argument("directory", type=Path) + args = parser.parse_args() + if args.command == "version": + print(read_version()) + elif args.command == "verify-notes": + read_release_notes() + elif args.command == "verify-engines": + verify_engines() + elif args.command == "package": + print(package(ROOT, args.target, args.output)) + else: + print(verify_assets(args.directory, read_version())) + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py new file mode 100644 index 0000000..b06c48f --- /dev/null +++ b/scripts/smoke_test.py @@ -0,0 +1,156 @@ +"""Exercise source or frozen CLI with nested, encrypted and split archives.""" + +import argparse +import io +import os +import shutil +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + +from complex_unzip_tool_v2.modules.archive_utils import ( + ArchivePasswordError, + readArchiveContentWith7z, + resolve_seven_zip_path, +) + + +def smoke(executable: Path | None = None) -> None: + """Verify native extraction and output bytes without touching user archives.""" + command = ( + [str(executable.resolve())] + if executable + else [sys.executable, "-m", "complex_unzip_tool_v2"] + ) + engine = resolve_seven_zip_path() + with tempfile.TemporaryDirectory(prefix="cuz-smoke-") as temporary: + root = Path(temporary) + if executable: + portable = root / "portable tool" + portable.mkdir() + copied = portable / executable.name + shutil.copy2(executable, copied) + command = [str(copied)] + # The frozen CLI must find this from a different working directory. + (portable / "passwords.txt").write_text("测试 password\n", encoding="utf-8") + + def run(arguments: list[str], cwd: Path = root) -> bytes: + result = subprocess.run( + arguments, + cwd=cwd, + stdin=subprocess.DEVNULL, + capture_output=True, + timeout=120, + env={ + **os.environ, + "PYTHONUTF8": "0" if sys.platform == "win32" else "1", + "PYTHONIOENCODING": "gbk" if sys.platform == "win32" else "utf-8", + }, + ) + if result.returncode: + raise RuntimeError( + f"Smoke command failed (exit {result.returncode}):\n" + + result.stdout.decode("utf-8", errors="replace") + + result.stderr.decode("utf-8", errors="replace") + ) + return result.stdout + + run(command + ["--help"]) + run(command + ["--version"]) + source = root / "source" + source.mkdir() + payload = source / "中文 file.txt" + payload.write_bytes(bytes(range(256)) * 1024) + for case_name in ( + "nested", + "encrypted", + "multipart", + "multipart-file", + "multipart-cloaked-file", + ): + case = root / f"{case_name} archives" + case.mkdir() + if case_name == "nested": + inner = io.BytesIO() + with zipfile.ZipFile(inner, "w") as archive: + archive.writestr(payload.name, payload.read_bytes()) + with zipfile.ZipFile(case / "outer.zip", "w") as archive: + archive.writestr("inner.zip", inner.getvalue()) + elif case_name == "encrypted": + protected = case / "protected.7z" + run( + [ + engine, + "a", + str(protected), + payload.name, + "-p测试 password", + "-mhe=on", + ], + source, + ) + try: + readArchiveContentWith7z(str(protected), password="wrong") + except ArchivePasswordError: + pass + else: + raise AssertionError("Wrong password was accepted") + if not executable: + (case / "passwords.txt").write_text( + "测试 password\n", encoding="utf-8" + ) + else: + run( + [ + engine, + "a", + str(case / "split.7z"), + payload.name, + "-mx=0", + "-v16k", + ], + source, + ) + assert (case / "split.7z.002").is_file() + if case_name == "multipart-cloaked-file": + for part in case.glob("split.7z.*"): + part.rename(part.with_name(part.name + "删除")) + originals = [p for p in case.iterdir() if p.name != "passwords.txt"] + target = case + single_input = case_name in {"multipart-file", "multipart-cloaked-file"} + if single_input: + suffix = "删除" if case_name == "multipart-cloaked-file" else "" + target = case / f"split.7z.001{suffix}" + unrelated = case / "split.zip" + with zipfile.ZipFile(unrelated, "w") as archive: + archive.writestr("unrelated.txt", b"keep me") + unrelated_bytes = unrelated.read_bytes() + output = run(command + ["--permanent-delete", str(target)]) + assert "🚀" in output.decode("utf-8"), case_name + outputs = [p for p in (case / "unzipped").rglob("*") if p.is_file()] + assert any( + p.read_bytes() == payload.read_bytes() for p in outputs + ), case_name + assert all(not p.exists() for p in originals), case_name + if single_input: + assert unrelated.read_bytes() == unrelated_bytes + assert not list(case.glob("split.7z.*")) + assert not (case / ".unzip-rename-history.tmp.json").exists() + print( + "Passed: help, version, nested ZIP, encrypted 7z, wrong password, " + "split 7z (directory, single-file and cloaked input), " + "redirected UTF-8 output" + ) + + +def main() -> None: + """Run against the source CLI or the supplied standalone executable.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--executable", type=Path) + smoke(parser.parse_args().executable) + + +if __name__ == "__main__": + main() diff --git a/tests/test_build.py b/tests/test_build.py new file mode 100644 index 0000000..6bcc74b --- /dev/null +++ b/tests/test_build.py @@ -0,0 +1,85 @@ +import platform +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from scripts import build + + +@pytest.mark.parametrize( + "system,machine,files", + [ + ( + "Windows", + "AMD64", + ["7z/windows-x64/7z.exe", "7z/windows-x64/7z.dll"], + ), + ("Darwin", "arm64", ["7z/macos/7zz"]), + ("Linux", "x86_64", ["7z/linux-x64/7zzs"]), + ("Linux", "aarch64", ["7z/linux-arm64/7zzs"]), + ], +) +def test_spec_packages_only_native_engine(monkeypatch, system, machine, files): + monkeypatch.setattr(platform, "system", lambda: system) + monkeypatch.setattr(platform, "machine", lambda: machine) + root = Path(__file__).resolve().parents[1] + analysis = {} + executable = {} + + def build_executable(*args, **kwargs): + executable.update(kwargs) + assert ("X utf8", None, "OPTION") in args[-1] + + def analyze(*args, **kwargs): + analysis.update(kwargs) + return SimpleNamespace(pure=[], scripts=[], binaries=[], datas=[]) + + exec( + build.generate_spec_content(root, root / "scripts"), + { + "Analysis": analyze, + "PYZ": lambda x: x, + "EXE": build_executable, + }, + ) + assert analysis["binaries"] == [ + (str(root / file), Path(file).parent.as_posix()) for file in files + ] + license_path = Path(files[0]).parent / "License.txt" + assert (str(root / license_path), license_path.parent.as_posix()) in analysis[ + "datas" + ] + assert bool(executable["icon"]) == (system == "Windows") + + +@pytest.mark.parametrize("missing", ["engine", "library", "license"]) +def test_missing_assets_keep_previous_build(monkeypatch, tmp_path, capsys, missing): + monkeypatch.setattr(platform, "system", lambda: "Windows") + monkeypatch.setattr(platform, "machine", lambda: "AMD64") + monkeypatch.setattr(build, "__file__", str(tmp_path / "scripts" / "build.py")) + (tmp_path / "scripts").mkdir() + assets = {"engine": "7z.exe", "library": "7z.dll", "license": "License.txt"} + engine_dir = tmp_path / "7z" / "windows-x64" + engine_dir.mkdir(parents=True) + for kind, name in assets.items(): + if kind != missing: + (engine_dir / name).touch(mode=0o755) + previous = tmp_path / "dist" / "previous.exe" + previous.parent.mkdir() + previous.write_bytes(b"previous build") + assert build.main() == 1 + assert str(engine_dir / assets[missing]) in capsys.readouterr().out + assert previous.read_bytes() == b"previous build" + + +def test_build_does_not_embed_personal_passwords(monkeypatch, tmp_path): + monkeypatch.setattr(platform, "system", lambda: "Darwin") + monkeypatch.setattr(platform, "machine", lambda: "arm64") + engine_dir = tmp_path / "7z" / "macos" + engine_dir.mkdir(parents=True) + (engine_dir / "7zz").touch(mode=0o755) + (engine_dir / "License.txt").touch() + (tmp_path / "passwords.txt").write_text("private-password", encoding="utf-8") + spec = build.generate_spec_content(tmp_path, tmp_path / "scripts") + assert "passwords.txt" not in spec diff --git a/tests/test_file_utils.py b/tests/test_file_utils.py index 2261098..4ff20a2 100644 --- a/tests/test_file_utils.py +++ b/tests/test_file_utils.py @@ -29,7 +29,7 @@ def test_multipart_archive_files(self): def test_path_with_directories(self): """Test with file paths containing directories.""" assert fu.get_archive_base_name("/path/to/test.zip") == ("test", "zip") - assert fu.get_archive_base_name("C:\\folder\\archive.7z.001") == ( + assert fu.get_archive_base_name(os.path.join("folder", "archive.7z.001")) == ( "archive", "7z", ) @@ -598,19 +598,14 @@ def test_no_validation_errors(self): groups = fu.create_groups_by_name(self.test_files) assert len(groups) > 0 # All files should result in groups - def test_7z_not_merged_into_spanned_zip_group(self): + def test_7z_not_merged_into_spanned_zip_group(self, tmp_path): """A standalone .7z sharing a base name with a spanned .zip set must stay in its own group, not get merged into the multipart zip group. Regression for the reported bug where a .7z grouped with a .zip/.z01 set caused the .7z to be deleted (and the zip mishandled). """ - files = [ - r"C:\in\foo.7z", - r"C:\in\foo.zip", - r"C:\in\foo.z01", - r"C:\in\foo.z02", - ] + files = [str(tmp_path / f"foo.{ext}") for ext in ("7z", "zip", "z01", "z02")] groups = fu.create_groups_by_name(files) # Exactly two groups: the standalone 7z, and the spanned zip set. @@ -622,17 +617,13 @@ def test_7z_not_merged_into_spanned_zip_group(self): assert "foo.7z" in by_main sevenz_group = by_main["foo.7z"] assert sevenz_group.isMultiPart is False - assert sevenz_group.files == [r"C:\in\foo.7z"] + assert sevenz_group.files == [files[0]] # The spanned zip set is multipart with .zip as the main entry point. assert "foo.zip" in by_main zip_group = by_main["foo.zip"] assert zip_group.isMultiPart is True - assert set(zip_group.files) == { - r"C:\in\foo.zip", - r"C:\in\foo.z01", - r"C:\in\foo.z02", - } + assert set(zip_group.files) == set(files[1:]) class TestMoveFilesPreservingStructure: diff --git a/tests/test_multipart_file_input.py b/tests/test_multipart_file_input.py new file mode 100644 index 0000000..692f21b --- /dev/null +++ b/tests/test_multipart_file_input.py @@ -0,0 +1,125 @@ +import pytest +import os +from pathlib import Path +from typer.testing import CliRunner + +import complex_unzip_tool_v2.main as main + +from complex_unzip_tool_v2.modules.file_utils import read_dir + + +def test_single_primary_input_collects_only_its_numbered_volumes(tmp_path): + selected = tmp_path / "sample.7z.001" + continuation = tmp_path / "sample.7z.002" + unrelated = ["sample.7z", "sample.zip.002", "sample-other.7z.002", "notes.txt"] + for name in [selected.name, continuation.name, *unrelated]: + (tmp_path / name).write_bytes(b"fixture") + nested = tmp_path / "nested" + nested.mkdir() + (nested / continuation.name).write_bytes(b"other set") + + assert set(read_dir([str(selected)])) == {str(selected), str(continuation)} + + +@pytest.mark.parametrize("primary", ["sample.7z.001", "sample.7z.00删1"]) +def test_file_input_discovers_cloaked_siblings_without_renaming(tmp_path, primary): + names = [primary, "sample.7z.002删除", "sample.7z.0删03"] + unrelated = ["sample.zip.002删除", "other.7z.002删除", "invoice002", "notes.txt"] + for name in names + unrelated: + (tmp_path / name).write_bytes(b"fixture") + before = {p.name: p.read_bytes() for p in tmp_path.iterdir()} + assert set(read_dir([str(tmp_path / primary)])) == { + str(tmp_path / name) for name in names + } + assert {p.name: p.read_bytes() for p in tmp_path.iterdir()} == before + + +@pytest.mark.parametrize("error", [PermissionError, FileNotFoundError]) +def test_unavailable_sibling_directory_preserves_explicit_input( + monkeypatch, tmp_path, error +): + primary = tmp_path / "sample.7z.001" + primary.write_bytes(b"source") + + def unavailable(path): + raise error("Directory unavailable") + + monkeypatch.setattr(os, "scandir", unavailable) + assert read_dir([str(primary)]) == [str(primary)] + assert primary.read_bytes() == b"source" + + +@pytest.mark.parametrize( + "parts,other", + [ + (["a.rar", "a.r00", "a.r01"], "a.part2.rar"), + (["a.part1.rar", "a.part2.rar"], "a.rar"), + (["a.zip", "a.z01", "a.z02"], "a.zip.001"), + (["a.zipx", "a.zx01"], "a.zip"), + (["a.arj", "a.a01"], "a.ace"), + (["a.ace", "a.c00"], "a.arj"), + (["a.iso.001", "a.iso.002"], "a.iso"), + (["a.zip.001", "a.zip.002"], "a.z01"), + (["a.tar.gz.001", "a.tar.gz.002"], "a.tar.bz2.002"), + (["a.7z.1", "a.7z.2"], "a.7z"), + ], +) +def test_file_input_discovers_its_volume_family(monkeypatch, tmp_path, parts, other): + monkeypatch.chdir(tmp_path) + for name in [*parts, other]: + (tmp_path / name).write_bytes(b"fixture") + for selected in parts: + assert set(read_dir([selected])) == set(parts) + assert sorted(read_dir(parts)) == sorted(parts) + + +@pytest.mark.parametrize("outcome", ["success", "failure", "password-skip"]) +@pytest.mark.parametrize("recycle", [False, True]) +@pytest.mark.parametrize("cloaked", [False, True]) +def test_cli_handles_all_selected_set_parts_safely( + monkeypatch, tmp_path, outcome, recycle, cloaked +): + normalized = [tmp_path / f"sample.7z.{n:03d}" for n in (1, 2, 3)] + parts = [Path(str(part) + "删除") for part in normalized] if cloaked else normalized + for part in parts: + part.write_bytes(b"source volume") + unrelated = tmp_path / "unrelated.7z.001" + unrelated.write_bytes(b"unrelated source") + + def extract(archive_path, output_path, **kwargs): + assert Path(archive_path) == normalized[0] + output = Path(output_path) / "result.txt" + output.parent.mkdir() + output.write_text("extracted", encoding="utf-8") + return { + "success": outcome != "failure", + "final_files": [str(output)], + "password_failed_archives": ["nested.7z"] + if outcome == "password-skip" + else [], + } + + monkeypatch.setattr(main.archive_utils, "extract_nested_archives", extract) + monkeypatch.setattr(main.archive_utils, "resolve_seven_zip_path", lambda: "7z") + recycled = [] + + def recycle_file(path): + recycled.append(Path(path).name) + Path(path).unlink() + + monkeypatch.setattr(main.file_utils, "send2trash", recycle_file) + arguments = [str(parts[0])] if recycle else ["--permanent-delete", str(parts[0])] + result = CliRunner().invoke(main.app, arguments) + assert result.exit_code == 0, result.output + if outcome == "success": + assert all(not part.exists() for part in parts) + assert all(not part.exists() for part in normalized) + assert (tmp_path / "unzipped/result.txt").read_text() == "extracted" + assert sorted(recycled) == ( + [part.name for part in normalized] if recycle else [] + ) + else: + assert all(part.read_bytes() == b"source volume" for part in parts) + assert not recycled + assert unrelated.read_bytes() == b"unrelated source" + assert not (tmp_path / ".unzip-rename-history.tmp.json").exists() diff --git a/tests/test_password_book.py b/tests/test_password_book.py new file mode 100644 index 0000000..611e3e3 --- /dev/null +++ b/tests/test_password_book.py @@ -0,0 +1,103 @@ +import importlib +import codecs +import sys +from pathlib import Path + +import pytest + +from complex_unzip_tool_v2.classes.PasswordBook import PasswordBook +from complex_unzip_tool_v2.modules.password_util import load_all_passwords +from typer.testing import CliRunner + +import complex_unzip_tool_v2.main as main + + +@pytest.mark.parametrize("frozen", [False, True]) +def test_password_book_uses_tool_directory_across_working_directories( + monkeypatch, tmp_path, frozen +): + tool = tmp_path / "tool" + target = tmp_path / "archives" + working = tmp_path / "working" + bundle = tmp_path / "bundle" + for directory in (tool, target, working, bundle): + directory.mkdir() + (bundle / "passwords.txt").write_text("embedded-decoy\n", encoding="utf-8") + monkeypatch.setattr(sys, "_MEIPASS", str(bundle), raising=False) + global_book = tool / "passwords.txt" + global_book.write_text("全局密码\n", encoding="utf-8") + target_book = target / "passwords.txt" + target_book.write_text("目标密码\n", encoding="utf-8") + cwd_book = working / "passwords.txt" + cwd_book.write_text("unrelated\n", encoding="utf-8") + monkeypatch.setattr(sys, "frozen", frozen, raising=False) + monkeypatch.setattr(sys, "executable", str(tool / "cuz.exe")) + module = importlib.import_module("complex_unzip_tool_v2.classes.PasswordBook") + monkeypatch.setattr( + module, "__file__", str(tool / "complex_unzip_tool_v2/classes/PasswordBook.py") + ) + monkeypatch.chdir(working) + + book = load_all_passwords([str(target)]) + assert set(book.get_passwords()) == {"全局密码", "目标密码"} + book.add_password("新密码") + monkeypatch.chdir(target) + book.save_passwords() + + assert set(global_book.read_text(encoding="utf-8").splitlines()) == {"全局密码", "新密码"} + assert target_book.read_text(encoding="utf-8") == "目标密码\n" + assert cwd_book.read_text(encoding="utf-8") == "unrelated\n" + assert not book.has_unsaved_changes() + + +def test_cli_finishes_when_password_book_is_not_writable(monkeypatch, tmp_path): + book = PasswordBook() + book.password_file = tmp_path / "read-only" / "passwords.txt" + book.add_password("synthetic-secret") + original_open = open + + def deny_password_write(file, mode="r", *args, **kwargs): + if Path(file) == book.password_file and "w" in mode: + raise PermissionError("Permission denied") + return original_open(file, mode, *args, **kwargs) + + monkeypatch.setattr("builtins.open", deny_password_write) + monkeypatch.setattr(main.password_util, "load_all_passwords", lambda paths: book) + monkeypatch.setattr(main.archive_utils, "resolve_seven_zip_path", lambda: "7z") + result = CliRunner().invoke(main.app, [str(tmp_path)]) + + assert result.exit_code == 0, result.output + assert "Could not save passwords" in result.output + assert "synthetic-secret" not in result.output + assert book.has_unsaved_changes() + assert not (tmp_path / ".unzip-rename-history.tmp.json").exists() + + +@pytest.mark.parametrize( + "encoding,bom", + [ + ("utf-8-sig", b""), + ("gbk", b""), + ("gb2312", b""), + ("utf-16-le", codecs.BOM_UTF16_LE), + ("utf-16-be", codecs.BOM_UTF16_BE), + ], +) +def test_password_book_reads_encoded_books_and_strips_bom( + monkeypatch, tmp_path, encoding, bom +): + module = importlib.import_module("complex_unzip_tool_v2.classes.PasswordBook") + monkeypatch.setattr(sys, "frozen", False, raising=False) + monkeypatch.setattr( + module, + "__file__", + str(tmp_path / "complex_unzip_tool_v2/classes/PasswordBook.py"), + ) + data = bom + "密码\n\n123456\n密码\n".encode(encoding) + path = tmp_path / "passwords.txt" + path.write_bytes(data) + book = PasswordBook() + assert set(book.get_passwords()) == {"密码", "123456"} + assert not book.has_unsaved_changes() + book.save_passwords() + assert path.read_bytes() == data diff --git a/tests/test_platform_cli.py b/tests/test_platform_cli.py new file mode 100644 index 0000000..868ab63 --- /dev/null +++ b/tests/test_platform_cli.py @@ -0,0 +1,134 @@ +import builtins +import os +import platform +import subprocess +import sys +from pathlib import Path + +import pytest +from rich.text import Text +from typer.testing import CliRunner + +import complex_unzip_tool_v2.main as main + + +def test_cli_rejects_unsupported_platform_before_extraction(monkeypatch, tmp_path): + monkeypatch.setattr(platform, "system", lambda: "Linux") + monkeypatch.setattr(platform, "machine", lambda: "riscv64") + archive = tmp_path / "archive.zip删除" + archive.write_bytes(b"unchanged") + monkeypatch.setattr( + main, "extract_files", lambda *a, **k: pytest.fail("Extraction started") + ) + result = CliRunner().invoke(main.app, [str(archive)]) + assert result.exit_code == 1 + assert "Unsupported 7-Zip platform" in Text.from_ansi(result.output).plain + assert list(tmp_path.iterdir()) == [archive] + assert archive.read_bytes() == b"unchanged" + + +@pytest.mark.parametrize("option", ["--help", "--version"]) +def test_cli_information_does_not_require_engine(monkeypatch, option): + monkeypatch.setattr(platform, "system", lambda: "FreeBSD") + assert CliRunner().invoke(main.app, [option]).exit_code == 0 + + +def test_cli_missing_engine_preserves_input(monkeypatch, tmp_path): + monkeypatch.setattr(platform, "system", lambda: "Darwin") + monkeypatch.setattr(platform, "machine", lambda: "arm64") + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path / "missing"), raising=False) + archive = tmp_path / "archive.zip删除" + archive.write_bytes(b"unchanged") + result = CliRunner().invoke(main.app, [str(archive)]) + assert result.exit_code == 1 + assert "7-Zip executable not found" in Text.from_ansi(result.output).plain + assert list(tmp_path.iterdir()) == [archive] + assert archive.read_bytes() == b"unchanged" + + +@pytest.mark.parametrize( + "terminal,interruption", + [(True, None), (False, None), (True, EOFError), (True, KeyboardInterrupt)], +) +def test_missing_engine_keeps_error_visible_without_losing_exit_code( + monkeypatch, tmp_path, terminal, interruption +): + monkeypatch.setattr(platform, "system", lambda: "Windows") + monkeypatch.setattr(platform, "machine", lambda: "AMD64") + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path / "missing"), raising=False) + prompts = [] + + def answer(prompt): + prompts.append(prompt) + if interruption: + raise interruption() + return "" + + monkeypatch.setattr(builtins, "input", answer) + runner = CliRunner() + with runner.isolation(): + monkeypatch.setattr(sys.stdin, "isatty", lambda: terminal) + monkeypatch.setattr(sys.stdout, "isatty", lambda: terminal) + with pytest.raises(SystemExit) as exc: + main.app(args=[str(tmp_path)]) + assert exc.value.code == 1 + assert len(prompts) == int(terminal) + assert not (tmp_path / "unzipped").exists() + + +@pytest.mark.parametrize( + "system,terminal,output_terminal,expected_prompts", + [ + ("Darwin", True, True, 0), + ("Linux", True, True, 0), + ("Windows", False, True, 0), + ("Windows", True, False, 0), + ("Windows", True, True, 1), + ], +) +def test_standalone_exit_only_pauses_in_windows_terminal( + monkeypatch, system, terminal, output_terminal, expected_prompts +): + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(platform, "system", lambda: system) + monkeypatch.setattr(sys.stdin, "isatty", lambda: terminal) + monkeypatch.setattr(sys.stdout, "isatty", lambda: output_terminal) + prompts = [] + monkeypatch.setattr(builtins, "input", lambda prompt: prompts.append(prompt)) + with pytest.raises(SystemExit) as exc: + main._ask_for_user_input_and_exit() + assert exc.value.code == 0 + assert len(prompts) == expected_prompts + + +@pytest.mark.parametrize("destination", ["pipe", "file"]) +def test_windows_cli_redirects_legacy_encoded_output_as_utf8(tmp_path, destination): + # Run the real CLI with GBK streams on any host, selecting Windows behavior. + script = ( + "import platform, sys; platform.system = lambda: 'Windows'; " + "platform.machine = lambda: 'AMD64'; " + "from complex_unzip_tool_v2.main import app; " + "print('错误 🚀', file=sys.stderr); app()" + ) + env = {**os.environ, "PYTHONUTF8": "0", "PYTHONIOENCODING": "gbk"} + command = [sys.executable, "-c", script, str(tmp_path)] + kwargs = dict( + cwd=Path(__file__).resolve().parents[1], + env=env, + stdin=subprocess.DEVNULL, + stderr=subprocess.PIPE, + timeout=30, + ) + if destination == "file": + log = tmp_path.parent / f"{tmp_path.name}.log" + with log.open("wb") as stream: + result = subprocess.run(command, stdout=stream, **kwargs) + output = log.read_bytes() + else: + result = subprocess.run(command, stdout=subprocess.PIPE, **kwargs) + output = result.stdout + assert result.returncode == 0, result.stderr + assert "🚀" in output.decode("utf-8") + assert "错误 🚀" in result.stderr.decode("utf-8") diff --git a/tests/test_publish_release.py b/tests/test_publish_release.py new file mode 100644 index 0000000..2dcf1f1 --- /dev/null +++ b/tests/test_publish_release.py @@ -0,0 +1,166 @@ +"""Exercise the publisher with an in-memory GitHub API; no remote writes.""" + +import shutil +import subprocess +import os +import textwrap +from pathlib import Path + +import pytest + + +@pytest.mark.skipif( + shutil.which("node") is None, reason="Publisher runs in Node on Actions" +) +@pytest.mark.parametrize( + "scenario", + [ + "new", + "resume", + "tag-conflict", + "draft-conflict", + "published", + "upload-failure", + "digest-mismatch", + "missing-notes", + "empty-notes", + ], +) +def test_publication_guards_and_draft_retry(tmp_path, scenario): + publisher = Path(__file__).resolve().parents[1] / "scripts/publish_release.cjs" + script = r""" +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const publish = require(process.argv[1]); +const [dir, scenario] = process.argv.slice(2); +const sha = 'a'.repeat(40); +const other = 'b'.repeat(40); +const tag = 'v1.3.0'; +const notesDir = path.join(dir, 'ReleaseNotes'); +fs.mkdirSync(notesDir); +const notes = '# v1.3.0 发布说明\n\n- Native builds\n'; +if (scenario !== 'missing-notes') { + fs.writeFileSync(path.join(notesDir, 'RELEASE_NOTES_v1.3.0.md'), + scenario === 'empty-notes' ? ' \n\t' : notes); +} +// Another version must never be used as a fallback. +fs.writeFileSync(path.join(notesDir, 'RELEASE_NOTES_v1.2.2.md'), 'Old release'); +const hash = data => 'sha256:' + crypto.createHash('sha256').update(data).digest('hex'); +const names = ['windows-x64.zip', 'macos-x64.tar.gz', 'macos-arm64.tar.gz', + 'linux-x64.tar.gz', 'linux-arm64.tar.gz'].map(x => 'app-' + x); +let checksums = ''; +for (const name of names) { + const data = Buffer.from(name); + fs.writeFileSync(path.join(dir, name), data); + checksums += hash(data).slice(7) + ' ' + name + '\n'; +} +fs.writeFileSync(path.join(dir, 'SHA256SUMS'), checksums); +const effects = []; +let assets = []; +let release = ['resume', 'draft-conflict', 'published'].includes(scenario) + ? {id: 42, tag_name: tag, draft: scenario !== 'published', + target_commitish: scenario === 'draft-conflict' ? other : sha} : null; +if (scenario === 'resume') assets.push({id: 10, name: names[0], digest: 'sha256:old'}); +const repos = { + listReleases: async () => release ? [release] : [], + generateReleaseNotes: async () => { + throw new Error('Do not generate GitHub notes'); + }, + createRelease: async args => { + assert.equal(args.draft, true); + assert.equal(args.target_commitish, sha); + assert.equal(args.body, notes); + effects.push('create'); + release = {id: 42, ...args}; + return {data: release}; + }, + listReleaseAssets: async () => assets, + deleteReleaseAsset: async ({asset_id}) => { + effects.push('delete'); assets = assets.filter(a => a.id !== asset_id); + }, + uploadReleaseAsset: async ({name, data}) => { + effects.push('upload'); + if (scenario === 'upload-failure') throw new Error('upload failed'); + assets.push({id: assets.length + 20, name, + digest: scenario === 'digest-mismatch' ? 'sha256:wrong' : hash(data)}); + }, + updateRelease: async args => { + assert.equal(args.draft, false); + assert.equal(args.body, notes); + assert.equal(args.make_latest, 'legacy'); + assert.equal(assets.length, 6); + effects.push('publish'); + return {data: {html_url: 'https://example.invalid/release'}}; + }, +}; +const github = {rest: {repos, git: { + getRef: async () => { + if (scenario === 'tag-conflict') + return {data: {object: {type: 'commit', sha: other}}}; + throw Object.assign(new Error('not found'), {status: 404}); + }, +}}, paginate: async (method, args) => method(args)}; +(async () => { + let error; + try { await publish({github, context: {repo: {owner: 'owner', repo: 'repo'}}, + core: {info() {}}, tag, sha, assetDir: dir, notesDir}); } + catch (e) { error = e; } + if (['new', 'resume'].includes(scenario)) { + assert.ifError(error); + assert.equal(effects.at(-1), 'publish'); + if (scenario === 'resume') assert.equal(effects.includes('create'), false); + } else { + assert.ok(error, 'unsafe publication must fail'); + assert.equal(effects.includes('publish'), false); + if (['tag-conflict', 'draft-conflict', 'published', + 'missing-notes', 'empty-notes'].includes(scenario)) + assert.deepEqual(effects, []); + if (['missing-notes', 'empty-notes'].includes(scenario)) + assert.match(error.message, /RELEASE_NOTES_v1\.3\.0\.md/); + if (['upload-failure', 'digest-mismatch'].includes(scenario)) + assert.equal(effects.includes('upload'), true); + } +})().catch(e => { console.error(e); process.exitCode = 1; }); +""" + result = subprocess.run( + ["node", "-e", script, str(publisher), str(tmp_path), scenario], + capture_output=True, + text=True, + timeout=20, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.skipif(shutil.which("node") is None, reason="Requires Node") +def test_workflow_loads_publisher_from_workspace_not_action_bundle(tmp_path): + root = Path(__file__).resolve().parents[1] + workflow = (root / ".github/workflows/release.yml").read_text() + script = textwrap.dedent(workflow.split("script: |\n", 1)[1]) + workspace = tmp_path / "workspace" + (workspace / "scripts").mkdir(parents=True) + (workspace / "scripts/publish_release.cjs").write_text( + "module.exports = async args => { console.log(args.tag); };" + ) + action_bundle = tmp_path / "action" / "dist" + action_bundle.mkdir(parents=True) + harness = """ +const {createRequire} = require('node:module'); +const actionFile = require('node:path').join(process.cwd(), 'index.js'); +const actionRequire = createRequire(actionFile); +const AsyncFunction = Object.getPrototypeOf(async function() {}).constructor; +new AsyncFunction('require', 'github', 'context', 'core', process.argv[1])( + actionRequire, {}, {}, {} +).catch(error => { console.error(error); process.exitCode = 1; }); +""" + result = subprocess.run( + ["node", "-e", harness, script], + cwd=action_bundle, + env={**os.environ, "GITHUB_WORKSPACE": str(workspace), "RELEASE_TAG": "v1.3.0"}, + capture_output=True, + text=True, + timeout=20, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "v1.3.0" diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..8da329c --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,147 @@ +import hashlib +import stat +import tarfile +import zipfile + +import pytest + +from scripts import release + + +@pytest.fixture +def project(tmp_path): + (tmp_path / "complex_unzip_tool_v2").mkdir() + (tmp_path / "complex_unzip_tool_v2" / "__init__.py").write_text( + '__version__ = "1.3.0"\n' + ) + (tmp_path / "pyproject.toml").write_text('[tool.poetry]\nversion = "1.3.0"\n') + (tmp_path / ".bumpversion.cfg").write_text( + "[bumpversion]\ncurrent_version = 1.3.0\n" + ) + return tmp_path + + +def test_release_version_matches_all_declarations(project): + assert release.read_version(project) == "1.3.0" + + +def test_release_notes_are_read_from_matching_version_file(project): + notes = "# v1.3.0 发布说明\n\n- Native builds\n" + directory = project / "ReleaseNotes" + directory.mkdir() + (directory / "RELEASE_NOTES_v1.3.0.md").write_text(notes, encoding="utf-8") + assert release.read_release_notes(project) == notes + + +@pytest.mark.parametrize("contents", [None, " \n\t"]) +def test_missing_or_empty_release_notes_fail_without_fallback(project, contents): + directory = project / "ReleaseNotes" + directory.mkdir() + (directory / "RELEASE_NOTES_v1.2.2.md").write_text("Previous version") + if contents is not None: + (directory / "RELEASE_NOTES_v1.3.0.md").write_text(contents) + with pytest.raises(ValueError, match="RELEASE_NOTES_v1.3.0.md"): + release.read_release_notes(project) + + +@pytest.mark.parametrize("bad_version", ["1.2.2", "1.3.0rc1", "1.3"]) +def test_release_version_rejects_inconsistent_or_nonstable_versions( + project, bad_version +): + (project / "pyproject.toml").write_text( + f'[tool.poetry]\nversion = "{bad_version}"\n' + ) + with pytest.raises(ValueError): + release.read_version(project) + + +@pytest.mark.parametrize( + "target,executable,license_path,suffix", + [ + ( + "windows-x64", + "complex-unzip-tool-v2.exe", + "7z/windows-x64/License.txt", + ".zip", + ), + ("macos-arm64", "complex-unzip-tool-v2", "7z/macos/License.txt", ".tar.gz"), + ], +) +def test_package_contains_executable_notices_and_checksum( + project, target, executable, license_path, suffix +): + (project / "dist").mkdir() + binary = project / "dist" / executable + binary.write_bytes(b"standalone program") + binary.chmod(0o755) + # Windows chmod cannot set POSIX execute bits; preserve the host's file mode. + expected_mode = stat.S_IMODE(binary.stat().st_mode) + notice = project / license_path + notice.parent.mkdir(parents=True) + notice.write_text("upstream notice") + for name in ("LICENSE", "README.md", "README.en.md", "7z/manifest.json"): + (project / name).write_text("documentation") + archive = release.package(project, target, project / "artifacts") + assert archive.name == f"complex-unzip-tool-v2-v1.3.0-{target}{suffix}" + expected_names = { + executable, + "LICENSE", + "README.md", + "README.en.md", + "7-Zip-License.txt", + "7-Zip-manifest.json", + } + if suffix == ".zip": + with zipfile.ZipFile(archive) as zipped: + assert set(zipped.namelist()) == expected_names + assert zipped.read(executable) == binary.read_bytes() + else: + with tarfile.open(archive) as tar: + assert set(tar.getnames()) == expected_names + assert tar.getmember(executable).mode == expected_mode + assert tar.extractfile(executable).read() == binary.read_bytes() + assert archive.with_name(archive.name + ".sha256").read_text() == ( + f"{hashlib.sha256(archive.read_bytes()).hexdigest()} {archive.name}\n" + ) + + +def release_assets(directory): + expected = {} + for target in ( + "windows-x64", + "macos-x64", + "macos-arm64", + "linux-x64", + "linux-arm64", + ): + extension = "zip" if target == "windows-x64" else "tar.gz" + archive = directory / f"complex-unzip-tool-v2-v1.3.0-{target}.{extension}" + archive.write_bytes(target.encode()) + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + expected[archive.name] = f"{digest} {archive.name}\n" + archive.with_name(archive.name + ".sha256").write_text( + f"{digest} {archive.name}\n" + ) + return expected + + +def test_verify_complete_release_writes_checksum_index(tmp_path): + expected = release_assets(tmp_path) + index = release.verify_assets(tmp_path, "1.3.0") + assert index.name == "SHA256SUMS" + assert index.read_text() == "".join(expected[name] for name in sorted(expected)) + + +@pytest.mark.parametrize("problem", ["missing", "tampered", "unexpected"]) +def test_incomplete_or_tampered_release_is_rejected(tmp_path, problem): + release_assets(tmp_path) + archive = tmp_path / "complex-unzip-tool-v2-v1.3.0-linux-x64.tar.gz" + if problem == "missing": + archive.unlink() + elif problem == "tampered": + archive.write_bytes(b"different build") + else: + (tmp_path / "unexpected.zip").touch() + with pytest.raises(ValueError): + release.verify_assets(tmp_path, "1.3.0") + assert not (tmp_path / "SHA256SUMS").exists() diff --git a/tests/test_seven_zip_runtime.py b/tests/test_seven_zip_runtime.py new file mode 100644 index 0000000..6821115 --- /dev/null +++ b/tests/test_seven_zip_runtime.py @@ -0,0 +1,102 @@ +import platform +import subprocess +import sys +from pathlib import Path + +import pytest + +from complex_unzip_tool_v2.classes.ArchiveTypes import SevenZipNotFoundError +from complex_unzip_tool_v2.modules import archive_utils as au + + +@pytest.mark.parametrize( + "system,machine,relative", + [ + ("Windows", "AMD64", "7z/windows-x64/7z.exe"), + ("Windows", "x86_64", "7z/windows-x64/7z.exe"), + ("Darwin", "arm64", "7z/macos/7zz"), + ("Darwin", "x86_64", "7z/macos/7zz"), + ("Linux", "x86_64", "7z/linux-x64/7zzs"), + ("Linux", "aarch64", "7z/linux-arm64/7zzs"), + ("Linux", "arm64", "7z/linux-arm64/7zzs"), + ], +) +def test_archive_operations_use_native_bundle( + monkeypatch, tmp_path, system, machine, relative +): + monkeypatch.setattr(platform, "system", lambda: system) + monkeypatch.setattr(platform, "machine", lambda: machine) + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path / "bundle"), raising=False) + engine = tmp_path / "bundle" / relative + engine.parent.mkdir(parents=True) + engine.touch(mode=0o755) + if system == "Windows": + engine.with_suffix(".dll").touch() + archive = tmp_path / "中文 archive.zip" + archive.touch() + monkeypatch.chdir(tmp_path) + calls = [] + + def run(cmd, **kwargs): + calls.append(cmd) + return subprocess.CompletedProcess( + cmd, 0, b"----------\nPath = file.txt\nSize = 5\nFolder = -\n", b"" + ) + + monkeypatch.setattr(subprocess, "run", run) + assert au.readArchiveContentWith7z(str(archive))[0]["name"] == "file.txt" + assert au.extractArchiveWith7z(str(archive), str(tmp_path / "output")) + assert [cmd[0] for cmd in calls] == [str(engine), str(engine)] + assert all(str(archive) in cmd for cmd in calls) + + +def test_explicit_engine_override_on_unsupported_platform(monkeypatch, tmp_path): + monkeypatch.setattr(platform, "system", lambda: "FreeBSD") + engine = tmp_path / "custom 7zz" + engine.touch(mode=0o755) + archive = tmp_path / "archive.zip" + archive.touch() + + def run(cmd, **kwargs): + assert cmd[0] == str(engine) + return subprocess.CompletedProcess(cmd, 0, b"", b"") + + monkeypatch.setattr(subprocess, "run", run) + assert au.extractArchiveWith7z( + str(archive), str(tmp_path / "output"), seven_zip_path=str(engine) + ) + + +@pytest.mark.parametrize("problem", ["missing", "permission", "directory"]) +def test_unusable_explicit_engine_fails_before_output_creation(tmp_path, problem): + engine = tmp_path / "7zz" + if problem == "permission": + if sys.platform == "win32": + pytest.skip("POSIX executable permission") + engine.touch(mode=0o644) + elif problem == "directory": + engine.mkdir() + archive = tmp_path / "archive.zip" + archive.touch() + output = tmp_path / "output" + with pytest.raises(SevenZipNotFoundError): + au.extractArchiveWith7z(str(archive), str(output), seven_zip_path=str(engine)) + assert not output.exists() + + +def test_source_checkout_engine_does_not_depend_on_cwd(monkeypatch, tmp_path): + monkeypatch.setattr(platform, "system", lambda: "Darwin") + monkeypatch.setattr(platform, "machine", lambda: "arm64") + monkeypatch.setattr(sys, "frozen", False, raising=False) + monkeypatch.chdir(tmp_path) + archive = tmp_path / "archive.zip" + archive.touch() + expected = Path(__file__).resolve().parents[1] / "7z" / "macos" / "7zz" + + def run(cmd, **kwargs): + assert cmd[0] == str(expected) + return subprocess.CompletedProcess(cmd, 0, b"", b"") + + monkeypatch.setattr(subprocess, "run", run) + assert au.extractArchiveWith7z(str(archive), str(tmp_path / "output"))