safeuploads 2.1.0__tar.gz → 3.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.agents/skills/safeuploads-library/references/code-standards.md +1 -1
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.agents/skills/safeuploads-library/references/testing.md +3 -2
- safeuploads-3.0.0/.github/workflows/mirror.yml +41 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.github/workflows/publish-safeuploads.yml +5 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.github/workflows/test-matrix-safeuploads.yml +2 -2
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.gitignore +1 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/AGENTS.md +3 -3
- {safeuploads-2.1.0 → safeuploads-3.0.0}/CHANGELOG.md +37 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/CONTRIBUTING.md +1 -1
- {safeuploads-2.1.0 → safeuploads-3.0.0}/PKG-INFO +6 -4
- {safeuploads-2.1.0 → safeuploads-3.0.0}/README.md +4 -2
- {safeuploads-2.1.0 → safeuploads-3.0.0}/docs/index.md +1 -1
- {safeuploads-2.1.0 → safeuploads-3.0.0}/docs/security/architecture.md +7 -3
- {safeuploads-2.1.0 → safeuploads-3.0.0}/docs/security/threat-model.md +41 -4
- {safeuploads-2.1.0 → safeuploads-3.0.0}/pyproject.toml +10 -6
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/__init__.py +2 -2
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/config.py +18 -18
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/exceptions.py +8 -4
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/inspectors/zip_inspector.py +56 -4
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/utils.py +17 -10
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/validators/extension_validator.py +8 -4
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/inspectors/test_gzip_inspector.py +2 -1
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/inspectors/test_zip_inspector.py +118 -3
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/test_audit.py +4 -2
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/test_config.py +8 -8
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/test_config_validation.py +5 -3
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/test_exceptions.py +28 -12
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/test_fastapi_integration.py +2 -2
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/test_file_validator.py +24 -2
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/test_performance.py +2 -1
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/test_sync_validation.py +25 -9
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/validators/test_extension_validator.py +43 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/uv.lock +271 -18
- safeuploads-2.1.0/.github/workflows/mirror.yml +0 -30
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.agents/skills/conventional-commits/SKILL.md +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.agents/skills/conventional-commits/references/pull-requests.md +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.agents/skills/debugging/SKILL.md +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.agents/skills/safeuploads-library/SKILL.md +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.agents/skills/safeuploads-library/references/security.md +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.agents/skills/skill-authoring/SKILL.md +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.github/CODEOWNERS +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.github/FUNDING.yml +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.github/copilot-instructions.md +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.github/dependabot.yml +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.github/scripts/check_conventional_commits.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.github/scripts/smoke_import.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.github/workflows/audit-safeuploads.yml +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.github/workflows/conventional-commits.yml +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.github/workflows/docs.yml +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.github/workflows/fuzz.yml +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/.github/workflows/lint-and-test.yml +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/CODE_OF_CONDUCT.md +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/LICENSE.md +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/SECURITY.md +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/docs/api.md +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/docs/rate-limiting.md +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/docs/security/integration-checklist.md +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/examples/README.md +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/examples/fastapi_example.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/mkdocs.yml +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/audit.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/enums.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/file_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/inspectors/__init__.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/inspectors/base.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/inspectors/content_inspector.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/inspectors/gzip_inspector.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/protocols.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/py.typed +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/validators/__init__.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/validators/base.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/validators/compression_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/validators/filename_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/validators/fit_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/validators/mime_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/validators/signature_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/validators/unicode_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/validators/windows_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/safeuploads/validators/xml_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/__init__.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/conftest.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/fuzz/__init__.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/fuzz/helpers.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/fuzz/test_fuzz_config.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/fuzz/test_fuzz_filenames.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/fuzz/test_fuzz_images.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/fuzz/test_fuzz_zip.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/inspectors/__init__.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/inspectors/test_content_inspector.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/test_enums.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/test_utils.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/test_validate_activity_file.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/test_validate_gzip_file.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/validators/__init__.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/validators/test_compression_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/validators/test_filename_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/validators/test_fit_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/validators/test_mime_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/validators/test_signature_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/validators/test_unicode_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/validators/test_windows_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-3.0.0}/tests/validators/test_xml_validator.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Code standards
|
|
2
2
|
|
|
3
3
|
## Python
|
|
4
|
-
- Python 3.
|
|
4
|
+
- Python 3.11 syntax and standard library (`requires-python = ">=3.11"`): `int | None`, `list[str]`, `dict[str, Any]`; no `typing.Optional/List/Dict`, and no 3.12+ features such as PEP 695 type parameters, `type` aliases or `typing.override`. The 3.11 CI job is the check.
|
|
5
5
|
- Lines up to 79 characters, enforced by `ruff format` (`line-length = 79`); keep comments and docstrings within it too.
|
|
6
6
|
- Imports grouped stdlib / third-party / local; `TYPE_CHECKING` to avoid cycles.
|
|
7
7
|
- Every module has a top-level docstring.
|
{safeuploads-2.1.0 → safeuploads-3.0.0}/.agents/skills/safeuploads-library/references/testing.md
RENAMED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
- Markers are declared in `pyproject.toml` (`unit`, `integration`, `slow`, `performance`, `fuzz`) and run with `--strict-markers`; mark a new test accordingly.
|
|
10
10
|
- Skips need `reason=`.
|
|
11
11
|
- Coverage is branch coverage with `fail_under = 90`; it is a ratchet, raise it when coverage rises and never lower it.
|
|
12
|
-
- Tests are deterministic and offline; a performance test asserts a generous bound, never an exact time.
|
|
12
|
+
- Tests are deterministic and offline; a performance test asserts a generous bound, never an exact time, and is deselected by default (`addopts` in `pyproject.toml`), so run it with `-m performance`.
|
|
13
13
|
- Do not mock a validator to test the orchestrator's decision; run the real one on a small payload.
|
|
14
14
|
|
|
15
15
|
## Fuzzing
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
20
|
uv run pytest tests/validators -v
|
|
21
|
-
uv run pytest -m "not
|
|
21
|
+
uv run pytest -m "not fuzz"
|
|
22
|
+
uv run pytest -m performance
|
|
22
23
|
uv run pytest --cov=safeuploads --cov-report=term-missing
|
|
23
24
|
```
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: Mirror to GitLab
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
|
|
6
|
+
concurrency:
|
|
7
|
+
group: gitlab-mirror
|
|
8
|
+
cancel-in-progress: false
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
mirror:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- name: Push full mirror to GitLab
|
|
18
|
+
env:
|
|
19
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
20
|
+
GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }}
|
|
21
|
+
# Credentials go in per-host Authorization headers passed through
|
|
22
|
+
# GIT_CONFIG_* environment variables, so they never appear in a
|
|
23
|
+
# command line, a remote URL or the mirror's stored git config.
|
|
24
|
+
run: |
|
|
25
|
+
set -euo pipefail
|
|
26
|
+
github_auth="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 -w0)"
|
|
27
|
+
gitlab_auth="$(printf 'oauth2:%s' "$GITLAB_TOKEN" | base64 -w0)"
|
|
28
|
+
echo "::add-mask::${github_auth}"
|
|
29
|
+
echo "::add-mask::${gitlab_auth}"
|
|
30
|
+
export GIT_CONFIG_COUNT=2
|
|
31
|
+
export GIT_CONFIG_KEY_0="http.https://github.com/.extraheader"
|
|
32
|
+
export GIT_CONFIG_VALUE_0="Authorization: Basic ${github_auth}"
|
|
33
|
+
export GIT_CONFIG_KEY_1="http.https://gitlab.com/.extraheader"
|
|
34
|
+
export GIT_CONFIG_VALUE_1="Authorization: Basic ${gitlab_auth}"
|
|
35
|
+
mirror_dir="$RUNNER_TEMP/safeuploads.git"
|
|
36
|
+
git clone --mirror \
|
|
37
|
+
"https://github.com/${GITHUB_REPOSITORY}.git" "$mirror_dir"
|
|
38
|
+
cd "$mirror_dir"
|
|
39
|
+
git remote set-url --push origin \
|
|
40
|
+
"https://gitlab.com/endurain-project/safeuploads.git"
|
|
41
|
+
git push --mirror
|
|
@@ -70,6 +70,11 @@ jobs:
|
|
|
70
70
|
exit 1
|
|
71
71
|
fi
|
|
72
72
|
|
|
73
|
+
# python-magic needs the libmagic shared library at import time; install it
|
|
74
|
+
# explicitly instead of relying on the runner image, as the test workflows do.
|
|
75
|
+
- name: Install system dependencies
|
|
76
|
+
run: sudo apt-get update && sudo apt-get install -y libmagic1
|
|
77
|
+
|
|
73
78
|
# The full gate runs on every PR, but a tag can point anywhere in history,
|
|
74
79
|
# so re-run it here: a release must never ship code that does not pass.
|
|
75
80
|
- name: Sync dependencies
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
name: Test matrix safeuploads
|
|
2
2
|
|
|
3
3
|
# Portability matrix on top of the fast default `Lint & Test` workflow:
|
|
4
|
-
# * every supported Python version (3.
|
|
4
|
+
# * every supported Python version (3.11 to 3.15) with the full
|
|
5
5
|
# dependency set (dev group + fastapi extra), proving the suite
|
|
6
6
|
# passes on every Python the package declares support for; and
|
|
7
7
|
# * a bare `import safeuploads` with only the base dependencies
|
|
@@ -40,7 +40,7 @@ jobs:
|
|
|
40
40
|
strategy:
|
|
41
41
|
fail-fast: false
|
|
42
42
|
matrix:
|
|
43
|
-
python: ["3.13", "3.14", "3.15"]
|
|
43
|
+
python: ["3.11", "3.12", "3.13", "3.14", "3.15"]
|
|
44
44
|
steps:
|
|
45
45
|
- name: Checkout code
|
|
46
46
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# safeuploads
|
|
2
2
|
|
|
3
|
-
Secure file upload validation for Python 3.
|
|
3
|
+
Secure file upload validation for Python 3.11+ applications.
|
|
4
4
|
Catches dangerous filenames, malicious extensions, Windows reserved names, compression bombs, polyglots and unsafe XML before an upload is accepted.
|
|
5
5
|
Runtime dependencies are minimal (`python-magic`, `defusedxml`; FastAPI behind the `fastapi` extra).
|
|
6
6
|
Published to PyPI; the public API is governed by SemVer.
|
|
@@ -33,7 +33,7 @@ docs/ Documentation site (mkdocs)
|
|
|
33
33
|
|
|
34
34
|
## Commands
|
|
35
35
|
|
|
36
|
-
From the repository root (Python 3.
|
|
36
|
+
From the repository root (Python 3.11+, `uv`, system `libmagic`):
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
39
|
uv sync --dev --extra fastapi
|
|
@@ -97,4 +97,4 @@ uv run pytest
|
|
|
97
97
|
- Never build a filesystem path from an uploaded filename.
|
|
98
98
|
- Logging uses `logging.getLogger(__name__)` and the `SecurityAuditLogger`; never log file contents.
|
|
99
99
|
- Comments and docstrings state what the code does now, not what it used to do.
|
|
100
|
-
- Python 3.
|
|
100
|
+
- Python 3.11 syntax and standard library (no 3.12+ features), 79-character lines (`pyproject.toml`), PEP 257 docstrings with `Args`, `Returns`, `Raises` where applicable.
|
|
@@ -4,6 +4,41 @@ All notable changes to this project are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [3.0.0] - 2026-11-05
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Support for Python 3.11 and 3.12; `requires-python` is now `>=3.11` and CI tests 3.11 to 3.15. A Python version stays supported until its CPython end of life and is dropped in the next minor release after that. The Unicode checks use the running Python's Unicode database, so a character assigned after that database's version is rejected as unassigned (for example U+1FAE8 on 3.11).
|
|
12
|
+
|
|
13
|
+
### Removed
|
|
14
|
+
|
|
15
|
+
- **Breaking:** the `ConfigValidationError` alias, deprecated in 2.2.0, is removed from `safeuploads` and `safeuploads.exceptions`. Use `ConfigValidationIssue`.
|
|
16
|
+
|
|
17
|
+
### Security
|
|
18
|
+
|
|
19
|
+
- ZIP entry names now get the filename checks a top-level upload gets ([GHSA-jjqv-c38c-fjf3](https://github.com/endurain-project/safeuploads/security/advisories/GHSA-jjqv-c38c-fjf3)). `ZipContentInspector` checked entries only against `ZipThreatCategory.EXECUTABLE_FILES`, `SCRIPT_FILES` and `SYSTEM_FILES`, so an entry such as `invoice.docm`, `a.hta`, `CON.txt` or a name with a right-to-left override was accepted although the same name is rejected as an upload. Each entry name is now checked against `DANGEROUS_UNICODE_CHARS` and the rejected Unicode categories, each path component against `WINDOWS_RESERVED_NAMES`, and a file's base name against `BLOCKED_EXTENSIONS` and `COMPOUND_BLOCKED_EXTENSIONS`, on the NFC and NFKC forms, all taken from `FileSecurityConfig`. A failing entry raises `ZipContentError` with `ZIP_CONTENT_THREAT`.
|
|
20
|
+
- The blocked-extension check ignores trailing dots and spaces, as Windows drops them when it saves a file. A filename such as `run.exe ` or `invoice.docm` followed by U+3000 or U+00A0 (which NFKC folds to a space) was accepted as an upload or a ZIP entry; it is now rejected with `EXTENSION_BLOCKED`.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **Breaking:** the ZIP entry name checks under Security reject archives that 2.2.0 accepted under the default configuration, for example archives with `.docm`, `.xlsm`, `.hta`, `.min.css` or `CON.txt` entries. Uploads and entries whose name ends in a blocked extension followed by spaces, such as `run.exe `, are rejected as well. An entry named `.jar`, `.war` or `.ear` whose content is a ZIP now reports `ZIP_CONTENT_THREAT` first instead of `ZIP_NESTED_ARCHIVE`.
|
|
25
|
+
|
|
26
|
+
## [2.2.0] - 2026-11-05
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- `ConfigValidationIssue`, exported from `safeuploads` and `safeuploads.exceptions`: the record `validate_instance()` and `validate_configuration()` return and `FileSecurityConfigurationError.errors` carries. It is the same dataclass under a name that does not suggest an exception.
|
|
31
|
+
|
|
32
|
+
### Deprecated
|
|
33
|
+
|
|
34
|
+
- `ConfigValidationError` is a deprecated alias of `ConfigValidationIssue` and will be removed in 3.0. Accessing or importing it from `safeuploads` or `safeuploads.exceptions` emits a `DeprecationWarning`; it is no longer in `safeuploads.__all__`. It was never an exception, so `except ConfigValidationError` could not catch anything.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- Development: the dev dependency `httpx` is replaced by `httpx2`, which Starlette's `TestClient` now expects.
|
|
39
|
+
- Development: performance tests (`-m performance`) are deselected by default; run them with `pytest -m performance`.
|
|
40
|
+
- CI: the publish workflow installs `libmagic1` explicitly, and the GitLab mirror passes its tokens as HTTP authorization headers through `GIT_CONFIG_*` environment variables instead of embedding them in remote URLs.
|
|
41
|
+
|
|
7
42
|
## [2.1.0] - 2026-11-05
|
|
8
43
|
|
|
9
44
|
### Added
|
|
@@ -207,6 +242,8 @@ Initial release.
|
|
|
207
242
|
- **MIME type verification:** magic-number validation for common file types.
|
|
208
243
|
- Framework-agnostic async validation, a rich exception hierarchy with machine-readable error codes, secure defaults, and full type hints.
|
|
209
244
|
|
|
245
|
+
[3.0.0]: https://github.com/endurain-project/safeuploads/compare/v2.2.0...v3.0.0
|
|
246
|
+
[2.2.0]: https://github.com/endurain-project/safeuploads/compare/v2.1.0...v2.2.0
|
|
210
247
|
[2.1.0]: https://github.com/endurain-project/safeuploads/compare/v2.0.0...v2.1.0
|
|
211
248
|
[2.0.0]: https://github.com/endurain-project/safeuploads/compare/v1.1.3...v2.0.0
|
|
212
249
|
[1.1.3]: https://github.com/endurain-project/safeuploads/compare/v1.1.2...v1.1.3
|
|
@@ -72,7 +72,7 @@ PRs that are too large to review efficiently will be asked to be split before th
|
|
|
72
72
|
docs: update the integration checklist
|
|
73
73
|
```
|
|
74
74
|
Commit subjects and PR titles are validated in CI; see `.agents/skills/conventional-commits/SKILL.md` for the allowed types and scopes.
|
|
75
|
-
5. **Run the checks** from the repository root (Python 3.
|
|
75
|
+
5. **Run the checks** from the repository root (Python 3.11+, [uv](https://docs.astral.sh/uv/) and the system `libmagic`):
|
|
76
76
|
```bash
|
|
77
77
|
uv sync --dev --extra fastapi
|
|
78
78
|
uv run ruff check safeuploads tests examples
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: safeuploads
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0
|
|
4
4
|
Summary: A comprehensive file security system for validating uploads and preventing attacks
|
|
5
5
|
Author-email: João Vitória Silva <joao@endurain.com>
|
|
6
6
|
License: MIT
|
|
7
7
|
License-File: LICENSE.md
|
|
8
|
-
Requires-Python: >=3.
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
9
|
Requires-Dist: defusedxml<1.0.0,>=0.7.1
|
|
10
10
|
Requires-Dist: python-magic<0.5.0,>=0.4.27
|
|
11
11
|
Provides-Extra: fastapi
|
|
@@ -18,11 +18,11 @@ Description-Content-Type: text/markdown
|
|
|
18
18
|
[](https://github.com/endurain-project/safeuploads/releases)
|
|
19
19
|
[](https://pypi.org/project/safeuploads/)
|
|
20
20
|
[](https://pypi.org/project/safeuploads/)
|
|
21
|
-
[](https://pypi.org/project/safeuploads/)
|
|
22
22
|
[](https://safeuploads.endurain.com/)
|
|
23
23
|
[](https://github.com/endurain-project/safeuploads)
|
|
24
24
|
|
|
25
|
-
Secure file upload validation for Python 3.
|
|
25
|
+
Secure file upload validation for Python 3.11+ applications. Catches dangerous filenames, malicious extensions, Windows reserved names, and compression-based attacks before you accept an upload.
|
|
26
26
|
|
|
27
27
|
## Features
|
|
28
28
|
|
|
@@ -46,6 +46,8 @@ Secure file upload validation for Python 3.13+ applications. Catches dangerous f
|
|
|
46
46
|
pip install safeuploads
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
+
safeuploads supports Python 3.11 and newer. A Python version stays supported until its CPython end of life and is dropped in the next minor release after that.
|
|
50
|
+
|
|
49
51
|
For FastAPI integration:
|
|
50
52
|
```bash
|
|
51
53
|
pip install safeuploads[fastapi]
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
[](https://github.com/endurain-project/safeuploads/releases)
|
|
5
5
|
[](https://pypi.org/project/safeuploads/)
|
|
6
6
|
[](https://pypi.org/project/safeuploads/)
|
|
7
|
-
[](https://pypi.org/project/safeuploads/)
|
|
8
8
|
[](https://safeuploads.endurain.com/)
|
|
9
9
|
[](https://github.com/endurain-project/safeuploads)
|
|
10
10
|
|
|
11
|
-
Secure file upload validation for Python 3.
|
|
11
|
+
Secure file upload validation for Python 3.11+ applications. Catches dangerous filenames, malicious extensions, Windows reserved names, and compression-based attacks before you accept an upload.
|
|
12
12
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
@@ -32,6 +32,8 @@ Secure file upload validation for Python 3.13+ applications. Catches dangerous f
|
|
|
32
32
|
pip install safeuploads
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
safeuploads supports Python 3.11 and newer. A Python version stays supported until its CPython end of life and is dropped in the next minor release after that.
|
|
36
|
+
|
|
35
37
|
For FastAPI integration:
|
|
36
38
|
```bash
|
|
37
39
|
pip install safeuploads[fastapi]
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
</a>
|
|
13
13
|
</div>
|
|
14
14
|
|
|
15
|
-
Secure file upload validation for Python 3.
|
|
15
|
+
Secure file upload validation for Python 3.11+ applications. Catches dangerous filenames, malicious extensions, Windows reserved names, and compression-based attacks before you accept an upload.
|
|
16
16
|
|
|
17
17
|
## Features
|
|
18
18
|
|
|
@@ -51,7 +51,7 @@ safeuploads/
|
|
|
51
51
|
| `CompressionSecurityValidator` | Checks compression ratio, uncompressed size, entry count, and nested archive limits, and that the listed entries fill the file exactly. |
|
|
52
52
|
| `XmlSecurityValidator` | Parses XML with `defusedxml`, blocking DTDs and external entities (raises `XmlSecurityError`). |
|
|
53
53
|
| `FitSecurityValidator` | Checks the FIT container: headers, header CRC, data size, CRC-16 per section, and that the sections cover the file exactly. |
|
|
54
|
-
| `ZipContentInspector` | Iterates ZIP entries checking for traversal, executables, scripts, symlinks, and recursive structures. With `allow_nested_archives`, inspects every inner ZIP like the top level. |
|
|
54
|
+
| `ZipContentInspector` | Iterates ZIP entries checking for traversal, executables, scripts, symlinks, and recursive structures, and runs the top-level Unicode, Windows reserved-name and blocked-extension checks on each entry name. With `allow_nested_archives`, inspects every inner ZIP like the top level. |
|
|
55
55
|
| `GzipContentInspector` | Streams gzip decompression, checking ratio and size progressively. |
|
|
56
56
|
| `ContentSecurityInspector` | Scans raw bytes for structurally verified executable headers, web shells, and polyglot patterns (image and activity uploads). |
|
|
57
57
|
| `ResourceMonitor` | Context manager enforcing wall-clock time and peak-RSS memory limits at loop boundaries and on exit. |
|
|
@@ -181,7 +181,11 @@ UploadFile
|
|
|
181
181
|
│ │ ├── Null bytes │
|
|
182
182
|
│ │ ├── Path traversal │
|
|
183
183
|
│ │ ├── Absolute paths │
|
|
184
|
-
│ │
|
|
184
|
+
│ │ ├── Symlinks │
|
|
185
|
+
│ │ └── Top-level name │
|
|
186
|
+
│ │ checks (Unicode, │
|
|
187
|
+
│ │ Windows names, │
|
|
188
|
+
│ │ blocked exts) │
|
|
185
189
|
│ ├── Dangerous entry types│
|
|
186
190
|
│ ├── Binary signatures │
|
|
187
191
|
│ ├── Nested archive magic │
|
|
@@ -373,7 +377,7 @@ Exception
|
|
|
373
377
|
All `FileSecurityError` subclasses carry an `error_code` from
|
|
374
378
|
`ErrorCode` for machine-readable classification.
|
|
375
379
|
`FileSecurityConfigurationError` is separate and exposes its
|
|
376
|
-
`errors` list instead. When no code is passed, the default is:
|
|
380
|
+
`errors` list of `ConfigValidationIssue` records instead. When no code is passed, the default is:
|
|
377
381
|
|
|
378
382
|
| Exception | Default `ErrorCode` |
|
|
379
383
|
|---|---|
|
|
@@ -59,6 +59,11 @@ is `.exe`.
|
|
|
59
59
|
rejected with `UnicodeSecurityError`; none is stripped. A
|
|
60
60
|
filename containing an emoji ZWJ sequence is therefore
|
|
61
61
|
rejected.
|
|
62
|
+
- Categories come from the running Python's Unicode database
|
|
63
|
+
(14.0 on Python 3.11, 15.0 on 3.12, 15.1 on 3.13), so a
|
|
64
|
+
character assigned in a later Unicode version is unassigned
|
|
65
|
+
(Cn) and rejected on an older Python. Older versions only
|
|
66
|
+
reject more.
|
|
62
67
|
- The Windows reserved-name and extension checks run on the NFC
|
|
63
68
|
name and on its NFKC form, so fullwidth letters or
|
|
64
69
|
superscript digits cannot disguise `CON`, `COM1` or `.php`.
|
|
@@ -99,6 +104,11 @@ improperly.
|
|
|
99
104
|
files, drivers, themes, and help files.
|
|
100
105
|
- Compound extensions (`.tar.gz`, `.user.js`, `.min.css`) are
|
|
101
106
|
checked via `CompoundExtensionCategory`.
|
|
107
|
+
- Trailing dots and spaces are ignored by the blocked-extension
|
|
108
|
+
check, because Windows drops them when it saves a file. The
|
|
109
|
+
check also runs on the NFKC form, so `run.exe ` or
|
|
110
|
+
`invoice.docm` followed by U+3000 (an ideographic space that
|
|
111
|
+
NFKC folds to a space) is rejected.
|
|
102
112
|
- Allowed extensions are validated against a configurable
|
|
103
113
|
allowlist per file type.
|
|
104
114
|
|
|
@@ -285,6 +295,32 @@ outside the extraction directory (Zip Slip).
|
|
|
285
295
|
bypasses (CWE-158). Python's `zipfile` truncates `filename`
|
|
286
296
|
at the first NUL, so the raw name is needed.
|
|
287
297
|
|
|
298
|
+
### Spoofed or Blocked Entry Names (CWE-184, CWE-434)
|
|
299
|
+
|
|
300
|
+
**Attack:** A file refused as a standalone upload (a
|
|
301
|
+
macro-enabled Office document, an `.hta`, a name with a
|
|
302
|
+
right-to-left override, `CON.txt`) is wrapped in a ZIP so that a
|
|
303
|
+
host extracting or listing the entries stores it anyway.
|
|
304
|
+
|
|
305
|
+
**Mitigations:**
|
|
306
|
+
|
|
307
|
+
- Every entry name gets the filename checks a top-level upload
|
|
308
|
+
gets, driven by the same `FileSecurityConfig` fields, so a
|
|
309
|
+
configuration that extends a list covers entries too:
|
|
310
|
+
- the whole name is checked against `DANGEROUS_UNICODE_CHARS`
|
|
311
|
+
and the rejected Unicode categories, and its NFKC form must
|
|
312
|
+
not introduce a path separator or `..`;
|
|
313
|
+
- every path component is checked against
|
|
314
|
+
`WINDOWS_RESERVED_NAMES`;
|
|
315
|
+
- a file's base name is checked against `BLOCKED_EXTENSIONS`
|
|
316
|
+
(every extension in the name) and
|
|
317
|
+
`COMPOUND_BLOCKED_EXTENSIONS`.
|
|
318
|
+
- The reserved-name and extension checks run on the NFC name and
|
|
319
|
+
its NFKC form, after stripping trailing dots and spaces that
|
|
320
|
+
Windows drops on extraction.
|
|
321
|
+
- A failing name is reported as `ZIP_CONTENT_THREAT`; the message
|
|
322
|
+
shows the name with control and bidi characters escaped.
|
|
323
|
+
|
|
288
324
|
### Executable Content in ZIP
|
|
289
325
|
|
|
290
326
|
**Attack:** Executables, scripts, system files, or shortcuts
|
|
@@ -292,10 +328,11 @@ hidden inside ZIP archives.
|
|
|
292
328
|
|
|
293
329
|
**Mitigations:**
|
|
294
330
|
|
|
295
|
-
-
|
|
296
|
-
and spaces, is checked
|
|
297
|
-
`ZipThreatCategory.EXECUTABLE_FILES`, `SCRIPT_FILES`,
|
|
298
|
-
`SYSTEM_FILES
|
|
331
|
+
- Besides `BLOCKED_EXTENSIONS` (see above), the entry's final
|
|
332
|
+
extension, after stripping trailing dots and spaces, is checked
|
|
333
|
+
against `ZipThreatCategory.EXECUTABLE_FILES`, `SCRIPT_FILES`,
|
|
334
|
+
and `SYSTEM_FILES`, which also cover configuration files such
|
|
335
|
+
as `.ini`, `.cfg` and `.conf`.
|
|
299
336
|
- Binary content is scanned for executable magic bytes from
|
|
300
337
|
`SuspiciousFilePattern.EXECUTABLE_SIGNATURES`. The first 512
|
|
301
338
|
bytes of each entry are read for this scan.
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "safeuploads"
|
|
3
|
-
version = "
|
|
3
|
+
version = "3.0.0"
|
|
4
4
|
description = "A comprehensive file security system for validating uploads and preventing attacks"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "João Vitória Silva",email = "joao@endurain.com"}
|
|
7
7
|
]
|
|
8
8
|
license = {text = "MIT"}
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
11
|
dependencies = [
|
|
12
12
|
"python-magic>=0.4.27,<0.5.0",
|
|
13
13
|
"defusedxml>=0.7.1,<1.0.0",
|
|
@@ -27,7 +27,7 @@ dev = [
|
|
|
27
27
|
"ty>=0.0.83",
|
|
28
28
|
"types-defusedxml",
|
|
29
29
|
# HTTP stack for the FastAPI multipart integration test
|
|
30
|
-
"
|
|
30
|
+
"httpx2>=2.16,<3.0",
|
|
31
31
|
"python-multipart>=0.0.9,<1.0",
|
|
32
32
|
]
|
|
33
33
|
docs = [
|
|
@@ -46,7 +46,7 @@ exclude-newer = "7 days"
|
|
|
46
46
|
|
|
47
47
|
[tool.ruff]
|
|
48
48
|
line-length = 79
|
|
49
|
-
target-version = "
|
|
49
|
+
target-version = "py311"
|
|
50
50
|
|
|
51
51
|
[tool.ruff.lint]
|
|
52
52
|
select = [
|
|
@@ -118,6 +118,10 @@ addopts = [
|
|
|
118
118
|
"--strict-markers",
|
|
119
119
|
"--strict-config",
|
|
120
120
|
"-ra",
|
|
121
|
+
# Benchmarks assert wall-clock bounds that depend on the machine;
|
|
122
|
+
# run them explicitly with `pytest -m performance`.
|
|
123
|
+
"-m",
|
|
124
|
+
"not performance",
|
|
121
125
|
"--cov=safeuploads",
|
|
122
126
|
"--cov-report=term-missing",
|
|
123
127
|
"--cov-report=html",
|
|
@@ -151,7 +155,7 @@ exclude_lines = [
|
|
|
151
155
|
]
|
|
152
156
|
|
|
153
157
|
[tool.mypy]
|
|
154
|
-
python_version = "3.
|
|
158
|
+
python_version = "3.11"
|
|
155
159
|
incremental = false
|
|
156
160
|
# strict = true enables the full strict bundle, including
|
|
157
161
|
# disallow_any_generics, warn_return_any, disallow_untyped_calls,
|
|
@@ -170,7 +174,7 @@ ignore_missing_imports = true
|
|
|
170
174
|
# ``ty`` runs beside mypy as an advisory, non-blocking CI job (it is pre-1.0);
|
|
171
175
|
# mypy stays the gate. Same scope as ``[tool.mypy]``.
|
|
172
176
|
[tool.ty.environment]
|
|
173
|
-
python-version = "3.
|
|
177
|
+
python-version = "3.11"
|
|
174
178
|
|
|
175
179
|
[tool.ty.src]
|
|
176
180
|
include = ["safeuploads"]
|
|
@@ -28,7 +28,7 @@ from .enums import (
|
|
|
28
28
|
)
|
|
29
29
|
from .exceptions import (
|
|
30
30
|
CompressionSecurityError,
|
|
31
|
-
|
|
31
|
+
ConfigValidationIssue,
|
|
32
32
|
ContentThreatError,
|
|
33
33
|
ErrorCode,
|
|
34
34
|
ExtensionSecurityError,
|
|
@@ -95,7 +95,7 @@ __all__ = [
|
|
|
95
95
|
"SeekableFile",
|
|
96
96
|
"UploadFileProtocol",
|
|
97
97
|
# Exceptions
|
|
98
|
-
"
|
|
98
|
+
"ConfigValidationIssue",
|
|
99
99
|
"FileSecurityConfigurationError",
|
|
100
100
|
"ErrorCode",
|
|
101
101
|
"FileSecurityError",
|
|
@@ -11,7 +11,7 @@ from .enums import (
|
|
|
11
11
|
DangerousExtensionCategory,
|
|
12
12
|
UnicodeAttackCategory,
|
|
13
13
|
)
|
|
14
|
-
from .exceptions import
|
|
14
|
+
from .exceptions import ConfigValidationIssue, FileSecurityConfigurationError
|
|
15
15
|
from .utils import bytes_to_mb
|
|
16
16
|
|
|
17
17
|
logger = logging.getLogger(__name__)
|
|
@@ -23,9 +23,9 @@ def _config_error(
|
|
|
23
23
|
component: str,
|
|
24
24
|
recommendation: str,
|
|
25
25
|
severity: str = "error",
|
|
26
|
-
) ->
|
|
26
|
+
) -> ConfigValidationIssue:
|
|
27
27
|
"""
|
|
28
|
-
Build a
|
|
28
|
+
Build a ConfigValidationIssue with a default severity.
|
|
29
29
|
|
|
30
30
|
Centralizes construction so the many validation branches
|
|
31
31
|
share one call shape instead of repeating every field.
|
|
@@ -38,9 +38,9 @@ def _config_error(
|
|
|
38
38
|
severity: Severity level ('error', 'warning', 'info').
|
|
39
39
|
|
|
40
40
|
Returns:
|
|
41
|
-
Populated
|
|
41
|
+
Populated ConfigValidationIssue instance.
|
|
42
42
|
"""
|
|
43
|
-
return
|
|
43
|
+
return ConfigValidationIssue(
|
|
44
44
|
error_type=error_type,
|
|
45
45
|
message=message,
|
|
46
46
|
severity=severity,
|
|
@@ -451,7 +451,7 @@ class FileSecurityConfig:
|
|
|
451
451
|
return None
|
|
452
452
|
|
|
453
453
|
@classmethod
|
|
454
|
-
def validate_configuration(cls) -> list[
|
|
454
|
+
def validate_configuration(cls) -> list[ConfigValidationIssue]:
|
|
455
455
|
"""
|
|
456
456
|
Validate the class-level default configuration.
|
|
457
457
|
|
|
@@ -460,7 +460,7 @@ class FileSecurityConfig:
|
|
|
460
460
|
"""
|
|
461
461
|
return cls._collect_errors(cls, cls.limits)
|
|
462
462
|
|
|
463
|
-
def validate_instance(self) -> list[
|
|
463
|
+
def validate_instance(self) -> list[ConfigValidationIssue]:
|
|
464
464
|
"""
|
|
465
465
|
Validate this instance's configuration.
|
|
466
466
|
|
|
@@ -479,7 +479,7 @@ class FileSecurityConfig:
|
|
|
479
479
|
cls,
|
|
480
480
|
config: "FileSecurityConfig | type[FileSecurityConfig]",
|
|
481
481
|
limits: SecurityLimits,
|
|
482
|
-
) -> list[
|
|
482
|
+
) -> list[ConfigValidationIssue]:
|
|
483
483
|
"""
|
|
484
484
|
Run all validation routines for the given configuration.
|
|
485
485
|
|
|
@@ -495,7 +495,7 @@ class FileSecurityConfig:
|
|
|
495
495
|
Returns:
|
|
496
496
|
List of detected validation errors.
|
|
497
497
|
"""
|
|
498
|
-
errors: list[
|
|
498
|
+
errors: list[ConfigValidationIssue] = []
|
|
499
499
|
|
|
500
500
|
# Validate limit types first so the range checks below
|
|
501
501
|
# never compare a wrongly typed value.
|
|
@@ -530,7 +530,7 @@ class FileSecurityConfig:
|
|
|
530
530
|
@staticmethod
|
|
531
531
|
def _validate_limit_types(
|
|
532
532
|
limits: SecurityLimits,
|
|
533
|
-
) -> list[
|
|
533
|
+
) -> list[ConfigValidationIssue]:
|
|
534
534
|
"""
|
|
535
535
|
Validate that every limit holds a value of its declared type.
|
|
536
536
|
|
|
@@ -577,7 +577,7 @@ class FileSecurityConfig:
|
|
|
577
577
|
@staticmethod
|
|
578
578
|
def _validate_file_size_limits(
|
|
579
579
|
limits: SecurityLimits,
|
|
580
|
-
) -> list[
|
|
580
|
+
) -> list[ConfigValidationIssue]:
|
|
581
581
|
"""
|
|
582
582
|
Validate the provided file size limits.
|
|
583
583
|
|
|
@@ -708,7 +708,7 @@ class FileSecurityConfig:
|
|
|
708
708
|
@staticmethod
|
|
709
709
|
def _validate_streaming_limits(
|
|
710
710
|
limits: SecurityLimits,
|
|
711
|
-
) -> list[
|
|
711
|
+
) -> list[ConfigValidationIssue]:
|
|
712
712
|
"""
|
|
713
713
|
Validate streaming, resource and content scan limits.
|
|
714
714
|
|
|
@@ -788,7 +788,7 @@ class FileSecurityConfig:
|
|
|
788
788
|
@staticmethod
|
|
789
789
|
def _validate_mime_configurations(
|
|
790
790
|
config: "FileSecurityConfig | type[FileSecurityConfig]",
|
|
791
|
-
) -> list[
|
|
791
|
+
) -> list[ConfigValidationIssue]:
|
|
792
792
|
"""
|
|
793
793
|
Validate MIME type configurations.
|
|
794
794
|
|
|
@@ -876,7 +876,7 @@ class FileSecurityConfig:
|
|
|
876
876
|
@staticmethod
|
|
877
877
|
def _validate_extension_configurations(
|
|
878
878
|
config: "FileSecurityConfig | type[FileSecurityConfig]",
|
|
879
|
-
) -> list[
|
|
879
|
+
) -> list[ConfigValidationIssue]:
|
|
880
880
|
"""
|
|
881
881
|
Validate file extension configurations.
|
|
882
882
|
|
|
@@ -1028,7 +1028,7 @@ class FileSecurityConfig:
|
|
|
1028
1028
|
@staticmethod
|
|
1029
1029
|
def _validate_compression_settings(
|
|
1030
1030
|
limits: SecurityLimits,
|
|
1031
|
-
) -> list[
|
|
1031
|
+
) -> list[ConfigValidationIssue]:
|
|
1032
1032
|
"""
|
|
1033
1033
|
Validate the provided compression-related limits.
|
|
1034
1034
|
|
|
@@ -1240,7 +1240,7 @@ class FileSecurityConfig:
|
|
|
1240
1240
|
return errors
|
|
1241
1241
|
|
|
1242
1242
|
@classmethod
|
|
1243
|
-
def _validate_enum_consistency(cls) -> list[
|
|
1243
|
+
def _validate_enum_consistency(cls) -> list[ConfigValidationIssue]:
|
|
1244
1244
|
"""
|
|
1245
1245
|
Validate enum categories for emptiness and overlaps.
|
|
1246
1246
|
|
|
@@ -1345,7 +1345,7 @@ class FileSecurityConfig:
|
|
|
1345
1345
|
@staticmethod
|
|
1346
1346
|
def _validate_cross_dependencies(
|
|
1347
1347
|
config: "FileSecurityConfig | type[FileSecurityConfig]",
|
|
1348
|
-
) -> list[
|
|
1348
|
+
) -> list[ConfigValidationIssue]:
|
|
1349
1349
|
"""
|
|
1350
1350
|
Validate cross-field configuration constraints.
|
|
1351
1351
|
|
|
@@ -1452,7 +1452,7 @@ class FileSecurityConfig:
|
|
|
1452
1452
|
|
|
1453
1453
|
@staticmethod
|
|
1454
1454
|
def _report_errors(
|
|
1455
|
-
errors: list[
|
|
1455
|
+
errors: list[ConfigValidationIssue], strict: bool
|
|
1456
1456
|
) -> None:
|
|
1457
1457
|
"""
|
|
1458
1458
|
Log validation issues and optionally raise.
|
|
@@ -9,10 +9,14 @@ from enum import StrEnum
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
@dataclass
|
|
12
|
-
class
|
|
12
|
+
class ConfigValidationIssue:
|
|
13
13
|
"""
|
|
14
14
|
Configuration validation issue with severity and recommendation.
|
|
15
15
|
|
|
16
|
+
A plain record, not an exception: ``validate_instance()`` and
|
|
17
|
+
``validate_configuration()`` return a list of them, and
|
|
18
|
+
``FileSecurityConfigurationError.errors`` carries them.
|
|
19
|
+
|
|
16
20
|
Attributes:
|
|
17
21
|
error_type: Type of the validation error.
|
|
18
22
|
message: Human-readable error message.
|
|
@@ -33,13 +37,13 @@ class FileSecurityConfigurationError(Exception):
|
|
|
33
37
|
Configuration validation failed with aggregated errors.
|
|
34
38
|
|
|
35
39
|
Args:
|
|
36
|
-
errors: List of
|
|
40
|
+
errors: List of ConfigValidationIssue instances.
|
|
37
41
|
|
|
38
42
|
Attributes:
|
|
39
43
|
errors: List of validation errors that caused failure.
|
|
40
44
|
"""
|
|
41
45
|
|
|
42
|
-
def __init__(self, errors: list[
|
|
46
|
+
def __init__(self, errors: list[ConfigValidationIssue]):
|
|
43
47
|
"""Initialize with validation errors."""
|
|
44
48
|
self.errors = errors
|
|
45
49
|
error_messages = [
|
|
@@ -49,7 +53,7 @@ class FileSecurityConfigurationError(Exception):
|
|
|
49
53
|
f"Configuration validation failed: {'; '.join(error_messages)}"
|
|
50
54
|
)
|
|
51
55
|
|
|
52
|
-
def __reduce__(self) -> tuple[type, tuple[list[
|
|
56
|
+
def __reduce__(self) -> tuple[type, tuple[list[ConfigValidationIssue]]]:
|
|
53
57
|
"""Rebuild from ``errors`` so pickling round-trips."""
|
|
54
58
|
return (type(self), (self.errors,))
|
|
55
59
|
|