zipctl is a standalone ZIP library derived from CPython's zipfile
module, extended with WinZip AES support adapted from pyzipper.
The project aims to provide a zipfile-style API for applications that need
to read and write standard ZIP archives, WinZip AES-encrypted archives and
traditional ZipCrypto archives.
Why this project exists
zipctl started as a split-off from pyzipper for deployments that need to
use the cryptography package with a FIPS-configured OpenSSL provider.
Pyzipper uses PyCryptodomeX for its cryptographic primitives, which is outside
the FIPS-validated cryptographic boundary used by those deployments.
This project is not itself FIPS-validated and using cryptography does not
make an application FIPS-compliant. A deployment must use a FIPS-validated
cryptographic module and a FIPS-configured Python/OpenSSL environment, and
must follow the applicable operational controls. For a FIPS-constrained
deployment, use WinZip AES only after confirming that the selected provider
permits the algorithms and modes required by the WinZip AES format. Do not use
the legacy ZipCrypto option for FIPS-constrained data; it is a compatibility
feature and is not a FIPS-approved encryption algorithm.
WinZip AES output uses AES version 2 by default. Version 2 omits the plaintext
CRC-32 from ZIP metadata, reducing offline candidate-guessing disclosure.
Select ZipFileExtra(force_wz_aes_version=1) only when compatibility with a
consumer that requires AES version 1 is more important than that metadata
protection; version 1 stores the plaintext CRC-32 in both ZIP headers.
What it provides?
- a familiar
ZipFileAPI. - read and write support for plain ZIP archives
- write support for WinZip AES and ZipCrypto encryption
- read support that auto-detects AES vs. ZipCrypto for encrypted members
- support for
ZIP_STORED,ZIP_DEFLATED,ZIP_BZIP2,ZIP_LZMAandZIP_ZSTANDARDcompression
ZIP LZMA archives declare their dictionary size in the member stream. zipctl rejects dictionaries larger than 1 GiB before constructing a decompressor. This bounds attacker-controlled allocation while retaining compatibility with normal ZIP LZMA archives; applications handling untrusted archives should also apply extraction size and compression-ratio limits.
Installation
pip install zipctl
Intended usage
The intended usage is the same as zipfile's: use zipctl.ZipFile to create your archive, optionally choose a compression and/or encryption methods and
set a password for encrypted archives if applicable.
Creating a plain ZIP archive
from zipctl import ZipFile, ZIP_DEFLATED
with ZipFile("example.zip", "w", compression=ZIP_DEFLATED) as zf:
zf.writestr("hello.txt", "hello world")
Reading a plain ZIP archive
from zipctl import ZipFile
with ZipFile("example.zip", "r") as zf:
data = zf.read("hello.txt")
Writing an AES-encrypted archive
from zipctl import ZipFile, WZ_AES, ZIP_DEFLATED
password = b"correct horse battery staple"
with ZipFile(
"secret-aes.zip",
"w",
compression=ZIP_DEFLATED,
encryption=WZ_AES,
) as zf:
zf.setpassword(password)
zf.writestr("secret.txt", b"sensitive payload")
Reading an encrypted ZIP archive
from zipctl import ZipFile
password = b"correct horse battery staple"
with ZipFile("secret-aes.zip", "r") as zf:
zf.setpassword(password)
data = zf.read("secret.txt")
NOTE: When reading, encryption is normally detected automatically from the archive metadata, so you typically do not need to specify an encryption mode.
Customizing AES settings with ZipFileExtra
ZipFileExtra is the write-time configuration object for AES-specific ZIP
output. It lets you override the WinZip AES version written into the extra
field and choose the AES key size.
from zipctl import ZipFile, ZipFileExtra, WZ_AES, ZIP_DEFLATED
password = b"correct horse battery staple"
extra = ZipFileExtra(force_wz_aes_version=1, wz_aes_nbits=256)
with ZipFile(
"secret-aes-v1.zip",
"w",
compression=ZIP_DEFLATED,
encryption=WZ_AES,
extra=extra,
) as zf:
zf.setpassword(password)
zf.writestr("secret.txt", b"payload")
Writing AES-encrypted archive with a different key size
from zipctl import ZipFile, ZipFileExtra, WZ_AES
password = b"correct horse battery staple"
extra = ZipFileExtra(wz_aes_nbits=128)
with ZipFile("secret-aes-128.zip", "w", encryption=WZ_AES, extra=extra) as zf:
zf.setpassword(password)
zf.writestr("secret.txt", b"payload")
Writing a ZipCrypto-encrypted archive
from zipctl import ZipFile, ZIP_CRYPTO, ZIP_DEFLATED
password = b"correct horse battery staple"
with ZipFile(
"secret-zipcrypto.zip",
"w",
compression=ZIP_DEFLATED,
encryption=ZIP_CRYPTO,
) as zf:
zf.setpassword(password)
zf.writestr("secret.txt", b"legacy compatible payload")
ZipCrypto is retained for legacy interoperability only. It is not a modern confidentiality mechanism and is unsuitable for FIPS-constrained or otherwise security-sensitive new archives; use WinZip AES instead.
Using in-memory buffers
import io
from zipctl import ZipFile, WZ_AES
password = b"correct horse battery staple"
buffer = io.BytesIO()
with ZipFile(buffer, "w", encryption=WZ_AES) as zf:
zf.setpassword(password)
zf.writestr("data.txt", b"payload")
buffer.seek(0)
with ZipFile(buffer, "r") as zf:
zf.setpassword(password)
data = zf.read("data.txt")
Per-entry encryption
Archive-level encryption remains the default for newly written members, but
individual entries can override it. Use INHERIT_ENCRYPTION to make
inheritance explicit, None for a plaintext member, or an encryption method
for a protected member.
from zipctl import INHERIT_ENCRYPTION, ZIP_CRYPTO, WZ_AES
with ZipFile("mixed.zip", "w", encryption=WZ_AES) as zf:
zf.setpassword(b"default-password")
zf.writestr("secret.txt", b"secret")
zf.writestr("public.txt", b"public", encryption=None)
zf.writestr(
"legacy.txt",
b"legacy",
encryption=ZIP_CRYPTO,
password=b"legacy-password",
)
zf.writestr("inherited.txt", b"inherited", encryption=INHERIT_ENCRYPTION)
An entry-level password overrides the archive default password. Per-entry encryption is part of the ZIP format, but consumers vary in their support for mixed algorithms or multiple passwords in one archive.
Opt-in extraction policy
The legacy extract() and extractall() behavior remains unchanged when no
policy is supplied. For untrusted archives, pass an ExtractPolicy; policy
enabled calls return structured results describing every member.
from zipctl import ExtractPolicy, ViolationAction
with ZipFile("input.zip") as zf:
result = zf.extractall(
"out",
policy=ExtractPolicy(
on_violation=ViolationAction.SKIP,
max_compression_ratio=100.0,
),
)
for member in result.members:
print(member.member, member.status, member.violations)
ExtractPolicy can enforce path, overwrite, file-size, archive-size,
entry-count, compression-ratio, extension, duplicate-target, and file-type
limits. Set preview_only=True for a dry run. Policy violations configured as
errors raise ExtractionError, whose result attribute contains the partial
structured result. Size limits are enforced both from archive metadata before
extraction and against actual bytes written during extraction; an actual-size
quota breach aborts that member and removes its partial output.
Progress reporting
extract() and extractall() accept progress=, a callable that receives a
frozen ProgressEvent as each member starts, roughly every MiB while its data
is written, and when it finishes. It works with and without a policy:
def show(event: ProgressEvent) -> None:
if event.phase is ProgressPhase.FINISH:
print(f"{event.member}: {event.status.value} "
f"({event.total_bytes_done}/{event.total_bytes} bytes)")
with ZipFile("input.zip") as zf:
zf.extractall("out", policy=ExtractPolicy(), progress=show)
Sizes come from the archive's declared metadata, so treat them as hints. The
callback runs in the extracting thread and must not write to the same
ZipFile. To cancel, raise from the callback: members already extracted stay
on disk, the member in flight leaves no partial file, and your exception
propagates unchanged (it is not turned into a per-member failure). With a
callback, members is resolved up front, so an unknown name raises KeyError
before anything is written.
Per-member passwords on extraction
pwd= of extract() / extractall() may also be a callable taking a
ZipInfo and returning that member's password (or None). It is asked only
for encrypted members, which suits archives protected per entry:
passwords = {"a.txt": b"one", "b.txt": b"two"}
with ZipFile("per-entry.zip") as zf:
zf.extractall("out", pwd=lambda info: passwords.get(info.filename))
Policy files (JSON)
An ExtractPolicy can be loaded from, and written to, plain JSON, so rulesets
live in a file instead of code. The field names are exactly the policy's field
names; policy_to_json(ExtractPolicy()) prints a complete starting document.
policy = zipctl.policy_from_json(Path("rules.json").read_text())
with ZipFile("input.zip") as zf:
zf.extractall("out", policy=policy)
{
"version": 1,
"on_violation": "skip",
"max_entries": 500,
"max_member_size": {"value": 52428800, "on_violation": "error"},
"max_compression_ratio": null,
"blocked_extensions": [".exe", ".dll"],
"overwrite_policy": "rename"
}
- Every field is optional; missing ones keep their default. Pass
base=to layer documents, so a later document overrides only what it mentions. - Limits are non-negative integers (
max_compression_ratiois a positive number);nulldisables one. Booleans must be real JSON booleans. - A field that accepts a per-rule action may be a bare value or
{"value": ..., "on_violation": "error|warn|skip"}. A rule aroundnullcollapses to plainnull, since there is no limit to act on. - Extensions look like
".txt"or".tar.gz"(or""for files without one) and are lower-cased. An entry matches any trailing chain of a name's suffixes:".gz"matchesx.tar.gzandx.gz, while".tar.gz"matches only names ending in.tar.gz. - Loading is strict. Unknown fields (with a "did you mean" hint), wrong types,
duplicate keys and
NaN/Infinityare errors, and aPolicyConfigErrorlists every problem at once (error.issuesholds(path, message)pairs). custom_validatoris Python code and cannot be expressed in JSON.
Checking a password
ZipFile.check_password() tells you whether a password matches the encrypted
members without extracting anything:
with ZipFile("secret.zip") as zf:
result = zf.check_password(b"hunter2")
if not result.ok:
print("rejected:", result.rejected)
By default it only checks each member's password verifier, so no data is read.
A rejection is definitive, but an acceptance only means the password is
probably right: a wrong password still passes about 1 time in 256 for ZipCrypto
and 1 in 65,536 for WinZip AES. Pass full=True for a definitive answer: it
authenticates each member (the AES HMAC, or the CRC-32 for ZipCrypto), which
reads the member. A member whose data fails that check is reported as
corrupt rather than rejected. Unencrypted members are reported as
unencrypted and never cause a failure. Opening an encrypted member without a
password raises PasswordRequired, and a wrong one raises BadPassword; both
are RuntimeError subclasses.
Metadata-only inspection
Use ZipFile.inspect() to produce a structured report before extraction.
Inspection reads the central directory and member metadata only: it never
opens, decompresses, decrypts, or writes a payload, and policy findings never
raise ExtractionError.
from zipctl import ExtractPolicy, ZipFile
with ZipFile("input.zip") as zf:
report = zf.inspect(policy=ExtractPolicy(max_compression_ratio=100.0))
print(report.total_entries, report.total_uncompressed_size)
print(report.suspicious_paths, report.encrypted_members)
print(report.duplicate_member_names, report.duplicate_targets)
for member in report.members:
print(member.member, member.violations)
The report separately identifies duplicate member names and duplicate
filesystem targets, suspicious paths, encrypted members, symlinks and special
files, large members, compression-ratio outliers, and entry/size policy
findings. path= controls the destination used for non-mutating target
resolution; it does not create or modify that path.
Command line
zipctl (or python -m zipctl) inspects and verifies archives without writing
any code. It needs nothing beyond zipctl itself.
zipctl list ARCHIVE [MEMBER ...] [-l] [--json] # names, or a table with -l
zipctl test ARCHIVE [MEMBER ...] [-v|-q] [--progress] [--json] # check integrity
zipctl inspect ARCHIVE [-d DIR] [-q] [--json] # what extraction would flag
zipctl create ARCHIVE PATH ... [-C DIR] [--exclude GLOB] [-n] # build an archive
zipctl extract ARCHIVE [MEMBER ...] [--match GLOB] [-d DIR] # policy-guarded extraction
zipctl check-password ARCHIVE [MEMBER ...] [--full] [-v] [--json]
zipctl encrypt IN OUT [--encryption METHOD] [--match GLOB ...] # protect a plain archive
zipctl decrypt IN OUT [--match GLOB ...] # remove the protection
zipctl rewrite IN OUT [--compression METHOD] [--encryption ...] # change method, password, compression
zipctl policy show [--policy FILE] [--policy-json TEXT]
zipctl policy validate FILE [FILE ...] [--json]
- Shell completion is optional:
pip install "zipctl[completion]", thenzipctl --print-completions fish > ~/.config/fish/completions/zipctl.fish(bash,zshandtcshwork too; each shell's own documentation says where its completion scripts go). listprints member names;-ladds a table with the mode, sizes, compression ratio, date, method, protection and CRC of each member, a total line, and the archive comment.MEMBERmay be an exact name or a pattern (*and?stay inside one directory,**crosses directories,[abc]is a character class; a name containing those characters also matches literally, and a pattern that matches nothing is an error).testandcheck-passwordtake the same patterns.testreads every member (or theMEMBERs you name) and reports each bad one (not just the first). It exits1if any member fails. Encrypted members need a password (below).-qprints nothing unless a member fails, and--progressreports each member on standard error.inspectreads only metadata, so it works on damaged or encrypted archives without a password. It applies the same default policy as extraction (or the one you give with--policy/--policy-json, see "Policy files (JSON)") and exits1if any violation would make extraction fail;skip- andwarn-level findings are listed but do not fail the run.-qprints nothing for a clean archive and only the violations and the verdict otherwise.createadds files and directories recursively (.adds the contents of the current directory;-C DIRtakes paths relative toDIRand stores them without it). Names are stored as given, minus any leading/; a path that climbs out of the current directory (../x) is refused unless you choose the base with-C. By default symbolic links to files are stored as their content, and links to directories, broken links and special files (pipes, devices) are skipped with a warning;--symlinks storekeeps every link (also directory and broken ones) as a link entry instead, and--symlinks skipleaves them all out. Links are never followed into directories, and a stored link is not encrypted, whatever the password options say: its target is a path, not content. Extraction refuses stored links unless the policy allows them ("allow_symlinks": true), and even then one that points out of the destination.--exclude GLOB(repeatable) leaves out what matches: a pattern without a/matches the last name at any depth ('*.pyc',.git), one with a/matches the whole archive name ('build/**'), and a matching directory is skipped with everything in it; an exclude that matches nothing is reported as a warning.-n/--dry-runruns every check, lists what would be added (with the protection each file would get) and writes nothing; it does not ask for typed passwords.--progressreports each entry on standard error. Compression is-m store|deflate|bzip2|lzma|zstd(defaultdeflate) with-L LEVEL. The archive is written to a scratch file beside it and moved into place only when everything succeeded, so a failed or interrupted run leaves nothing behind. An existing archive is refused unless you pass--force(replace) or--append(add members, keeping the rest; it is rewritten through a copy, and names already in it are an error).-
--encryption aes256|aes192|aes128|zipcrypto|noneprotects every file with one password (from--password-file,--password-stdin,ZIPCTL_PASSWORD, or typed twice at a terminal).--wz-aes-version 1writes the older AES format for tools that need it; ZipCrypto is weak and prints a warning. -
--protect 'GLOB[=METHOD]'(repeatable) protects the members matchingGLOBwith a password of their own, asked for at a terminal. The first matching rule wins, members no rule matches follow--encryption(or stay plain), andGLOB=nonecarves out plain members. A rule that decides no member is an error, so a typo cannot leave files unprotected. -
--encryption-spec FILE(-is standard input) is the scriptable form: a JSON file of the same rules whose passwords are references, never values:{ "version": 1, "default": {"method": "aes256", "password": {"env": "ZIPCTL_PASSWORD"}}, "rules": [ {"match": "secrets/**", "method": "aes256", "password": {"file": "/run/secrets/vault"}}, {"match": "*.key", "method": "aes128", "password": {"prompt": "Password for keys"}}, {"match": "public/**", "method": "none"} ] }
A password is exactly one of
{"env": NAME},{"file": PATH},{"prompt": LABEL}or{"stdin": true}(at most once). Unknown keys, inline passwords, duplicate patterns and unusable references are reported together and exit2. It cannot be combined with--encryption,--protector the password options.
-
extractapplies the default extraction policy unless told otherwise, so traversal, absolute paths, symlinks, special files and compression bombs are refused per member (each is listed asFAILED/SKIPPEDwith the reason; the rest is still extracted, and the exit code is1). It takesMEMBERnames (exact, not patterns; an unknown one fails before anything is written),--match GLOB(repeatable) to add members by pattern (a pattern that matches nothing is an error),-d DIR(default: the current directory), the--policy/--policy-jsonoptions,--overwrite {error,skip,replace,rename}(defaulterror: existing files are never touched),--dry-run(report what would happen, write nothing, do not createDIR),--no-fsync,--progress, and-q/-v.--no-policyuses plain extraction (path traversal is still neutralised, but no limits apply) and cannot be combined with the options that configure the policy.check-passwordanswers "is this the password?" without extracting. It takes one password (from a file, standard input,ZIPCTL_PASSWORDor a single prompt, never as an argument) and tests it against every encrypted member, or against theMEMBERnames / patterns you give (*and?stay inside one directory,**crosses directories,[abc]is a character class; a name containing those characters also matches literally, and a pattern that matches nothing is an error). It exits0when no member rejects the password and1otherwise, listing eachREJECTEDorCORRUPTmember. By default only the password verifier is checked, so a wrong password can still pass (about 1 in 256 for ZipCrypto, 1 in 65,536 for AES);--fullalso authenticates each member's data, which is definitive but reads them. An archive with no encrypted members has nothing to reject and exits0.encrypt,decryptandrewritecopyINinto a new archiveOUT; they never work in place (OUTmay not beIN, even with--force, or a link to it), and an existingOUTis refused unless you pass--force. Members are streamed one at a time, so size is no problem. Each keeps its name, date, mode, comment and compression method, and so does the archive comment; extra fields are not copied. Data is decompressed and recompressed, since there is no raw copy.OUTis written beside its final name, read back and compared with what was copied (names, dates, modes, comments, protection, and every member's data) and only then moved into place, so a failed check, a wrong password, a full disk or Ctrl-C leavesOUTuntouched and no scratch file behind.--no-verifyskips the read-back. Output is-q/-v/--jsonlikecreate.encrypt IN OUTgives every file--encryption aes256|aes192|aes128|zipcrypto(defaultaes256;--wz-aes-version 1for old tools) with one password: from--password-file,--password-stdin,ZIPCTL_PASSWORD, or typed twice at a terminal.--match GLOB(repeatable) protects only the matching files. A pattern that matches nothing is an error, and so is an input that already has encrypted members (userewritefor those).decrypt IN OUTneeds the passwords of the encrypted members, read liketestdoes (sources first, then a prompt per distinct password).--match GLOBdecrypts only those members; the others stay encrypted with their own scheme and password, so all passwords are still needed. An input with nothing encrypted is an error.rewrite IN OUTchanges what you ask for and keeps the rest. Members are unlocked with--old-password-file,--old-password-stdin,ZIPCTL_OLD_PASSWORD, or a prompt, and protected again by the same options ascreate(--encryption,--protect,--encryption-spec,--wz-aes-version, whose passwords come from the ordinary password options); a member that no rule covers keeps its current scheme and password. So--encryption aes256changes everything to one new password,--encryption nonedecrypts,--protect 'secrets/**'re-protects just those, and no encryption option at all only recompresses.--compression store|deflate|bzip2|lzma|zstd(with-L LEVEL) sets the compression of every member.
- Member names come from the archive and can contain terminal escape sequences,
so human-readable output escapes anything unprintable (
\x1b,\u202e).--jsonoutput is plain ASCII and keeps names exactly. --jsonprints one JSON document on standard output; problems go to standard error aszipctl: error: ..., and are also written to standard output as{"ok": false, "error": ..., "code": N, "details": [...]}so a script can read one stream. (A command line argparse rejects, exit2, is reported as plain text only.)ZIPCTL_POLICYnames a policy file thatextract,inspectandpolicy showstart from, under--policyand--policy-json;extract --no-policyignores it. It is a default for convenience, not a control: anyone can override it with--policy.ZIPCTL_DEBUG=1prints the traceback of an error, and an error the CLI did not expect is otherwise reported aszipctl: error: unexpected ....
Exit codes: 0 success, 1 the operation ran and failed (a bad member, policy
violations, an unreadable archive), 2 a bad command line or configuration
file, 130 interrupted (Ctrl-C), 141 the reader of the output went away.
Passwords are never accepted as arguments, since they would show up in process
listings and shell history. They come from, in this order: --password-file FILE and/or --password-stdin (the first line), else the ZIPCTL_PASSWORD
environment variable (--password-prompt skips the variable and asks at the
terminal instead). When a terminal is attached and no source has the right
password, you are asked for the password of each encrypted member that needs
one; every accepted password is remembered, so an archive whose members share a
password asks once, and one with several passwords asks once per password. Three
wrong tries fail that member; an empty answer (or Ctrl-D) stops asking. Typed
passwords are masked with * on Python 3.14+; older Pythons prompt silently.
Public API
The package exports these primary entry points:
ZipFileis_zipfileINHERIT_ENCRYPTIONExtractPolicy,ExtractResult,ExtractMemberResult,ExtractionErrorInspectionMember,InspectionResultZipFileExtraWZ_AES,WZ_AES_V1,WZ_AES_V2ZIP_CRYPTOZIP_STORED,ZIP_DEFLATED,ZIP_BZIP2,ZIP_LZMA,ZIP_ZSTANDARDWzAesExtra
Assessment And Extraction
Policy-enabled extraction is a two-stage operation. Metadata is assessed first;
only members whose effective policy action permits it are materialized. The
assessment stage does not open, decrypt, decompress, or write member payloads.
ZipFile.inspect() exposes this metadata-only behavior through an
InspectionResult. MemberAssessment describes the normalized target, entry
type, and violations for one member; ArchiveAssessment is available for
applications that need to build security tooling around the shared assessment
model.
For callers that need the shared lower-level model directly, use
ZipFile.assess():
with ZipFile("input.zip") as zf:
assessment = zf.assess(
"out",
ExtractPolicy(max_compression_ratio=100.0),
)
for member in assessment.members:
print(member.info.filename, member.target, member.violations)
ViolationAction.ERROR, WARN, and SKIP control ordinary policy findings.
ExtractPolicy.custom_validator takes a callable (ZipInfo, Path) -> None or a
sequence of them. Each validator runs for every member; one that raises
ValueError, OSError, RuntimeError or BadZipFile adds a
custom_validator violation carrying the exception message, and the remaining
validators still run.
max_entries applies to the archive as a whole and is reported once: ERROR
extracts nothing, SKIP extracts only the first max_entries members, and
WARN warns and extracts everything.
Security-critical path and file-type findings remain errors when WARN is
selected. preview_only=True performs assessment and returns member results
without creating or modifying the destination; previewed_count counts the
members it would extract (they are not in skipped_count). An ExtractionError contains
the partial ExtractResult in its result attribute.
Regular files are written to a temporary file in the destination directory and
atomically committed only after the member has been fully read and quota checks
have succeeded. Existing files are therefore preserved when a member fails.
Each file is fsynced before it is moved into place; pass
ExtractPolicy(fsync_files=False) to skip that when extraction throughput matters
more than durability across power loss.
Symlinks and special files are rejected by default. Allowed symlinks are
materialized without following their targets, and supported FIFOs can be
materialized on platforms that provide os.mkfifo. Descriptor-backed
no-follow checks are used where the platform exposes the required APIs; other
platforms use the strongest path-based checks available.
Compression Registries
Each ZipFile receives an archive-local snapshot of the compression registry.
Applications can provide a custom Registry with the compression_registry=
constructor option. Registering or replacing a method in one archive does not
change other archives or the module-level default registry. Stored entries use
the same no-op compressor/decompressor strategy as other compression methods.
The snapshot is taken when the archive is constructed, so later changes to the
module-level registry do not affect an existing ZipFile.
ZipInfo Compatibility Names
ZIP header serialization is side-effect-free: calling ZipInfo.FileHeader() or
ZipInfo.central_directory() calculates effective ZIP versions without
rewriting the ZipInfo object's create_version or extract_version fields.
Prefer these correctly spelled data-descriptor APIs:
use_data_descriptorencode_data_descriptor()data_descriptor()
The historical CPython-derived spellings remain supported as compatibility
aliases: use_datadescripter, encode_datadescripter(), and
datadescripter(). The _compresslevel alias likewise remains available for
compatibility with code using the CPython-style metadata attribute.
Notes
ZIP_ZSTANDARDcompression uses stdlibcompression.zstd(Python 3.14+); on Python 3.10-3.13 install the optional extra (pip install "zipctl[zstd]", which pulls inbackports.zstd), otherwise using it raisesRuntimeError- ZIP archives that span multiple disks are not supported (same as the standard
library) and are rejected with
BadZipFile - only one write handle may be open per archive at a time; opening a second
one, or reading while a writer is active, raises
ValueError - use WinZip AES for modern encrypted ZIP workflows (ZipCrypto is mainly for compatibility with older tools)
- passwords must be byte strings
- decompression is streamed and bounded per read, but callers should still enforce application-level limits on total extracted bytes and archive member counts when processing untrusted archives
Interoperability
The project is intended to interoperate with common ZIP tooling while exposing an API that feels like the standard library.
- the functional test suite includes 7-Zip interoperability checks in both
directions: archives written by
zipctlare validated by 7-Zip, and AES- and ZipCrypto-encrypted archives written by 7-Zip are read byzipctl - the same holds for the command line: 7-Zip reads what
create,encrypt,decryptandrewritewrite (AES-128/192/256, ZipCrypto, per-file protection), anddecryptreads what 7-Zip encrypts - WinZip AES is the primary encrypted format to use for modern workflows
- ZipCrypto is included for compatibility with older ZIP consumers and tools
- plain ZIP archives remain readable through the same
ZipFileAPI
This is not a claim of universal compatibility with every ZIP tool and every feature combination. If interoperability matters for your environment, verify the exact compression and encryption combinations you plan to ship.
License
This project is licensed under the MIT License. Additional upstream licensing and attribution files are included for the CPython- and pyzipper-derived portions of the codebase:
LICENSENOTICElicenses/CPYTHON-3.14.3.txtlicenses/pyzipper-MIT.txt
Release files for zipctl 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| zipctl-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Release files / zipctl-0.1.0-py3-none-any.whl
| Download URL | zipctl-0.1.0-py3-none-any.whl |
|---|---|
| Size | 187.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
5f4863c5c0b0514b5e3683cfad55a507da0706c7efd76e2e90d4527f5a971689
|
|
BLAKE2b-256 checksum How to use checksums |
1c08cb1d8368f83720450ad40da31b84d60ee04720812214fceee436e4dab129
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.
Transparency log