Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@
`NotImplementedError` directing callers to the public `download_binary()`
entry point instead of returning a fabricated cache-relative path
(OVH-05).
- Added a strict `mypy` gate (`strict = true`, Python 3.9 target) over the
`py7zip` runtime package with a matching CI lane, and annotated the
wrapper's public methods without changing runtime behavior.
- Landed the options contract (audit decision D5 / OVH-16): the safe
runtime accepts only `""` or a sequence of individual arguments and
raises `TypeError` for any non-empty options string; the legacy
runtime forwards a sequence as a `shell=False` argument vector while
keeping the historical `shell=True` string interpolation.
- Fixed the safe runtime's `wrapper()`/`decompress()`/`extract()`
dispatch to route decompression through `SafePy7zip.decompress()`,
whose archive-member validation raises `ArchiveTraversalError`
before extraction begins; the dispatch previously called
`SafePy7zip.run()` directly and bypassed that guard. Compression
behaviour and the options contract are unchanged.

## 0.7.3

Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,23 @@ jobs:
- name: Run offline test suite
run: python -m pytest tests -p no:cacheprovider

typecheck:
name: Strict type check (mypy)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install package and type checker
# The mypy constraint and stubs live in the `typecheck` extra in
# pyproject.toml, so local and CI gates enforce the same constraint
# (mypy<2: each install resolves the newest matching release).
run: python -m pip install --upgrade pip -e ".[typecheck]"
- name: Run mypy (strict) on py7zip
run: python -m mypy

package:
name: Build and inspect wheel
runs-on: ubuntu-latest
Expand Down
61 changes: 53 additions & 8 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,67 @@
# USAGE

## Example Python script:
import py7zip
from py7zip.py7zip import Py7zip

# Sets up binary for the system if it does not already exist and assigns the 7zip class to 'z'
z = py7zip.Py7zip()
# Constructs the wrapper (offline: nothing is downloaded at
# construction). The verified binary is acquired automatically on
# first use if it is not already installed.
z = Py7zip()
src = "path/to/zipfile"
dst = "path/to/output/directory" # Usually contains a .zip extension
options = "" # Optional

# Compress source file or directory to destination
z.compress(src, dst, options)
# Compress source file or directory to destination.
# options is "" (no options) or a sequence of individual arguments.
z.compress(src, dst, options=["-y", "-mx=9"])

# Decompress source archive back to destination directory
z.decompress(dst, src, options)

z.decompress(dst, src, options="")


## The `options` argument

Every execution method — `wrapper`, the compress aliases
(`compress`/`archive`/`backup`) and the extract aliases
(`decompress`/`extract`) — forwards `options` unchanged to the same code
path. What `options` accepts depends on the runtime chosen at
construction.

### Safe runtime (default)

`Py7zip()` never invokes a shell. `options` must be one of:

- `""` — the default. No options are forwarded to 7-Zip.
- A sequence (list, tuple, ...) of individual arguments, each forwarded
to 7-Zip as exactly one argument: `options=["-y", "-mx=9"]`. Elements
are never split, quoted, or re-interpreted; an element containing
spaces stays a single argument.

Any non-empty string — `"-y -mx=9"`, `"-oC:\Temp"`, even `" "` — raises
`TypeError` ("options must be a sequence of individual arguments") before
any 7-Zip process is started. Shell-style option text is never parsed or
tokenized: there is deliberately no `shlex`-based bridge on the safe
runtime (audit decision D5 / OVH-16). Split the text into a sequence of
individual arguments instead.

### Legacy runtime (`Py7zip(legacy=True)`)

The legacy runtime exists for backward compatibility with the historical
0.6.x behaviour and is planned for deprecation and removal.

- A string is interpolated verbatim into one shell command that runs with
`shell=True`. The shell interprets the text: quotes, `$()`, `;` and
pipes are all live. Only ever pass fully trusted, caller-controlled
text.
- A sequence is forwarded element by element as an argument vector with
`shell=False`: no element is re-interpreted by a shell, and an element
containing spaces or shell metacharacters stays a single argument.


## 7zip Usage (Command Line Arguments; aka "Options")

The switches listed below are the values a sequence element carries;
pass each switch as its own element, e.g. `options=["-y", "-mx=9"]`.

Usage: 7zz <command> [<switches>...] <archive_name> [<file_names>...] [@listfile]

<Commands>
Expand Down
57 changes: 45 additions & 12 deletions docs/planning/COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ between them. It is written before the contract changes so that each break is
a recorded decision rather than an accident of refactoring.

The behaviour described in "current contract" is not asserted from reading
the source; every line is pinned by a test in `tests/`.
the source; every line is pinned by a test in `tests/`. Where a piece of
the target contract has already landed, it is recorded in place as its own
section, naming the decision that authorised it and the tests that pin it
— see "Options contract" below.

## Current contract (Phase 0 baseline)

Expand All @@ -26,13 +29,22 @@ the source; every line is pinned by a test in `tests/`.

`decompress` and `extract` run the `x` (extract) operation; `compress`,
`archive` and `backup` run the `a` (add) operation. All five forward to the
single `wrapper` method.

**Every alias discards the caller's `options` argument.** Each one passes the
literal empty string to `wrapper`, so `obj.extract(a, b, options="-y")` runs
without `-y`. Only calling `wrapper(..., options=...)` directly forwards them.
This is characterized by
`tests/test_characterization_wrapper.py::test_aliases_silently_discard_the_callers_options`.
single `wrapper` method, and **all five forward the caller's `options`
argument unchanged, on both runtimes.**

At the Phase 0 baseline the aliases discarded `options`: each passed the
literal empty string to `wrapper`, so `obj.extract(a, b, options="-y")` ran
without `-y` (pinned then by
`test_aliases_silently_discard_the_callers_options`). When the
safe-by-default runtime landed (`4713a75`), the aliases changed to forward
`options` and the discard pin was replaced in that same commit, per rule 3,
by forwarding pins on both runtimes:
`tests/test_characterization_wrapper.py::test_legacy_aliases_forward_the_callers_options`
and, for the safe runtime,
`tests/test_default_api.py::test_default_aliases_require_individual_option_arguments`.
This PR adds
`tests/test_default_api.py::test_default_runtime_accepts_individual_option_arguments`
as a separate pin for the safe runtime's individual-argument contract.

### Return values and errors

Expand All @@ -50,15 +62,36 @@ features and are removed in Phase 4.

### Unspecified behaviour

* `wrapper` builds one shell string and passes it to
`subprocess.run(..., shell=True)`. The caller's `options` text is
interpolated verbatim, so it is interpreted by the shell.
* On the legacy runtime with string `options`, `wrapper` builds one shell
string and passes it to `subprocess.run(..., shell=True)`; the caller's
`options` text is interpolated verbatim, so it is interpreted by the
shell. Sequence `options` are the exception on both runtimes: they run
as an argument vector with `shell=False`, and the safe runtime never
builds a shell string at all (see "Options contract" below).
* Source and destination are never validated. Nonexistent paths and paths
containing `..` are passed through unchanged.
* Downloaded bytes are written to disk and marked `0o755` with no size bound,
content check, or digest.
* `Py7zip.cd` changes the working directory of the entire process.

## Options contract (landed 2026-10-11 — audit decision D5 / OVH-16)

`options` is typed `str | Sequence[str]` with default `""` and means
different things on the two runtimes. Every row below is pinned by a test.

| Runtime | `options` value | Behaviour | Pinned by |
|--------|-----------------|-----------|-----------|
| Safe (default, `legacy=False`) — the supported runtime | `""` | No options are forwarded. | `tests/test_default_api.py::test_default_runtime_empty_string_options_are_empty_arguments` |
| Safe (default) | Sequence | Each element is forwarded as exactly one argv element to the no-shell runner; elements are never split or re-interpreted. | `tests/test_default_api.py::test_default_runtime_accepts_individual_option_arguments`; `tests/test_safe.py::test_runner_passes_an_argument_vector_without_shell` |
| Safe (default) | Any non-empty `str` (and `bytes`) | `TypeError("options must be a sequence of individual arguments")` raised before any subprocess starts. No `shlex` bridge exists — decision D5 rejects one because POSIX-mode `shlex` silently corrupts unquoted Windows backslashes (`"-oC:\Temp"`) and raises `ValueError` on an unbalanced quote. | `tests/test_default_api.py::test_default_runtime_shell_text_never_reaches_parsing_or_execution`; `tests/test_safe.py::test_runner_rejects_opaque_option_strings` |
| Legacy (`legacy=True`) — compatibility surface, slated for sunset | `str` | Interpolated verbatim into one shell string run with `shell=True` — the historical 0.6.x behaviour, shell word-splitting and command substitution included. Retained unchanged; the existing unsafe compatibility behaviour. | `tests/test_characterization_wrapper.py::test_options_are_appended_verbatim_to_the_command_string`, `::test_the_shell_word_splits_the_options_string`, `::test_options_are_evaluated_by_the_shell`, `::test_options_can_run_additional_commands` |
| Legacy (`legacy=True`) | Sequence | Forwarded element by element as an argument vector with `shell=False`; no element is re-interpreted by a shell. | `tests/test_characterization_wrapper.py::test_sequence_options_are_forwarded_as_an_argument_vector`, `::test_sequence_options_reach_the_binary_one_argument_each`, `::test_sequence_options_with_spaces_stay_one_argument` |

All five aliases forward `options` on both runtimes (see "Aliases" above).
The legacy string path is the retained unsafe compatibility surface; its
removal is governed by the legacy-sunset gate OVH-27 (audit decision D1),
implemented by OVH-19, which requires this options story to exist first.

## Target contract (from Phase 2 onward)

| Aspect | Target |
Expand All @@ -79,7 +112,7 @@ features and are removed in Phase 4.
| `import py7zip` then `py7zip.Py7zip(...)` | still unsupported today; the package root exports nothing | Import from `py7zip.py7zip`. A root-level re-export may be added later as an *additive* change, not a break. |
| `Py7zip()` implicitly downloading a binary | explicit `ensure_binary()` | Call `ensure_binary()` where the download used to happen implicitly. |
| `Py7zip.__version__` from HTTP | package metadata; no network | No change for correct callers. Callers that relied on `"0.0.0"` as an offline sentinel must handle the metadata-backed value. |
| `wrapper(src, dst, options, method)` shell string | argv-based, typed options | Replace any free-text options string with the typed structure or explicit sequence. |
| `wrapper(src, dst, options, method)` shell string | Safe runtime (default): `""` or a sequence of individual arguments; every non-empty string raises `TypeError` before any subprocess — decision D5 landed with no `shlex` bridge. Legacy runtime: strings keep the historical `shell=True` interpolation until the legacy sunset (OVH-27/OVH-19); sequences run as `shell=False` argv (OVH-16, landed 2026-10-11). | Pass options as a sequence of individual arguments (`["-y", "-mx=9"]`), or move to the default safe runtime where that is the only accepted form. `""` still means "no options" on both runtimes. |
| Ignoring the return value of `compress` / `decompress` | a structured `ArchiveResult` | Inspect the result or catch the typed error; failures will no longer be silent. |
| `full`, `incremental`, `differential`, `snapshot` | removed | Stop calling them. They have never done anything. |
| `Py7zip.cd` (process-wide `chdir`) | deprecated, then removed | Manage the working directory in caller code (`os.chdir` / `contextlib.chdir`) or pass explicit paths; `cd` mutates process-global state and is not part of the wrapper's archive contract. |
Expand Down
Loading
Loading