diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e24302e..e5e5307 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,12 @@ --- name: Tests -on: [push, pull_request] +on: + push: + branches: + - master + pull_request: + +permissions: {} jobs: tests: @@ -8,17 +14,20 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - python -m pip install --upgrade pip tox tox-factor + python -m pip install pip==26.1.1 + python -m pip install tox==4.53.1 - name: Run tox targets for ${{ matrix.python-version }} run: | PYVERSION=$(python -c "import sys; print(''.join([str(sys.version_info.major), str(sys.version_info.minor)]))") diff --git a/HISTORY.rst b/HISTORY.rst index 04fc885..5dceb3a 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,6 +2,17 @@ History ======= +0.4.0 (2026-05-13) +------------------ +* Update package to use pyproject.toml +* Fix bug where CRC32 values were not removed for small files (CVE-2026-44722). + pyzipper no longer writes any CRC32 values by default, regardless of file + size. The CRC32 value could be used to brute-force candidate plaintexts by + computing CRC32(candidate) and comparing against the stored value. In + practice, this attack is feasible today only against small or low-entropy + files, as CRC32 exhaustion across a large plaintext space is computationally + prohibitive on current hardware. + 0.3.6 (2022-07-31) ------------------ * Exclude test, not tests, from setup.py packaging diff --git a/LICENSE b/LICENSE index ca9a7b3..6ec914b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,4 @@ -This project is adapted from Python's zipfile module and tests. The intention -is to get as much of this project into mainline Python as possible. +MIT License Copyright (c) 2017 Daniel Hillier diff --git a/LICENSE.python b/LICENSE.python new file mode 100644 index 0000000..9c46f53 --- /dev/null +++ b/LICENSE.python @@ -0,0 +1,287 @@ +pyzipper is licensed under the MIT license; see the file LICENSE for details. + +pyzipper is forked from the Python standard library and its tests, which are +licensed under the Python license, a permissive open source license. The +copyright and license is included below for compliance with Python's terms. + +---------------------------------------------------------------------- + +Copyright (c) 2001-present Python Software Foundation; All Rights Reserved + +A. HISTORY OF THE SOFTWARE +========================== + +Python was created in the early 1990s by Guido van Rossum at Stichting +Mathematisch Centrum (CWI, see https://www.cwi.nl) in the Netherlands +as a successor of a language called ABC. Guido remains Python's +principal author, although it includes many contributions from others. + +In 1995, Guido continued his work on Python at the Corporation for +National Research Initiatives (CNRI, see https://www.cnri.reston.va.us) +in Reston, Virginia where he released several versions of the +software. + +In May 2000, Guido and the Python core development team moved to +BeOpen.com to form the BeOpen PythonLabs team. In October of the same +year, the PythonLabs team moved to Digital Creations, which became +Zope Corporation. In 2001, the Python Software Foundation (PSF, see +https://www.python.org/psf/) was formed, a non-profit organization +created specifically to own Python-related Intellectual Property. +Zope Corporation was a sponsoring member of the PSF. + +All Python releases are Open Source (see https://opensource.org for +the Open Source Definition). Historically, most, but not all, Python +releases have also been GPL-compatible; the table below summarizes +the various releases. + + Release Derived Year Owner GPL- + from compatible? (1) + + 0.9.0 thru 1.2 1991-1995 CWI yes + 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes + 1.6 1.5.2 2000 CNRI no + 2.0 1.6 2000 BeOpen.com no + 1.6.1 1.6 2001 CNRI yes (2) + 2.1 2.0+1.6.1 2001 PSF no + 2.0.1 2.0+1.6.1 2001 PSF yes + 2.1.1 2.1+2.0.1 2001 PSF yes + 2.1.2 2.1.1 2002 PSF yes + 2.1.3 2.1.2 2002 PSF yes + 2.2 and above 2.1.1 2001-now PSF yes + +Footnotes: + +(1) GPL-compatible doesn't mean that we're distributing Python under + the GPL. All Python licenses, unlike the GPL, let you distribute + a modified version without making your changes open source. The + GPL-compatible licenses make it possible to combine Python with + other software that is released under the GPL; the others don't. + +(2) According to Richard Stallman, 1.6.1 is not GPL-compatible, + because its license has a choice of law clause. According to + CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 + is "not incompatible" with the GPL. + +Thanks to the many outside volunteers who have worked under Guido's +direction to make these releases possible. + + +B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON +=============================================================== + +Python software and documentation are licensed under the +Python Software Foundation License Version 2. + +Starting with Python 3.8.6, examples, recipes, and other code in +the documentation are dual licensed under the PSF License Version 2 +and the Zero-Clause BSD license. + +Some software incorporated into Python is under different licenses. +The licenses are listed with code falling under that license. + + +PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +-------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001 Python Software Foundation; All Rights Reserved" +are retained in Python alone or in any derivative version prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 +------------------------------------------- + +BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 + +1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an +office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the +Individual or Organization ("Licensee") accessing and otherwise using +this software in source or binary form and its associated +documentation ("the Software"). + +2. Subject to the terms and conditions of this BeOpen Python License +Agreement, BeOpen hereby grants Licensee a non-exclusive, +royalty-free, world-wide license to reproduce, analyze, test, perform +and/or display publicly, prepare derivative works, distribute, and +otherwise use the Software alone or in any derivative version, +provided, however, that the BeOpen Python License is retained in the +Software, alone or in any derivative version prepared by Licensee. + +3. BeOpen is making the Software available to Licensee on an "AS IS" +basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE +SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS +AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY +DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +5. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +6. This License Agreement shall be governed by and interpreted in all +respects by the law of the State of California, excluding conflict of +law provisions. Nothing in this License Agreement shall be deemed to +create any relationship of agency, partnership, or joint venture +between BeOpen and Licensee. This License Agreement does not grant +permission to use BeOpen trademarks or trade names in a trademark +sense to endorse or promote products or services of Licensee, or any +third party. As an exception, the "BeOpen Python" logos available at +http://www.pythonlabs.com/logos.html may be used according to the +permissions granted on that web page. + +7. By copying, installing or otherwise using the software, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 +--------------------------------------- + +1. This LICENSE AGREEMENT is between the Corporation for National +Research Initiatives, having an office at 1895 Preston White Drive, +Reston, VA 20191 ("CNRI"), and the Individual or Organization +("Licensee") accessing and otherwise using Python 1.6.1 software in +source or binary form and its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, CNRI +hereby grants Licensee a nonexclusive, royalty-free, world-wide +license to reproduce, analyze, test, perform and/or display publicly, +prepare derivative works, distribute, and otherwise use Python 1.6.1 +alone or in any derivative version, provided, however, that CNRI's +License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) +1995-2001 Corporation for National Research Initiatives; All Rights +Reserved" are retained in Python 1.6.1 alone or in any derivative +version prepared by Licensee. Alternately, in lieu of CNRI's License +Agreement, Licensee may substitute the following text (omitting the +quotes): "Python 1.6.1 is made available subject to the terms and +conditions in CNRI's License Agreement. This Agreement together with +Python 1.6.1 may be located on the internet using the following +unique, persistent identifier (known as a handle): 1895.22/1013. This +Agreement may also be obtained from a proxy server on the internet +using the following URL: http://hdl.handle.net/1895.22/1013". + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python 1.6.1 or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python 1.6.1. + +4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" +basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. This License Agreement shall be governed by the federal +intellectual property law of the United States, including without +limitation the federal copyright law, and, to the extent such +U.S. federal law does not apply, by the law of the Commonwealth of +Virginia, excluding Virginia's conflict of law provisions. +Notwithstanding the foregoing, with regard to derivative works based +on Python 1.6.1 that incorporate non-separable material that was +previously distributed under the GNU General Public License (GPL), the +law of the Commonwealth of Virginia shall govern this License +Agreement only as to issues arising under or with respect to +Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this +License Agreement shall be deemed to create any relationship of +agency, partnership, or joint venture between CNRI and Licensee. This +License Agreement does not grant permission to use CNRI trademarks or +trade name in a trademark sense to endorse or promote products or +services of Licensee, or any third party. + +8. By clicking on the "ACCEPT" button where indicated, or by copying, +installing or otherwise using Python 1.6.1, Licensee agrees to be +bound by the terms and conditions of this License Agreement. + + ACCEPT + + +CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 +-------------------------------------------------- + +Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, +The Netherlands. All rights reserved. + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of Stichting Mathematisch +Centrum or CWI not be used in advertising or publicity pertaining to +distribution of the software without specific, written prior +permission. + +STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO +THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE +FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION +---------------------------------------------------------------------- + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in index 1613d90..8e9bc1d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,5 @@ +include AUTHORS.rst include LICENSE +include LICENSE.python include README.rst prune test diff --git a/README.rst b/README.rst index d9784d8..f8f810a 100644 --- a/README.rst +++ b/README.rst @@ -1,12 +1,3 @@ -.. image:: https://img.shields.io/pypi/v/pyzipper.svg - :target: https://pypi.org/project/pyzipper/ - :alt: Current Version on PyPi - -.. image:: https://img.shields.io/pypi/pyversions/pyzipper.svg - :target: https://pypi.org/project/pyzipper/ - :alt: Supported Python Versions - - pyzipper ======== @@ -44,6 +35,43 @@ Usage my_secrets = zf.read('test.txt') +CRC32 Values +------------ + +From pyzipper v0.4.0 onwards, no CRC32 values are included in the file headers +by default. Prior to v0.4.0 (2026-05-13), a bug resulted in CRC32 values +erroneously being included for small files (<20 uncompressed bytes). + +From the `WZ AES FAQ`_ about CRC values: + + Within the Zip format, the primary use of the CRC value is to detect accidental corruption of data that has been stored in the Zip file. With files encrypted according to the Zip 2.0 encryption specification, it also functions to some extent as a method of detecting deliberate attempts to modify the encrypted data, but not one that can be considered cryptographically strong. The CRC is not needed for these purposes with the WinZip AES encryption specification, where the HMAC-SHA1-based authentication code instead serves these roles. + + The CRC has a drawback in that for very small files, such as files with four or fewer bytes, the CRC can be used, independent of the encryption algorithm, to determine the unencrypted contents of the file. And, in general, it is preferable to store as little information as possible about the encrypted file in the unencrypted Zip headers. + + The CRC does serve one purpose that the authentication code does not. The CRC is computed based on the original uncompressed, unencrypted contents of the file, and it is checked after the file has been decrypted and decompressed. In contrast, the authentication code used with WinZip AES encryption is computed after compression/encryption and it is checked before decryption/decompression. In the very rare event of a hardware or software error that corrupts data during compression and encryption, or during decryption and decompression, the CRC will catch the error, but the authentication code will not. + +You can conditionally enable the inclusion of the CRC32 values for files with +at least ``min_bytes_to_include_crc`` uncompressed bytes using the +following code: + +.. code-block:: python + + with pyzipper.AESZipFile( + 'new_test.zip', + 'w', + compression=pyzipper.ZIP_LZMA, + ) as zf: + zf.setencryption( + pyzipper.WZ_AES, + conditionally_include_crc=True, + min_bytes_to_include_crc=, + ) + ... + +The lowest number of bytes ``min_bytes_to_include_crc`` may be set to is +``20``, as in `WZ AES FAQ`_. + + AES Strength ------------ @@ -61,7 +89,7 @@ encryption kwargs: 'w', compression=pyzipper.ZIP_LZMA) as zf: zf.setpassword(secret_password) - zf.setencryption(pyzipper.WZ_AES, nbits=128) + zf.setencryption(pyzipper.WZ_AES, nbits=256) zf.writestr('test.txt', "What ever you do, don't tell anyone!") with pyzipper.AESZipFile('new_test.zip') as zf: @@ -80,3 +108,4 @@ The docs skeleton was created with Cookiecutter_ and the `audreyr/cookiecutter-p .. _Cookiecutter: https://github.com/audreyr/cookiecutter .. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage +.. _`WZ AES FAQ`: https://www.winzip.com/en/support/aes-encryption/#crc-faq diff --git a/SECURITY.rst b/SECURITY.rst new file mode 100644 index 0000000..6cf525a --- /dev/null +++ b/SECURITY.rst @@ -0,0 +1,11 @@ +Security +======== + +If you have discovered a security vulnerability in this project, please report it +privately. **Do not disclose it as a public issue.** This gives us time to work with you +to fix the issue before public exposure. + +Please submit the report as a `Github draft security advisory`_. Please include +a description of the impact and how to reproduce it. + +.. _`Github draft security advisory`: https://github.com/danifus/pyzipper/security/advisories diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index b9c5736..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,78 +0,0 @@ -# As config was originally based on an example by Olivier Grisel. Thanks! -# https://github.com/pyinstaller/pyinstaller/blob/develop/appveyor.yml - -clone_depth: 50 - -environment: - PYTEST: py.test -n3 --maxfail 5 --durations=10 --junitxml=junit-results.xml - # uncomment to disable cache restore -# APPVEYOR_CACHE_SKIP_RESTORE: true - APPVEYOR_SAVE_CACHE_ON_ERROR: true - - matrix: - - PYTHON: C:\Python36-x64 - PYTHON_VERSION: 3.6 - PYTHON_ARCH: 64 - -init: - - "ECHO %PYTHON% %PYTHON_VERSION% %PYTHON_ARCH%" - - "ECHO \"%APPVEYOR_SCHEDULED_BUILD%\"" - # If there is a newer build queued for the same PR, cancel this one. - # The AppVeyor 'rollout builds' option is supposed to serve the same - # purpose but it is problematic because it tends to cancel builds pushed - # directly to master instead of just PR builds (or the converse). - # credits: JuliaLang developers. - - ps: if ($env:APPVEYOR_PULL_REQUEST_NUMBER -and $env:APPVEYOR_BUILD_NUMBER -ne ((Invoke-RestMethod ` - https://ci.appveyor.com/api/projects/$env:APPVEYOR_ACCOUNT_NAME/$env:APPVEYOR_PROJECT_SLUG/history?recordsNumber=50).builds | ` - Where-Object pullRequestId -eq $env:APPVEYOR_PULL_REQUEST_NUMBER)[0].buildNumber) { ` - throw "There are newer queued builds for this pull request, failing early." } - -cache: - # Cache downloaded pip packages and built wheels. - - '%LOCALAPPDATA%\pip\Cache\http' - - '%LOCALAPPDATA%\pip\Cache\wheels' - # Cache Tox envs - - 'C:\projects\pyzipper\.tox' - - -install: - # Show size of cache - - if exist "%LOCALAPPDATA%\pip\Cache" C:\cygwin\bin\du -hs "%LOCALAPPDATA%\pip\Cache" - - if exist "C:\projects\pyzipper\.tox" C:\cygwin\bin\du -hs "C:\projects\pyzipper\.tox" - # set python path - - SET PATH=%PYTHON%;%PYTHON%\Scripts;%PATH% - - # Upgrade to the latest pip. - # calling pip with "%CMD_IN_ENV% python -m" prevents an access denied error - - '%CMD_IN_ENV% python -m pip install -U pip setuptools wheel' - - # install tox - - '%CMD_IN_ENV% python -m pip install -U tox coverage' - -build: none - -test_script: - # delete old .coverage file - - coverage erase - - tox -e py{36,37} - -artifacts: - - path: .coverage - -on_finish: - # Remove old or huge cache files to hopefully not exceed the 1GB cache limit. - # - # If the cache limit is reached, the cache will not be updated (of not even - # created in the first run). So this is a trade of between keeping the cache - # current and having a cache at all. - # NB: This is done only `on_success` since the cache in uploaded only on - # success anyway. - - C:\cygwin\bin\find "%LOCALAPPDATA%\pip" -type f -mtime +360 -delete - - C:\cygwin\bin\find "%LOCALAPPDATA%\pip" -type f -size +10M -delete - - C:\cygwin\bin\find "%LOCALAPPDATA%\pip" -empty -delete - - C:\cygwin\bin\find "C:\projects\pyzipper\.tox" -type f -mtime +360 -delete - - C:\cygwin\bin\find "C:\projects\pyzipper\.tox" -empty -delete - # Show size of cache - - C:\cygwin\bin\du -hs "%LOCALAPPDATA%\pip\Cache" - - C:\cygwin\bin\du -hs "C:\projects\pyzipper\.tox" - - \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index b208991..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,25 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -W -SPHINXBUILD = python -msphinx -SPHINXPROJ = pyzipper -SOURCEDIR = . -BUILDDIR = _build -API_TOCTREE_DIR = api - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -clean_all: - rm -rf $(BUILDDIR)/* - rm -rf $(API_TOCTREE_DIR)/* - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst deleted file mode 100644 index 633a3e5..0000000 --- a/docs/_templates/autosummary/class.rst +++ /dev/null @@ -1,50 +0,0 @@ -{{ objname | escape | underline}} - -.. currentmodule:: {{ module }} - -.. autoclass:: {{ objname }} - - {% block attributes_summary %} - {% if attributes %} - - .. rubric:: Attributes Summary - - .. autosummary:: - {% for item in attributes %} - ~{{ item }} - {%- endfor %} - - {% endif %} - {% endblock %} - - {% block methods_summary %} - {% if methods %} - - {% if '__init__' in methods %} - {% set caught_result = methods.remove('__init__') %} - {% endif %} - - .. rubric:: Methods Summary - - .. autosummary:: - :toctree: {{ objname }}/methods - :nosignatures: - - {% for item in methods %} - ~{{ name }}.{{ item }} - {%- endfor %} - - {% endif %} - {% endblock %} - - {% block methods_documentation %} - {% if methods %} - - .. rubric:: Methods Documentation - - {% for item in methods %} - .. automethod:: {{ name }}.{{ item }} - {%- endfor %} - - {% endif %} - {% endblock %} diff --git a/docs/_templates/autosummary/method.rst b/docs/_templates/autosummary/method.rst deleted file mode 100644 index fb55241..0000000 --- a/docs/_templates/autosummary/method.rst +++ /dev/null @@ -1,4 +0,0 @@ -{{ name | escape | underline }} - -.. automethod:: {{ fullname }} - :noindex: diff --git a/docs/authors.rst b/docs/authors.rst deleted file mode 100644 index e122f91..0000000 --- a/docs/authors.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../AUTHORS.rst diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 2d86e59..0000000 --- a/docs/conf.py +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# -# pyzipper documentation build configuration file, created by -# sphinx-quickstart on Fri Jun 9 13:47:02 2017. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -# If extensions (or modules to document with autodoc) are in another -# directory, add these directories to sys.path here. If the directory is -# relative to the documentation root, use os.path.abspath to make it -# absolute, like shown here. -# -import os -import sys -sys.path.insert(0, os.path.abspath('..')) - -import pyzipper - -# -- General configuration --------------------------------------------- - -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', - 'sphinx.ext.autosummary', - 'sphinx.ext.viewcode', - 'sphinx.ext.napoleon'] - -autoclass_content = "both" -autosummary_generate = True -add_module_names = False -autodoc_member_order = "bysource" - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = '.rst' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = u'pyzipper' -copyright = u"2018, Daniel Hillier" -author = u"Daniel Hillier" - -# The version info for the project you're documenting, acts as replacement -# for |version| and |release|, also used in various other places throughout -# the built documents. -# -# The short X.Y version. -version = pyzipper.__version__ -# The full version, including alpha/beta/rc tags. -release = pyzipper.__version__ - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = False - - -# -- Options for HTML output ------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = 'sphinx_rtd_theme' - -# Theme options are theme-specific and customize the look and feel of a -# theme further. For a list of options available for each theme, see the -# documentation. -# -# html_theme_options = {} - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -# html_static_path = ['_static'] - - -# -- Options for HTMLHelp output --------------------------------------- - -# Output file base name for HTML help builder. -htmlhelp_basename = 'pyzipperdoc' - - -# -- Options for LaTeX output ------------------------------------------ - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass -# [howto, manual, or own class]). -latex_documents = [ - (master_doc, 'pyzipper.tex', - u'pyzipper Documentation', - u'Daniel Hillier', 'manual'), -] - - -# -- Options for manual page output ------------------------------------ - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'pyzipper', - u'pyzipper Documentation', - [author], 1) -] - - -# -- Options for Texinfo output ---------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - (master_doc, 'pyzipper', - u'pyzipper Documentation', - author, - 'pyzipper', - 'Python AES encryption for zipfile.', - 'Miscellaneous'), -] - - - diff --git a/docs/contributing.rst b/docs/contributing.rst deleted file mode 100644 index e582053..0000000 --- a/docs/contributing.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../CONTRIBUTING.rst diff --git a/docs/history.rst b/docs/history.rst deleted file mode 100644 index 2506499..0000000 --- a/docs/history.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../HISTORY.rst diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 43d4b37..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,20 +0,0 @@ -Welcome to pyzipper's documentation! -====================================== - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - readme - installation - usage - modules - contributing - authors - history - -Indices and tables -================== -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/docs/installation.rst b/docs/installation.rst deleted file mode 100644 index 73a5ea2..0000000 --- a/docs/installation.rst +++ /dev/null @@ -1,51 +0,0 @@ -.. highlight:: shell - -============ -Installation -============ - - -Stable release --------------- - -To install pyzipper, run this command in your terminal: - -.. code-block:: console - - $ pip install pyzipper - -This is the preferred method to install pyzipper, as it will always install the most recent stable release. - -If you don't have `pip`_ installed, this `Python installation guide`_ can guide -you through the process. - -.. _pip: https://pip.pypa.io/en/stable/ -.. _Python installation guide: https://docs.python-guide.org/starting/installation/ - - -From sources ------------- - -The sources for pyzipper can be downloaded from the `Github repo`_. - -You can either clone the public repository: - -.. code-block:: console - - $ git clone git://github.com/danifus/pyzipper - -Or download the `tarball`_: - -.. code-block:: console - - $ curl -OL https://github.com/danifus/pyzipper/tarball/master - -Once you have a copy of the source, you can install it with: - -.. code-block:: console - - $ python setup.py install - - -.. _Github repo: https://github.com/danifus/pyzipper -.. _tarball: https://github.com/danifus/pyzipper/tarball/master diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 98d1e65..0000000 --- a/docs/make.bat +++ /dev/null @@ -1,47 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=python -msphinx -) -set SOURCEDIR=. -set BUILDDIR=_build -set API_TOCTREE_DIR=api -set SPHINXPROJ=pyzipper -set SPHINXOPTS=-W - -if "%1" == "" goto help - -if "%1" == "clean_all" ( - for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i - del /q /s %BUILDDIR%\* - for /d %%i in (%API_TOCTREE_DIR%\*) do rmdir /q /s %%i - del /q /s %API_TOCTREE_DIR%\* - goto end -) - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The Sphinx module was not found. Make sure you have Sphinx installed, - echo.then set the SPHINXBUILD environment variable to point to the full - echo.path of the 'sphinx-build' executable. Alternatively you may add the - echo.Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% -goto end - - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% - -:end -popd diff --git a/docs/modules.rst b/docs/modules.rst deleted file mode 100644 index ddb687f..0000000 --- a/docs/modules.rst +++ /dev/null @@ -1,15 +0,0 @@ -================= -API Documentation -================= - -This is the detailed documentation of ``AESZipFile``, which -holds all the functionality of ``pyzipper``. - -.. currentmodule:: pyzipper.zipfile_aes - -.. autosummary:: - :nosignatures: - :toctree: api/ - - AESZipFile - AESZipInfo diff --git a/docs/readme.rst b/docs/readme.rst deleted file mode 100644 index 72a3355..0000000 --- a/docs/readme.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../README.rst diff --git a/docs/usage.rst b/docs/usage.rst deleted file mode 100644 index f7f212f..0000000 --- a/docs/usage.rst +++ /dev/null @@ -1,45 +0,0 @@ -===== -Usage -===== - -.. code-block:: python - - import pyzipper - - secret_password = b'lost art of keeping a secret' - - with pyzipper.AESZipFile('new_test.zip', - 'w', - compression=pyzipper.ZIP_LZMA, - encryption=pyzipper.WZ_AES) as zf: - zf.pwd = secret_password - zf.writestr('test.txt', "What ever you do, don't tell anyone!") - - with pyzipper.AESZipFile('new_test.zip') as zf: - zf.pwd = secret_password - my_secrets = zf.read('test.txt') - - -AES Strength ------------- - -The strength of the AES encryption can be configure to be 128, 192 or 256 bits. -By default it is 256 bits. Use the ``setencryption()`` method to specify the -encryption kwargs: - -.. code-block:: python - - import pyzipper - - secret_password = b'lost art of keeping a secret' - - with pyzipper.AESZipFile('new_test.zip', - 'w', - compression=pyzipper.ZIP_LZMA) as zf: - zf.pwd = secret_password - zf.setencryption(pyzipper.WZ_AES, nbits=128) - zf.writestr('test.txt', "What ever you do, don't tell anyone!") - - with pyzipper.AESZipFile('new_test.zip') as zf: - zf.pwd = secret_password - my_secrets = zf.read('test.txt') diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d26a591 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[build-system] +requires = ["setuptools>=77.0.3"] +build-backend = "setuptools.build_meta" + +[project] +name = "pyzipper" +dynamic = ["version"] +requires-python = ">= 3.4" +dependencies = [ + 'pycryptodomex', +] +authors = [ + {name = "Daniel Hillier", email = "daniel.hillier@gmail.com"}, +] +description = 'AES encryption for zipfile.' +readme = "README.rst" +license = "MIT" +license-files = ["LICENSE", "LICENSE.python", "AUTHORS.rst"] +classifiers = [ + 'Development Status :: 4 - Beta', + 'Topic :: System :: Archiving :: Compression', + 'Intended Audience :: Developers', + 'Operating System :: MacOS :: MacOS X', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: POSIX :: Linux', + 'Operating System :: POSIX', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', + 'Programming Language :: Python :: 3.14', +] + +[project.urls] +Homepage = "https://github.com/danifus/pyzipper" + +[tool.setuptools.dynamic] +version = {attr = "pyzipper.__version__.__version__"} diff --git a/pyzipper/__version__.py b/pyzipper/__version__.py index fad2bed..9f59e6c 100644 --- a/pyzipper/__version__.py +++ b/pyzipper/__version__.py @@ -1,3 +1,3 @@ -VERSION = (0, 3, 6) +VERSION = (0, 4, 0) __version__ = '.'.join(map(str, VERSION)) diff --git a/pyzipper/zipfile.py b/pyzipper/zipfile.py index ae46a65..ed5ddc2 100644 --- a/pyzipper/zipfile.py +++ b/pyzipper/zipfile.py @@ -1628,6 +1628,8 @@ def close(self): self._zinfo.compress_size = self._compress_size self._zinfo.CRC = self._crc self._zinfo.file_size = self._file_size + if self._encrypter: + self._encrypter.finalize_zipinfo(self._zinfo) if not self._zip64: if self._file_size > ZIP64_LIMIT: diff --git a/pyzipper/zipfile_aes.py b/pyzipper/zipfile_aes.py index ddfd84a..138146d 100644 --- a/pyzipper/zipfile_aes.py +++ b/pyzipper/zipfile_aes.py @@ -87,10 +87,24 @@ def check_hmac(self, hmac_check): class BaseZipEncrypter: def update_zipinfo(self, zipinfo): + """Called when zip entry is opened for write and the encrypted is configured.""" raise NotImplementedError( 'BaseZipEncrypter implementations must implement `update_zipinfo`.' ) + def finalize_zipinfo(self, zipinfo): + """Updates the zipinfo instance when the zip entry is being closed. + + It may be possible to update some fields based on information that + wasn't available when update_zipinfo was called. This function should + not change properties that would need to be written to the local file + header if the underlying zip is not seekable as it isn't possible to + return to the local file header to rewrite the new values. + """ + raise NotImplementedError( + 'BaseZipEncrypter implementations must implement `finalize_zipinfo`.' + ) + def encrypt(self, data): raise NotImplementedError( 'BaseZipEncrypter implementations must implement `encrypt`.' @@ -110,7 +124,14 @@ class AESZipEncrypter(BaseZipEncrypter): hmac_size = 10 - def __init__(self, pwd, nbits=256, force_wz_aes_version=None): + def __init__( + self, + pwd, + nbits=256, + force_wz_aes_version=None, + conditionally_include_crc=None, + min_bytes_to_include_crc=None, + ): if not pwd: raise RuntimeError( '%s encryption requires a password.' % WZ_AES @@ -120,8 +141,41 @@ def __init__(self, pwd, nbits=256, force_wz_aes_version=None): raise RuntimeError( "`nbits` must be one of 128, 192, 256. Got '%s'" % nbits ) + if conditionally_include_crc is not None: + if ( + conditionally_include_crc is not True + and conditionally_include_crc is not False + ): + raise ValueError( + "`conditionally_include_crc` must be True or False or None" + ) + if conditionally_include_crc: + if min_bytes_to_include_crc is None: + raise ValueError( + "`min_bytes_to_include_crc` must be set if `conditionally_include_crc` is True" # noqa: E501 + ) + if min_bytes_to_include_crc < 20: + raise ValueError( + "`min_bytes_to_include_crc` must be 20 or greater" + ) + if min_bytes_to_include_crc is not None and not conditionally_include_crc: + raise ValueError( + "`conditionally_include_crc` must be True if `min_bytes_to_include_crc` is set" + ) + + if force_wz_aes_version is not None: + if force_wz_aes_version not in (WZ_AES_V1, WZ_AES_V2): + raise ValueError( + "`force_wz_aes_version` must be WZ_AES_V1 (1) or WZ_AES_V2 (2)" + ) + if conditionally_include_crc: + raise ValueError( + "`force_wz_aes_version` and `conditionally_include_crc` must not be specified at the same time." # noqa: E501 + ) self.force_wz_aes_version = force_wz_aes_version + self.conditionally_include_crc = conditionally_include_crc + self.min_bytes_to_include_crc = min_bytes_to_include_crc salt_lengths = { 128: 8, 192: 12, @@ -157,11 +211,37 @@ def __init__(self, pwd, nbits=256, force_wz_aes_version=None): encmac_key = keymaterial[key_length:2*key_length] self.hmac = HMAC.new(encmac_key, digestmod=SHA1Hash()) + def compute_aes_version(self, zipinfo): + # The only difference between version 1 and 2 is the + # handling of the CRC values. For version 2 the CRC value + # is not used and must be set to 0. + # For small files, the CRC files can leak the contents of + # the encrypted data. + if self.force_wz_aes_version is not None: + return self.force_wz_aes_version + elif zipinfo.compress_type == ZIP_BZIP2: + # For bzip2, the compression already has integrity checks + # so CRC is not required. + return WZ_AES_V2 + elif ( + self.conditionally_include_crc + and zipinfo.file_size >= self.min_bytes_to_include_crc + ): + return WZ_AES_V1 + return WZ_AES_V2 + def update_zipinfo(self, zipinfo): zipinfo.wz_aes_vendor_id = WZ_AES_VENDOR_ID zipinfo.wz_aes_strength = self.aes_strength - if self.force_wz_aes_version is not None: - zipinfo.wz_aes_version = self.force_wz_aes_version + zipinfo.wz_aes_version = self.compute_aes_version(zipinfo) + + def finalize_zipinfo(self, zipinfo): + # If we can't seek back, we have to keep the aes version we specified + # when the local file header was written. If the file size is unknown, + # this should be ae-2 + if not zipinfo.use_datadescripter: + # We can go back and change the aes version we specified earlier. + zipinfo.wz_aes_version = self.compute_aes_version(zipinfo) def encryption_header(self): return self.salt + self.encpwdverify @@ -223,28 +303,15 @@ def encode_extra(self, crc, compress_type): wz_aes_extra = b'' if self.wz_aes_vendor_id is not None: compress_type = WZ_AES_COMPRESS_TYPE - aes_version = self.wz_aes_version - if aes_version is None: - if self.file_size < 20 | self.compress_type == ZIP_BZIP2: - # The only difference between version 1 and 2 is the - # handling of the CRC values. For version 2 the CRC value - # is not used and must be set to 0. - # For small files, the CRC files can leak the contents of - # the encrypted data. - # For bzip2, the compression already has integrity checks - # so CRC is not required. - aes_version = WZ_AES_V2 - else: - aes_version = WZ_AES_V1 - - if aes_version == WZ_AES_V2: + + if self.wz_aes_version == WZ_AES_V2: crc = 0 wz_aes_extra = struct.pack( "<3H2sBH", EXTRA_WZ_AES, 7, # extra block body length: H2sBH - aes_version, + self.wz_aes_version, self.wz_aes_vendor_id, self.wz_aes_strength, self.compress_type, @@ -271,6 +338,11 @@ def encode_central_directory(self, *, crc, compress_type, extra_data, extra_data=extra_data+wz_aes_extra, **kwargs) + def encode_datadescripter(self, zip64, crc, compress_size, file_size): + if self.wz_aes_version == WZ_AES_V2: + crc = 0 + return super().encode_datadescripter(zip64, crc, compress_size, file_size) + class AESZipExtFile(ZipExtFile): diff --git a/readthedocs.yml b/readthedocs.yml deleted file mode 100644 index 4057211..0000000 --- a/readthedocs.yml +++ /dev/null @@ -1,6 +0,0 @@ -build: - image: latest - -python: - version: 3.6 - setup_py_install: true diff --git a/requirements_build.txt b/requirements_build.txt new file mode 100644 index 0000000..b54105b --- /dev/null +++ b/requirements_build.txt @@ -0,0 +1,3 @@ +pip==26.1.1 +twine==6.2.0 +build==1.5.0 diff --git a/requirements_dev.txt b/requirements_dev.txt index 8aa2767..72c7aff 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,18 +1,8 @@ -pip>=18.1 -bumpversion>=0.5.3 -twine>=1.12.1 -wheel>=0.32.1 - # fixed version of pycryptodomex so pyup bot can send PR's on updates -pycryptodomex==3.7.3 - -# documentation dependencies -Sphinx>=1.8.1 -sphinx-rtd-theme>=0.3.1 +pycryptodomex==3.23.0 # testing dependencies -flake8>=3.5.0 -tox>=3.5.2 -pytest>=4.0.0 -pytest-cov>=2.5.1 -coverage>=4.5.1 +flake8==7.3.0 +tox==4.53.1 +pytest==9.0.3 +pytest-cov==7.1.0 diff --git a/setup.py b/setup.py deleted file mode 100644 index 6668f35..0000000 --- a/setup.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -# Note: To use the 'upload' functionality of this file, you must: -# $ pip install twine - -import io -import os -import sys -from shutil import rmtree - -from setuptools import find_packages, setup, Command - -# Package meta-data. -NAME = 'pyzipper' -DESCRIPTION = 'AES encryption for zipfile.' -URL = 'https://github.com/danifus/pyzipper' -EMAIL = 'daniel.hillier@gmail.com' -AUTHOR = 'Daniel Hillier' -REQUIRES_PYTHON = '>=3.4' -VERSION = None - -# What packages are required for this module to be executed? -REQUIRED = [ - 'pycryptodomex', -] - -# The rest you shouldn't have to touch too much :) -# ------------------------------------------------ -# Except, perhaps the License and Trove Classifiers! -# If you do change the License, remember to change the Trove Classifier for that! - -here = os.path.abspath(os.path.dirname(__file__)) - -# Import the README and use it as the long-description. -# Note: this will only work if 'README.rst' is present in your MANIFEST.in file! -with io.open(os.path.join(here, 'README.rst'), encoding='utf-8') as f: - long_description = '\n' + f.read() - -# Load the package's __version__.py module as a dictionary. -about = {} -if not VERSION: - with open(os.path.join(here, 'pyzipper', '__version__.py')) as f: - exec(f.read(), about) -else: - about['__version__'] = VERSION - - -class UploadCommand(Command): - """Support setup.py upload.""" - - description = 'Build and publish the package.' - user_options = [] - - @staticmethod - def status(s): - """Prints things in bold.""" - print('\033[1m{0}\033[0m'.format(s)) - - def initialize_options(self): - pass - - def finalize_options(self): - pass - - def run(self): - try: - self.status('Removing previous builds…') - rmtree(os.path.join(here, 'dist')) - except OSError: - pass - - self.status('Building Source and Wheel (universal) distribution…') - os.system('{0} setup.py sdist bdist_wheel --universal'.format(sys.executable)) - - self.status('Uploading the package to PyPi via Twine…') - os.system('twine upload dist/*') - - self.status('Pushing git tags…') - os.system('git tag v{0}'.format(about['__version__'])) - os.system('git push --tags') - - sys.exit() - - -# Where the magic happens: -setup( - name=NAME, - version=about['__version__'], - description=DESCRIPTION, - long_description=long_description, - author=AUTHOR, - author_email=EMAIL, - keywords="zip aes encrypt decrypt", - python_requires=REQUIRES_PYTHON, - url=URL, - packages=find_packages(exclude=('test', 'docs')), - install_requires=REQUIRED, - include_package_data=True, - license='MIT', - classifiers=[ - 'Development Status :: 4 - Beta', - 'Topic :: System :: Archiving :: Compression', - 'Intended Audience :: Developers', - 'Operating System :: MacOS :: MacOS X', - 'Operating System :: Microsoft :: Windows', - 'Operating System :: POSIX :: Linux', - 'Operating System :: POSIX', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - ], - # $ setup.py publish support. - cmdclass={ - 'upload': UploadCommand, - }, -) diff --git a/test/support/__init__.py b/test/support/__init__.py index 53aee0f..d610a31 100644 --- a/test/support/__init__.py +++ b/test/support/__init__.py @@ -16,7 +16,6 @@ import importlib.util import io import logging.handlers -import nntplib import os import platform import re diff --git a/test/test_zipfile.py b/test/test_zipfile.py index e1a3bae..c753bce 100644 --- a/test/test_zipfile.py +++ b/test/test_zipfile.py @@ -2627,12 +2627,24 @@ def test_from_dir(self): class CommandLineTest(unittest.TestCase): def zipfilecmd(self, *args, **kwargs): - rc, out, err = script_helper.assert_python_ok('-m', 'pyzipper.zipfile', *args, - **kwargs) + rc, out, err = script_helper.assert_python_ok( + '-m', + 'pyzipper.zipfile', + *args, + __cwd=os.getcwd(), + __cleanenv=True, + **kwargs, + ) return out.replace(os.linesep.encode(), b'\n') def zipfilecmd_failure(self, *args): - return script_helper.assert_python_failure('-m', 'pyzipper.zipfile', *args) + return script_helper.assert_python_failure( + '-m', + 'pyzipper.zipfile', + *args, + __cleanenv=True, + __cwd=os.getcwd(), + ) def test_bad_use(self): rc, out, err = self.zipfilecmd_failure() diff --git a/test/test_zipfile2.py b/test/test_zipfile2.py index 37c626a..859c863 100644 --- a/test/test_zipfile2.py +++ b/test/test_zipfile2.py @@ -2293,12 +2293,24 @@ def test_from_dir(self): class CommandLineTest(unittest.TestCase): def zipfilecmd(self, *args, **kwargs): - rc, out, err = script_helper.assert_python_ok('-m', 'pyzipper.zipfile', *args, - **kwargs) + rc, out, err = script_helper.assert_python_ok( + '-m', + 'pyzipper.zipfile', + *args, + __cwd=os.getcwd(), + __cleanenv=True, + **kwargs, + ) return out.replace(os.linesep.encode(), b'\n') def zipfilecmd_failure(self, *args): - return script_helper.assert_python_failure('-m', 'pyzipper.zipfile', *args) + return script_helper.assert_python_failure( + '-m', + 'pyzipper.zipfile', + *args, + __cleanenv=True, + __cwd=os.getcwd(), + ) def test_bad_use(self): rc, out, err = self.zipfilecmd_failure() diff --git a/test/test_zipfile_aes.py b/test/test_zipfile_aes.py index 2240ca3..3af7bdb 100644 --- a/test/test_zipfile_aes.py +++ b/test/test_zipfile_aes.py @@ -7,7 +7,7 @@ from test.support import unlink from .test_zipfile import ( - TESTFN, TESTFN2, get_files, requires_bz2, requires_zlib, requires_lzma, + TESTFN, TESTFN2, Unseekable, get_files, requires_bz2, requires_zlib, requires_lzma, ) from pyzipper import zipfile @@ -246,6 +246,95 @@ def test_read_password_required(self): with zipfile_aes.AESZipFile(fname) as zipfp: self.assertRaises(RuntimeError, zipfp.read, 'test.txt') + def test_crc_options(self): + fname = TESTFN + with zipfile_aes.AESZipFile(fname, "w") as zipfp: + zipfp.setpassword(b"passwd") + zipfp.setencryption( + zipfile_aes.WZ_AES, + conditionally_include_crc=1, + ) + with self.assertRaises( + ValueError, + msg="`conditionally_include_crc` must be True or False or None" + ): + zipfp.writestr("test_fname", b"test") + + zipfp.setencryption( + zipfile_aes.WZ_AES, + conditionally_include_crc=True, + ) + with self.assertRaises( + ValueError, + msg="`min_bytes_to_include_crc` must be set if `conditionally_include_crc` is True" + ): + zipfp.writestr("test_fname", b"test") + + zipfp.setencryption( + zipfile_aes.WZ_AES, + conditionally_include_crc=True, + min_bytes_to_include_crc=10, + ) + with self.assertRaises( + ValueError, + msg="`min_bytes_to_include_crc` must be 20 or greater" + ): + zipfp.writestr("test_fname", b"test") + + zipfp.setencryption( + zipfile_aes.WZ_AES, + min_bytes_to_include_crc=10, + ) + with self.assertRaises( + ValueError, + msg="`conditionally_include_crc` must be True if `min_bytes_to_include_crc` is set" + ): + zipfp.writestr("test_fname", b"test") + + zipfp.setencryption( + zipfile_aes.WZ_AES, + conditionally_include_crc=False, + min_bytes_to_include_crc=10, + ) + with self.assertRaises( + ValueError, + msg="`conditionally_include_crc` must be True if `min_bytes_to_include_crc` is set" + ): + zipfp.writestr("test_fname", b"test") + + zipfp.setencryption( + zipfile_aes.WZ_AES, + force_wz_aes_version=3, + ) + with self.assertRaises( + ValueError, + msg="`force_wz_aes_version` must be WZ_AES_V1 (1) or WZ_AES_V2 (2)" + ): + zipfp.writestr("test_fname", b"test") + + zipfp.setencryption( + zipfile_aes.WZ_AES, + force_wz_aes_version=zipfile_aes.WZ_AES_V1, + conditionally_include_crc=True, + min_bytes_to_include_crc=20, + ) + with self.assertRaises( + ValueError, + msg="`force_wz_aes_version` and `conditionally_include_crc` must not be specified at the same time." + ): + zipfp.writestr("test_fname", b"test") + + zipfp.setencryption( + zipfile_aes.WZ_AES, + force_wz_aes_version=zipfile_aes.WZ_AES_V1, + min_bytes_to_include_crc=20, + ) + with self.assertRaises( + ValueError, + msg="`min_bytes_to_include_crc` must be set if `conditionally_include_crc` is True" + ): + zipfp.writestr("test_fname", b"test") + def do_test_force_wz_aes_version(self, force_wz_aes_version): fname = TESTFN pwd = b'passwd' @@ -265,6 +354,10 @@ def do_test_force_wz_aes_version(self, force_wz_aes_version): self.assertEqual(zinfo.wz_aes_version, force_wz_aes_version) read_content = zipfp.read(content_fname) self.assertEqual(content, read_content) + if force_wz_aes_version == zipfile_aes.WZ_AES_V2: + self.assertEqual(zinfo.CRC, 0) + else: + self.assertEqual(zinfo.CRC, zipfile.crc32(content)) def test_force_wz_aes_version(self): """Supplying force_wz_aes_version overrides the calculated version.""" @@ -272,6 +365,226 @@ def test_force_wz_aes_version(self): self.do_test_force_wz_aes_version(force_wz_aes_version=1) self.do_test_force_wz_aes_version(force_wz_aes_version=2) + def test_no_crc_by_default(self): + fname = TESTFN + pwd = b'passwd' + content_fname = 'test.txt' + for compress_method in [ + zipfile.ZIP_STORED, + zipfile.ZIP_DEFLATED, + zipfile.ZIP_BZIP2, + zipfile.ZIP_LZMA, + ]: + for i in [1, 10, 19, 20, 200, 1000]: + with self.subTest(f"{compress_method}: content length: {i}"): + content = b"a" * i + with zipfile_aes.AESZipFile(fname, "w") as zipfp: + zipfp.setpassword(pwd) + zipfp.setencryption(zipfile_aes.WZ_AES) + zipfp.writestr(content_fname, content) + + with zipfile_aes.AESZipFile(fname) as zipfp: + zinfo = zipfp.NameToInfo[content_fname] + zipfp.setpassword(pwd) + self.assertEqual(zinfo.wz_aes_version, zipfile_aes.WZ_AES_V2) + read_content = zipfp.read(content_fname) + self.assertEqual(content, read_content) + self.assertEqual(zinfo.CRC, 0) + + def test_conditional_crc_small_files(self): + """Entries of size less than min_bytes_to_include_crc bytes have no CRC""" + fname = TESTFN + pwd = b'passwd' + content_fname = 'test.txt' + for min_bytes_to_include_crc in [20, 30]: + for i in range(1, min_bytes_to_include_crc): + with self.subTest(f"min_bytes_to_include_crc: {min_bytes_to_include_crc}; content length: {i}"): + content = b"a" * i + with zipfile_aes.AESZipFile(fname, "w") as zipfp: + zipfp.setpassword(pwd) + zipfp.setencryption( + zipfile_aes.WZ_AES, + conditionally_include_crc=True, + min_bytes_to_include_crc=min_bytes_to_include_crc, + ) + zipfp.writestr(content_fname, content) + + with zipfile_aes.AESZipFile(fname) as zipfp: + zinfo = zipfp.NameToInfo[content_fname] + zipfp.setpassword(pwd) + self.assertEqual(zinfo.wz_aes_version, zipfile_aes.WZ_AES_V2) + read_content = zipfp.read(content_fname) + self.assertEqual(content, read_content) + self.assertEqual(zinfo.CRC, 0) + + def test_conditional_crc_larger_files(self): + """Entries of size min_bytes_to_include_crc bytes or more include CRC""" + fname = TESTFN + pwd = b'passwd' + content_fname = 'test.txt' + for i in [20, 200, 1000]: + with self.subTest(f"content length: {i}"): + content = b"a" * i + with zipfile_aes.AESZipFile(fname, "w") as zipfp: + zipfp.setpassword(pwd) + zipfp.setencryption( + zipfile_aes.WZ_AES, + conditionally_include_crc=True, + min_bytes_to_include_crc=20, + ) + zipfp.writestr(content_fname, content) + + with zipfile_aes.AESZipFile(fname) as zipfp: + zinfo = zipfp.NameToInfo[content_fname] + zipfp.setpassword(pwd) + self.assertEqual(zinfo.wz_aes_version, zipfile_aes.WZ_AES_V1) + read_content = zipfp.read(content_fname) + self.assertEqual(content, read_content) + self.assertEqual(zinfo.CRC, zipfile.crc32(content)) + + def test_bzip2_conditional_crc(self): + """bzip2 streams have an internal integrity check and don't have CRC32""" + fname = TESTFN + pwd = b'passwd' + content_fname = 'test.txt' + for i in [1, 10, 19, 20, 200, 1000]: + with self.subTest(f"content length: {i}"): + content = b"a" * i + with zipfile_aes.AESZipFile(fname, "w", compression=zipfile.ZIP_BZIP2) as zipfp: + zipfp.setpassword(pwd) + zipfp.setencryption( + zipfile_aes.WZ_AES, + conditionally_include_crc=True, + min_bytes_to_include_crc=20, + ) + zipfp.writestr(content_fname, content) + + with zipfile_aes.AESZipFile(fname) as zipfp: + zinfo = zipfp.NameToInfo[content_fname] + zipfp.setpassword(pwd) + self.assertEqual(zinfo.wz_aes_version, zipfile_aes.WZ_AES_V2) + read_content = zipfp.read(content_fname) + self.assertEqual(content, read_content) + self.assertEqual(zinfo.CRC, 0) + + def read_datadescripter(self, fid, zinfo, is_zip64=False): + if not zinfo.use_datadescripter: + raise ValueError("datadescriptor not set") + + fid.seek(zinfo.header_offset) + fheader_bytes = fid.read(zipfile.sizeFileHeader) + if len(fheader_bytes) != zipfile.sizeFileHeader: + raise zipfile.BadZipFile("Truncated file header") + fheader = struct.unpack(zipfile.structFileHeader, fheader_bytes) + if fheader[zipfile._FH_SIGNATURE] != zipfile.stringFileHeader: + raise zipfile.BadZipFile("Bad magic number for file header") + + fname = fid.read(fheader[zipfile._FH_FILENAME_LENGTH]) + if fheader[zipfile._FH_EXTRA_FIELD_LENGTH]: + fid.read(fheader[zipfile._FH_EXTRA_FIELD_LENGTH]) + fid.seek(zinfo.compress_size, os.SEEK_CUR) + if is_zip64: + size = 24 + fmt = '