safeuploads 2.1.0__tar.gz → 2.2.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.
Files changed (106) hide show
  1. {safeuploads-2.1.0 → safeuploads-2.2.0}/.agents/skills/safeuploads-library/references/testing.md +3 -2
  2. safeuploads-2.2.0/.github/workflows/mirror.yml +41 -0
  3. {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/workflows/publish-safeuploads.yml +5 -0
  4. {safeuploads-2.1.0 → safeuploads-2.2.0}/.gitignore +1 -0
  5. {safeuploads-2.1.0 → safeuploads-2.2.0}/CHANGELOG.md +16 -0
  6. {safeuploads-2.1.0 → safeuploads-2.2.0}/PKG-INFO +1 -1
  7. {safeuploads-2.1.0 → safeuploads-2.2.0}/docs/security/architecture.md +1 -1
  8. {safeuploads-2.1.0 → safeuploads-2.2.0}/pyproject.toml +6 -2
  9. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/__init__.py +13 -2
  10. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/config.py +18 -18
  11. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/exceptions.py +51 -4
  12. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/utils.py +17 -10
  13. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/inspectors/test_gzip_inspector.py +2 -1
  14. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/inspectors/test_zip_inspector.py +4 -2
  15. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_audit.py +4 -2
  16. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_config.py +8 -8
  17. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_config_validation.py +5 -3
  18. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_exceptions.py +51 -13
  19. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_fastapi_integration.py +2 -2
  20. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_file_validator.py +6 -2
  21. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_performance.py +2 -1
  22. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_sync_validation.py +25 -9
  23. {safeuploads-2.1.0 → safeuploads-2.2.0}/uv.lock +34 -15
  24. safeuploads-2.1.0/.github/workflows/mirror.yml +0 -30
  25. {safeuploads-2.1.0 → safeuploads-2.2.0}/.agents/skills/conventional-commits/SKILL.md +0 -0
  26. {safeuploads-2.1.0 → safeuploads-2.2.0}/.agents/skills/conventional-commits/references/pull-requests.md +0 -0
  27. {safeuploads-2.1.0 → safeuploads-2.2.0}/.agents/skills/debugging/SKILL.md +0 -0
  28. {safeuploads-2.1.0 → safeuploads-2.2.0}/.agents/skills/safeuploads-library/SKILL.md +0 -0
  29. {safeuploads-2.1.0 → safeuploads-2.2.0}/.agents/skills/safeuploads-library/references/code-standards.md +0 -0
  30. {safeuploads-2.1.0 → safeuploads-2.2.0}/.agents/skills/safeuploads-library/references/security.md +0 -0
  31. {safeuploads-2.1.0 → safeuploads-2.2.0}/.agents/skills/skill-authoring/SKILL.md +0 -0
  32. {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/CODEOWNERS +0 -0
  33. {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/FUNDING.yml +0 -0
  34. {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  35. {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  36. {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  37. {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  38. {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/copilot-instructions.md +0 -0
  39. {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/dependabot.yml +0 -0
  40. {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/scripts/check_conventional_commits.py +0 -0
  41. {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/scripts/smoke_import.py +0 -0
  42. {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/workflows/audit-safeuploads.yml +0 -0
  43. {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/workflows/conventional-commits.yml +0 -0
  44. {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/workflows/docs.yml +0 -0
  45. {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/workflows/fuzz.yml +0 -0
  46. {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/workflows/lint-and-test.yml +0 -0
  47. {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/workflows/test-matrix-safeuploads.yml +0 -0
  48. {safeuploads-2.1.0 → safeuploads-2.2.0}/AGENTS.md +0 -0
  49. {safeuploads-2.1.0 → safeuploads-2.2.0}/CODE_OF_CONDUCT.md +0 -0
  50. {safeuploads-2.1.0 → safeuploads-2.2.0}/CONTRIBUTING.md +0 -0
  51. {safeuploads-2.1.0 → safeuploads-2.2.0}/LICENSE.md +0 -0
  52. {safeuploads-2.1.0 → safeuploads-2.2.0}/README.md +0 -0
  53. {safeuploads-2.1.0 → safeuploads-2.2.0}/SECURITY.md +0 -0
  54. {safeuploads-2.1.0 → safeuploads-2.2.0}/docs/api.md +0 -0
  55. {safeuploads-2.1.0 → safeuploads-2.2.0}/docs/index.md +0 -0
  56. {safeuploads-2.1.0 → safeuploads-2.2.0}/docs/rate-limiting.md +0 -0
  57. {safeuploads-2.1.0 → safeuploads-2.2.0}/docs/security/integration-checklist.md +0 -0
  58. {safeuploads-2.1.0 → safeuploads-2.2.0}/docs/security/threat-model.md +0 -0
  59. {safeuploads-2.1.0 → safeuploads-2.2.0}/examples/README.md +0 -0
  60. {safeuploads-2.1.0 → safeuploads-2.2.0}/examples/fastapi_example.py +0 -0
  61. {safeuploads-2.1.0 → safeuploads-2.2.0}/mkdocs.yml +0 -0
  62. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/audit.py +0 -0
  63. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/enums.py +0 -0
  64. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/file_validator.py +0 -0
  65. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/inspectors/__init__.py +0 -0
  66. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/inspectors/base.py +0 -0
  67. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/inspectors/content_inspector.py +0 -0
  68. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/inspectors/gzip_inspector.py +0 -0
  69. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/inspectors/zip_inspector.py +0 -0
  70. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/protocols.py +0 -0
  71. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/py.typed +0 -0
  72. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/__init__.py +0 -0
  73. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/base.py +0 -0
  74. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/compression_validator.py +0 -0
  75. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/extension_validator.py +0 -0
  76. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/filename_validator.py +0 -0
  77. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/fit_validator.py +0 -0
  78. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/mime_validator.py +0 -0
  79. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/signature_validator.py +0 -0
  80. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/unicode_validator.py +0 -0
  81. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/windows_validator.py +0 -0
  82. {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/xml_validator.py +0 -0
  83. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/__init__.py +0 -0
  84. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/conftest.py +0 -0
  85. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/fuzz/__init__.py +0 -0
  86. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/fuzz/helpers.py +0 -0
  87. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/fuzz/test_fuzz_config.py +0 -0
  88. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/fuzz/test_fuzz_filenames.py +0 -0
  89. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/fuzz/test_fuzz_images.py +0 -0
  90. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/fuzz/test_fuzz_zip.py +0 -0
  91. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/inspectors/__init__.py +0 -0
  92. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/inspectors/test_content_inspector.py +0 -0
  93. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_enums.py +0 -0
  94. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_utils.py +0 -0
  95. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_validate_activity_file.py +0 -0
  96. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_validate_gzip_file.py +0 -0
  97. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/validators/__init__.py +0 -0
  98. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/validators/test_compression_validator.py +0 -0
  99. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/validators/test_extension_validator.py +0 -0
  100. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/validators/test_filename_validator.py +0 -0
  101. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/validators/test_fit_validator.py +0 -0
  102. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/validators/test_mime_validator.py +0 -0
  103. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/validators/test_signature_validator.py +0 -0
  104. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/validators/test_unicode_validator.py +0 -0
  105. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/validators/test_windows_validator.py +0 -0
  106. {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/validators/test_xml_validator.py +0 -0
@@ -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 performance and not fuzz"
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
@@ -15,6 +15,7 @@ site/
15
15
  # Python
16
16
  __pycache__
17
17
  .ruff_cache/
18
+ .mypy_cache/
18
19
  .hypothesis/
19
20
 
20
21
  # Tests
@@ -4,6 +4,22 @@ 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
+ ## [2.2.0] - 2026-11-05
8
+
9
+ ### Added
10
+
11
+ - `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.
12
+
13
+ ### Deprecated
14
+
15
+ - `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.
16
+
17
+ ### Changed
18
+
19
+ - Development: the dev dependency `httpx` is replaced by `httpx2`, which Starlette's `TestClient` now expects.
20
+ - Development: performance tests (`-m performance`) are deselected by default; run them with `pytest -m performance`.
21
+ - 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.
22
+
7
23
  ## [2.1.0] - 2026-11-05
8
24
 
9
25
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: safeuploads
3
- Version: 2.1.0
3
+ Version: 2.2.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
@@ -373,7 +373,7 @@ Exception
373
373
  All `FileSecurityError` subclasses carry an `error_code` from
374
374
  `ErrorCode` for machine-readable classification.
375
375
  `FileSecurityConfigurationError` is separate and exposes its
376
- `errors` list instead. When no code is passed, the default is:
376
+ `errors` list of `ConfigValidationIssue` records instead. When no code is passed, the default is:
377
377
 
378
378
  | Exception | Default `ErrorCode` |
379
379
  |---|---|
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "safeuploads"
3
- version = "2.1.0"
3
+ version = "2.2.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"}
@@ -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
- "httpx>=0.27,<1.0",
30
+ "httpx2>=2.16,<3.0",
31
31
  "python-multipart>=0.0.9,<1.0",
32
32
  ]
33
33
  docs = [
@@ -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",
@@ -6,6 +6,7 @@ validating uploads and preventing attacks.
6
6
  """
7
7
 
8
8
  from importlib.metadata import PackageNotFoundError, version
9
+ from typing import TYPE_CHECKING
9
10
 
10
11
  from .audit import (
11
12
  AuditEvent,
@@ -28,7 +29,7 @@ from .enums import (
28
29
  )
29
30
  from .exceptions import (
30
31
  CompressionSecurityError,
31
- ConfigValidationError,
32
+ ConfigValidationIssue,
32
33
  ContentThreatError,
33
34
  ErrorCode,
34
35
  ExtensionSecurityError,
@@ -75,6 +76,16 @@ from .validators import (
75
76
  XmlSecurityValidator,
76
77
  )
77
78
 
79
+ if TYPE_CHECKING:
80
+ # Deprecated alias, resolved by ``__getattr__`` at runtime
81
+ from .exceptions import ConfigValidationError as ConfigValidationError
82
+ else:
83
+ from .exceptions import _resolve_deprecated
84
+
85
+ def __getattr__(name: str) -> object:
86
+ """Resolve deprecated package attributes (PEP 562)."""
87
+ return _resolve_deprecated(__name__, name)
88
+
78
89
  # Configuration is validated explicitly rather than at import
79
90
  # time: FileValidator validates its config on construction, and
80
91
  # callers can invoke FileSecurityConfig.validate_and_report() or
@@ -95,7 +106,7 @@ __all__ = [
95
106
  "SeekableFile",
96
107
  "UploadFileProtocol",
97
108
  # Exceptions
98
- "ConfigValidationError",
109
+ "ConfigValidationIssue",
99
110
  "FileSecurityConfigurationError",
100
111
  "ErrorCode",
101
112
  "FileSecurityError",
@@ -11,7 +11,7 @@ from .enums import (
11
11
  DangerousExtensionCategory,
12
12
  UnicodeAttackCategory,
13
13
  )
14
- from .exceptions import ConfigValidationError, FileSecurityConfigurationError
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
- ) -> ConfigValidationError:
26
+ ) -> ConfigValidationIssue:
27
27
  """
28
- Build a ConfigValidationError with a default severity.
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 ConfigValidationError instance.
41
+ Populated ConfigValidationIssue instance.
42
42
  """
43
- return ConfigValidationError(
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[ConfigValidationError]:
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[ConfigValidationError]:
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[ConfigValidationError]:
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[ConfigValidationError] = []
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[ConfigValidationError]:
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[ConfigValidationError]:
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[ConfigValidationError]:
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[ConfigValidationError]:
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[ConfigValidationError]:
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[ConfigValidationError]:
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[ConfigValidationError]:
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[ConfigValidationError]:
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[ConfigValidationError], strict: bool
1455
+ errors: list[ConfigValidationIssue], strict: bool
1456
1456
  ) -> None:
1457
1457
  """
1458
1458
  Log validation issues and optionally raise.
@@ -1,7 +1,9 @@
1
1
  """File security exception classes and error codes."""
2
2
 
3
+ import warnings
3
4
  from dataclasses import dataclass
4
5
  from enum import StrEnum
6
+ from typing import TYPE_CHECKING
5
7
 
6
8
  # ============================================================================
7
9
  # Configuration Validation
@@ -9,10 +11,14 @@ from enum import StrEnum
9
11
 
10
12
 
11
13
  @dataclass
12
- class ConfigValidationError:
14
+ class ConfigValidationIssue:
13
15
  """
14
16
  Configuration validation issue with severity and recommendation.
15
17
 
18
+ A plain record, not an exception: ``validate_instance()`` and
19
+ ``validate_configuration()`` return a list of them, and
20
+ ``FileSecurityConfigurationError.errors`` carries them.
21
+
16
22
  Attributes:
17
23
  error_type: Type of the validation error.
18
24
  message: Human-readable error message.
@@ -28,18 +34,59 @@ class ConfigValidationError:
28
34
  recommendation: str = ""
29
35
 
30
36
 
37
+ _DEPRECATED_NAMES = {"ConfigValidationError": "ConfigValidationIssue"}
38
+
39
+ if TYPE_CHECKING:
40
+ # Deprecated alias; at runtime ``__getattr__`` resolves it with a
41
+ # DeprecationWarning. Removed in 3.0.
42
+ ConfigValidationError = ConfigValidationIssue
43
+ else:
44
+
45
+ def _resolve_deprecated(module: str, name: str) -> object:
46
+ """
47
+ Return the object a deprecated name stands for, with a warning.
48
+
49
+ Args:
50
+ module: Name of the module the attribute was looked up on.
51
+ name: Attribute name being looked up.
52
+
53
+ Returns:
54
+ The object that replaces the deprecated name.
55
+
56
+ Raises:
57
+ AttributeError: ``name`` is not a deprecated name.
58
+ """
59
+ new_name = _DEPRECATED_NAMES.get(name)
60
+ if new_name is None:
61
+ raise AttributeError(
62
+ f"module {module!r} has no attribute {name!r}"
63
+ )
64
+ warnings.warn(
65
+ f"{name} is deprecated and will be removed in safeuploads"
66
+ f" 3.0; use {new_name}",
67
+ DeprecationWarning,
68
+ # Skip this helper and the module ``__getattr__``
69
+ stacklevel=3,
70
+ )
71
+ return globals()[new_name]
72
+
73
+ def __getattr__(name: str) -> object:
74
+ """Resolve deprecated module attributes (PEP 562)."""
75
+ return _resolve_deprecated(__name__, name)
76
+
77
+
31
78
  class FileSecurityConfigurationError(Exception):
32
79
  """
33
80
  Configuration validation failed with aggregated errors.
34
81
 
35
82
  Args:
36
- errors: List of ConfigValidationError instances.
83
+ errors: List of ConfigValidationIssue instances.
37
84
 
38
85
  Attributes:
39
86
  errors: List of validation errors that caused failure.
40
87
  """
41
88
 
42
- def __init__(self, errors: list[ConfigValidationError]):
89
+ def __init__(self, errors: list[ConfigValidationIssue]):
43
90
  """Initialize with validation errors."""
44
91
  self.errors = errors
45
92
  error_messages = [
@@ -49,7 +96,7 @@ class FileSecurityConfigurationError(Exception):
49
96
  f"Configuration validation failed: {'; '.join(error_messages)}"
50
97
  )
51
98
 
52
- def __reduce__(self) -> tuple[type, tuple[list[ConfigValidationError]]]:
99
+ def __reduce__(self) -> tuple[type, tuple[list[ConfigValidationIssue]]]:
53
100
  """Rebuild from ``errors`` so pickling round-trips."""
54
101
  return (type(self), (self.errors,))
55
102
 
@@ -11,20 +11,30 @@ from types import TracebackType
11
11
  from .exceptions import ErrorCode, ResourceLimitError
12
12
  from .protocols import SeekableFile
13
13
 
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # ``resource.getrusage`` reports peak RSS (``ru_maxrss``) in bytes on
17
+ # macOS but in kilobytes on Linux/BSD. Normalize to bytes with a
18
+ # platform-specific multiplier resolved once at import time.
19
+ _RSS_UNIT_BYTES: int = 1 if sys.platform == "darwin" else 1024
20
+
14
21
  try:
15
22
  # ``resource`` is a Unix-only stdlib module and is absent on
16
23
  # Windows; memory accounting degrades to a no-op there while
17
24
  # the wall-clock timeout keeps working.
18
25
  import resource
19
26
  except ImportError: # pragma: no cover - platform-specific (Windows)
20
- resource = None # type: ignore[assignment]
21
27
 
22
- logger = logging.getLogger(__name__)
28
+ def _read_peak_rss_bytes() -> int:
29
+ """Return 0: this platform has no ``resource`` module."""
30
+ return 0
23
31
 
24
- # ``resource.getrusage`` reports peak RSS (``ru_maxrss``) in bytes on
25
- # macOS but in kilobytes on Linux/BSD. Normalize to bytes with a
26
- # platform-specific multiplier resolved once at import time.
27
- _RSS_UNIT_BYTES: int = 1 if sys.platform == "darwin" else 1024
32
+ else:
33
+
34
+ def _read_peak_rss_bytes() -> int:
35
+ """Return the process peak RSS in bytes from ``getrusage``."""
36
+ usage = resource.getrusage(resource.RUSAGE_SELF)
37
+ return usage.ru_maxrss * _RSS_UNIT_BYTES
28
38
 
29
39
 
30
40
  def bytes_to_mb(num_bytes: int) -> int:
@@ -316,10 +326,7 @@ class ResourceMonitor:
316
326
  module (e.g. Windows), which disables memory-limit
317
327
  enforcement while leaving the timeout active.
318
328
  """
319
- if resource is None: # pragma: no cover - platform-specific
320
- return 0
321
- usage = resource.getrusage(resource.RUSAGE_SELF)
322
- return usage.ru_maxrss * _RSS_UNIT_BYTES
329
+ return _read_peak_rss_bytes()
323
330
 
324
331
 
325
332
  _active_monitor: contextvars.ContextVar[ResourceMonitor | None] = (
@@ -422,10 +422,11 @@ class TestGzipMemberHandling:
422
422
  )
423
423
  member = gzip.compress(bytes(400 * 1024))
424
424
  data = member * 4
425
- with pytest.raises(ZipBombError):
425
+ with pytest.raises(ZipBombError) as exc_info:
426
426
  GzipContentInspector(config).inspect_gzip_content(
427
427
  io.BytesIO(data), len(data)
428
428
  )
429
+ assert exc_info.value.error_code == ErrorCode.ZIP_BOMB_DETECTED
429
430
 
430
431
  def test_ratio_limit_counts_all_members(self):
431
432
  """The ratio is checked on the output summed across members."""
@@ -1042,8 +1042,9 @@ class TestZipInspectorStructureGaps:
1042
1042
 
1043
1043
  monkeypatch.setattr(zipfile.ZipFile, "open", _mock_open)
1044
1044
  caplog.set_level("WARNING", logger=zip_inspector.__name__)
1045
- with pytest.raises(ZipContentError):
1045
+ with pytest.raises(ZipContentError) as exc_info:
1046
1046
  inspector.inspect_zip_content(io.BytesIO(zip_data))
1047
+ assert exc_info.value.error_code == ErrorCode.ZIP_CONTENT_THREAT
1047
1048
 
1048
1049
  messages = [
1049
1050
  r.getMessage()
@@ -1318,8 +1319,9 @@ class TestZipInspectorNestedArchives:
1318
1319
 
1319
1320
  monkeypatch.setattr(zipfile.ZipFile, "open", _fail_open)
1320
1321
  caplog.set_level("WARNING", logger=zip_inspector.__name__)
1321
- with pytest.raises(ZipContentError):
1322
+ with pytest.raises(ZipContentError) as exc_info:
1322
1323
  inspector.inspect_nested_archives(io.BytesIO(outer_bytes))
1324
+ assert exc_info.value.error_code == ErrorCode.ZIP_CONTENT_THREAT
1323
1325
 
1324
1326
  messages = [
1325
1327
  r.getMessage()
@@ -17,7 +17,6 @@ from safeuploads.audit import (
17
17
  )
18
18
  from safeuploads.config import FileSecurityConfig
19
19
  from safeuploads.exceptions import (
20
- CompressionSecurityError,
21
20
  ErrorCode,
22
21
  FileSizeError,
23
22
  UnicodeSecurityError,
@@ -444,9 +443,12 @@ class TestThreatAuditEvents:
444
443
 
445
444
  with (
446
445
  caplog.at_level(logging.DEBUG, logger="safeuploads.audit"),
447
- pytest.raises((ZipBombError, CompressionSecurityError)),
446
+ pytest.raises(ZipBombError) as exc_info,
448
447
  ):
449
448
  await validator.validate_zip_file(file)
449
+ assert (
450
+ exc_info.value.error_code == ErrorCode.COMPRESSION_RATIO_EXCEEDED
451
+ )
450
452
 
451
453
  threat_records = [
452
454
  r
@@ -6,7 +6,7 @@ import pytest
6
6
 
7
7
  from safeuploads.config import FileSecurityConfig, SecurityLimits
8
8
  from safeuploads.exceptions import (
9
- ConfigValidationError,
9
+ ConfigValidationIssue,
10
10
  FileSecurityConfigurationError,
11
11
  )
12
12
 
@@ -171,8 +171,8 @@ class TestConfigurationValidation:
171
171
  pytest.fail("Should not raise for valid configuration")
172
172
 
173
173
  def test_config_validation_error_attributes(self):
174
- """Test ConfigValidationError attributes."""
175
- error = ConfigValidationError(
174
+ """Test ConfigValidationIssue attributes."""
175
+ error = ConfigValidationIssue(
176
176
  error_type="test_error",
177
177
  message="Test message",
178
178
  severity="error",
@@ -187,8 +187,8 @@ class TestConfigurationValidation:
187
187
  assert error.recommendation == "Test recommendation"
188
188
 
189
189
  def test_config_validation_error_string_representation(self):
190
- """Test ConfigValidationError string representation."""
191
- error = ConfigValidationError(
190
+ """Test ConfigValidationIssue string representation."""
191
+ error = ConfigValidationIssue(
192
192
  error_type="test_error",
193
193
  message="Test message",
194
194
  severity="error",
@@ -269,13 +269,13 @@ class TestConfigurationValidation:
269
269
 
270
270
  def test_file_security_configuration_error_with_errors(self):
271
271
  """Test FileSecurityConfigurationError with error list."""
272
- error1 = ConfigValidationError(
272
+ error1 = ConfigValidationIssue(
273
273
  error_type="test1",
274
274
  message="Error 1",
275
275
  severity="error",
276
276
  component="test",
277
277
  )
278
- error2 = ConfigValidationError(
278
+ error2 = ConfigValidationIssue(
279
279
  error_type="test2",
280
280
  message="Error 2",
281
281
  severity="warning",
@@ -368,7 +368,7 @@ class TestConfigInstanceIsolation:
368
368
  config.ALLOWED_ACTIVITY_EXTENSIONS.add(".evil")
369
369
 
370
370
 
371
- def _errors_for(**overrides: Any) -> list[ConfigValidationError]:
371
+ def _errors_for(**overrides: Any) -> list[ConfigValidationIssue]:
372
372
  """
373
373
  Validate an instance whose limits carry the given overrides.
374
374
 
@@ -15,6 +15,7 @@ from safeuploads.enums import (
15
15
  UnicodeAttackCategory,
16
16
  )
17
17
  from safeuploads.exceptions import (
18
+ ErrorCode,
18
19
  FileSecurityConfigurationError,
19
20
  WindowsReservedNameError,
20
21
  )
@@ -817,7 +818,7 @@ class TestValidateAndReportLogging:
817
818
  """
818
819
  import logging
819
820
 
820
- from safeuploads.exceptions import ConfigValidationError
821
+ from safeuploads.exceptions import ConfigValidationIssue
821
822
 
822
823
  original = FileSecurityConfig._validate_enum_consistency
823
824
 
@@ -825,7 +826,7 @@ class TestValidateAndReportLogging:
825
826
  def _force_info(cls):
826
827
  results = original.__func__(cls)
827
828
  results.append(
828
- ConfigValidationError(
829
+ ConfigValidationIssue(
829
830
  error_type="test_info",
830
831
  message="Test info message",
831
832
  severity="info",
@@ -1086,8 +1087,9 @@ class TestInstanceOverrideValidation:
1086
1087
  assert config.validate_instance() == []
1087
1088
  validator = FileValidator(config=config)
1088
1089
 
1089
- with pytest.raises(WindowsReservedNameError):
1090
+ with pytest.raises(WindowsReservedNameError) as exc_info:
1090
1091
  validator._sanitize_filename("con.png")
1092
+ assert exc_info.value.error_code == ErrorCode.WINDOWS_RESERVED_NAME
1091
1093
  assert validator._sanitize_filename("photo.png") == "photo.png"
1092
1094
 
1093
1095
  def test_class_validation_still_reports_class_problems(self, monkeypatch):