diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 42c1f6e..5112ec3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -14,7 +14,7 @@ - Ask for clarification if the scope is unclear rather than assuming additional deliverables are wanted. ## Style Expectations -- Target Python 3.13+. Use modern type hint syntax (`int | None`, `list[str]`, `dict[str, Any]`) instead of `Optional`, `List`, `Dict`, etc. +- Target Python 3.11+. Use modern type hint syntax (`int | None`, `list[str]`, `dict[str, Any]`) instead of `Optional`, `List`, `Dict`, etc. - Preserve async boundaries in validator methods; do not block event loops with synchronous I/O inside `async` functions. - Use module-level `logging.getLogger(__name__)` for security-relevant events; never rely on application-specific loggers. - Enforce PEP 8 line limits: diff --git a/.github/instructions/python.instructions.md b/.github/instructions/python.instructions.md index 5f10be0..0f4ee2d 100644 --- a/.github/instructions/python.instructions.md +++ b/.github/instructions/python.instructions.md @@ -1,9 +1,9 @@ --- -description: 'Python 3.13 + FastAPI + SQLAlchemy + Alembic coding standards, docstring format, testing patterns, and module organization for the Endurain backend' +description: 'Python 3.11 + FastAPI + SQLAlchemy + Alembic coding standards, docstring format, testing patterns, and module organization for the Endurain backend' applyTo: '**/*.py' --- # Project Context -- **Python Version:** 3.13+ (required) +- **Python Version:** 3.11+ (required) - **Framework:** FastAPI with SQLAlchemy ORM and Alembic migrations - **Dependency Management:** uv (see `pyproject.toml`) - **Project Structure:** All backend code in `backend/app/` @@ -12,7 +12,7 @@ applyTo: '**/*.py' # Development Setup - **Install uv:** `pip install uv` - **Install dependencies:** `uv sync --group dev` -- **Use Docker:** If system Python < 3.13, use Docker for +- **Use Docker:** If system Python < 3.11, use Docker for development # SQLAlchemy 2.0 Standards @@ -48,11 +48,11 @@ applyTo: '**/*.py' - **No hardcoded secrets:** Use environment variables - **Async file I/O:** Use `await file.read()`, not sync -# Modern Python Syntax (Python 3.13+) +# Modern Python Syntax (Python 3.11+) - Use modern type hint syntax: `int | None`, `list[str]`, `dict[str, Any]` - Do NOT use `typing.Optional`, `typing.List`, `typing.Dict`, etc. -- Target Python 3.13+ features and syntax +- Target Python 3.11+ features and syntax - Always prioritize readability and clarity # PEP 8 Line Limits diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..8c3d451 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,58 @@ +name: CodeQL + +# `pip-audit` covers known vulnerabilities in our dependencies; ruff's +# flake8-bandit rules cover single-line patterns in our own code. Neither does +# interprocedural taint tracking, which is what actually catches an untrusted +# filename reaching a filesystem or subprocess sink. That is CodeQL's job. + +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + # Weekly, so a newly-published query pack is run against unchanged code. + - cron: '15 4 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze Python + runs-on: ubuntu-latest + permissions: + # Required to upload the SARIF result to the Security tab. + security-events: write + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + + # `build-mode: none` is the correct setting for an interpreted language: + # CodeQL reads the sources directly instead of watching a compiler. + - name: Initialize CodeQL + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + languages: python + build-mode: none + # `security-extended` adds lower-severity and precision queries on top + # of the default pack. Appropriate for a security library, where a + # false positive costs far less than a missed sink. + queries: security-extended + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + category: /language:python diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 0000000..af524aa --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,85 @@ +name: Mutation testing + +# 98%+ line coverage says every line runs; it does not say anything asserted on +# the result. Mutation testing perturbs the source and reports which changes the +# suite fails to notice. Scheduled and non-blocking: a surviving mutant is a +# lead to investigate, not a build break. + +on: + schedule: + # Every Sunday at 05:00 UTC, after the fuzz run. + - cron: '0 5 * * 0' + workflow_dispatch: + inputs: + filter: + description: Mutant name glob, e.g. safeuploads.utils.* + required: false + default: '' + type: string + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + mutate: + name: Mutate and score + runs-on: ubuntu-latest + # The full run is a few thousand mutants; cap it so a pathological + # mutant that hangs cannot hold a runner all day. + timeout-minutes: 60 + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libmagic1 + + - name: Install uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + version-file: pyproject.toml + + - name: Install dependencies + run: uv sync --frozen --dev --extra fastapi --group mutation + + # A failing suite makes every mutant "killed" and the score meaningless, + # so establish that the baseline is green before mutating anything. + - name: Verify the baseline suite passes + run: uv run pytest -q --no-cov -m "not performance and not fuzz" + + # The filter goes through the environment, never through `${{ }}` + # interpolation into the script body, which would let anyone able to + # dispatch the workflow inject shell. + - name: Run mutation testing + continue-on-error: true + env: + MUTMUT_FILTER: ${{ inputs.filter }} + run: | + set -euo pipefail + if [ -n "${MUTMUT_FILTER}" ]; then + uv run mutmut run --max-children 4 "${MUTMUT_FILTER}" + else + uv run mutmut run --max-children 4 + fi + + - name: Summarise surviving mutants + if: always() + run: | + set -euo pipefail + { + echo '## Surviving mutants' + echo + echo 'Each line is a source change the test suite did not notice.' + echo + echo '```' + uv run mutmut results || true + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..86e8080 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,65 @@ +name: OpenSSF Scorecard + +# Scores this repository's *supply-chain posture* rather than its code: are +# actions pinned to SHAs, are workflow permissions least-privilege, is branch +# protection on, are releases signed. Those are the properties a consumer of a +# security library cannot verify from the source alone. + +on: + branch_protection_rule: + push: + branches: + - main + schedule: + # Weekly, offset from the CodeQL run so the two do not contend. + - cron: '45 4 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + # Upload the SARIF result to the Security tab. + security-events: write + # Publish the result to the OpenSSF REST API so the badge resolves. + id-token: write + contents: read + actions: read + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + # Publishing makes the score readable by anyone evaluating the + # package, which is the point of running it. Requires a public repo. + publish_results: true + + # Retained separately from the Security tab so a score regression can be + # diffed against a specific run. + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: scorecard-results + path: results.sarif + retention-days: 7 + + - name: Upload to code-scanning + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + sarif_file: results.sarif diff --git a/.github/workflows/test-matrix-safeuploads.yml b/.github/workflows/test-matrix-safeuploads.yml index 906785a..a1ace45 100644 --- a/.github/workflows/test-matrix-safeuploads.yml +++ b/.github/workflows/test-matrix-safeuploads.yml @@ -1,7 +1,7 @@ name: Test matrix safeuploads # Portability matrix on top of the fast default `Lint & Test` workflow: -# * every supported Python version (3.13, 3.14) with the full +# * every supported Python version (3.11 - 3.14) with the full # dependency set (dev group + fastapi extra), proving the suite # passes on every Python the package declares support for; and # * a bare `import safeuploads` with only the base dependencies @@ -40,7 +40,7 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.13", "3.14"] + python: ["3.11", "3.12", "3.13", "3.14"] steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.gitignore b/.gitignore index 56d8275..c2e1acf 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,9 @@ __pycache__ # Tests .coverage htmlcov/ -.pytest_cache/ \ No newline at end of file +.pytest_cache/ + +# Mutation testing +mutants/ +.mutmut-cache +mutants.sqlite diff --git a/CHANGELOG.md b/CHANGELOG.md index ea6cf1b..dadbd15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,195 @@ The format is based on project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.0] - 2026-08-21 + +Major version because this release removes public symbols and changes +which uploads are accepted. Read the upgrade notes before bumping. + +### Upgrade notes + +1. **`max_validation_memory_mb` no longer fails a validation by + default.** This is the only change that makes safeuploads accept + something it previously rejected. If you relied on it, set + `enforce_memory_limit=True` — and only in a process that validates + one upload at a time. Configuration validation reports an + informational notice when the budget is customised but enforcement + is off; it does not fail strict validation, because leaving + enforcement off is the correct choice under concurrency. +2. **More uploads are rejected than before.** Images whose dimensions + cannot be read, arbitrary XML behind a `.gpx`/`.tcx` name, and ZIPs + containing executable or script entries all now fail. Re-run your + own fixtures before deploying. +3. **The time budget aborts mid-flight.** Uploads that previously ran + past `max_validation_time_seconds` and still completed now raise + `ResourceLimitError`. +4. **Removed symbols raise `AttributeError`.** See *Removed* below; + none of them could produce a meaningful result before. + +### Added + +- Image decompression bomb detection. PNG `IHDR` and JPEG + start-of-frame headers are parsed and the declared pixel count is + bounded by the new `max_image_pixels` limit (default 89,478,485, + matching Pillow's `MAX_IMAGE_PIXELS`). Breaches raise the new + `ImageSecurityError`. +- `ResourceMonitor.check()`, which enforces the configured budgets + together, and the `enforce_memory_limit` flag that opts memory back + into enforcement. +- Activity XML files must now declare the root element matching their + extension: `.gpx` requires a `gpx` root, `.tcx` requires a + `TrainingCenterDatabase` root. Namespaces are stripped before + matching. Arbitrary XML (including an HTML or SVG payload) wearing a + `.gpx` name is rejected with the new `XML_INVALID_ROOT` code. +- `max_xml_elements` limit (default 1,000,000). XML is now parsed + incrementally and completed elements are discarded as they close, so + a flat document with millions of elements can no longer amplify a + bounded upload into an unbounded object graph. +- `gzip_analysis_timeout` limit (default 25 s) bounding gzip inflation + independently of any caller-supplied `ResourceMonitor`. A breach + raises `ZipBombError` with `ZIP_ANALYSIS_TIMEOUT`, the same error + code the ZIP inspector uses for its own timeout. The default is + sized to inflate `max_uncompressed_size` at a conservative + 50 MB/s; configuration validation reports + `gzip_timeout_below_size_limit` when the two are set against each + other, because a timeout too short for the permitted size turns + every slow-but-legitimate upload into a reported decompression + bomb. +- `safe_label()` utility, applied to every untrusted filename and ZIP + entry name before it reaches a log record, audit event, or exception + message. +- `temp_dir` limit controlling where uploads larger than + `max_memory_buffer_size` spill to disk. Configuration validation + reports `invalid_temp_dir` when the directory does not exist, rather + than failing later at rollover time. +- `blocked_zip_entry_categories` limit naming the `ZipThreatCategory` + members whose extensions are rejected on a ZIP entry. Defaults to + `EXECUTABLE_FILES` and `SCRIPT_FILES`; add `SYSTEM_FILES` to also + reject `.dll`, `.so`, `.ini` and `.conf` entries, or pass an empty + set to disable the check without also disabling the unrelated + content scanning that `scan_zip_content` gates. Configuration + validation reports `unknown_zip_entry_category` for a name that is + not a `ZipThreatCategory` member, so a typo cannot silently disable + the check. +- `FileSecurityConfig` now accepts `limits` directly + (`FileSecurityConfig(SecurityLimits(...))`). The object is copied, so + it is never aliased or shared, and configuring an instance no longer + requires mutating class state. +- `UploadFileProtocol` and `reset_correlation_id` are now exported from + the top-level package. `UploadFileProtocol` is the interface a + non-FastAPI framework adapter implements, so it belonged in the + public API alongside `SeekableFile`. +- `set_source_ip()`, which attaches the client address to every audit + event in the current context, together with `get_source_ip()` and + `reset_source_ip()`. `AuditEvent.source_ip` existed but was never + populated. +- Release-verification instructions for consumers, covering PEP 740 + attestation checks with `pypi-attestations`. + +### Changed + +- **Breaking:** `max_validation_memory_mb` is no longer enforced by + default. It samples the process-wide peak RSS, which never decreases + and misattributes concurrent work, so it is near-inert after a + process's first peak and fires mainly when it is wrong. Exceeding it + is now logged as a warning; set `enforce_memory_limit=True` (or + `ResourceMonitor(enforce_memory=True)`) to restore the previous + behaviour, and only in a process that validates one upload at a + time. The real memory bounds are the byte limits in + `SecurityLimits`, which cap every buffer the library allocates. +- **Breaking:** the validation time budget is now enforced *during* + validation instead of only on completion. `ResourceMonitor` is + threaded through the streaming reads, the ZIP entry loop, recursive + nested-archive inspection, strict decompression verification, and + the gzip inflation loop, so a runaway upload is aborted while it + runs. Uploads that previously completed after exceeding the budget + now raise `ResourceLimitError` earlier. +- **Lowered the minimum supported Python from 3.13 to 3.11.** + Supported and tested on 3.11, 3.12, 3.13 and 3.14. +- `ResourceLimitError` now propagates out of the ZIP and gzip + inspectors instead of being wrapped as an internal + `FileProcessingError`. +- A breached resource budget is now audited as `RESOURCE_LIMIT` + instead of a generic `VALIDATION_FAILURE`. The integration checklist + already told integrators to alert on this event type, but nothing + emitted it. +- `FileProcessingError` accepts an optional `error_code`, and XML + failures now carry `XML_MALFORMED`, `XML_FORBIDDEN_CONSTRUCT`, + `XML_INVALID_ROOT`, or `XML_TOO_MANY_ELEMENTS`. +- `find_text_pattern()` lower-cases the scan window once and searches + it as bytes, instead of decoding it to text and lower-casing that, + removing one full-size copy of the content-analysis window (up to + 50 MB). `find_embedded_signature()` takes a start offset, so + skipping an expected header no longer copies the window either. + Both now scan candidates in a canonical longest-first order, so + which pattern is reported when a buffer matches several no longer + depends on set iteration order. Whether a threat is detected is + unchanged, with one exception: matching over bytes does not see + through interleaved invalid bytes the way decoding with + `errors="ignore"` did, so a pattern split by junk bytes is no + longer joined back together. +- Documentation and the FastAPI example no longer return `str(err)` to + clients. Exception messages embed values derived from the upload, + such as the detected MIME type and ZIP entry names, and + `err.filename` carries the client-supplied name; the examples now log + the detail and return `err.error_code`. +- `ZipContentInspector.inspect_zip_content()` and + `GzipContentInspector.inspect_gzip_content()` take an optional + `filename`, so a `THREAT_DETECTED` audit event names the file instead + of leaving the field empty for a correlation-ID join. +- `FileSecurityConfig.ACTIVITY_XML_ROOTS` is a read-only mapping rather + than a plain `dict`, matching the frozen allow-lists beside it. A + consumer can no longer mutate it process-wide. +- Audit fields are escaped once, at the emission point, instead of also + being escaped by the caller. Escaping twice could truncate an + adversarial filename mid-escape-sequence, because the escaped form + is longer than the input and is re-truncated on the second pass. + +### Removed + +- **Breaking:** the `validate()` alias on every validator, and the + `BaseValidator` abstract method behind it. The abstraction was + false: each validator takes different arguments, so the "uniform" + interface could never be used polymorphically. Call the + purpose-named method instead + (`validate_unicode_security`, `validate_extensions`, + `validate_windows_reserved_names`, `validate_zip_compression_ratio`, + `validate_xml_safety`). `BaseValidator` remains as a plain base + class, matching `BaseInspector`. +- **Breaking:** eight `ErrorCode` members that no code path could ever + produce: `FILE_SIZE_UNKNOWN`, `MIME_DETECTION_FAILED`, + `FILE_SIGNATURE_INVALID`, `ZIP_INVALID_STRUCTURE`, + `ZIP_DIRECTORY_TRAVERSAL`, `ZIP_SYMLINK_DETECTED`, + `ZIP_ABSOLUTE_PATH`, and `MEMORY_ERROR`. +- **Breaking:** three unused `ZipThreatCategory` members that held + marker strings rather than extensions: `RECURSIVE_STRUCTURE`, + `QUINE_ARCHIVE`, and `COMPLEXITY_ATTACK`. The corresponding + `ErrorCode` values are unaffected and still raised. +- A redundant entry-count check in `CompressionSecurityValidator` that + applied `max_total_entries_recursive` to a single flat archive. That + limit counts entries across nesting levels and is enforced in + `ZipContentInspector`; a flat archive is capped by + `max_zip_entries`. + +### Fixed + +- **Log injection (CWE-117):** a filename containing a newline could + forge an audit log line, and directional or zero-width characters + could hide the real name from an analyst. Untrusted text is now + escaped at every logging site and again at the audit emission point. + Filename sanitization now strips every control, format, surrogate + and line-separator code point rather than only the C0 range, so + U+0085, U+009B, U+2028 and U+2029 no longer survive into the name + returned to the caller. Unicode validation errors report the + offending code point and its Unicode name instead of echoing the + character. +- ZIP entries are now rejected when their name carries an extension + from a blocked `ZipThreatCategory`. Every dot-separated suffix is + checked, so a disguised name such as `invoice.php.txt` is caught. + The threat model documented this mitigation but it was never + implemented. Which categories are blocked is controlled by the new + `blocked_zip_entry_categories` limit. + ## [1.1.1] - 2026-08-19 ### Changed @@ -152,6 +341,7 @@ Initial release. - Framework-agnostic async validation, a rich exception hierarchy with machine-readable error codes, secure defaults, and full type hints. +[2.0.0]: https://github.com/endurain-project/safeuploads/compare/v1.1.1...v2.0.0 [1.1.1]: https://github.com/endurain-project/safeuploads/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/endurain-project/safeuploads/compare/v1.0.1...v1.1.0 [1.0.1]: https://github.com/endurain-project/safeuploads/releases/tag/v1.0.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9135cc3..a7e8b93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,18 +1,18 @@ -# Contributing to Endurain +# Contributing to safeuploads Thank you for considering contributing to Endurain! Before diving in, please read these guidelines carefully. They exist to make the process sustainable for everyone. -## A Note on Maintainership +## A note on maintainership Endurain is maintained by a single person in their spare time. This means review bandwidth is genuinely limited. Following these guidelines isn't bureaucracy, it's what allows contributions to actually get merged rather than sitting in a queue indefinitely. -## Before You Write Any Code +## Before you write any code **Open an issue first.** For anything beyond a small bug fix, typo, or documentation improvement, please open an issue and wait for a response before writing code. This takes minutes and can save you hours of work on something that won't be merged because it conflicts with planned direction, existing work, or project scope. If an issue already exists, comment on it to signal your intent so work isn't duplicated. -## Pull Request Size — The Most Important Rule +## Pull request size — the most important rule **Keep PRs small and focused on a single concern.** @@ -26,9 +26,9 @@ PRs that are too large to review efficiently will be asked to be split before th **Excluded from the line count:** `uv.lock`, migration files, and other generated or vendored files. -## How to Contribute +## How to contribute -### Bug Fixes +### Bug fixes - Check if an issue already exists before opening a new one - Include clear steps to reproduce in the issue @@ -40,19 +40,19 @@ PRs that are too large to review efficiently will be asked to be split before th - Improvements to the docs site, inline code comments, and the README all count - Keep the same tone and structure as existing docs -### New Features +### New features - **Always discuss in an issue first** — this is required, not optional - Features that haven't been discussed and approved in an issue may be closed without review, regardless of quality -### Refactors and Code Quality +### Refactors and code quality - Must be discussed in an issue first - Pure refactor PRs (no behaviour change) are easiest to review. Keep them separate from feature or fix PRs - Include a clear explanation of what improved and why -## Getting Started +## Getting started 1. **Fork the repository** on GitHub 2. **Clone your fork** locally: @@ -73,10 +73,10 @@ PRs that are too large to review efficiently will be asked to be split before th ``` 5. **Push and open a PR** against the `master` branch, filling in the PR template completely -## Response Time Expectations +## Response time expectations Reviews may take days to weeks depending on availability. A PR sitting without a response is not a rejection. Please feel free to leave a polite ping after two weeks if there's been no activity. -## Thank You +## Thank you Even small contributions make a real difference. Thank you for taking the time to improve Endurain for everyone who self-hosts it. \ No newline at end of file diff --git a/README.md b/README.md index e356179..b45a9ee 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ [![Release](https://img.shields.io/github/v/release/endurain-project/safeuploads?label=release&color=blue)](https://github.com/endurain-project/safeuploads/releases) [![PyPI version](https://img.shields.io/pypi/v/safeuploads)](https://pypi.org/project/safeuploads/) [![PyPI downloads](https://img.shields.io/pypi/dm/safeuploads)](https://pypi.org/project/safeuploads/) -[![Python](https://img.shields.io/badge/python-3.13%2B-blue)](https://pypi.org/project/safeuploads/) +[![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://pypi.org/project/safeuploads/) [![Docs](https://img.shields.io/badge/docs-safeuploads.endurain.com-blue)](https://safeuploads.endurain.com/) [![Stars](https://img.shields.io/github/stars/endurain-project/safeuploads?label=stars&logo=github)](https://github.com/endurain-project/safeuploads) -Secure file upload validation for Python 3.13+ applications. Catches dangerous filenames, malicious extensions, Windows reserved names, and compression-based attacks before you accept an upload. +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. ## Features @@ -16,11 +16,14 @@ Secure file upload validation for Python 3.13+ applications. Catches dangerous f - Filename sanitization and Unicode security checks - Extension validation with configurable allow/block lists - ZIP bomb detection, nested archive inspection, and recursive structure protection +- Dangerous ZIP entry rejection (executables and scripts by default, configurable) +- Image decompression bomb detection via declared pixel dimensions - MIME type verification with file signature validation -- Activity file support (.gpx, .tcx, .fit) with XXE-safe XML parsing +- Activity file support (.gpx, .tcx, .fit) with XXE-safe XML parsing and root-element enforcement - Gzip archive validation with decompression bomb detection - Streaming validation for memory-efficient large file processing -- Resource monitoring (CPU time and memory limits) +- Wall-clock limits enforced inside the validation loops +- Log-injection-safe logging of untrusted filenames - Content analysis with malware signature and polyglot detection - Structured audit logging with correlation IDs - Rich exception hierarchy with machine-readable error codes @@ -37,7 +40,21 @@ For FastAPI integration: pip install safeuploads[fastapi] ``` -## Quick Start +### Verifying a release + +Releases are built and published by [this repository's release workflow](.github/workflows/publish-safeuploads.yml) through PyPI Trusted Publishing, with [PEP 740](https://peps.python.org/pep-0740/) attestations. You can confirm a downloaded artifact came from that workflow and was not substituted: + +```bash +uvx pypi-attestations verify pypi \ + --repository https://github.com/endurain-project/safeuploads \ + pypi:safeuploads--py3-none-any.whl +``` + +A successful run prints `OK: `. `Provenance for file ... was not found` means the artifact predates attested publishing rather than that verification failed. + +Each release run also produces a CycloneDX SBOM and `SHA256SUMS`, generated from a clean install of the built wheel. These are retained as workflow artifacts on the release run rather than published to PyPI. + +## Quick start ```python from fastapi import FastAPI, UploadFile, HTTPException @@ -51,28 +68,38 @@ validator = FileValidator() async def upload_image(file: UploadFile): try: await validator.validate_image_file(file) - except FileValidationError as e: - raise HTTPException(status_code=400, detail=str(e)) - + except FileValidationError as err: + # Return the machine-readable code, never `str(err)`: exception + # messages embed upload-derived values such as the detected MIME + # type, and `err.filename` carries the client-supplied name. + raise HTTPException(status_code=400, detail=err.error_code) + return {"status": "success", "filename": file.filename} ``` ## Configuration ```python -from safeuploads import FileValidator, FileSecurityConfig +from safeuploads import FileValidator, FileSecurityConfig, SecurityLimits # Use default secure configuration validator = FileValidator() -# Or customize limits -config = FileSecurityConfig() -config.limits.max_image_size = 10 * 1024 * 1024 # 10 MiB -config.limits.max_compression_ratio = 50 - -# Opt in to strict ZIP checking: decompress every entry to -# reject archives with forged central-directory metadata -config.limits.verify_zip_decompression = True +# Or pass explicit limits. Anything you leave out keeps its +# secure default, and the limits object is copied, so nothing +# is shared between configs. +config = FileSecurityConfig( + SecurityLimits( + max_image_size=10 * 1024 * 1024, # 10 MiB + max_image_pixels=50_000_000, # Reject bigger decoded images + max_compression_ratio=50, + # Decompress every ZIP entry to reject archives with + # forged central-directory metadata + verify_zip_decompression=True, + # Keep spilled uploads off the system temp directory + temp_dir="/var/lib/myapp/uploads-tmp", + ) +) validator = FileValidator(config=config) @@ -85,50 +112,70 @@ pooled_validator = FileValidator( ) ``` -## Exception Handling +## Exception handling + +Exception messages are written for your logs, not for your users. They +embed values derived from the upload — the detected MIME type, ZIP entry +names, declared image dimensions — and `err.filename` carries the +client-supplied name. Never return `str(err)` or `err.filename` to a +client. Branch on the exception type and surface `err.error_code`, which +is a stable machine-readable string. ```python +import logging + from safeuploads.exceptions import ( FileValidationError, # Base exception FileSizeError, # File too large ExtensionSecurityError, # Dangerous extension + ImageSecurityError, # Image decompression bomb ZipBombError, # Compression attack ) +logger = logging.getLogger(__name__) + try: await validator.validate_image_file(file) except FileSizeError as err: return {"error": "File too large", "max_size": err.max_size} except ExtensionSecurityError as err: - return {"error": "File type not allowed", "extension": err.extension} + return {"error": "File type not allowed", "code": err.error_code} +except ImageSecurityError as err: + return {"error": "Image too large to decode", "code": err.error_code} except FileValidationError as err: - return {"error": str(err), "code": err.error_code} + # Full detail goes to the log; the client only sees the code. + logger.warning("Upload rejected: %s", err) + return {"error": "Upload rejected", "code": err.error_code} ``` -## Current Status +## Current status ### Implemented - **Filename Security**: Unicode normalization, directory traversal prevention, Windows reserved names blocking - **Extension Validation**: Allow/block lists with configurable rules, dangerous extension detection - **Compression Security**: ZIP bomb detection, nested archive inspection, recursive structure and quine detection, size and ratio limits, optional strict decompression verification -- **Content Inspection**: Deep ZIP content analysis with configurable depth and entry limits +- **Content Inspection**: Deep ZIP content analysis with configurable depth and entry limits, plus rejection of entries whose extension falls in a blocked `ZipThreatCategory` (`EXECUTABLE_FILES` and `SCRIPT_FILES` by default, via `blocked_zip_entry_categories`) +- **Image Bomb Protection**: PNG and JPEG headers are parsed and the declared pixel count is bounded by `max_image_pixels` - **MIME Type Verification**: Magic number validation for images, ZIP, activity files, and gzip - **Streaming Validation**: Memory-efficient processing via `SpooledTemporaryFile` for large files -- **Resource Monitoring**: CPU time and memory limits enforced via `ResourceMonitor` -- **Activity File Support**: GPX, TCX, and FIT file validation with XXE-safe XML parsing -- **Gzip Support**: Gzip archive validation with decompression bomb detection +- **Resource Monitoring**: Wall-clock limits enforced by `ResourceMonitor`, checked inside the streaming, ZIP, and gzip loops so a runaway upload is aborted while it runs. Memory is best-effort telemetry (see Known Limitations) +- **Activity File Support**: GPX, TCX, and FIT validation with XXE-safe XML parsing, a required root element per extension, and a cap on parsed element count +- **Gzip Support**: Gzip archive validation with decompression bomb detection and an inflation timeout - **Content Analysis**: Optional malware signature, web shell, and polyglot file detection - **Audit Logging**: Structured security event logging with correlation IDs via `contextvars` - **Performance Optimizations**: Pre-compiled pattern sets, `frozenset` lookups, LRU-cached MIME guessing - **Rich Exception System**: Machine-readable error codes with detailed context - **Fuzzing Tests**: Hypothesis-based property testing for filenames, ZIP, images, and config -### Known Limitations +### Known limitations - No built-in rate limiting (application-level concern — see documentation) - MIME detection covers first 8 KB; advanced polyglot attacks may require `enable_content_analysis` -- `SpooledTemporaryFile` uses the system default temp directory +- Image dimensions are read from the declared PNG/IHDR or JPEG/SOF header within the first 1 MiB; images whose dimensions cannot be read are rejected +- `max_validation_memory_mb` is best-effort telemetry, not a limit: it samples the process-wide peak RSS, so it cannot be attributed to a single validation. Exceeding it is logged; set `enforce_memory_limit=True` to enforce, and only in a process that validates one upload at a time +- `verify_zip_decompression` is off by default; enable it if anything other than Python's `zipfile` extracts your archives +- Uploads larger than `max_memory_buffer_size` spill to disk; set `temp_dir` to control where, otherwise the system default temporary directory is used ## Documentation diff --git a/SECURITY.md b/SECURITY.md index d2d4262..8466353 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,17 +1,52 @@ -# Security Policy | +# Security policy -## Reporting a Vulnerability +## Supported versions -If you discover a security vulnerability, please follow these steps: +Security fixes are released against the latest published version only; there +are no maintained release branches. Upgrading to the current release is the +supported way to receive a fix. -1. **Do not** open a public issue; -2. Send an email to joao@endurain.com with the details of the vulnerability; -3. Include the following in your report: -- Steps to reproduce the vulnerability; -- Potential impact; -- Any suggested fixes, if available. -4. I will provide an acknowledgment when possible. +| Version | Supported | +|---|---| +| `2.0.x` | Yes | +| `< 2.0` | No | -Please include as much information as possible to help me resolve the issue promptly. +## Scope -Thank you for helping keep this project secure! +In scope: anything in the `safeuploads` package. That includes the filename +and extension validators, archive and image bomb detection, the ZIP and gzip +inspectors, the XML parsing behind activity files, the resource monitor, and +any way a crafted upload could bypass a check, exhaust the host, or corrupt or +expose a host application's data through safeuploads' own code. + +Out of scope: vulnerabilities in an application that *uses* safeuploads but +stem from its own code or configuration — for example allow-listing an +executable extension through `SecurityLimits`, serving an accepted upload back +inline from the document root, or storing a file under the client-supplied +name. Report those to that application's maintainers. + +Vulnerabilities in a dependency should be reported upstream first. If +safeuploads' use of it makes the impact materially worse, report that here too. + +## Reporting a vulnerability + +1. **Do not** open a public issue. +2. Email with the details. +3. Include: + - steps to reproduce; + - the affected version; + - potential impact; + - any suggested fix, if you have one. +4. You will get an acknowledgement when possible. + +Please include as much detail as you can — a sample payload and the +configuration it was validated under are what make a report actionable rather +than a starting point for investigation. + +## What to expect + +This project is maintained by one person in their spare time, so response times +vary. A fix will be released as soon as it is ready, and the advisory will +credit you unless you would rather stay anonymous. + +Thank you for helping keep this project secure. diff --git a/docs/api.md b/docs/api.md index 3e98168..ec5903f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,4 +1,4 @@ -# API Reference +# API reference ::: safeuploads handler: python diff --git a/docs/index.md b/docs/index.md index cf19d05..c6fbc06 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,34 +1,10 @@ # safeuploads -
- - License - - - Release - - - Stars - -
- -Secure file upload validation for Python 3.13+ applications. Catches dangerous filenames, malicious extensions, Windows reserved names, and compression-based attacks before you accept an upload. - -## Features - -- **Framework-agnostic** async validation (FastAPI, generic) -- Filename sanitization and Unicode security checks -- Extension validation with configurable allow/block lists -- ZIP bomb detection, nested archive inspection, and recursive structure protection -- MIME type verification with file signature validation -- Activity file support (.gpx, .tcx, .fit) with XXE-safe XML parsing -- Gzip archive validation with decompression bomb detection -- Streaming validation for memory-efficient large file processing -- Resource monitoring (CPU time and memory limits) -- Content analysis with malware signature and polyglot detection -- Structured audit logging with correlation IDs -- Rich exception hierarchy with machine-readable error codes -- Zero configuration required—secure defaults out of the box +Secure file upload validation for Python 3.11+ applications. Hand it an upload before you accept it, and it rejects dangerous filenames, disallowed extensions, Windows reserved names, forged MIME types, compression bombs and XXE payloads — raising a typed exception with a machine-readable error code rather than returning a verdict you have to interpret. + +Validation is `async` and framework-agnostic: anything matching `UploadFileProtocol` works, and FastAPI's `UploadFile` is picked up when FastAPI happens to be installed — there is no hard dependency on it. Uploads are read in chunks and spooled to a `SpooledTemporaryFile` rather than held whole in memory, and every loop that could be made to run long is bounded by a wall-clock budget. + +This site is the reference documentation. For the feature list and project overview, see the [README on GitHub](https://github.com/endurain-project/safeuploads). ## Installation @@ -37,11 +13,14 @@ pip install safeuploads ``` For FastAPI integration: + ```bash pip install safeuploads[fastapi] ``` -## Quick Start +`python-magic` needs the `libmagic` system library present on the deployment target. + +## Quick start ```python from fastapi import FastAPI, UploadFile, HTTPException @@ -55,92 +34,50 @@ validator = FileValidator() async def upload_image(file: UploadFile): try: await validator.validate_image_file(file) - except FileValidationError as e: - raise HTTPException(status_code=400, detail=str(e)) - + except FileValidationError as err: + # Return the machine-readable code, never `str(err)`: exception + # messages embed upload-derived values such as the detected MIME + # type, and `err.filename` carries the client-supplied name. + # hands attacker-controlled bytes back to the browser. + raise HTTPException(status_code=400, detail=err.error_code) + return {"status": "success", "filename": file.filename} ``` -## Configuration - -```python -from safeuploads import FileValidator, FileSecurityConfig - -# Use default secure configuration -validator = FileValidator() - -# Or customize limits -config = FileSecurityConfig() -config.limits.max_image_size = 10 * 1024 * 1024 # 10 MiB -config.limits.max_compression_ratio = 50 +`FileValidator()` with no arguments is already a secure configuration. Pass a `FileSecurityConfig` when you want to narrow it further — see [File Validation Configuration](security/integration-checklist.md#file-validation-configuration). -# Opt in to strict ZIP checking: decompress every entry to -# reject archives with forged central-directory metadata -config.limits.verify_zip_decompression = True +The sibling methods are `validate_zip_file`, `validate_activity_file` (GPX, TCX, FIT) and `validate_gzip_file`. Each pipeline is described in [Architecture](security/architecture.md#validation-pipelines). -validator = FileValidator(config=config) +## What safeuploads does not do -# Optionally offload blocking inspection to a bounded pool -from concurrent.futures import ThreadPoolExecutor - -pooled_validator = FileValidator( - config=config, - executor=ThreadPoolExecutor(max_workers=4), -) -``` +Knowing where the boundary sits matters more than the feature list, because everything past it is still your application's job: -## Exception Handling +**It does not rate limit.** A validator that correctly rejects ten thousand ZIP bombs has still burned the CPU rejecting them. Throttling belongs in front of the application — see the [Rate Limiting](rate-limiting.md) guide. -```python -from safeuploads.exceptions import ( - FileValidationError, # Base exception - FileSizeError, # File too large - ExtensionSecurityError, # Dangerous extension - ZipBombError, # Compression attack -) - -try: - await validator.validate_image_file(file) -except FileSizeError as err: - return {"error": "File too large", "max_size": err.max_size} -except ExtensionSecurityError as err: - return {"error": "File type not allowed", "extension": err.extension} -except FileValidationError as err: - return {"error": str(err), "code": err.error_code} -``` +**It does not store, rename, or transform files.** Nothing is written anywhere except the temporary spill buffer, which is discarded afterwards. Choosing a storage path, generating a non-guessable name, and setting permissions are yours to get right; the [Integration Checklist](security/integration-checklist.md#file-storage-security) lists what that involves. -## Current Status +**It is not an antivirus.** `enable_content_analysis` scans for known malware signatures, web shells, and polyglot markers — useful, but a fixed pattern set rather than a maintained threat database. High-risk deployments should run a real scanner as well. -### Implemented +**It does not decode media.** Image bombs are caught by reading the declared dimensions out of the PNG or JPEG header, never by decoding pixels — that is what stops a bomb detonating during validation, and it also means safeuploads cannot tell you whether an image is otherwise well-formed. -- **Filename Security**: Unicode normalization, directory traversal prevention, Windows reserved names blocking -- **Extension Validation**: Allow/block lists with configurable rules, dangerous extension detection -- **Compression Security**: ZIP bomb detection, nested archive inspection, recursive structure and quine detection, size and ratio limits, optional strict decompression verification -- **Content Inspection**: Deep ZIP content analysis with configurable depth and entry limits -- **MIME Type Verification**: Magic number validation for images, ZIP, activity files, and gzip -- **Streaming Validation**: Memory-efficient processing via `SpooledTemporaryFile` for large files -- **Resource Monitoring**: CPU time and memory limits enforced via `ResourceMonitor` -- **Activity File Support**: GPX, TCX, and FIT file validation with XXE-safe XML parsing -- **Gzip Support**: Gzip archive validation with decompression bomb detection -- **Content Analysis**: Optional malware signature, web shell, and polyglot file detection -- **Audit Logging**: Structured security event logging with correlation IDs via `contextvars` -- **Performance Optimizations**: Pre-compiled pattern sets, `frozenset` lookups, LRU-cached MIME guessing -- **Rich Exception System**: Machine-readable error codes with detailed context -- **Fuzzing Tests**: Hypothesis-based property testing for filenames, ZIP, images, and config +**It does not judge accepted content.** A validated GPX file is well-formed XML with the expected root element, in which safeuploads found no attack. Whether its contents mean anything to your domain is a separate question. -### Known Limitations +## Known limitations - No built-in rate limiting (application-level concern — see [Rate Limiting](rate-limiting.md) guide) - MIME detection covers first 8 KB; advanced polyglot attacks may require `enable_content_analysis` -- `SpooledTemporaryFile` uses the system default temp directory - -## Documentation - -- [API Reference](api.md) — full public API documentation -- [Rate Limiting](rate-limiting.md) — production rate limiting guide -- [Threat Model](security/threat-model.md) — threat categories and mitigations -- [Architecture](security/architecture.md) — validation pipeline and data flow -- [Integration Checklist](security/integration-checklist.md) — production deployment checklist +- Image dimensions are read from the declared PNG/IHDR or JPEG/SOF header within the first 1 MiB; images whose dimensions cannot be read are rejected +- `max_validation_memory_mb` is best-effort telemetry, not a limit: it samples the process-wide peak RSS, so it cannot be attributed to a single validation. Exceeding it is logged; set `enforce_memory_limit=True` to enforce, and only in a process that validates one upload at a time +- `verify_zip_decompression` is off by default; enable it if anything other than Python's `zipfile` extracts your archives (see [Integration Checklist](security/integration-checklist.md#zip-metadata-verification)) +- Uploads larger than `max_memory_buffer_size` spill to disk; set `temp_dir` to control where, otherwise the system default temporary directory is used + +## Where to go next + +- [API Reference](api.md) — every public class, method, and exception, generated from the source. +- [Rate Limiting](rate-limiting.md) — the layer safeuploads deliberately leaves to you, with SlowApi, nginx, Caddy, and Traefik recipes. +- [Threat Model](security/threat-model.md) — each attack class, the CWE it maps to, and the check that stops it. +- [Architecture](security/architecture.md) — components, the four validation pipelines, and where file content is actually read. +- [Integration Checklist](security/integration-checklist.md) — the list to work through before running it in production, including how to verify a release's provenance. ## License diff --git a/docs/rate-limiting.md b/docs/rate-limiting.md index 6f779ed..1a00370 100644 --- a/docs/rate-limiting.md +++ b/docs/rate-limiting.md @@ -1,4 +1,4 @@ -# Rate Limiting +# Rate limiting File upload endpoints are high-value targets for abuse. Without rate limiting, attackers can exhaust server resources through @@ -6,7 +6,7 @@ rapid-fire uploads, even when each individual file passes validation. **safeuploads validates file content — rate limiting protects the endpoint itself.** -## Why Rate Limiting Matters for Uploads +## Why rate limiting matters for uploads | Threat | Impact | Mitigation | |---|---|---| @@ -15,7 +15,7 @@ protects the endpoint itself.** | Storage exhaustion | Disk full, service outage | Global upload quotas | | Zip bomb floods | CPU exhaustion during analysis | Combined with `ResourceMonitor` | -## Recommended Limits +## Recommended limits | Endpoint type | Suggested rate | Burst | |---|---|---| @@ -28,7 +28,7 @@ Adjust based on your application's expected traffic patterns. --- -## FastAPI with SlowApi +## FastAPI with SlowAPI [SlowApi](https://github.com/laurents/slowapi) wraps [limits](https://limits.readthedocs.io/) for use with Starlette @@ -40,7 +40,7 @@ and FastAPI. pip install slowapi ``` -### Basic Setup +### Basic setup ```python from fastapi import FastAPI, Request, UploadFile @@ -74,7 +74,7 @@ async def upload_zip(request: Request, file: UploadFile): return {"filename": file.filename} ``` -### Per-User Limits (Authenticated) +### Per-user limits (authenticated) ```python from fastapi import Depends @@ -101,7 +101,7 @@ async def upload_image_authed( return {"filename": file.filename} ``` -### Custom Error Response +### Custom error response ```python from fastapi.responses import JSONResponse @@ -129,7 +129,7 @@ app.add_exception_handler( --- -## Custom Middleware (No Dependencies) +## Custom middleware (no dependencies) If you prefer not to add `slowapi`, a simple token-bucket middleware works for basic per-IP limiting: @@ -181,7 +181,7 @@ async def rate_limit_middleware(request: Request, call_next): --- -## Reverse Proxy Rate Limiting +## Reverse proxy rate limiting For production, rate limiting at the reverse proxy layer is more efficient and protects the application before requests diff --git a/docs/security/architecture.md b/docs/security/architecture.md index 23cec12..e9dfd8f 100644 --- a/docs/security/architecture.md +++ b/docs/security/architecture.md @@ -5,7 +5,7 @@ component responsibilities, and data flow for each file type. --- -## Component Overview +## Component overview ``` safeuploads/ @@ -50,9 +50,9 @@ safeuploads/ --- -## Validation Pipelines +## Validation pipelines -### Image Validation (`validate_image_file`) +### Image validation (`validate_image_file`) ``` UploadFile @@ -114,7 +114,7 @@ UploadFile └─────────────────────────────┘ ``` -### ZIP Validation (`validate_zip_file`) +### ZIP validation (`validate_zip_file`) ``` UploadFile @@ -194,7 +194,7 @@ UploadFile └──────────────────────────────┘ ``` -### Activity File Validation (`validate_activity_file`) +### Activity file validation (`validate_activity_file`) ``` UploadFile (.gpx, .tcx, .fit) @@ -249,7 +249,7 @@ UploadFile (.gpx, .tcx, .fit) └──────────────────────────────┘ ``` -### Gzip Validation (`validate_gzip_file`) +### Gzip validation (`validate_gzip_file`) ``` UploadFile (.gz) @@ -302,7 +302,7 @@ UploadFile (.gz) --- -## Data Flow: Where File Content Is Read +## Data flow: where file content is read | Stage | What is read | Buffer size | |---|---|---| @@ -318,7 +318,7 @@ UploadFile (.gz) --- -## Exception Hierarchy +## Exception hierarchy ``` Exception @@ -344,7 +344,7 @@ machine-readable classification. --- -## Audit Event Flow +## Audit event flow ``` FileValidator.validate_*() diff --git a/docs/security/integration-checklist.md b/docs/security/integration-checklist.md index 2369127..e595490 100644 --- a/docs/security/integration-checklist.md +++ b/docs/security/integration-checklist.md @@ -1,4 +1,4 @@ -# Integration Checklist +# Integration checklist Production deployment checklist for applications using safeuploads. Each item links to the relevant threat in the @@ -7,14 +7,14 @@ that addresses it. --- -## HTTPS & Transport Security +## HTTPS & transport security - [ ] All upload endpoints served over HTTPS. - [ ] `Strict-Transport-Security` header set with `max-age=31536000; includeSubDomains`. - [ ] HTTP requests redirected to HTTPS at the reverse proxy. -## Rate Limiting +## Rate limiting - [ ] Per-IP rate limits applied to upload endpoints (see [Rate Limiting](../rate-limiting.md) guide). @@ -24,7 +24,7 @@ that addresses it. - [ ] `429 Too Many Requests` responses include a `Retry-After` header. -## File Validation Configuration +## File validation configuration - [ ] `FileValidator` instantiated with explicit `FileSecurityConfig` (not relying solely on defaults). @@ -35,14 +35,90 @@ that addresses it. - `max_gzip_size` — set if accepting gzip files. - `max_compression_ratio` — default 100:1 is reasonable for most workloads; lower for stricter environments. + - `max_xml_elements` — default 1,000,000; lower if you only + accept small GPX/TCX files. + - `gzip_analysis_timeout` — default 25 s for gzip inflation, + sized to cover `max_uncompressed_size` at a conservative + 50 MB/s. Lower it only alongside `max_uncompressed_size`: + a timeout too short for the permitted size rejects slow + but legitimate uploads as decompression bombs, and + configuration validation warns when the two disagree. - `max_validation_time_seconds` — default 30 s; lower in latency-sensitive services. - - `max_validation_memory_mb` — default 512 MB; adjust based - on container memory limits. + - `max_validation_memory_mb` — default 512 MB. This is + **telemetry, not a limit** (see below). - [ ] Allowed extensions and MIME types reviewed and narrowed to only what your application accepts. -## Content Analysis +Anything you leave out of `SecurityLimits` keeps its secure +default, and the limits object is copied, so nothing is shared +between configs: + +```python +from concurrent.futures import ThreadPoolExecutor + +from safeuploads import ( + FileSecurityConfig, + FileValidator, + SecurityLimits, +) + +config = FileSecurityConfig( + SecurityLimits( + max_image_size=10 * 1024 * 1024, # 10 MiB + max_image_pixels=50_000_000, # Reject bigger decoded images + max_compression_ratio=50, + # Decompress every ZIP entry to reject archives with + # forged central-directory metadata + verify_zip_decompression=True, + # Keep spilled uploads off the system temp directory + temp_dir="/var/lib/myapp/uploads-tmp", + ) +) + +# Optionally offload blocking inspection to a bounded pool +validator = FileValidator( + config=config, + executor=ThreadPoolExecutor(max_workers=4), +) +``` + +## ZIP metadata verification + +safeuploads reads the declared entry sizes from the ZIP central +directory, which an attacker controls. `verify_zip_decompression` +is **off by default** because enabling it decompresses every +entry, costing up to `max_uncompressed_size` of inflation per +upload. That default is safe only because of who consumes the +archive afterwards: + +- [ ] Determine how your application extracts the archive. + - Python's `zipfile` caps reads at the declared size and + raises `BadZipFile` on the resulting CRC mismatch, so + forged metadata cannot bomb it. The default is fine. + - Anything that inflates the raw DEFLATE stream directly + (`zlib`), or an external tool that trusts local headers + (`unzip`, `7z`), is **not** protected by the declared + sizes. Set `verify_zip_decompression=True`. +- [ ] If enabling it, confirm `max_validation_time_seconds` is + large enough for the archive sizes you accept, since the + whole archive is now inflated during validation. + +## Memory enforcement + +- [ ] Leave `enforce_memory_limit` at its default (`False`) + unless the process validates one upload at a time. The + underlying metric is the process-wide peak RSS, so under + concurrency it attributes other requests' allocations to this + one and will reject legitimate uploads. +- [ ] Rely on the byte limits (`max_image_size`, `max_zip_size`, + `max_uncompressed_size`, `max_memory_buffer_size`, + `content_scan_max_size`, `max_xml_elements`) as the real + memory bound, plus a container memory limit. +- [ ] Alert on the "memory budget exceeded (not enforced)" + warning rather than treating it as a control. + +## Content analysis - [ ] `enable_content_analysis` set to `True` if accepting files from untrusted users. @@ -50,19 +126,23 @@ that addresses it. - [ ] Consider supplemental antivirus scanning (ClamAV or similar) for high-risk environments. -## Audit Logging +## Audit logging - [ ] `enable_audit_logging` set to `True` in production. - [ ] Log handler attached to `safeuploads.audit` logger (or parent `safeuploads` logger). - [ ] Structured log output configured (JSON formatter recommended for log aggregation). +- [ ] `set_source_ip()` called with the client address before + validating, so audit events can be attributed to a caller. +- [ ] `reset_source_ip()` called when the address must not + outlive the request, if your framework reuses the context. - [ ] Log storage retention policy defined (minimum 90 days recommended for security incident investigation). - [ ] Alerting configured for `THREAT_DETECTED` and `RESOURCE_LIMIT` audit event types. -## Error Handling +## Error handling - [ ] Application catches specific exception types (`FileSizeError`, `ExtensionSecurityError`, etc.) and @@ -74,7 +154,43 @@ that addresses it. - [ ] Generic 500 errors for unexpected failures — no stack traces in production responses. -## File Storage Security +!!! warning + Exception messages embed values derived from the upload — the + detected MIME type, ZIP entry names, declared image + dimensions — and `err.filename` carries the client-supplied + name. Returning `str(err)` or `err.filename` to a client + reflects attacker-controlled bytes back to the browser. + Branch on the exception type and surface `err.error_code`, + which is a stable machine-readable string. + +```python +import logging + +from safeuploads.exceptions import ( + FileValidationError, # Base exception + FileSizeError, # File too large + ExtensionSecurityError, # Dangerous extension + ImageSecurityError, # Image decompression bomb + ZipBombError, # Compression attack +) + +logger = logging.getLogger(__name__) + +try: + await validator.validate_image_file(file) +except FileSizeError as err: + return {"error": "File too large", "max_size": err.max_size} +except ExtensionSecurityError as err: + return {"error": "File type not allowed", "code": err.error_code} +except ImageSecurityError as err: + return {"error": "Image too large to decode", "code": err.error_code} +except FileValidationError as err: + # Full detail goes to the log; the client only sees the code. + logger.warning("Upload rejected: %s", err) + return {"error": "Upload rejected", "code": err.error_code} +``` + +## File storage security - [ ] Uploaded files stored outside the web-accessible document root. @@ -88,7 +204,7 @@ that addresses it. the filename. - `X-Content-Type-Options: nosniff` header. -## Security Headers (for serving uploaded content) +## Security headers (for serving uploaded content) - [ ] `Content-Security-Policy` configured to prevent inline script execution if serving HTML/SVG content. @@ -96,7 +212,7 @@ that addresses it. - [ ] `X-Frame-Options: DENY` or `SAMEORIGIN` as appropriate. - [ ] `Cache-Control: no-store` for sensitive uploaded content. -## Resource Limits +## Resource limits - [ ] Container or process memory limits set — safeuploads `max_validation_memory_mb` should be below the container @@ -105,14 +221,18 @@ that addresses it. application level — should be above `max_validation_time_seconds`. - [ ] Disk space monitored for temporary file spill - (`SpooledTemporaryFile` uses the system temp directory). -- [ ] Consider setting `TMPDIR` environment variable to a - dedicated partition with quota enforcement. + (uploads above `max_memory_buffer_size` are written to disk). +- [ ] `temp_dir` set to a dedicated, quota-enforced partition, + or `TMPDIR` set if you prefer to configure it out of band. + The directory must exist; configuration validation reports + `invalid_temp_dir` when it does not. -## Dependency Management +## Dependency management - [ ] `safeuploads` pinned to a specific version in `requirements.txt` or `pyproject.toml`. +- [ ] Release provenance verified before promoting a new version + (see below). - [ ] `pip-audit` or `safety` run in CI to detect known vulnerabilities in dependencies. - [ ] `defusedxml` and `python-magic` dependencies kept @@ -120,6 +240,27 @@ that addresses it. - [ ] `libmagic` system library installed and up to date on the deployment target. +Releases are built and published by the repository's release +workflow through PyPI Trusted Publishing, with +[PEP 740](https://peps.python.org/pep-0740/) attestations, so +you can confirm a downloaded artifact came from that workflow +and was not substituted: + +```bash +uvx pypi-attestations verify pypi \ + --repository https://github.com/endurain-project/safeuploads \ + pypi:safeuploads--py3-none-any.whl +``` + +A successful run prints `OK: `. `Provenance for file +... was not found` means the artifact predates attested +publishing rather than that verification failed. + +Each release run also produces a CycloneDX SBOM and +`SHA256SUMS`, generated from a clean install of the built wheel. +These are retained as workflow artifacts on the release run +rather than published to PyPI. + ## Testing - [ ] Unit tests verify validation rejects known-bad payloads @@ -131,7 +272,7 @@ that addresses it. - [ ] Penetration testing includes crafted uploads: ZIP bombs, polyglot files, XXE payloads, traversal filenames. -## Monitoring & Incident Response +## Monitoring & incident response - [ ] Upload validation metrics tracked (success rate, failure rate, latency). diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index c4a3f09..2720d90 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -1,4 +1,4 @@ -# Threat Model +# Threat model This document describes the threat categories that safeuploads protects against, the attack vectors for each, and the @@ -6,9 +6,9 @@ mitigations implemented in the library. --- -## Filename Attacks +## Filename attacks -### Directory Traversal (CWE-22) +### Directory traversal (CWE-22) **Attack:** Filenames containing `../`, `..\\`, or URL-encoded variants (`%2e%2e%2f`) attempt to write files outside the @@ -23,7 +23,7 @@ intended upload directory. - Null bytes in filenames are rejected to prevent C-string truncation attacks. -### Unicode Obfuscation (CWE-116) +### Unicode obfuscation (CWE-116) **Attack:** Right-to-left override characters (U+202E) and zero-width joiners can disguise file extensions so that @@ -39,7 +39,7 @@ is `.exe`. - Fullwidth period (U+FF0E) and dot leader (U+2024) are flagged to prevent extension spoofing. -### Windows Reserved Names (CWE-20) +### Windows reserved names (CWE-20) **Attack:** Filenames like `CON`, `PRN`, `NUL`, or `COM1` cause undefined behavior on Windows file systems, potentially @@ -53,9 +53,9 @@ leading to denial of service. --- -## Extension Attacks +## Extension attacks -### Dangerous Extensions (CWE-434) +### Dangerous extensions (CWE-434) **Attack:** Uploading executable files (`.exe`, `.bat`, `.ps1`, `.php`, `.jsp`) that could be executed if served or stored @@ -76,9 +76,9 @@ improperly. --- -## Compression Attacks +## Compression attacks -### ZIP Bombs (CWE-400) +### ZIP bombs (CWE-400) **Attack:** A small ZIP archive that decompresses to an enormous size (e.g., 42.zip — 42 KB compressed, 4.5 PB @@ -114,7 +114,7 @@ uncompressed), exhausting disk and memory. declared metadata are then rejected as `ZIP_CORRUPT`. This is off by default because it decompresses the full archive. -### Recursive / Quine ZIP Archives +### Recursive / quine ZIP archives **Attack:** A ZIP containing itself (quine) or deeply nested ZIPs that cause infinite recursion during inspection. @@ -126,11 +126,12 @@ ZIPs that cause infinite recursion during inspection. raises `ZIP_QUINE_DETECTED`. - `max_zip_depth` (default 10) limits nesting level. - `max_total_entries_recursive` (default 50,000) limits the - cumulative entry count across all nesting levels. + cumulative entry count across all nesting levels. A single + flat archive is capped by `max_zip_entries` instead. - `ZIP_RECURSIVE_STRUCTURE` and `ZIP_COMPLEXITY_ATTACK` error codes provide precise feedback. -### Nested Archive Detection +### Nested archive detection **Attack:** Archives hidden inside other archives to bypass single-level content inspection. @@ -145,9 +146,9 @@ single-level content inspection. --- -## Content Threats (ZIP Entries) +## Content threats (ZIP entries) -### Path Traversal in ZIP Entry Names (CWE-22) +### Path traversal in ZIP entry names (CWE-22) **Attack:** ZIP entry filenames like `../../etc/passwd` write outside the extraction directory (Zip Slip). @@ -159,22 +160,28 @@ outside the extraction directory (Zip Slip). - Null bytes in entry filenames are rejected first to prevent C-string truncation bypasses (CWE-158). -### Executable Content in ZIP +### Executable content in ZIP **Attack:** Executables, scripts, system files, or shortcuts hidden inside ZIP archives. **Mitigations:** -- Entry extensions are checked against - `ZipThreatCategory.EXECUTABLE_FILES`, `SCRIPT_FILES`, and - `SYSTEM_FILES`. +- `ZipContentInspector._check_dangerous_extension()` rejects any + entry whose name carries an extension from a `ZipThreatCategory` + listed in `blocked_zip_entry_categories`, which defaults to + `EXECUTABLE_FILES` and `SCRIPT_FILES`. Add `SYSTEM_FILES` to also + reject `.dll`, `.so`, `.ini` and `.conf` entries; that category is + mostly configuration rather than executable content, so it is + opt-in. Every dot-separated suffix is checked, so a disguised name + such as `invoice.php.txt` is still rejected. This check is + metadata-level and runs even when `scan_zip_content=False`. - Binary content is scanned for executable magic bytes from `SuspiciousFilePattern.EXECUTABLE_SIGNATURES`. - Text content is scanned for script injection patterns (shebangs, `eval()`, `` named `track.gpx`; +it passes the `` is roughly twelve +million elements, and a full DOM of those costs an order of +magnitude more memory than the file itself. + +**Mitigations:** + +- Parsing is incremental (`iterparse`); completed elements and + the accumulated root children are discarded as they close, so + peak memory stays flat regardless of document length. +- The element count is capped by `max_xml_elements` + (default 1,000,000), raising `XML_TOO_MANY_ELEMENTS`. + --- -## Resource Exhaustion +## Resource exhaustion -### Memory Exhaustion (CWE-400) +### Memory exhaustion (CWE-400) **Attack:** Uploading very large files or files that expand significantly during validation consumes all available memory. @@ -263,13 +328,17 @@ significantly during validation consumes all available memory. - Streaming validation via `SpooledTemporaryFile` keeps memory usage under `max_memory_buffer_size` (default 10 MB) by spilling to disk for larger files. -- `ResourceMonitor` tracks memory delta via - `resource.getrusage()` and enforces - `max_validation_memory_mb` (default 512 MB). +- Every buffer the library allocates is bounded by an explicit + byte limit: `chunk_size`, `content_scan_max_size`, + `max_uncompressed_size`, and `max_xml_elements`. - File size is enforced progressively during chunked reads, not after loading the entire file. +- `ResourceMonitor` additionally reports peak-RSS growth against + `max_validation_memory_mb`. See the caveat under CPU + Exhaustion: this is telemetry, not a limit, unless + `enforce_memory_limit` is set. -### CPU Exhaustion (CWE-400) +### CPU exhaustion (CWE-400) **Attack:** Crafted files that trigger expensive validation paths (e.g., ZIP with many entries, deeply nested structures). @@ -277,14 +346,31 @@ paths (e.g., ZIP with many entries, deeply nested structures). **Mitigations:** - `ResourceMonitor` enforces `max_validation_time_seconds` - (default 30 s) using `time.monotonic()`. + (default 30 s) using `time.monotonic()`. The budget is + checked on every chunk of the streaming reads, every ZIP + entry, every recursive nesting step, and every gzip chunk, so + a runaway file is aborted while it runs rather than reported + after the fact. - ZIP analysis has its own `zip_analysis_timeout` (default 5 s), compared against `time.monotonic()` on each entry during iteration. - `max_zip_entries` (default 10,000) caps per-archive entry count. -### Gzip Decompression Bombs +**Memory accounting caveat:** the memory budget samples the +process-wide peak RSS (`ru_maxrss`), a monotonic high-water +mark. It cannot be attributed to a single validation: after the +first peak the measured delta is near zero, and under +concurrency it picks up other requests' allocations. It is +therefore **best-effort telemetry, not a limit** — exceeding +`max_validation_memory_mb` is logged, not enforced. Set +`enforce_memory_limit=True` to make it fail the validation, and +only do so in a process that validates one upload at a time. +The real memory bounds are structural: `max_memory_buffer_size`, +`chunk_size`, `content_scan_max_size`, `max_uncompressed_size` +and `max_xml_elements` cap every buffer the library allocates. + +### Gzip decompression bombs **Attack:** A small gzip file that decompresses to massive size, similar to ZIP bombs. @@ -296,12 +382,42 @@ size, similar to ZIP bombs. `SecurityLimits` progressively. - Exceeding either limit raises a validation error immediately, without reading the rest of the stream. +- Inflation is additionally bounded by `gzip_analysis_timeout` + (default 25 s), so a stream that stays inside the ratio and + size limits still cannot burn unbounded CPU. The bound does + not depend on the caller supplying a `ResourceMonitor`. The + default is sized to inflate `max_uncompressed_size` at a + conservative 50 MB/s, so it fires on pathological CPU cost + rather than on a large but legitimate stream. --- -## Audit & Observability +## Audit & observability + +### Log injection (CWE-117) + +**Attack:** A filename or ZIP entry name containing a newline +(`upload.jpg\nWARNING forged entry`) forges an extra log line, +or uses directional and zero-width characters to hide the real +name from an analyst reading the log. + +**Mitigations:** -### Undetected Security Events (CWE-778) +- `safe_label()` escapes control, format, surrogate and + line-separator characters to `\uXXXX` and bounds the length + before any untrusted text reaches a log record, an audit + event, or an exception message. +- The raw client filename is escaped in `FileValidator` before + the first audit event is emitted, which happens before any + sanitization has run. +- `SecurityAuditLogger.log_event()` escapes the filename, + result and details fields again at the emission point, so + every caller is covered regardless of how the event was + built. +- Unicode validation errors report the offending code point and + its Unicode name rather than echoing the character itself. + +### Undetected security events (CWE-778) **Attack:** Security-relevant events (validation failures, threat detections) go unlogged, preventing incident response. @@ -310,15 +426,20 @@ threat detections) go unlogged, preventing incident response. - `SecurityAuditLogger` emits structured log records under the `safeuploads.audit` logger for every validation start, - success, failure, and threat detection. + success, failure, and threat detection. A breached resource + budget is recorded as `RESOURCE_LIMIT` rather than a generic + failure, so it can be alerted on separately. - Correlation IDs (via `contextvars`) link all log messages from a single validation call. +- `set_source_ip()` attaches the client address to every audit + event in the current context. safeuploads never sees the + request, so the application supplies it. - Audit logging is off by default (`enable_audit_logging= False`) to avoid noise in development, enabled in production. --- -## Error Information Leakage (CWE-209) +## Error information leakage (CWE-209) **Attack:** Detailed internal error messages in API responses help attackers understand the validation pipeline and craft diff --git a/examples/README.md b/examples/README.md index 954df98..356a78a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,7 +10,7 @@ This directory contains a working example demonstrating how to integrate `safeup ## Prerequisites -The example requires Python 3.13+ and the `safeuploads` library with FastAPI: +The example requires Python 3.11+ and the `safeuploads` library with FastAPI: ```bash pip install safeuploads[fastapi] @@ -103,9 +103,9 @@ strict_limits = SecurityLimits( max_compression_ratio=50, # Stricter ratio ) -# Apply to config -config = FileSecurityConfig() -config.limits = strict_limits +# Apply to config. The limits are copied, so the object above is +# never aliased; assigning to `config.limits` instead would share it. +config = FileSecurityConfig(strict_limits) # Use with validator validator = FileValidator(config=config) diff --git a/examples/fastapi_example.py b/examples/fastapi_example.py index cc51ba9..789b298 100644 --- a/examples/fastapi_example.py +++ b/examples/fastapi_example.py @@ -10,6 +10,7 @@ pip install slowapi """ +import logging from concurrent.futures import ThreadPoolExecutor import uvicorn @@ -32,6 +33,7 @@ FileProcessingError, FileSizeError, FileValidationError, + ImageSecurityError, MimeTypeError, ResourceLimitError, UnicodeSecurityError, @@ -40,6 +42,8 @@ ZipContentError, ) +logger = logging.getLogger(__name__) + # Initialize FastAPI app app = FastAPI( title="SafeUploads FastAPI Example", @@ -63,8 +67,7 @@ ) # Create custom configuration with strict limits -strict_config = FileSecurityConfig() -strict_config.limits = strict_limits +strict_config = FileSecurityConfig(strict_limits) # Initialize validators default_validator = FileValidator() # Uses default config @@ -74,8 +77,9 @@ # forged central-directory metadata, and offload blocking # inspection to a bounded thread pool so large uploads never # starve the event loop. -hardened_config = FileSecurityConfig() -hardened_config.limits = SecurityLimits(verify_zip_decompression=True) +hardened_config = FileSecurityConfig( + SecurityLimits(verify_zip_decompression=True) +) hardened_validator = FileValidator( config=hardened_config, executor=ThreadPoolExecutor(max_workers=4), @@ -89,7 +93,13 @@ async def file_validation_exception_handler(request, exc: FileValidationError): Converts safeuploads exceptions to HTTP responses with appropriate status codes and detailed error information. + + Exception messages embed upload-derived values such as the detected + MIME type, so they are logged rather than returned. Clients receive + a static message plus the machine-readable ``error_code``. """ + logger.warning("Upload rejected: %r", exc) + # Map exception types to HTTP status codes status_code = status.HTTP_400_BAD_REQUEST @@ -97,15 +107,24 @@ async def file_validation_exception_handler(request, exc: FileValidationError): if isinstance(exc, FileSizeError): detail = { "error": "file_too_large", - "message": str(exc), + "message": "File exceeds the configured size limit.", "size": exc.size, "max_size": exc.max_size, "error_code": exc.error_code, } + elif isinstance(exc, ImageSecurityError): + detail = { + "error": "image_too_large", + "message": "Image is too large once decoded.", + "width": exc.width, + "height": exc.height, + "max_pixels": exc.max_pixels, + "error_code": exc.error_code, + } elif isinstance(exc, MimeTypeError): detail = { "error": "invalid_mime_type", - "message": str(exc), + "message": "File content type is not allowed.", "detected_mime": exc.detected_mime, "allowed_mimes": list(exc.allowed_mimes), "error_code": exc.error_code, @@ -113,15 +132,14 @@ async def file_validation_exception_handler(request, exc: FileValidationError): elif isinstance(exc, ZipBombError): detail = { "error": "zip_bomb_detected", - "message": str(exc), + "message": "Archive expands beyond the allowed limits.", "compression_ratio": exc.compression_ratio, "error_code": exc.error_code, } elif isinstance(exc, ZipContentError): detail = { "error": "dangerous_zip_content", - "message": str(exc), - "threats": exc.threats, + "message": "Archive contains disallowed entries.", "error_code": exc.error_code, } elif isinstance( @@ -134,15 +152,14 @@ async def file_validation_exception_handler(request, exc: FileValidationError): ): detail = { "error": "filename_security_violation", - "message": str(exc), - "filename": exc.filename, + "message": "Filename failed security validation.", "error_code": exc.error_code, } else: # Generic file validation error detail = { "error": "validation_failed", - "message": str(exc), + "message": "Upload failed validation.", "error_code": getattr(exc, "error_code", None), } @@ -158,12 +175,14 @@ async def file_processing_exception_handler(request, exc: FileProcessingError): from FileProcessingError rather than FileValidationError, so they need their own handler. """ + logger.warning("Upload processing failed: %r", exc) + if isinstance(exc, ResourceLimitError): return JSONResponse( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, content={ "error": "resource_limit_exceeded", - "message": str(exc), + "message": "Validation exceeded its resource budget.", "error_code": exc.error_code, }, ) @@ -172,7 +191,7 @@ async def file_processing_exception_handler(request, exc: FileProcessingError): status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content={ "error": "processing_error", - "message": str(exc), + "message": "File could not be processed.", "error_code": exc.error_code, }, ) @@ -215,7 +234,7 @@ async def upload_image_strict(file: UploadFile): ) max_size_mb = e.max_size / 1024 / 1024 else: - message = str(e) + message = "Image exceeds the configured size limit." max_size_mb = None raise HTTPException( @@ -344,11 +363,11 @@ async def upload_multiple(files: list[UploadFile]): ) except FileValidationError as e: # Continue processing other files even if one fails + logger.warning("Upload rejected in batch: %r", e) results.append( { "filename": file.filename, "status": "failed", - "error": str(e), "error_code": getattr(e, "error_code", None), } ) diff --git a/mkdocs.yml b/mkdocs.yml index a55e012..a2b94b8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,4 +1,6 @@ site_name: safeuploads documentation +site_description: Secure file upload validation for Python applications +site_url: https://safeuploads.endurain.com/ repo_url: https://github.com/endurain-project/safeuploads theme: name: material @@ -16,7 +18,20 @@ theme: toggle: icon: material/brightness-4 name: Switch to light mode + features: + - content.code.copy + - navigation.sections + - navigation.top + - toc.follow +markdown_extensions: + - admonition + - tables + - toc: + permalink: true + - pymdownx.highlight + - pymdownx.superfences plugins: + - search - mkdocstrings: handlers: python: @@ -24,11 +39,12 @@ plugins: docstring_style: google show_source: true separate_signature: true + show_root_heading: true nav: - Home: index.md - API: api.md - - Rate Limiting: rate-limiting.md + - Rate limiting: rate-limiting.md - Security: - - Threat Model: security/threat-model.md + - Threat model: security/threat-model.md - Architecture: security/architecture.md - - Integration Checklist: security/integration-checklist.md \ No newline at end of file + - Integration checklist: security/integration-checklist.md \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index dca5cad..3136cf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "safeuploads" -version = "1.1.1" +version = "2.0.0" description = "A comprehensive file security system for validating uploads and preventing attacks" authors = [ {name = "João Vitória Silva",email = "joao@endurain.com"} ] license = {text = "MIT"} readme = "README.md" -requires-python = ">=3.13" +requires-python = ">=3.11" dependencies = [ "python-magic>=0.4.27,<0.5.0", "defusedxml>=0.7.1,<1.0.0", @@ -33,6 +33,11 @@ docs = [ "mkdocs-material>=9.6.0", "mkdocstrings[python]>=0.29.0", ] +# Kept out of `dev` because a mutation run is scheduled, not something +# you want resolved on every local sync. +mutation = [ + "mutmut>=3.2,<4.0", +] [build-system] @@ -45,7 +50,7 @@ exclude-newer = "30 days" [tool.ruff] line-length = 79 -target-version = "py313" +target-version = "py311" [tool.ruff.lint] select = [ @@ -127,6 +132,7 @@ markers = [ "slow: Slow running tests", "performance: Performance and benchmarking tests", "fuzz: Hypothesis-based fuzzing tests", + "corpus: Regression corpus of known upload attacks", ] [tool.coverage.run] @@ -146,11 +152,30 @@ exclude_lines = [ "raise NotImplementedError", "if __name__ == .__main__.:", "if TYPE_CHECKING:", - "@abstractmethod", ] +[tool.mutmut] +source_paths = ["safeuploads/"] +pytest_add_cli_args_test_selection = ["tests/"] +# mutmut's trampoline cannot instrument the static methods that +# config.py calls from its own class body, so importing a mutated +# copy fails outright. Excluded rather than reshaping the module +# to suit the tool. +do_not_mutate = ["safeuploads/config.py"] +# Coverage reporting and the slow suites would be paid once per +# mutant, so drop them: mutation scoring only needs pass/fail. +pytest_add_cli_args = [ + "--no-cov", + "-p", + "no:cacheprovider", + "-m", + "not performance and not fuzz", +] +# Line coverage says a line ran; a surviving mutant says nothing +# asserted on it. Run on a schedule, read as a signal, not a gate. + [tool.mypy] -python_version = "3.13" +python_version = "3.11" incremental = false # strict = true enables the full strict bundle, including # disallow_any_generics, warn_return_any, disallow_untyped_calls, diff --git a/safeuploads/__init__.py b/safeuploads/__init__.py index 788c420..b6f96e6 100644 --- a/safeuploads/__init__.py +++ b/safeuploads/__init__.py @@ -10,7 +10,11 @@ AuditEventType, SecurityAuditLogger, get_correlation_id, + get_source_ip, + reset_correlation_id, + reset_source_ip, set_correlation_id, + set_source_ip, ) # Core classes and configurations @@ -36,6 +40,7 @@ FileSignatureError, FileSizeError, FileValidationError, + ImageSecurityError, MimeTypeError, ResourceLimitError, UnicodeSecurityError, @@ -54,7 +59,7 @@ GzipContentInspector, ZipContentInspector, ) -from .protocols import SeekableFile +from .protocols import SeekableFile, UploadFileProtocol from .utils import ResourceMonitor # Specialized validators @@ -79,6 +84,7 @@ "FileSecurityConfig", # Protocols "SeekableFile", + "UploadFileProtocol", # Exceptions "ConfigValidationError", "FileSecurityConfigurationError", @@ -92,6 +98,7 @@ "FileSizeError", "MimeTypeError", "FileSignatureError", + "ImageSecurityError", "CompressionSecurityError", "ZipBombError", "ZipContentError", @@ -127,4 +134,8 @@ "AuditEventType", "get_correlation_id", "set_correlation_id", + "reset_correlation_id", + "get_source_ip", + "set_source_ip", + "reset_source_ip", ] diff --git a/safeuploads/audit.py b/safeuploads/audit.py index 2480ad8..ad32a4f 100644 --- a/safeuploads/audit.py +++ b/safeuploads/audit.py @@ -22,6 +22,8 @@ from enum import Enum from typing import Any +from .utils import safe_label + # ---------------------------------------------------------------- # Context variable for correlation ID # ---------------------------------------------------------------- @@ -62,6 +64,39 @@ def reset_correlation_id() -> None: correlation_id_var.set(None) +source_ip_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "safeuploads_source_ip", default=None +) + + +def get_source_ip() -> str | None: + """ + Return the client address recorded for this context. + + Returns: + Client address string, or None if not set. + """ + return source_ip_var.get() + + +def set_source_ip(ip: str | None) -> None: + """ + Record the client address for audit events in this context. + + safeuploads never sees the request, so the application sets + this from its own framework before validating. + + Args: + ip: Client address, or None to clear. + """ + source_ip_var.set(ip) + + +def reset_source_ip() -> None: + """Clear the client address recorded for this context.""" + source_ip_var.set(None) + + def log_extra( extra: dict[str, Any] | None = None, ) -> dict[str, Any]: @@ -77,7 +112,7 @@ def log_extra( merged: dict[str, Any] = dict(extra) if extra else {} cid = correlation_id_var.get() if cid is not None: - merged["correlation_id"] = cid + merged["correlation_id"] = safe_label(cid) return merged @@ -127,7 +162,7 @@ class AuditEvent: result: str = "" details: str = "" duration_ms: float = 0.0 - source_ip: str | None = None + source_ip: str | None = field(default_factory=source_ip_var.get) timestamp: float = field(default_factory=time.monotonic) @@ -164,20 +199,28 @@ def log_event(self, event: AuditEvent) -> None: """ Emit an audit event as a structured log record. + Untrusted fields are escaped here so a crafted filename + cannot forge or hide inside a log line, regardless of + which caller built the event. + Args: event: The audit event to record. """ if not self.enabled: return + correlation_id = safe_label(event.correlation_id) + filename = safe_label(event.filename) + result = safe_label(event.result, max_length=512) + extra = { "audit_event_type": event.event_type.value, - "audit_correlation_id": event.correlation_id, - "audit_filename": event.filename, - "audit_result": event.result, - "audit_details": event.details, + "audit_correlation_id": correlation_id, + "audit_filename": filename, + "audit_result": result, + "audit_details": safe_label(event.details, max_length=1024), "audit_duration_ms": event.duration_ms, - "audit_source_ip": event.source_ip or "", + "audit_source_ip": safe_label(event.source_ip or ""), } level = logging.INFO @@ -191,10 +234,10 @@ def log_event(self, event: AuditEvent) -> None: _audit_logger.log( level, "[%s] %s file=%s result=%s", - event.correlation_id[:12], + correlation_id[:12], event.event_type.value, - event.filename, - event.result, + filename, + result, extra=extra, ) @@ -250,6 +293,7 @@ def failure( duration_ms: float, error: str, details: str = "", + event_type: AuditEventType = AuditEventType.VALIDATION_FAILURE, ) -> None: """ Log a validation failure event. @@ -260,10 +304,11 @@ def failure( duration_ms: Validation duration in milliseconds. error: Short error description. details: Additional failure context. + event_type: Category to record the failure under. """ self.log_event( AuditEvent( - event_type=(AuditEventType.VALIDATION_FAILURE), + event_type=event_type, correlation_id=correlation_id, filename=filename, result=error, diff --git a/safeuploads/config.py b/safeuploads/config.py index 2491b57..75cf9f1 100644 --- a/safeuploads/config.py +++ b/safeuploads/config.py @@ -2,19 +2,29 @@ import itertools import logging +import os +from collections.abc import Mapping from dataclasses import dataclass, replace +from types import MappingProxyType from typing import Any, ClassVar from .enums import ( CompoundExtensionCategory, DangerousExtensionCategory, UnicodeAttackCategory, + ZipThreatCategory, ) from .exceptions import ConfigValidationError, FileSecurityConfigurationError from .utils import bytes_to_mb logger = logging.getLogger(__name__) +# Conservative floor for gzip inflation throughput, used only to +# size the analysis timeout against the uncompressed byte limit. +# Well below what a modern host sustains, so the derived timeout +# stays generous rather than borderline. +_MIN_INFLATE_THROUGHPUT_MB_S = 50 + def _config_error( error_type: str, @@ -55,15 +65,26 @@ class SecurityLimits: Attributes: max_image_size: Maximum size in bytes for image files. + max_image_pixels: Maximum width x height product allowed + for an image, guarding against decompression bombs + that are small on the wire but huge once decoded. max_zip_size: Maximum size in bytes for ZIP archives. max_activity_file_size: Maximum size in bytes for GPX/TCX/FIT activity files. max_gzip_size: Maximum size in bytes for gzip files. max_memory_buffer_size: Bytes kept in memory before a streamed upload spills to a temporary file on disk. + temp_dir: Directory used for spilled uploads. Uses the + system default temporary directory when unset. chunk_size: Chunk size in bytes for streaming reads. - max_validation_memory_mb: Maximum memory in MB allowed - during a single validation. + max_validation_memory_mb: Peak-RSS growth budget in MB + for a single validation. Best-effort telemetry only + unless ``enforce_memory_limit`` is set. + enforce_memory_limit: Whether exceeding + ``max_validation_memory_mb`` fails the validation + instead of logging a warning. Off by default because + peak RSS is process-wide and misattributes + concurrent work. max_validation_time_seconds: Overall validation timeout in seconds. max_compression_ratio: Maximum expansion ratio for ZIP files. @@ -71,6 +92,10 @@ class SecurityLimits: max_individual_file_size: Maximum size of single file in ZIP. max_zip_entries: Maximum number of file entries in ZIP. zip_analysis_timeout: Maximum seconds for ZIP analysis. + gzip_analysis_timeout: Maximum seconds spent inflating a + gzip stream during inspection. + max_xml_elements: Maximum number of elements parsed from + an XML activity file before it is rejected. max_zip_depth: Maximum directory nesting depth in ZIP. max_filename_length: Maximum length for filenames in ZIP. max_path_length: Maximum length for full paths in ZIP. @@ -82,6 +107,9 @@ class SecurityLimits: allow_nested_archives: Whether nested archives are permitted. allow_symlinks: Whether symbolic links are permitted. allow_absolute_paths: Whether absolute paths are permitted. + blocked_zip_entry_categories: ``ZipThreatCategory`` names + whose extensions are rejected when they appear on a + ZIP entry. scan_zip_content: Whether deep content inspection is enabled. verify_zip_decompression: Whether to decompress every ZIP entry to reject forged central-directory metadata. @@ -101,14 +129,28 @@ class SecurityLimits: max_activity_file_size: int = 50 * 1024 * 1024 # 50MB for GPX/TCX/FIT max_gzip_size: int = 500 * 1024 * 1024 # 500MB for gzip files + # Decoded image size limit. Matches Pillow's default + # MAX_IMAGE_PIXELS, the de facto decompression-bomb + # threshold (~0.25GB uncompressed at 3 bytes per pixel). + max_image_pixels: int = 89_478_485 + # Streaming validation settings max_memory_buffer_size: int = ( 10 * 1024 * 1024 # 10MB before spilling to disk ) + # Where spilled uploads land. Point this at a dedicated, + # quota-enforced partition to keep large uploads off the + # system temp directory. + temp_dir: str | None = None chunk_size: int = 65536 # 64KB chunks for streaming reads # Resource monitoring limits - max_validation_memory_mb: int = 512 # Max MB during validation + max_validation_memory_mb: int = 512 # Peak-RSS growth budget + # Peak RSS is a process-wide high-water mark, so it cannot be + # attributed to one validation under concurrency. Enforcement + # is opt-in and only sound when the process validates one + # upload at a time. + enforce_memory_limit: bool = False max_validation_time_seconds: float = ( 30.0 # Overall validation timeout in seconds ) @@ -125,6 +167,14 @@ class SecurityLimits: zip_analysis_timeout: float = ( 5.0 # Maximum seconds to spend analyzing ZIP structure ) + gzip_analysis_timeout: float = ( + 25.0 # Maximum seconds to spend inflating a gzip stream + ) + + # XML activity file limits. Entity expansion is blocked by + # defusedxml, but a flat document with millions of elements + # still costs CPU, so cap the element count. + max_xml_elements: int = 1_000_000 # ZIP content inspection settings max_zip_depth: int = 10 # Maximum nesting depth for directories in ZIP @@ -141,6 +191,13 @@ class SecurityLimits: allow_symlinks: bool = False # Whether to allow absolute paths in ZIP allow_absolute_paths: bool = False + # Entry extensions rejected inside an accepted archive. + # EXECUTABLE_FILES and SCRIPT_FILES are code; SYSTEM_FILES is + # mostly configuration and is a different threat class, so it + # is available but not on by default. + blocked_zip_entry_categories: frozenset[str] = frozenset( + {"EXECUTABLE_FILES", "SCRIPT_FILES"} + ) scan_zip_content: bool = True # Whether to perform deep content inspection # Decompress every ZIP entry to reject forged central- # directory metadata (extra CPU/IO; off by default) @@ -165,8 +222,16 @@ class FileSecurityConfig: """ Centralizes file upload security settings and validation. + The class-level ``limits`` is the template copied into each + new instance, not the live configuration. Pass a + ``SecurityLimits`` to the constructor to configure an + instance; assigning to ``FileSecurityConfig.limits`` or + mutating it in place changes the default for every config + created afterwards. + Attributes: - limits: Security limits configuration instance. + limits: Security limits for this instance. At class + level, the template new instances are built from. ALLOWED_IMAGE_MIMES: Permitted MIME types for images. ALLOWED_ZIP_MIMES: Permitted MIME types for ZIP files. ALLOWED_ACTIVITY_MIMES: Permitted MIME types for activity @@ -177,6 +242,8 @@ class FileSecurityConfig: ALLOWED_ACTIVITY_EXTENSIONS: Permitted activity file extensions. ALLOWED_GZIP_EXTENSIONS: Permitted gzip file extensions. + ACTIVITY_XML_ROOTS: Required XML root element per + activity extension. BLOCKED_EXTENSIONS: Dangerous file extensions to block. COMPOUND_BLOCKED_EXTENSIONS: Multi-part extensions to block. DANGEROUS_UNICODE_CHARS: Unicode characters for filename attacks. @@ -240,6 +307,16 @@ class FileSecurityConfig: ) ALLOWED_GZIP_EXTENSIONS: ClassVar[frozenset[str]] = frozenset({".gz"}) + # Required root element per XML activity format, lower-cased + # and namespace-stripped. Guards against an arbitrary XML + # document (or an HTML/SVG payload) wearing a .gpx name. + ACTIVITY_XML_ROOTS: ClassVar[Mapping[str, str]] = MappingProxyType( + { + ".gpx": "gpx", + ".tcx": "trainingcenterdatabase", + } + ) + # Generate dangerous file extensions from categorized enums @staticmethod def _generate_blocked_extensions() -> frozenset[str]: @@ -334,16 +411,20 @@ def _generate_dangerous_unicode_chars() -> frozenset[int]: } ) - def __init__(self) -> None: + def __init__(self, limits: SecurityLimits | None = None) -> None: """ Create a config instance with isolated mutable state. - Copies the class-level ``limits`` so mutating one - instance's limits never affects other instances or - the shared class default. + The supplied or class-level ``limits`` is copied, so + mutating one instance's limits never affects other + instances, the caller's object, or the class default. + + Args: + limits: Security limits to use. Falls back to the + class-level default when omitted. """ # Per-instance copy prevents cross-instance mutation - self.limits = replace(type(self).limits) + self.limits = replace(type(self).limits if limits is None else limits) # Configuration validation trigger @classmethod @@ -616,6 +697,92 @@ def _validate_file_size_limits( ) ) + # Validate decoded image size limit + if limits.max_image_pixels <= 0: + errors.append( + _config_error( + "invalid_pixel_limit", + "max_image_pixels must be greater than 0", + "file_sizes", + ( + "Set max_image_pixels to a positive" + " value (e.g., 89478485)" + ), + ) + ) + + # Validate XML element cap + if limits.max_xml_elements <= 0: + errors.append( + _config_error( + "invalid_xml_element_limit", + "max_xml_elements must be greater than 0", + "file_sizes", + ( + "Set max_xml_elements to a positive" + " value (e.g., 1000000)" + ), + ) + ) + + if limits.content_scan_max_size <= 0: + errors.append( + _config_error( + "invalid_content_scan_size", + "content_scan_max_size must be greater than 0", + "content_analysis", + ( + "Set content_scan_max_size to a positive" + " byte limit (e.g., 50MB)" + ), + ) + ) + + # A missing temp directory only surfaces when an upload + # spills to disk, so check it up front. + if limits.temp_dir is not None and not os.path.isdir(limits.temp_dir): + errors.append( + _config_error( + "invalid_temp_dir", + f"temp_dir '{limits.temp_dir}' is not a directory", + "file_sizes", + ( + "Create the directory or leave temp_dir" + " unset to use the system default" + ), + ) + ) + + # Someone who tuned the memory budget but left enforcement + # off believes they have a control they do not have. Only + # informational: leaving enforcement off is the correct + # choice under concurrency, so this must not fail strict + # validation for an otherwise sound configuration. + if ( + not limits.enforce_memory_limit + and limits.max_validation_memory_mb + != SecurityLimits.max_validation_memory_mb + ): + errors.append( + _config_error( + "memory_limit_not_enforced", + ( + "max_validation_memory_mb is set to" + f" {limits.max_validation_memory_mb}MB but" + " enforce_memory_limit is False, so exceeding" + " it is only logged" + ), + "resource_limits", + ( + "Set enforce_memory_limit=True if this must" + " fail the validation, and only in a process" + " that validates one upload at a time;" + " otherwise rely on the byte limits" + ), + severity="info", + ) + ) + return errors @classmethod @@ -965,6 +1132,66 @@ def _validate_compression_settings( ) ) + if limits.gzip_analysis_timeout <= 0: + errors.append( + _config_error( + "invalid_timeout", + "gzip_analysis_timeout must be greater than 0", + "compression", + "Set a reasonable timeout for gzip inflation", + ) + ) + + # A timeout too short to inflate a permitted stream turns + # every slow-but-legitimate upload into a ZipBombError and + # a THREAT_DETECTED audit event, so the two limits have to + # be sized against each other. + required = ( + bytes_to_mb(limits.max_uncompressed_size) + / _MIN_INFLATE_THROUGHPUT_MB_S + ) + if 0 < limits.gzip_analysis_timeout < required: + errors.append( + _config_error( + "gzip_timeout_below_size_limit", + ( + "gzip_analysis_timeout" + f" ({limits.gzip_analysis_timeout}s) is too" + " short to inflate max_uncompressed_size" + f" ({bytes_to_mb(limits.max_uncompressed_size)}MB)," + f" which needs about {required:.0f}s at" + f" {_MIN_INFLATE_THROUGHPUT_MB_S}MB/s; legitimate" + " uploads will be rejected as decompression bombs" + ), + "compression", + ( + f"Raise gzip_analysis_timeout to at least" + f" {required:.0f}s or lower" + " max_uncompressed_size" + ), + severity="warning", + ) + ) + + # A misspelled category would silently disable the check. + known = {category.name for category in ZipThreatCategory} + unknown = sorted(set(limits.blocked_zip_entry_categories) - known) + if unknown: + errors.append( + _config_error( + "unknown_zip_entry_category", + ( + "blocked_zip_entry_categories contains" + f" unknown names: {', '.join(unknown)}" + ), + "compression", + ( + "Use ZipThreatCategory member names" + f" ({', '.join(sorted(known))})" + ), + ) + ) + return errors @classmethod @@ -1211,7 +1438,7 @@ def _report_errors( ) # Raise exception if there are errors and strict mode is enabled - if error_list and strict: + if strict and error_list: raise FileSecurityConfigurationError(error_list) - if (error_list or warning_list) and strict: - raise FileSecurityConfigurationError(error_list + warning_list) + if strict and warning_list: + raise FileSecurityConfigurationError(warning_list) diff --git a/safeuploads/enums.py b/safeuploads/enums.py index cd477fb..3a2b7bc 100644 --- a/safeuploads/enums.py +++ b/safeuploads/enums.py @@ -423,9 +423,6 @@ class ZipThreatCategory(Enum): EXECUTABLE_FILES: Executable content threats. SCRIPT_FILES: Script and code threats. SYSTEM_FILES: System and configuration threats. - RECURSIVE_STRUCTURE: Self-referencing ZIP structures. - QUINE_ARCHIVE: ZIP that contains a copy of itself. - COMPLEXITY_ATTACK: Algorithmic complexity exploits. """ # Archive format threats @@ -498,15 +495,6 @@ class ZipThreatCategory(Enum): ".ini", } - # Self-referencing ZIP structures - RECURSIVE_STRUCTURE = {"recursive_zip"} - - # ZIP that contains a copy of itself - QUINE_ARCHIVE = {"quine_zip"} - - # Algorithmic complexity exploits - COMPLEXITY_ATTACK = {"complexity_attack"} - class MalwareSignatureCategory(Enum): """ diff --git a/safeuploads/exceptions.py b/safeuploads/exceptions.py index 1349e23..7f719b5 100644 --- a/safeuploads/exceptions.py +++ b/safeuploads/exceptions.py @@ -89,30 +89,33 @@ class ErrorCode(StrEnum): # File size errors FILE_TOO_LARGE = "FILE_TOO_LARGE" FILE_EMPTY = "FILE_EMPTY" - FILE_SIZE_UNKNOWN = "FILE_SIZE_UNKNOWN" # MIME type errors MIME_TYPE_INVALID = "MIME_TYPE_INVALID" MIME_TYPE_MISMATCH = "MIME_TYPE_MISMATCH" - MIME_DETECTION_FAILED = "MIME_DETECTION_FAILED" # File signature errors - FILE_SIGNATURE_INVALID = "FILE_SIGNATURE_INVALID" FILE_SIGNATURE_MISSING = "FILE_SIGNATURE_MISSING" FILE_SIGNATURE_MISMATCH = "FILE_SIGNATURE_MISMATCH" + # Image content errors + IMAGE_DIMENSIONS_EXCEEDED = "IMAGE_DIMENSIONS_EXCEEDED" + IMAGE_DIMENSIONS_UNREADABLE = "IMAGE_DIMENSIONS_UNREADABLE" + + # XML content errors + XML_MALFORMED = "XML_MALFORMED" + XML_FORBIDDEN_CONSTRUCT = "XML_FORBIDDEN_CONSTRUCT" + XML_INVALID_ROOT = "XML_INVALID_ROOT" + XML_TOO_MANY_ELEMENTS = "XML_TOO_MANY_ELEMENTS" + # Compression and ZIP errors ZIP_BOMB_DETECTED = "ZIP_BOMB_DETECTED" ZIP_CONTENT_THREAT = "ZIP_CONTENT_THREAT" COMPRESSION_RATIO_EXCEEDED = "COMPRESSION_RATIO_EXCEEDED" ZIP_TOO_MANY_ENTRIES = "ZIP_TOO_MANY_ENTRIES" - ZIP_INVALID_STRUCTURE = "ZIP_INVALID_STRUCTURE" ZIP_CORRUPT = "ZIP_CORRUPT" ZIP_TOO_LARGE = "ZIP_TOO_LARGE" ZIP_NESTED_ARCHIVE = "ZIP_NESTED_ARCHIVE" - ZIP_DIRECTORY_TRAVERSAL = "ZIP_DIRECTORY_TRAVERSAL" - ZIP_SYMLINK_DETECTED = "ZIP_SYMLINK_DETECTED" - ZIP_ABSOLUTE_PATH = "ZIP_ABSOLUTE_PATH" ZIP_ANALYSIS_TIMEOUT = "ZIP_ANALYSIS_TIMEOUT" ZIP_RECURSIVE_STRUCTURE = "ZIP_RECURSIVE_STRUCTURE" ZIP_QUINE_DETECTED = "ZIP_QUINE_DETECTED" @@ -126,7 +129,6 @@ class ErrorCode(StrEnum): # Processing errors PROCESSING_ERROR = "PROCESSING_ERROR" IO_ERROR = "IO_ERROR" - MEMORY_ERROR = "MEMORY_ERROR" # ============================================================================ @@ -388,6 +390,45 @@ def __init__( ) +class ImageSecurityError(FileValidationError): + """ + Image header declares unsafe or unreadable dimensions. + + Args: + message: Human-readable error description. + filename: Optional filename that failed the check. + width: Optional declared width in pixels. + height: Optional declared height in pixels. + max_pixels: Optional maximum allowed pixel count. + error_code: Optional error code (defaults to + IMAGE_DIMENSIONS_EXCEEDED). + + Attributes: + width: Declared width in pixels. + height: Declared height in pixels. + max_pixels: Maximum allowed pixel count. + """ + + def __init__( + self, + message: str, + filename: str | None = None, + width: int | None = None, + height: int | None = None, + max_pixels: int | None = None, + error_code: str | None = None, + ): + """Initialize with image dimension details.""" + self.width = width + self.height = height + self.max_pixels = max_pixels + super().__init__( + message, + filename=filename, + error_code=error_code or ErrorCode.IMAGE_DIMENSIONS_EXCEEDED, + ) + + # ============================================================================ # Compression and ZIP Exceptions # ============================================================================ @@ -488,15 +529,24 @@ class FileProcessingError(FileSecurityError): Args: message: Human-readable error description. original_error: Optional original exception that was caught. + error_code: Optional error code (defaults to + PROCESSING_ERROR). Attributes: original_error: The original exception that was caught. """ - def __init__(self, message: str, original_error: Exception | None = None): + def __init__( + self, + message: str, + original_error: Exception | None = None, + error_code: str | None = None, + ): """Initialize with original error.""" self.original_error = original_error - super().__init__(message, error_code=ErrorCode.PROCESSING_ERROR) + super().__init__( + message, error_code=error_code or ErrorCode.PROCESSING_ERROR + ) # ============================================================================ diff --git a/safeuploads/file_validator.py b/safeuploads/file_validator.py index fba665b..49f850a 100644 --- a/safeuploads/file_validator.py +++ b/safeuploads/file_validator.py @@ -29,6 +29,7 @@ from .protocols import UploadFileProtocol as UploadFile from .audit import ( + AuditEventType, SecurityAuditLogger, reset_correlation_id, set_correlation_id, @@ -42,13 +43,21 @@ FileSignatureError, FileSizeError, FileValidationError, + ImageSecurityError, MimeTypeError, ResourceLimitError, ) from .inspectors import ZipContentInspector from .inspectors.content_inspector import ContentSecurityInspector from .inspectors.gzip_inspector import GzipContentInspector -from .utils import ResourceMonitor, bytes_to_mb +from .utils import ( + ResourceMonitor, + bytes_to_mb, + matches_signature_prefix, + parse_image_dimensions, + safe_label, + strip_unsafe_chars, +) from .validators import ( CompressionSecurityValidator, ExtensionSecurityValidator, @@ -61,6 +70,33 @@ _T = TypeVar("_T") +# A large EXIF or preview block can push a JPEG frame header +# well past the 8 KB MIME sample. Anything beyond this window +# is treated as malformed rather than scanned indefinitely. +_IMAGE_DIMENSION_SCAN_BYTES = 1024 * 1024 + +# Header bytes that identify each accepted format. Kept separate +# from the threat signatures in ``enums`` on purpose: these +# answer "is this the format we asked for", not "is this a +# threat". +_FILE_SIGNATURES: dict[str, tuple[bytes, ...]] = { + "image": ( + b"\xff\xd8\xff", # JPEG + b"\xff\xd8\xff\xe1", # JPEG EXIF (additional JPEG variant) + b"\x89PNG\r\n\x1a\n", # PNG + ), + "zip": ( + b"PK\x03\x04", # ZIP file + b"PK\x05\x06", # Empty ZIP + b"PK\x07\x08", # ZIP with spanning + ), + "gzip": (b"\x1f\x8b",), # gzip magic number + "activity": ( + b" ResourceMonitor: + """ + Build a resource monitor from the active configuration. + + Returns: + Monitor carrying the configured time budget and the + opt-in memory enforcement flag. + """ + return ResourceMonitor( + max_time_seconds=self.config.limits.max_validation_time_seconds, + max_memory_mb=self.config.limits.max_validation_memory_mb, + enforce_memory=self.config.limits.enforce_memory_limit, + ) + async def _to_thread(self, func: Callable[..., _T], *args: object) -> _T: """ Run a blocking callable in a worker thread. @@ -293,35 +343,12 @@ def _validate_file_signature( error_code=ErrorCode.FILE_SIGNATURE_MISSING, ) - # Common file signatures - signatures = { - "image": [ - b"\xff\xd8\xff", # JPEG - b"\xff\xd8\xff\xe1", # JPEG EXIF (additional JPEG variant) - b"\x89PNG\r\n\x1a\n", # PNG - ], - "zip": [ - b"PK\x03\x04", # ZIP file - b"PK\x05\x06", # Empty ZIP - b"PK\x07\x08", # ZIP with spanning - ], - "gzip": [ - b"\x1f\x8b", # gzip magic number - ], - "activity": [ - b" None: + """ + Reject images whose decoded pixel count is unsafe. + + Byte-size limits do not bound decoded size: a small + PNG or JPEG can declare dimensions that expand to + gigabytes in any downstream decoder. + + Args: + dimensions: Parsed ``(width, height)`` pair, or None + if the header could not be read. + filename: Sanitized filename for error context. + + Raises: + ImageSecurityError: If the dimensions are unreadable, + non-positive, or exceed ``max_image_pixels``. + """ + if dimensions is None: + logger.warning("Image dimensions unreadable for '%s'", filename) + raise ImageSecurityError( + "Image dimensions could not be read from the file header", + filename=filename, + error_code=ErrorCode.IMAGE_DIMENSIONS_UNREADABLE, + ) + + width, height = dimensions + if width <= 0 or height <= 0: + raise ImageSecurityError( + f"Image declares invalid dimensions: {width}x{height}", + filename=filename, + width=width, + height=height, + error_code=ErrorCode.IMAGE_DIMENSIONS_UNREADABLE, + ) + + max_pixels = self.config.limits.max_image_pixels + pixels = width * height + if pixels > max_pixels: + logger.warning( + "Image decompression bomb rejected: %dx%d = %d pixels" + " (max %d)", + width, + height, + pixels, + max_pixels, + ) + raise ImageSecurityError( + ( + "Image too large when decoded:" + f" {width}x{height} = {pixels} pixels." + f" Maximum: {max_pixels} pixels" + ), + filename=filename, + width=width, + height=height, + max_pixels=max_pixels, + ) + + logger.debug("Image dimensions accepted: %dx%d", width, height) + def _sanitize_filename(self, filename: str) -> str: """ Sanitize user-provided filename to prevent security risks. @@ -380,10 +469,11 @@ def _sanitize_filename(self, filename: str) -> str: # Remove path components to prevent directory traversal filename = os.path.basename(filename) - # Remove null bytes and control characters - filename = "".join( - char for char in filename if ord(char) >= 32 and char != "\x7f" - ) + # Drop control, format and separator code points. C0 alone + # is not enough: U+2028, U+2029 and the C1 controls also + # break a log line, and the sanitized name is returned to + # the caller for storage, not just logged. + filename = strip_unsafe_chars(filename) # Remove dangerous characters that could be used # for path traversal or command injection @@ -529,7 +619,10 @@ def _validate_file_extension( logger.debug("File extension '%s' accepted", ext) async def _validate_file_size( - self, file: UploadFile, max_file_size: int + self, + file: UploadFile, + max_file_size: int, + monitor: ResourceMonitor | None = None, ) -> tuple[bytes, int]: """ Validate uploaded file size by sampling content. @@ -539,6 +632,8 @@ async def _validate_file_size( Args: file: Uploaded file supporting asynchronous read and seek. max_file_size: Maximum allowed file size in bytes. + monitor: Optional resource monitor checked once per + chunk so a slow upload is aborted mid-read. Returns: Tuple containing first 8 KB of file content and detected file @@ -546,6 +641,8 @@ async def _validate_file_size( Raises: FileSizeError: File size exceeds maximum or file is empty. + ResourceLimitError: If the monitor's time or memory + limit is exceeded while reading. """ # Read first chunk for content analysis file_content = await file.read(8192) # Read first 8KB @@ -574,6 +671,8 @@ async def _validate_file_size( chunk_size = self.config.limits.chunk_size file_size = 0 while True: + if monitor is not None: + monitor.check() chunk = await file.read(chunk_size) if not chunk: break @@ -598,7 +697,10 @@ async def _validate_file_size( return file_content, file_size async def _stream_to_temp_file( - self, file: UploadFile, max_file_size: int + self, + file: UploadFile, + max_file_size: int, + monitor: ResourceMonitor | None = None, ) -> tuple[tempfile.SpooledTemporaryFile[bytes], int]: """ Stream uploaded file to a SpooledTemporaryFile with size validation. @@ -611,6 +713,8 @@ async def _stream_to_temp_file( Args: file: Uploaded file supporting asynchronous read/seek. max_file_size: Maximum allowed file size in bytes. + monitor: Optional resource monitor checked once per + chunk so a slow upload is aborted mid-read. Returns: Tuple of SpooledTemporaryFile positioned at start and @@ -619,9 +723,12 @@ async def _stream_to_temp_file( Raises: FileSizeError: File exceeds maximum or is empty. + ResourceLimitError: If the monitor's time or memory + limit is exceeded while reading. """ temp = tempfile.SpooledTemporaryFile( # noqa: SIM115 - max_size=self.config.limits.max_memory_buffer_size + max_size=self.config.limits.max_memory_buffer_size, + dir=self.config.limits.temp_dir, ) total_bytes = 0 chunk_size = self.config.limits.chunk_size @@ -630,6 +737,8 @@ async def _stream_to_temp_file( try: while True: + if monitor is not None: + monitor.check() chunk = await file.read(chunk_size) if not chunk: break @@ -753,14 +862,21 @@ async def _run_validation( unexpected internal error. """ cid = set_correlation_id() - filename = file.filename or "unknown" - self._audit.start(filename, cid) - logger.debug("Starting %s file validation: %s", file_type, filename) + # Audit fields are escaped at the emission point, so pass + # the raw name through; escaping twice would truncate an + # adversarial name mid-escape-sequence. + raw_name = file.filename or "unknown" + self._audit.start(raw_name, cid) + logger.debug( + "Starting %s file validation: %s", + file_type, + safe_label(raw_name), + ) t0 = time.monotonic() try: await body(file) ms = (time.monotonic() - t0) * 1000 - self._audit.success(file.filename or filename, cid, ms) + self._audit.success(file.filename or raw_name, cid, ms) except ( FileValidationError, ResourceLimitError, @@ -768,16 +884,21 @@ async def _run_validation( ) as exc: ms = (time.monotonic() - t0) * 1000 self._audit.failure( - file.filename or filename, + file.filename or raw_name, cid, ms, str(exc), + event_type=( + AuditEventType.RESOURCE_LIMIT + if isinstance(exc, ResourceLimitError) + else AuditEventType.VALIDATION_FAILURE + ), ) raise except Exception as err: ms = (time.monotonic() - t0) * 1000 self._audit.failure( - file.filename or filename, + file.filename or raw_name, cid, ms, "internal_error", @@ -810,6 +931,9 @@ async def validate_image_file(self, file: UploadFile) -> None: MimeTypeError: MIME type is not in allowed image types. FileSignatureError: File signature doesn't match expected image format. + ImageSecurityError: Decoded pixel count exceeds + ``max_image_pixels`` or the header dimensions cannot + be read. FileProcessingError: Unexpected error during validation. """ await self._run_validation(file, "image", self._validate_image_body) @@ -833,14 +957,11 @@ async def _validate_image_body(self, file: UploadFile) -> None: file, self.config.ALLOWED_IMAGE_EXTENSIONS ) - with ResourceMonitor( - max_time_seconds=self.config.limits.max_validation_time_seconds, - max_memory_mb=self.config.limits.max_validation_memory_mb, - ): + with self._monitor() as monitor: # Validate file size (raises on failure, # returns content and size on success) file_content, file_size = await self._validate_file_size( - file, self.config.limits.max_image_size + file, self.config.limits.max_image_size, monitor ) # Detect MIME type @@ -856,6 +977,16 @@ async def _validate_image_body(self, file: UploadFile) -> None: # Validate file signature (raises exceptions on failure) self._validate_file_signature(file_content, "image") + # Reject decompression bombs: a small file can declare + # dimensions that expand to gigabytes once decoded. + dimensions = parse_image_dimensions(file_content) + if dimensions is None and file_size > len(file_content): + await file.seek(0) + wider = await file.read(_IMAGE_DIMENSION_SCAN_BYTES) + await file.seek(0) + dimensions = parse_image_dimensions(wider) + self._enforce_image_dimensions(dimensions, filename) + # Optional content analysis (offloaded — scans up to # content_scan_max_size bytes and is CPU-bound) if self.config.limits.enable_content_analysis: @@ -919,20 +1050,21 @@ async def _validate_zip_body(self, file: UploadFile) -> None: # Validate file extension (raises exceptions on failure) self._validate_file_extension(file, self.config.ALLOWED_ZIP_EXTENSIONS) - with ResourceMonitor( - max_time_seconds=self.config.limits.max_validation_time_seconds, - max_memory_mb=self.config.limits.max_validation_memory_mb, - ): + with self._monitor() as monitor: # Stream file to SpooledTemporaryFile with size validation temp_file, file_size = await self._stream_to_temp_file( - file, self.config.limits.max_zip_size + file, self.config.limits.max_zip_size, monitor ) try: # Offload the CPU/IO-bound ZIP inspection off the loop filename = file.filename or "unknown" await self._to_thread( - self._inspect_zip_sync, temp_file, file_size, filename + self._inspect_zip_sync, + temp_file, + file_size, + filename, + monitor, ) finally: temp_file.close() @@ -942,6 +1074,7 @@ def _inspect_zip_sync( temp_file: tempfile.SpooledTemporaryFile[bytes], file_size: int, filename: str, + monitor: ResourceMonitor | None = None, ) -> None: """ Run synchronous ZIP inspection off the event loop. @@ -950,11 +1083,15 @@ def _inspect_zip_sync( temp_file: Spooled temp file holding the ZIP data. file_size: Compressed archive size in bytes. filename: Sanitized filename for context. + monitor: Optional resource monitor checked once per + entry so a runaway archive is aborted mid-scan. Raises: MimeTypeError: If the MIME type is not allowed. FileSignatureError: If the signature mismatches. CompressionSecurityError: If a zip bomb is detected. + ResourceLimitError: If the monitor's time or memory + limit is exceeded during inspection. FileProcessingError: If content analysis finds threats. """ # Read header for MIME/signature checks @@ -973,13 +1110,15 @@ def _inspect_zip_sync( # Validate ZIP compression ratio self.compression_validator.validate_zip_compression_ratio( - temp_file, file_size + temp_file, file_size, monitor ) # Perform ZIP content inspection if enabled if self.config.limits.scan_zip_content: temp_file.seek(0) - self.zip_inspector.inspect_zip_content(temp_file) + self.zip_inspector.inspect_zip_content( + temp_file, monitor, filename + ) # Optional content analysis if self.config.limits.enable_content_analysis: @@ -1024,14 +1163,16 @@ async def _validate_activity_body(self, file: UploadFile) -> None: Run activity-file-specific validation steps. Handles XXE-safe XML parsing for GPX/TCX and binary - signature validation for FIT files. + signature validation for FIT files, followed by optional + deep content analysis. Args: file: Uploaded activity file to validate. Raises: FileValidationError: If an activity check fails. - FileProcessingError: If XML parsing fails. + FileProcessingError: If XML parsing or content analysis + fails. """ self._validate_filename(file) self._validate_file_extension( @@ -1039,13 +1180,11 @@ async def _validate_activity_body(self, file: UploadFile) -> None: self.config.ALLOWED_ACTIVITY_EXTENSIONS, ) - with ResourceMonitor( - max_time_seconds=self.config.limits.max_validation_time_seconds, - max_memory_mb=self.config.limits.max_validation_memory_mb, - ): + with self._monitor() as monitor: temp_file, file_size = await self._stream_to_temp_file( file, self.config.limits.max_activity_file_size, + monitor, ) try: @@ -1055,6 +1194,7 @@ async def _validate_activity_body(self, file: UploadFile) -> None: temp_file, file_size, filename, + monitor, ) finally: temp_file.close() @@ -1064,22 +1204,27 @@ def _inspect_activity_sync( temp_file: tempfile.SpooledTemporaryFile[bytes], file_size: int, filename: str, + monitor: ResourceMonitor | None = None, ) -> None: """ Run synchronous activity-file inspection off the loop. Handles XXE-safe XML parsing for GPX/TCX and binary - signature validation for FIT files. + signature validation for FIT files, followed by optional + deep content analysis. Args: temp_file: Spooled temp file holding the data. file_size: File size in bytes. filename: Sanitized filename for context. + monitor: Optional resource monitor checked around + content analysis. Raises: MimeTypeError: If the MIME type is not allowed. FileSignatureError: If the signature mismatches. - FileProcessingError: If XML parsing fails. + FileProcessingError: If XML parsing or content analysis + fails. """ _, ext = os.path.splitext(filename.lower()) is_fit = ext == ".fit" @@ -1098,9 +1243,24 @@ def _inspect_activity_sync( error_code=ErrorCode.MIME_TYPE_MISMATCH, ) - # XXE-safe XML validation for GPX/TCX + # XXE-safe XML validation for GPX/TCX. The root element + # must match the extension, so an arbitrary XML document + # cannot be accepted under a .gpx name. if not is_fit: - self.xml_validator.validate_xml_safety(temp_file) + self.xml_validator.validate_xml_safety( + temp_file, self.config.ACTIVITY_XML_ROOTS.get(ext) + ) + + if self.config.limits.enable_content_analysis: + if monitor is not None: + monitor.check() + temp_file.seek(0) + scan_size = self.config.limits.content_scan_max_size + sample = temp_file.read(scan_size) + temp_file.seek(0) + self._raise_on_content_threats(sample, filename, "activity") + if monitor is not None: + monitor.check() logger.debug( "Activity file validation passed: %s (%s, %s bytes)", @@ -1148,13 +1308,11 @@ async def _validate_gzip_body(self, file: UploadFile) -> None: self.config.ALLOWED_GZIP_EXTENSIONS, ) - with ResourceMonitor( - max_time_seconds=self.config.limits.max_validation_time_seconds, - max_memory_mb=self.config.limits.max_validation_memory_mb, - ): + with self._monitor() as monitor: temp_file, file_size = await self._stream_to_temp_file( file, self.config.limits.max_gzip_size, + monitor, ) try: @@ -1164,6 +1322,7 @@ async def _validate_gzip_body(self, file: UploadFile) -> None: temp_file, file_size, filename, + monitor, ) finally: temp_file.close() @@ -1173,6 +1332,7 @@ def _inspect_gzip_sync( temp_file: tempfile.SpooledTemporaryFile[bytes], file_size: int, filename: str, + monitor: ResourceMonitor | None = None, ) -> None: """ Run synchronous gzip inspection off the event loop. @@ -1181,11 +1341,15 @@ def _inspect_gzip_sync( temp_file: Spooled temp file holding the gzip data. file_size: Compressed size in bytes. filename: Sanitized filename for context. + monitor: Optional resource monitor checked once per + chunk so a slow stream is aborted mid-inflation. Raises: MimeTypeError: If the MIME type is not allowed. FileSignatureError: If the signature mismatches. ZipBombError: If a decompression bomb is detected. + ResourceLimitError: If the monitor's time or memory + limit is exceeded during inspection. CompressionSecurityError: If the gzip is invalid. """ _, detected_mime = self._read_header_and_detect( @@ -1202,7 +1366,9 @@ def _inspect_gzip_sync( ) # Decompression bomb check - self.gzip_inspector.inspect_gzip_content(temp_file, file_size) + self.gzip_inspector.inspect_gzip_content( + temp_file, file_size, monitor, filename + ) logger.debug( "Gzip file validation passed: %s (%s, %s bytes)", diff --git a/safeuploads/inspectors/content_inspector.py b/safeuploads/inspectors/content_inspector.py index a14c280..e895fe4 100644 --- a/safeuploads/inspectors/content_inspector.py +++ b/safeuploads/inspectors/content_inspector.py @@ -13,7 +13,7 @@ from ..audit import get_correlation_id, log_extra from ..enums import MalwareSignatureCategory, SuspiciousFilePattern -from ..utils import find_embedded_signature, find_text_pattern +from ..utils import find_embedded_signature, find_text_pattern, safe_label from .base import BaseInspector if TYPE_CHECKING: @@ -95,34 +95,37 @@ def scan_content( means content is clean. """ threats: list[str] = [] + label = safe_label(filename) - logger.debug("Scanning content of '%s' for embedded threats", filename) + logger.debug("Scanning content of '%s' for embedded threats", label) # 1. Executable signature scan - threats.extend(self._check_executable_signatures(content, filename)) + threats.extend(self._check_executable_signatures(content, label)) # 2. Script injection scan - threats.extend(self._check_script_patterns(content, filename)) + threats.extend(self._check_script_patterns(content, label)) # 3. Polyglot detection - threats.extend(self._check_polyglot(content, filename, expected_type)) + threats.extend(self._check_polyglot(content, label, expected_type)) if threats: logger.warning( "Content analysis threats detected in '%s': %s", - filename, + label, "; ".join(threats), extra=log_extra(), ) cid = get_correlation_id() if cid: + # Raw name: the audit logger escapes on emission, + # and escaping twice can truncate mid-sequence. self._audit.threat( filename, cid, "; ".join(threats), ) else: - logger.debug("Content scan clean for '%s'", filename) + logger.debug("Content scan clean for '%s'", label) return threats @@ -202,8 +205,7 @@ def _check_polyglot( # Skip first 8 bytes (longest common header is # PNG at 8 bytes) and search rest for secondary # signatures to detect polyglot files - tail = content[8:] - sig = find_embedded_signature(tail, self._polyglot_sigs) + sig = find_embedded_signature(content, self._polyglot_sigs, 8) if sig is not None: return [ f"Polyglot file detected" diff --git a/safeuploads/inspectors/gzip_inspector.py b/safeuploads/inspectors/gzip_inspector.py index 85bee36..2adff79 100644 --- a/safeuploads/inspectors/gzip_inspector.py +++ b/safeuploads/inspectors/gzip_inspector.py @@ -4,6 +4,7 @@ import gzip import logging +import time from typing import TYPE_CHECKING from ..audit import get_correlation_id, log_extra @@ -11,6 +12,7 @@ CompressionSecurityError, ErrorCode, FileProcessingError, + ResourceLimitError, ZipBombError, ) from ..utils import bytes_to_mb @@ -18,6 +20,7 @@ if TYPE_CHECKING: from ..protocols import SeekableFile + from ..utils import ResourceMonitor logger = logging.getLogger(__name__) @@ -39,6 +42,8 @@ def inspect_gzip_content( self, file_obj: SeekableFile, compressed_size: int, + monitor: ResourceMonitor | None = None, + filename: str = "", ) -> None: """ Inspect gzip archive for decompression bombs. @@ -46,12 +51,18 @@ def inspect_gzip_content( Args: file_obj: Seekable file containing gzip data. compressed_size: Size of the compressed file in bytes. + monitor: Optional resource monitor checked once per + chunk so a slow stream is aborted mid-inflation. + filename: Sanitized filename recorded on audit events. Raises: ZipBombError: If compression ratio or uncompressed - size exceeds configured limits. + size exceeds configured limits, or inflation + exceeds ``gzip_analysis_timeout``. CompressionSecurityError: If the gzip structure is invalid or corrupted. + ResourceLimitError: If the monitor's time or memory + limit is exceeded during inspection. FileProcessingError: If an unexpected error occurs. """ file_obj.seek(0) @@ -59,6 +70,8 @@ def inspect_gzip_content( chunk_size = self.config.limits.chunk_size max_ratio = self.config.limits.max_compression_ratio max_uncompressed = self.config.limits.max_uncompressed_size + timeout = self.config.limits.gzip_analysis_timeout + start_time = time.monotonic() logger.debug( "Inspecting gzip stream (compressed size %d bytes)", compressed_size, @@ -67,6 +80,31 @@ def inspect_gzip_content( try: with gzip.open(file_obj, "rb") as gz: while True: + if monitor is not None: + monitor.check() + + if time.monotonic() - start_time > timeout: + logger.error( + "Gzip inflation timeout after %.1fs", + timeout, + extra=log_extra(), + ) + cid = get_correlation_id() + if cid: + self._audit.threat( + filename, + cid, + "Gzip inflation timeout", + ) + raise ZipBombError( + message=( + "Gzip inflation timeout after" + f" {timeout}s" + " - potential decompression bomb" + ), + error_code=ErrorCode.ZIP_ANALYSIS_TIMEOUT, + ) + chunk = gz.read(chunk_size) if not chunk: break @@ -83,7 +121,7 @@ def inspect_gzip_content( cid = get_correlation_id() if cid: self._audit.threat( - "", + filename, cid, "Gzip decompression bomb — size exceeded", ) @@ -115,7 +153,7 @@ def inspect_gzip_content( cid = get_correlation_id() if cid: self._audit.threat( - "", + filename, cid, "Gzip decompression bomb — ratio exceeded", ) @@ -136,6 +174,10 @@ def inspect_gzip_content( except ZipBombError: raise + except ResourceLimitError: + # A breached time/memory budget must abort the request, + # not be reported as an internal processing failure. + raise except gzip.BadGzipFile as err: logger.error( "Invalid or corrupted gzip file", diff --git a/safeuploads/inspectors/zip_inspector.py b/safeuploads/inspectors/zip_inspector.py index 363d4d0..7b52f4b 100644 --- a/safeuploads/inspectors/zip_inspector.py +++ b/safeuploads/inspectors/zip_inspector.py @@ -16,13 +16,23 @@ SuspiciousFilePattern, ZipThreatCategory, ) -from ..exceptions import ErrorCode, FileProcessingError, ZipContentError -from ..utils import find_text_pattern, matches_signature_prefix +from ..exceptions import ( + ErrorCode, + FileProcessingError, + ResourceLimitError, + ZipContentError, +) +from ..utils import ( + find_text_pattern, + matches_signature_prefix, + safe_label, +) from .base import BaseInspector if TYPE_CHECKING: from ..config import FileSecurityConfig from ..protocols import SeekableFile + from ..utils import ResourceMonitor logger = logging.getLogger(__name__) @@ -70,18 +80,37 @@ def __init__(self, config: FileSecurityConfig): self._recursable_exts: frozenset[str] = frozenset( ZipThreatCategory.RECURSABLE_ARCHIVES.value ) - - def inspect_zip_content(self, file_obj: SeekableFile) -> None: + # Extension to category name, so a rejection names the + # category that blocked it. Unknown names are reported by + # configuration validation and ignored here. + self._dangerous_entry_exts: dict[str, str] = { + ext.lower(): category.name + for category in ZipThreatCategory + if category.name in config.limits.blocked_zip_entry_categories + for ext in category.value + } + + def inspect_zip_content( + self, + file_obj: SeekableFile, + monitor: ResourceMonitor | None = None, + filename: str = "", + ) -> None: """ Inspect ZIP archive for potential security threats. Args: file_obj: Seekable file-like object containing ZIP data. + monitor: Optional resource monitor checked once per + entry so a runaway archive is aborted mid-scan. + filename: Sanitized filename recorded on audit events. Raises: ZipContentError: If security threats are detected in ZIP content such as directory traversal, symlinks, nested archives, or suspicious patterns. + ResourceLimitError: If the monitor's time or memory + limit is exceeded during inspection. FileProcessingError: If ZIP structure is invalid or unexpected error occurs during inspection. """ @@ -98,6 +127,9 @@ def inspect_zip_content(self, file_obj: SeekableFile) -> None: # Analyze each entry in the ZIP for entry in zip_entries: + if monitor is not None: + monitor.check() + # Check for timeout if ( time.monotonic() - start_time @@ -147,7 +179,7 @@ def inspect_zip_content(self, file_obj: SeekableFile) -> None: cid = get_correlation_id() if cid: self._audit.threat( - "", + filename, cid, "; ".join(threats_found), ) @@ -169,11 +201,15 @@ def inspect_zip_content(self, file_obj: SeekableFile) -> None: # when nested archives are allowed if self.config.limits.allow_nested_archives: file_obj.seek(0) - self.inspect_nested_archives(file_obj) + self.inspect_nested_archives(file_obj, monitor=monitor) except ZipContentError: # Re-raise our own exceptions raise + except ResourceLimitError: + # A breached time/memory budget must abort the request, + # not be reported as an internal processing failure. + raise except zipfile.BadZipFile as err: logger.error( "Invalid or corrupted ZIP file structure", exc_info=True @@ -208,25 +244,28 @@ def _inspect_zip_entry( """ threats = [] filename = entry.filename + # Entry names are attacker-controlled and end up in log + # records and error messages, so report an escaped copy. + label = safe_label(filename) # 1. Check for null bytes (truncation attacks) if "\x00" in filename: - threats.append(f"Null byte in filename: '{filename}'") + threats.append(f"Null byte in filename: '{label}'") # 2. Check for directory traversal attacks if self._has_directory_traversal(filename): - threats.append(f"Directory traversal attack in '{filename}'") + threats.append(f"Directory traversal attack in '{label}'") # 3. Check for absolute paths if ( not self.config.limits.allow_absolute_paths and self._has_absolute_path(filename) ): - threats.append(f"Absolute path detected in '{filename}'") + threats.append(f"Absolute path detected in '{label}'") # 4. Check for symbolic links if not self.config.limits.allow_symlinks and self._is_symlink(entry): - threats.append(f"Symbolic link detected: '{filename}'") + threats.append(f"Symbolic link detected: '{label}'") # 5. Check filename length limits if ( @@ -234,16 +273,14 @@ def _inspect_zip_entry( > self.config.limits.max_filename_length ): threats.append( - f"Filename too long: '{filename}'" + f"Filename too long: '{label}'" f" ({len(os.path.basename(filename))}" " chars)" ) # 6. Check path length limits if len(filename) > self.config.limits.max_path_length: - threats.append( - f"Path too long: '{filename}' ({len(filename)} chars)" - ) + threats.append(f"Path too long: '{label}' ({len(filename)} chars)") # 7. Check for suspicious filename patterns suspicious_patterns = self._check_suspicious_patterns(filename) @@ -254,9 +291,12 @@ def _inspect_zip_entry( not self.config.limits.allow_nested_archives and self._is_nested_archive(filename) ): - threats.append(f"Nested archive detected: '{filename}'") + threats.append(f"Nested archive detected: '{label}'") + + # 9. Check for dangerous entry extensions + threats.extend(self._check_dangerous_extension(filename)) - # 9. Check file content if enabled + # 10. Check file content if enabled # Only first 512 bytes are read, so no size gate needed if self.config.limits.scan_zip_content and not entry.is_dir(): content_threats = self._inspect_entry_content(entry, zip_file) @@ -264,6 +304,30 @@ def _inspect_zip_entry( return threats + def _check_dangerous_extension(self, filename: str) -> list[str]: + """ + Check an entry name for dangerous file extensions. + + Every dot-separated suffix is checked, so a disguised + name such as ``shell.php.txt`` is still rejected. + + Args: + filename: ZIP entry name to check. + + Returns: + List of threat descriptions. + """ + basename = os.path.basename(filename).lower() + parts = basename.split(".") + for part in parts[1:]: + category = self._dangerous_entry_exts.get(f".{part}") + if category is not None: + return [ + f"Dangerous entry extension '.{part}'" + f" ({category}) in '{safe_label(filename)}'" + ] + return [] + def _inspect_zip_structure( self, entries: list[zipfile.ZipInfo] ) -> list[str]: @@ -372,11 +436,12 @@ def _check_suspicious_patterns(self, filename: str) -> list[str]: threats = [] filename_lower = filename.lower() basename = os.path.basename(filename_lower) + label = safe_label(filename) # Check suspicious names for pattern in self._suspicious_names: if basename == pattern: - threats.append(f"Suspicious filename pattern: '{filename}'") + threats.append(f"Suspicious filename pattern: '{label}'") break # Check suspicious path components @@ -384,7 +449,7 @@ def _check_suspicious_patterns(self, filename: str) -> list[str]: if pattern in filename_lower: threats.append( "Suspicious path component:" - f" '{filename}' contains" + f" '{label}' contains" f" '{pattern}'" ) break @@ -418,6 +483,7 @@ def _inspect_entry_content( List of content threat descriptions. """ threats = [] + label = safe_label(entry.filename) try: # Read first few bytes to check for executable signatures @@ -427,40 +493,36 @@ def _inspect_entry_content( # Executable signatures are matched against the # entry header (anchored): the entry either is or # is not an executable. - if matches_signature_prefix( - content_sample, self._exec_signatures - ): - threats.append( - f"Executable content detected in '{entry.filename}'" + if ( + matches_signature_prefix( + content_sample, self._exec_signatures ) + is not None + ): + threats.append(f"Executable content detected in '{label}'") ext = os.path.splitext(entry.filename)[1].lower() if ( ext not in self._binary_exts - and self._contains_script_patterns( - content_sample, entry.filename - ) + and self._contains_script_patterns(content_sample) ): - threats.append( - f"Script content detected in '{entry.filename}'" - ) + threats.append(f"Script content detected in '{label}'") except Exception as err: logger.warning( "Could not inspect content of '%s': %s", - entry.filename, + label, err, ) return threats - def _contains_script_patterns(self, content: bytes, filename: str) -> bool: + def _contains_script_patterns(self, content: bytes) -> bool: """ Check content for malicious script patterns. Args: content: Raw bytes to inspect. - filename: Filename for context. Returns: True if script patterns found. @@ -499,6 +561,7 @@ def inspect_nested_archives( seen_hashes: set[str] | None = None, entry_counter: list[int] | None = None, start_time: float | None = None, + monitor: ResourceMonitor | None = None, ) -> None: """ Recursively inspect nested archives. @@ -516,10 +579,14 @@ def inspect_nested_archives( cumulative entry count shared across all recursion branches. start_time: Monotonic timestamp of initial call. + monitor: Optional resource monitor checked once per + entry so a runaway archive is aborted mid-scan. Raises: ZipContentError: If recursive structure, quine, or complexity attack is detected. + ResourceLimitError: If the monitor's time or memory + limit is exceeded during inspection. """ if seen_hashes is None: seen_hashes = set() @@ -578,6 +645,9 @@ def inspect_nested_archives( ) for entry in entries: + if monitor is not None: + monitor.check() + # Timeout elapsed = time.monotonic() - start_time if elapsed > timeout: @@ -610,7 +680,7 @@ def inspect_nested_archives( except Exception: logger.warning( "Could not read nested archive '%s'", - entry.filename, + safe_label(entry.filename), ) continue @@ -625,10 +695,15 @@ def inspect_nested_archives( seen_hashes=seen_hashes, entry_counter=entry_counter, start_time=start_time, + monitor=monitor, ) except ZipContentError: raise + except ResourceLimitError: + # A breached time/memory budget must abort the request, + # not be downgraded to a skipped branch. + raise except zipfile.BadZipFile: # A corrupt archive at this nesting level is not itself a # threat signal; log for traceability and stop descending diff --git a/safeuploads/utils.py b/safeuploads/utils.py index 1b37547..665d615 100644 --- a/safeuploads/utils.py +++ b/safeuploads/utils.py @@ -3,8 +3,10 @@ import logging import sys import time +import unicodedata from collections.abc import Iterable from types import TracebackType +from typing import TypeVar from .exceptions import ErrorCode, ResourceLimitError @@ -37,6 +39,58 @@ def bytes_to_mb(num_bytes: int) -> int: return num_bytes // (1024 * 1024) +# Control, format, surrogate and line/paragraph separator +# characters. These are what let an attacker-supplied name forge +# or hide inside a log line. +_UNSAFE_CATEGORIES = frozenset({"Cc", "Cf", "Cs", "Zl", "Zp"}) + + +def safe_label(value: str, max_length: int = 256) -> str: + """ + Escape untrusted text for inclusion in a log record. + + Args: + value: Untrusted text such as a filename or ZIP entry name. + max_length: Characters kept before truncation. + + Returns: + Escaped, length-bounded text safe to log. + """ + if not value: + return "" + + escaped = "".join( + f"\\u{ord(char):04x}" + if unicodedata.category(char) in _UNSAFE_CATEGORIES + else char + for char in value[:max_length] + ) + if len(value) > max_length: + escaped += "..." + return escaped + + +def strip_unsafe_chars(value: str) -> str: + """ + Remove characters that can forge or hide inside a log line. + + Covers C0 and C1 controls, format and surrogate code points, + and the line and paragraph separators, so the result is safe + to store and to log without further escaping. + + Args: + value: Untrusted text such as a filename. + + Returns: + Text with every unsafe character removed. + """ + return "".join( + char + for char in value + if unicodedata.category(char) not in _UNSAFE_CATEGORIES + ) + + def matches_signature_prefix( content: bytes, signatures: Iterable[bytes] ) -> bytes | None: @@ -59,69 +113,219 @@ def matches_signature_prefix( return None +_TNeedle = TypeVar("_TNeedle", str, bytes) + + +def _canonical_order(needles: Iterable[_TNeedle]) -> tuple[_TNeedle, ...]: + """ + Deduplicate and order needles so scans are reproducible. + + Callers pass sets, whose iteration order is an implementation + detail; without this, which of several matching patterns gets + reported could vary. Longest-first makes the most specific + candidate win when two of them match. + + Args: + needles: Patterns or signatures to canonicalize. + + Returns: + Deduplicated tuple ordered longest-first, then by value. + """ + return tuple(sorted(set(needles), key=lambda n: (-len(n), n))) + + def find_embedded_signature( - content: bytes, signatures: Iterable[bytes] + content: bytes, signatures: Iterable[bytes], start: int = 0 ) -> bytes | None: """ Return the first signature found anywhere in the content. Substring match; use when detecting a format embedded inside otherwise-valid content (polyglots, appended payloads). + Scanning uses ``bytes.find`` from ``start``: its C search + beats a compiled alternation over the same literals, and the + offset skips an expected header without copying the window. Args: content: Raw bytes to scan. signatures: Candidate byte signatures. + start: Offset to begin scanning at, so a caller can skip + an expected header without slicing the buffer. Returns: The first matching signature, or None if none present. """ - for sig in signatures: - if sig in content: + for sig in _canonical_order(signatures): + if content.find(sig, start) != -1: return sig return None def find_text_pattern(content: bytes, patterns: Iterable[str]) -> str | None: """ - Return the first text pattern present in decoded content. + Return the first text pattern present in the content. - Content is decoded as UTF-8 with errors ignored and lower- - cased so binary data degrades gracefully. Any decoding - failure is treated as "no match". + The window is lower-cased once and searched as bytes. A + case-insensitive regex alternation over the same literals + measures an order of magnitude slower on a large window, and + decoding to text would cost a second full-size copy. Args: content: Raw bytes to scan. - patterns: Lower-case substrings to search for. + patterns: Lower-case ASCII substrings to search for. Returns: - The first matching pattern, or None if none present. + The matching pattern, or None if none are present. """ - try: - text = content.decode("utf-8", errors="ignore").lower() - except Exception: + candidates = _canonical_order(patterns) + if not candidates: return None - for pattern in patterns: - if pattern in text: + + lowered = content.lower() + for pattern in candidates: + if lowered.find(pattern.encode("utf-8")) != -1: return pattern return None -class ResourceMonitor: +_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + +# Start-of-frame markers that carry the frame dimensions. 0xC4 +# (DHT), 0xC8 (JPG) and 0xCC (DAC) share the range but are not +# start-of-frame markers, so they are excluded. +_JPEG_SOF_MARKERS: frozenset[int] = frozenset( + { + 0xC0, + 0xC1, + 0xC2, + 0xC3, + 0xC5, + 0xC6, + 0xC7, + 0xC9, + 0xCA, + 0xCB, + 0xCD, + 0xCE, + 0xCF, + } +) + + +def _parse_png_dimensions(content: bytes) -> tuple[int, int] | None: + """ + Read width and height from a PNG IHDR chunk. + + Args: + content: Raw bytes starting at the PNG signature. + + Returns: + ``(width, height)`` tuple, or None if the IHDR chunk is + missing or truncated. + """ + # IHDR is required to be the first chunk: 8-byte signature, + # 4-byte length, 4-byte type, then two big-endian uint32. + if len(content) < 24 or content[12:16] != b"IHDR": + return None + width = int.from_bytes(content[16:20], "big") + height = int.from_bytes(content[20:24], "big") + return width, height + + +def _parse_jpeg_dimensions(content: bytes) -> tuple[int, int] | None: + """ + Walk JPEG segments to the first start-of-frame marker. + + Args: + content: Raw bytes starting at the SOI marker. + + Returns: + ``(width, height)`` tuple, or None if no start-of-frame + segment is present before the entropy-coded data. """ - Context manager that enforces wall-clock and memory limits. + pos = 2 # Skip the SOI marker. + total = len(content) + + while pos + 3 < total: + # Segments are contiguous; anything else is malformed. + if content[pos] != 0xFF: + return None + + marker = content[pos + 1] + + # 0xFF fill bytes may pad the gap before a marker. + if marker == 0xFF: + pos += 1 + continue + + # Standalone markers carry no length field. + if marker == 0x01 or 0xD0 <= marker <= 0xD8: + pos += 2 + continue + + # EOI or the start of scan data: no frame header found. + if marker in (0xD9, 0xDA): + return None + + segment_len = int.from_bytes(content[pos + 2 : pos + 4], "big") + if segment_len < 2: + return None + + if marker in _JPEG_SOF_MARKERS: + # SOF payload: precision, height, width. + sof = content[pos + 4 : pos + 9] + if len(sof) < 5: + return None + height = int.from_bytes(sof[1:3], "big") + width = int.from_bytes(sof[3:5], "big") + return width, height + + pos += 2 + segment_len + + return None - Tracks elapsed time continuously and samples memory usage - via ``resource.getrusage``. Memory accounting uses the - process peak RSS (``ru_maxrss``), a monotonic high-water - mark for the whole process, so the reported delta is a - coarse, best-effort upper bound rather than the exact - memory used by this validation. Call ``check_time`` and - ``check_memory`` inside long loops for early enforcement; - otherwise limits are checked on context exit. + +def parse_image_dimensions(content: bytes) -> tuple[int, int] | None: + """ + Extract pixel dimensions from a PNG or JPEG header. + + Args: + content: Leading bytes of the image file. + + Returns: + ``(width, height)`` tuple, or None if the format is + unsupported or the header is malformed or truncated. + """ + if content.startswith(_PNG_SIGNATURE): + return _parse_png_dimensions(content) + if content.startswith(b"\xff\xd8"): + return _parse_jpeg_dimensions(content) + return None + + +class ResourceMonitor: + """ + Context manager that enforces a wall-clock limit. + + Elapsed time is a hard limit: call ``check`` inside long + loops so a runaway operation is aborted while it runs; + otherwise it is only checked on context exit. + + Memory is **best-effort telemetry, not a limit**. Accounting + uses the process peak RSS (``ru_maxrss``), a monotonic + high-water mark for the whole process, so it never decreases + and may attribute concurrent work to this validation. + Exceeding ``max_memory_mb`` is logged as a warning; set + ``enforce_memory`` to raise instead, and only do so when the + process handles one validation at a time. Real memory bounds + come from the byte limits in ``SecurityLimits``, which cap + every buffer the library allocates. Attributes: max_time_seconds: Maximum allowed wall-clock seconds. - max_memory_bytes: Maximum allowed peak-RSS growth in bytes. + max_memory_bytes: Peak-RSS growth budget in bytes. + enforce_memory: Whether exceeding the memory budget + raises instead of logging a warning. start_time: Timestamp when the context was entered. start_memory: Peak process RSS in bytes at context entry. """ @@ -130,16 +334,20 @@ def __init__( self, max_time_seconds: float = 30.0, max_memory_mb: int = 512, + enforce_memory: bool = False, ): """ Initialize the resource monitor. Args: max_time_seconds: Wall-clock timeout in seconds. - max_memory_mb: Maximum memory delta in megabytes. + max_memory_mb: Peak-RSS growth budget in megabytes. + enforce_memory: Raise when the memory budget is + exceeded instead of logging a warning. """ self.max_time_seconds = max_time_seconds self.max_memory_bytes = max_memory_mb * 1024 * 1024 + self.enforce_memory = enforce_memory self.start_time: float = 0.0 self.start_memory: int = 0 self._elapsed: float = 0.0 @@ -171,8 +379,9 @@ def __exit__( exc_tb: Exception traceback if raised inside block. Raises: - ResourceLimitError: If time or memory limits were - exceeded during the monitored block. + ResourceLimitError: If the wall-clock limit was + exceeded, or the memory budget was exceeded and + enforcement is on. """ if exc_type is not None: return @@ -200,17 +409,33 @@ def check_memory(self) -> None: """ Check peak memory growth mid-operation. - Enables early enforcement inside long-running loops - instead of waiting for context exit. Uses the process - peak RSS high-water mark, so it is a coarse upper bound. + Uses the process peak RSS high-water mark, so it is a + coarse upper bound. Only raises when ``enforce_memory`` + is set; otherwise an over-budget reading is logged. Raises: ResourceLimitError: If peak-RSS growth since context - entry exceeds the configured memory limit. + entry exceeds the budget and enforcement is on. """ delta = max(0, self._get_peak_rss_bytes() - self.start_memory) self._raise_if_memory_exceeded(delta) + def check(self) -> None: + """ + Check the resource budgets mid-operation. + + Memory is only sampled when ``enforce_memory`` is set, so + the common path costs a single clock read per call. + + Raises: + ResourceLimitError: If the wall-clock limit has been + exceeded, or the memory budget has been exceeded + and enforcement is on. + """ + self.check_time() + if self.enforce_memory: + self.check_memory() + def _raise_if_time_exceeded(self, elapsed: float) -> None: """ Raise if elapsed wall-clock time exceeds the limit. @@ -240,18 +465,27 @@ def _raise_if_time_exceeded(self, elapsed: float) -> None: def _raise_if_memory_exceeded(self, delta: int) -> None: """ - Raise if peak-RSS growth exceeds the limit. + Report peak-RSS growth beyond the budget. Args: delta: Peak-RSS growth in bytes since context entry. Raises: - ResourceLimitError: If the memory limit is exceeded. + ResourceLimitError: If the budget is exceeded and + ``enforce_memory`` is set. """ if delta <= self.max_memory_bytes: return delta_mb = bytes_to_mb(delta) max_mb = bytes_to_mb(self.max_memory_bytes) + if not self.enforce_memory: + logger.warning( + "Validation memory budget exceeded: %dMB > %dMB" + " (not enforced; peak RSS is process-wide)", + delta_mb, + max_mb, + ) + return logger.error( "Validation memory limit exceeded: %dMB > %dMB", delta_mb, diff --git a/safeuploads/validators/base.py b/safeuploads/validators/base.py index d429693..d41091d 100644 --- a/safeuploads/validators/base.py +++ b/safeuploads/validators/base.py @@ -1,9 +1,8 @@ -"""Base validator interface for file security checks.""" +"""Base validator providing shared configuration and audit state.""" from __future__ import annotations -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from ..audit import SecurityAuditLogger @@ -11,9 +10,14 @@ from ..config import FileSecurityConfig -class BaseValidator(ABC): +class BaseValidator: """ - Abstract base class for file security validators. + Base class for file security validators. + + Centralizes storage of the security configuration and the + shared audit logger. Concrete validators expose a + purpose-named entry point rather than a common ``validate`` + method, because their inputs are not interchangeable. Attributes: config: File security configuration parameters. @@ -30,16 +34,3 @@ def __init__(self, config: FileSecurityConfig): self._audit = SecurityAuditLogger( enabled=config.limits.enable_audit_logging ) - - @abstractmethod - def validate(self, *args: Any, **kwargs: Any) -> Any: - """ - Validate data using subclass-specific logic. - - Args: - *args: Positional arguments for concrete validator. - **kwargs: Keyword arguments for concrete validator. - - Returns: - Validated result defined by subclass. - """ diff --git a/safeuploads/validators/compression_validator.py b/safeuploads/validators/compression_validator.py index 7d66f28..ccdefa5 100644 --- a/safeuploads/validators/compression_validator.py +++ b/safeuploads/validators/compression_validator.py @@ -13,14 +13,16 @@ CompressionSecurityError, ErrorCode, FileProcessingError, + ResourceLimitError, ZipBombError, ) -from ..utils import bytes_to_mb +from ..utils import bytes_to_mb, safe_label from .base import BaseValidator if TYPE_CHECKING: from ..config import FileSecurityConfig from ..protocols import SeekableFile + from ..utils import ResourceMonitor logger = logging.getLogger(__name__) @@ -47,7 +49,10 @@ def __init__(self, config: FileSecurityConfig): ) def validate_zip_compression_ratio( - self, file_obj: SeekableFile, compressed_size: int + self, + file_obj: SeekableFile, + compressed_size: int, + monitor: ResourceMonitor | None = None, ) -> None: """ Validate ZIP archive against security limits. @@ -62,6 +67,8 @@ def validate_zip_compression_ratio( Args: file_obj: Seekable file-like object containing ZIP data. compressed_size: Size of the compressed archive in bytes. + monitor: Optional resource monitor checked once per + entry so a runaway archive is aborted mid-scan. Raises: ZipBombError: If compression ratio exceeds maximum allowed @@ -69,6 +76,8 @@ def validate_zip_compression_ratio( CompressionSecurityError: If ZIP structure is invalid, too many entries, nested archives detected, or individual file too large. + ResourceLimitError: If the monitor's time or memory + limit is exceeded during validation. FileProcessingError: If unexpected error occurs during validation such as memory errors or I/O errors. """ @@ -123,6 +132,9 @@ def validate_zip_compression_ratio( # Analyze each entry in the ZIP for entry in zip_entries: + if monitor is not None: + monitor.check() + # Check for timeout if ( time.monotonic() - start_time @@ -171,6 +183,7 @@ def validate_zip_compression_ratio( compression_ratio > self.config.limits.max_compression_ratio ): + entry_label = safe_label(entry.filename) logger.error( "Excessive compression ratio", extra=log_extra( @@ -178,7 +191,7 @@ def validate_zip_compression_ratio( "error_type": ( "compression_ratio_exceeded" ), - "file_name": entry.filename, + "file_name": entry_label, "compression_ratio": ( compression_ratio ), @@ -190,6 +203,8 @@ def validate_zip_compression_ratio( ) cid = get_correlation_id() if cid: + # Raw name: the audit logger escapes + # on emission. self._audit.threat( entry.filename, cid, @@ -203,7 +218,7 @@ def validate_zip_compression_ratio( "Excessive compression" " ratio detected:" f" {compression_ratio:.1f}:1" - f" for '{entry.filename}'." + f" for '{entry_label}'." " Maximum allowed:" f" {max_ratio}:1" ), @@ -219,7 +234,7 @@ def validate_zip_compression_ratio( filename_lower.endswith(ext) for ext in self._nested_archive_exts ): - nested_archives.append(entry.filename) + nested_archives.append(safe_label(entry.filename)) # Check for excessively large individual files # Use the configurable max_individual_file_size limit @@ -227,12 +242,13 @@ def validate_zip_compression_ratio( uncompressed_size > self.config.limits.max_individual_file_size ): + entry_label = safe_label(entry.filename) logger.warning( "Individual file too large", extra=log_extra( { "error_type": "file_too_large", - "file_name": entry.filename, + "file_name": entry_label, "size_mb": bytes_to_mb(uncompressed_size), "max_size_mb": bytes_to_mb( self.config.limits.max_individual_file_size @@ -246,7 +262,7 @@ def validate_zip_compression_ratio( raise CompressionSecurityError( message=( "Individual file too" - f" large: '{entry.filename}'" + f" large: '{entry_label}'" " would expand to" f" {bytes_to_mb(uncompressed_size)}MB." " Maximum allowed:" @@ -354,33 +370,12 @@ def validate_zip_compression_ratio( error_code=ErrorCode.ZIP_NESTED_ARCHIVE, ) - # Cumulative entry count check for - # complexity attack prevention - max_recursive = self.config.limits.max_total_entries_recursive - if file_count > max_recursive: - logger.error( - "ZIP entry count exceeds recursive limit", - extra=log_extra( - { - "file_count": file_count, - "max_recursive": max_recursive, - } - ), - ) - raise CompressionSecurityError( - message=( - "ZIP entry count" - f" ({file_count})" - " exceeds recursive limit" - f" ({max_recursive})" - ), - error_code=(ErrorCode.ZIP_COMPLEXITY_ATTACK), - ) - # Optional: read every entry through zipfile to # confirm the declared metadata is not forged. if self.config.limits.verify_zip_decompression: - self._verify_entries_decompress(zip_file, zip_entries) + self._verify_entries_decompress( + zip_file, zip_entries, monitor + ) # Log analysis results logger.debug( @@ -418,6 +413,10 @@ def validate_zip_compression_ratio( except (ZipBombError, CompressionSecurityError): # Re-raise our own exceptions raise + except ResourceLimitError: + # A breached time/memory budget must abort the request, + # not be reported as an internal processing failure. + raise except Exception as err: logger.error( "Unexpected error during ZIP compression validation", @@ -431,6 +430,7 @@ def _verify_entries_decompress( self, zip_file: zipfile.ZipFile, zip_entries: list[zipfile.ZipInfo], + monitor: ResourceMonitor | None = None, ) -> None: """ Decompress every entry to detect forged metadata. @@ -446,6 +446,12 @@ def _verify_entries_decompress( Args: zip_file: Open ZIP archive to verify. zip_entries: Entries listed in the archive. + monitor: Optional resource monitor checked once per + chunk so a slow archive is aborted mid-read. + + Raises: + ResourceLimitError: If the monitor's time or memory + limit is exceeded during verification. """ chunk_size = self.config.limits.chunk_size for entry in zip_entries: @@ -453,20 +459,5 @@ def _verify_entries_decompress( continue with zip_file.open(entry, "r") as stream: while stream.read(chunk_size): - pass - - def validate(self, file_obj: SeekableFile, compressed_size: int) -> None: - """ - Validate the compression ratio of a ZIP file. - - Args: - file_obj: Seekable file-like object of the ZIP. - compressed_size: Size of the file after compression - in bytes. - - Raises: - ZipBombError: If compression ratio exceeds maximum. - CompressionSecurityError: If ZIP structure is invalid. - FileProcessingError: If unexpected error occurs. - """ - return self.validate_zip_compression_ratio(file_obj, compressed_size) + if monitor is not None: + monitor.check() diff --git a/safeuploads/validators/extension_validator.py b/safeuploads/validators/extension_validator.py index d45af71..3c6253d 100644 --- a/safeuploads/validators/extension_validator.py +++ b/safeuploads/validators/extension_validator.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from ..exceptions import ErrorCode, ExtensionSecurityError +from ..utils import safe_label from .base import BaseValidator if TYPE_CHECKING: @@ -58,7 +59,7 @@ def validate_extensions(self, filename: str) -> None: "Dangerous compound extension detected", extra={ "error_type": "compound_extension_blocked", - "file_name": filename, + "file_name": safe_label(filename), "extension": compound_ext, }, ) @@ -84,7 +85,7 @@ def validate_extensions(self, filename: str) -> None: "Dangerous extension detected", extra={ "error_type": "extension_blocked", - "file_name": filename, + "file_name": safe_label(filename), "extension": ext, }, ) @@ -101,16 +102,3 @@ def validate_extensions(self, filename: str) -> None: ) logger.debug("Extension validation passed") - - def validate(self, filename: str) -> None: - """ - Validate the given filename. - - Args: - filename: Name of the file to validate. - - Raises: - ExtensionSecurityError: If filename extension is not - permitted. - """ - return self.validate_extensions(filename) diff --git a/safeuploads/validators/unicode_validator.py b/safeuploads/validators/unicode_validator.py index 7ab2f39..6f099d0 100644 --- a/safeuploads/validators/unicode_validator.py +++ b/safeuploads/validators/unicode_validator.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING from ..exceptions import ErrorCode, UnicodeSecurityError +from ..utils import safe_label from .base import BaseValidator if TYPE_CHECKING: @@ -69,15 +70,18 @@ def validate_unicode_security(self, filename: str) -> str: char_details = [] for char, code, pos in dangerous_chars_found: char_name = unicodedata.name(char, f"U+{code:04X}") + # The character itself is deliberately not echoed: + # these are exactly the invisible and directional + # code points used to spoof text. char_details.append( - f"'{char}' (U+{code:04X}: {char_name}) at position {pos}" + f"U+{code:04X} ({char_name}) at position {pos}" ) logger.warning( "Dangerous Unicode characters detected", extra={ "error_type": "unicode_security", - "file_name": filename, + "file_name": safe_label(filename), "char_codes": [ code for _, code, _ in dangerous_chars_found ], @@ -109,8 +113,8 @@ def validate_unicode_security(self, filename: str) -> str: if normalized_filename != filename: logger.info( "Unicode normalization applied: '%s' -> '%s'", - filename, - normalized_filename, + safe_label(filename), + safe_label(normalized_filename), ) # Additional check: ensure normalized filename @@ -125,8 +129,8 @@ def validate_unicode_security(self, filename: str) -> str: "Unicode normalization resulted in dangerous character", extra={ "error_type": "unicode_normalization_error", - "file_name": filename, - "normalized_filename": normalized_filename, + "file_name": safe_label(filename), + "normalized_filename": safe_label(normalized_filename), "char_code": char_code, }, ) @@ -134,9 +138,8 @@ def validate_unicode_security(self, filename: str) -> str: message=( "Unicode normalization resulted" " in dangerous character:" - f" '{char}'" - f" (U+{char_code:04X}:" - f" {char_name})" + f" U+{char_code:04X}" + f" ({char_name})" ), filename=filename, dangerous_chars=[(char, char_code, 0)], @@ -145,15 +148,3 @@ def validate_unicode_security(self, filename: str) -> str: logger.debug("Unicode validation passed") return normalized_filename - - def validate(self, filename: str) -> str: - """ - Validate a filename for Unicode security issues. - - Args: - filename: The name of the file to assess. - - Returns: - The validated and normalized filename. - """ - return self.validate_unicode_security(filename) diff --git a/safeuploads/validators/windows_validator.py b/safeuploads/validators/windows_validator.py index ab20ee2..3c56963 100644 --- a/safeuploads/validators/windows_validator.py +++ b/safeuploads/validators/windows_validator.py @@ -4,15 +4,11 @@ import logging import os -from typing import TYPE_CHECKING from ..exceptions import WindowsReservedNameError +from ..utils import safe_label from .base import BaseValidator -if TYPE_CHECKING: - from ..config import FileSecurityConfig - - logger = logging.getLogger(__name__) @@ -24,15 +20,6 @@ class WindowsSecurityValidator(BaseValidator): config: File security configuration settings. """ - def __init__(self, config: FileSecurityConfig): - """ - Initialize the validator. - - Args: - config: File security configuration settings. - """ - super().__init__(config) - def validate_windows_reserved_names(self, filename: str) -> None: """ Validate filename against Windows reserved device names. @@ -67,13 +54,13 @@ def validate_windows_reserved_names(self, filename: str) -> None: "Windows reserved name detected", extra={ "error_type": "windows_reserved_name", - "file_name": filename, + "file_name": safe_label(filename), "reserved_name": name_to_check.upper(), }, ) raise WindowsReservedNameError( message=( - f"Filename '{filename}' uses" + f"Filename '{safe_label(filename)}' uses" f" Windows reserved name" f" '{name_to_check.upper()}'." f" Reserved names:" @@ -94,16 +81,3 @@ def validate_windows_reserved_names(self, filename: str) -> None: current_name = name_without_ext logger.debug("No Windows reserved name detected") - - def validate(self, filename: str) -> None: - """ - Validate filename against Windows reserved naming rules. - - Args: - filename: The filename to validate. - - Raises: - WindowsReservedNameError: If filename matches a Windows - reserved device name. - """ - return self.validate_windows_reserved_names(filename) diff --git a/safeuploads/validators/xml_validator.py b/safeuploads/validators/xml_validator.py index 1f38787..1990000 100644 --- a/safeuploads/validators/xml_validator.py +++ b/safeuploads/validators/xml_validator.py @@ -13,11 +13,11 @@ ExternalReferenceForbidden, ) -from ..exceptions import FileProcessingError +from ..exceptions import ErrorCode, FileProcessingError +from ..utils import safe_label from .base import BaseValidator if TYPE_CHECKING: - from ..config import FileSecurityConfig from ..protocols import SeekableFile @@ -30,73 +30,161 @@ class XmlSecurityValidator(BaseValidator): Uses ``defusedxml`` to parse XML safely. Rejects files containing DTD declarations, external entities, or - excessive entity expansion. + excessive entity expansion. Parsing is incremental and the + element count is capped, so a flat document with millions of + elements cannot amplify a bounded upload into an unbounded + object graph. Attributes: config: Security configuration for validation limits. """ - def __init__(self, config: FileSecurityConfig): - """ - Initialize the XML security validator. - - Args: - config: Security configuration with file limits. - """ - super().__init__(config) - - def validate_xml_safety(self, file_obj: SeekableFile) -> None: + def validate_xml_safety( + self, + file_obj: SeekableFile, + expected_root: str | None = None, + ) -> None: """ Parse XML with XXE protections and validate structure. Args: file_obj: Seekable file containing XML data. + expected_root: Required root element name, lower-cased + and namespace-free. Any root configured in + ``ACTIVITY_XML_ROOTS`` is accepted when omitted. Raises: FileProcessingError: If the XML is malformed, contains - XXE attacks, or fails safety checks. + XXE attacks, declares an unexpected root element, + or exceeds the element cap. """ file_obj.seek(0) logger.debug("Parsing activity XML with XXE protections") try: - # defusedxml blocks external entities and - # entity expansion by default. - # forbid_dtd=True rejects ALL DTD declarations. - DefusedET.parse(file_obj, forbid_dtd=True) + root_tag = self._parse_bounded(file_obj) + except FileProcessingError: + raise except DTDForbidden as err: logger.warning("XML contains forbidden DTD declaration") raise FileProcessingError( - "XML contains forbidden DTD declaration" + "XML contains forbidden DTD declaration", + error_code=ErrorCode.XML_FORBIDDEN_CONSTRUCT, ) from err except EntitiesForbidden as err: logger.warning("XML contains forbidden entity reference") raise FileProcessingError( - "XML contains forbidden external entity" + "XML contains forbidden external entity", + error_code=ErrorCode.XML_FORBIDDEN_CONSTRUCT, ) from err except ExternalReferenceForbidden as err: logger.warning("XML contains forbidden external reference") raise FileProcessingError( - "XML contains forbidden external reference" + "XML contains forbidden external reference", + error_code=ErrorCode.XML_FORBIDDEN_CONSTRUCT, ) from err except ParseError as err: logger.warning("Malformed XML: %s", err) - raise FileProcessingError("Malformed XML content") from err + raise FileProcessingError( + "Malformed XML content", + error_code=ErrorCode.XML_MALFORMED, + ) from err except Exception as err: logger.warning("XML validation failed: %s", err) raise FileProcessingError("XML validation failed") from err + self._enforce_root(root_tag, expected_root) + logger.debug("XML safety validation passed") file_obj.seek(0) - def validate(self, file_obj: SeekableFile) -> None: + def _parse_bounded(self, file_obj: SeekableFile) -> str | None: """ - Validate XML file for security threats. + Parse incrementally, capping the element count. + + Completed elements are discarded as they close so peak + memory stays flat regardless of document length. Args: file_obj: Seekable file containing XML data. + Returns: + The root element tag, or None for an empty document. + Raises: - FileProcessingError: If the XML fails safety checks. + FileProcessingError: If the element cap is exceeded. """ - return self.validate_xml_safety(file_obj) + max_elements = self.config.limits.max_xml_elements + root = None + count = 0 + + # forbid_dtd=True rejects ALL DTD declarations; external + # entities and entity expansion are blocked by default. + events = DefusedET.iterparse( + file_obj, events=("start", "end"), forbid_dtd=True + ) + for event, element in events: + if event == "start": + count += 1 + if root is None: + root = element + if count > max_elements: + logger.warning( + "XML element count exceeded: %d > %d", + count, + max_elements, + ) + raise FileProcessingError( + ( + "XML contains too many elements." + f" Maximum allowed: {max_elements}" + ), + error_code=ErrorCode.XML_TOO_MANY_ELEMENTS, + ) + elif element is not root and root is not None: + element.clear() + root.clear() + + return None if root is None else str(root.tag) + + def _enforce_root( + self, root_tag: str | None, expected_root: str | None + ) -> None: + """ + Check the document root against the allowed names. + + Args: + root_tag: Root element tag, possibly namespace-qualified. + expected_root: Required root name, or None to accept any + root configured in ``ACTIVITY_XML_ROOTS``. + + Raises: + FileProcessingError: If the root element is missing or + not permitted. + """ + allowed = ( + {expected_root} + if expected_root is not None + else set(self.config.ACTIVITY_XML_ROOTS.values()) + ) + + if root_tag is None: + raise FileProcessingError( + "XML document has no root element", + error_code=ErrorCode.XML_INVALID_ROOT, + ) + + # ElementTree reports namespaced tags as ``{uri}local``. + local_name = root_tag.rpartition("}")[2].lower() + if local_name not in allowed: + logger.warning( + "Unexpected XML root element: %s", + safe_label(local_name, max_length=64), + ) + raise FileProcessingError( + ( + "Unexpected XML root element." + f" Expected: {', '.join(sorted(allowed))}" + ), + error_code=ErrorCode.XML_INVALID_ROOT, + ) diff --git a/tests/conftest.py b/tests/conftest.py index 995bf82..56d687f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,19 @@ from safeuploads.config import FileSecurityConfig, SecurityLimits +# Baseline SOF0 frame header declaring a 16x16 image. Real JPEGs +# always carry a start-of-frame segment; without one the decoded +# dimensions are unknown and validation rejects the file. +JPEG_SOF0 = ( + b"\xff\xc0" # SOF0 marker + b"\x00\x11" # Segment length (17) + b"\x08" # Sample precision + b"\x00\x10" # Height + b"\x00\x10" # Width + b"\x03" # Component count + b"\x01\x11\x00\x02\x11\x01\x03\x11\x01" # Component specs +) + @pytest.fixture def default_config() -> FileSecurityConfig: @@ -56,7 +69,7 @@ def __init__( ): self.filename = filename self.content = content - self.size = size or len(content) + self.size = size if size is not None else len(content) self._position = 0 async def read(self, size: int = -1) -> bytes: @@ -101,7 +114,8 @@ def valid_jpeg_bytes() -> bytes: Returns: Minimal valid JPEG file content. """ - # Minimal valid JPEG: SOI marker + APP0 segment + EOI marker + # Minimal valid JPEG: SOI marker + APP0 segment + SOF0 frame + # header + EOI marker return ( b"\xff\xd8\xff\xe0" # JPEG SOI + APP0 b"\x00\x10" # APP0 length @@ -110,7 +124,8 @@ def valid_jpeg_bytes() -> bytes: b"\x00" # Density units b"\x00\x01\x00\x01" # X and Y density b"\x00\x00" # Thumbnail dimensions - b"\xff\xd9" # JPEG EOI + + JPEG_SOF0 + + b"\xff\xd9" # JPEG EOI ) diff --git a/tests/corpus/__init__.py b/tests/corpus/__init__.py new file mode 100644 index 0000000..3875fa3 --- /dev/null +++ b/tests/corpus/__init__.py @@ -0,0 +1 @@ +"""Regression corpus of known upload attacks.""" diff --git a/tests/corpus/test_attack_corpus.py b/tests/corpus/test_attack_corpus.py new file mode 100644 index 0000000..9377fda --- /dev/null +++ b/tests/corpus/test_attack_corpus.py @@ -0,0 +1,400 @@ +""" +Traceability corpus: every attack the threat model claims to stop. + +Each sample is a named, deterministically constructed file rather +than a checked-in binary, so the repository carries no payload that +an antivirus scanner would quarantine. Samples are structural — a +traversal entry name, a compression ratio, an XML entity — not +functional malware. + +Adding a threat to ``docs/security/threat-model.md`` without adding +it here should be treated as an incomplete change. +""" + +import gzip +import io +import os +import zipfile +from dataclasses import dataclass + +import pytest + +from safeuploads import ( + CompressionSecurityError, + ErrorCode, + ExtensionSecurityError, + FileProcessingError, + FileSecurityConfig, + FileSizeError, + FileValidator, + ImageSecurityError, + MimeTypeError, + SecurityLimits, + UnicodeSecurityError, + WindowsReservedNameError, + ZipBombError, + ZipContentError, +) +from tests.conftest import JPEG_SOF0 + +# ---------------------------------------------------------------- +# Builders +# ---------------------------------------------------------------- + + +def _png(width: int, height: int) -> bytes: + """Build a PNG header declaring the given dimensions.""" + return ( + b"\x89PNG\r\n\x1a\n" + + b"\x00\x00\x00\x0d" + + b"IHDR" + + width.to_bytes(4, "big") + + height.to_bytes(4, "big") + + b"\x08\x02\x00\x00\x00" + + b"\x00\x00\x00\x00" + ) + + +def _jpeg(extra: bytes = b"") -> bytes: + """Build a structurally valid JPEG with optional trailing bytes.""" + return b"\xff\xd8" + JPEG_SOF0 + extra + b"\xff\xd9" + + +def _zip( + files: dict[str, bytes], + compression: int = zipfile.ZIP_STORED, +) -> bytes: + """Build a ZIP archive from a name-to-content mapping.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", compression=compression) as zf: + for name, data in files.items(): + zf.writestr(name, data) + return buffer.getvalue() + + +def _zip_with_symlink() -> bytes: + """Build a ZIP whose single entry is a symbolic link.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as zf: + info = zipfile.ZipInfo("link") + info.external_attr = 0o120777 << 16 + zf.writestr(info, "/etc/passwd") + return buffer.getvalue() + + +def _gpx(body: bytes = b"") -> bytes: + """Build a minimal GPX document.""" + return b'' + body + b"" + + +def _assert_cannot_escape(name: str) -> None: + """Assert a sanitized name stays inside its upload directory.""" + assert "/" not in name + assert "\\" not in name + assert os.path.basename(name) == name + + resolved = os.path.normpath(os.path.join("/srv/uploads", name)) + assert resolved.startswith("/srv/uploads/") + + +# ---------------------------------------------------------------- +# Corpus +# ---------------------------------------------------------------- + + +@dataclass(frozen=True) +class AttackSample: + """ + A single attack and the rejection it must produce. + + Attributes: + name: Threat name, matching the threat model. + filename: Name the file is uploaded under. + content: Raw bytes of the upload. + method: ``FileValidator`` method under test. + expected: Exception type the upload must raise. + error_code: Machine-readable code the error must carry. + limits: Optional limits needed to exercise the attack. + """ + + name: str + filename: str + content: bytes + method: str + expected: type[Exception] + error_code: str | None = None + limits: SecurityLimits | None = None + + +_CONTENT_ANALYSIS = SecurityLimits(enable_content_analysis=True) + +REJECTED: tuple[AttackSample, ...] = ( + # --- Filename attacks --- + AttackSample( + name="rtl-override-extension-spoof", + filename="photo\u202egpj.exe", + content=_jpeg(), + method="validate_image_file", + expected=UnicodeSecurityError, + error_code=ErrorCode.UNICODE_DANGEROUS_CHARS, + ), + AttackSample( + name="zero-width-joiner-filename", + filename="photo\u200d.jpg", + content=_jpeg(), + method="validate_image_file", + expected=UnicodeSecurityError, + error_code=ErrorCode.UNICODE_DANGEROUS_CHARS, + ), + AttackSample( + name="windows-reserved-device-name", + filename="CON.jpg", + content=_jpeg(), + method="validate_image_file", + expected=WindowsReservedNameError, + error_code=ErrorCode.WINDOWS_RESERVED_NAME, + ), + AttackSample( + name="double-extension-php", + filename="photo.php.jpg", + content=_jpeg(), + method="validate_image_file", + expected=ExtensionSecurityError, + error_code=ErrorCode.EXTENSION_BLOCKED, + ), + AttackSample( + name="disallowed-extension", + filename="photo.txt", + content=_jpeg(), + method="validate_image_file", + expected=ExtensionSecurityError, + error_code=ErrorCode.EXTENSION_NOT_ALLOWED, + ), + # --- Content / type confusion --- + AttackSample( + name="gif-masquerading-as-jpeg", + filename="photo.jpg", + content=b"GIF89a" + b"\x00" * 64, + method="validate_image_file", + expected=MimeTypeError, + ), + AttackSample( + name="empty-upload", + filename="photo.jpg", + content=b"", + method="validate_image_file", + expected=FileSizeError, + error_code=ErrorCode.FILE_EMPTY, + ), + AttackSample( + name="oversized-upload", + filename="photo.jpg", + content=_jpeg(b"\x00" * 4096), + method="validate_image_file", + expected=FileSizeError, + error_code=ErrorCode.FILE_TOO_LARGE, + limits=SecurityLimits(max_image_size=1024), + ), + # --- Image decompression bombs --- + AttackSample( + name="png-pixel-bomb", + filename="bomb.png", + content=_png(30000, 30000), + method="validate_image_file", + expected=ImageSecurityError, + error_code=ErrorCode.IMAGE_DIMENSIONS_EXCEEDED, + ), + AttackSample( + name="jpeg-without-frame-header", + filename="headerless.jpg", + content=b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00" + b"\x00\x01\x00\x01\x00\x00\xff\xd9", + method="validate_image_file", + expected=ImageSecurityError, + error_code=ErrorCode.IMAGE_DIMENSIONS_UNREADABLE, + ), + # --- Polyglot and embedded executables --- + AttackSample( + name="gifar-polyglot", + filename="poly.jpg", + content=_jpeg(b"\x00" * 64 + b"PK\x03\x04"), + method="validate_image_file", + expected=FileProcessingError, + limits=_CONTENT_ANALYSIS, + ), + AttackSample( + name="pe-header-embedded-in-image", + filename="dropper.jpg", + content=_jpeg(b"\x00" * 64 + b"MZ\x90\x00"), + method="validate_image_file", + expected=FileProcessingError, + limits=_CONTENT_ANALYSIS, + ), + # --- Compression attacks --- + AttackSample( + name="zip-bomb-compression-ratio", + filename="bomb.zip", + content=_zip({"big.txt": b"\x00" * 2_000_000}, zipfile.ZIP_DEFLATED), + method="validate_zip_file", + expected=ZipBombError, + error_code=ErrorCode.COMPRESSION_RATIO_EXCEEDED, + ), + AttackSample( + name="zip-slip-traversal-entry", + filename="slip.zip", + content=_zip({"../../etc/passwd": b"root:x:0:0"}), + method="validate_zip_file", + expected=ZipContentError, + error_code=ErrorCode.ZIP_CONTENT_THREAT, + ), + AttackSample( + name="zip-absolute-path-entry", + filename="abs.zip", + content=_zip({"/etc/shadow": b"secret"}), + method="validate_zip_file", + expected=ZipContentError, + error_code=ErrorCode.ZIP_CONTENT_THREAT, + ), + AttackSample( + name="zip-symlink-entry", + filename="link.zip", + content=_zip_with_symlink(), + method="validate_zip_file", + expected=ZipContentError, + error_code=ErrorCode.ZIP_CONTENT_THREAT, + ), + AttackSample( + name="zip-webshell-entry-extension", + filename="shell.zip", + content=_zip({"invoice.php.txt": b"harmless looking text"}), + method="validate_zip_file", + expected=ZipContentError, + error_code=ErrorCode.ZIP_CONTENT_THREAT, + ), + AttackSample( + name="zip-nested-archive", + filename="nested.zip", + content=_zip({"inner.zip": _zip({"a.txt": b"a"})}), + method="validate_zip_file", + expected=CompressionSecurityError, + error_code=ErrorCode.ZIP_NESTED_ARCHIVE, + ), + AttackSample( + name="gzip-decompression-bomb", + filename="bomb.gz", + content=gzip.compress(b"\x00" * 2_000_000), + method="validate_gzip_file", + expected=ZipBombError, + error_code=ErrorCode.COMPRESSION_RATIO_EXCEEDED, + ), + # --- XML attacks --- + AttackSample( + name="xxe-external-entity", + filename="track.gpx", + content=b'' + b']>' + b"&xxe;", + method="validate_activity_file", + expected=FileProcessingError, + error_code=ErrorCode.XML_FORBIDDEN_CONSTRUCT, + ), + AttackSample( + name="billion-laughs", + filename="track.gpx", + content=b'' + b"' + b'' + b"]>" + b"&lol2;", + method="validate_activity_file", + expected=FileProcessingError, + error_code=ErrorCode.XML_FORBIDDEN_CONSTRUCT, + ), + AttackSample( + name="html-payload-behind-gpx-extension", + filename="track.gpx", + content=b'' + b"", + method="validate_activity_file", + expected=FileProcessingError, + error_code=ErrorCode.XML_INVALID_ROOT, + ), + AttackSample( + name="xml-element-flood", + filename="track.gpx", + content=_gpx(b"" * 200), + method="validate_activity_file", + expected=FileProcessingError, + error_code=ErrorCode.XML_TOO_MANY_ELEMENTS, + limits=SecurityLimits(max_xml_elements=10), + ), +) + + +@pytest.mark.corpus +class TestAttackCorpus: + """Every catalogued attack must be rejected.""" + + @pytest.mark.parametrize( + "sample", REJECTED, ids=[s.name for s in REJECTED] + ) + @pytest.mark.asyncio + async def test_attack_is_rejected(self, sample, mock_upload_file): + config = ( + FileSecurityConfig(sample.limits) + if sample.limits is not None + else FileSecurityConfig() + ) + validator = FileValidator(config=config) + file = mock_upload_file( + filename=sample.filename, content=sample.content + ) + + with pytest.raises(sample.expected) as exc_info: + await getattr(validator, sample.method)(file) + + if sample.error_code is not None: + assert exc_info.value.error_code == sample.error_code + + def test_every_sample_is_uniquely_named(self): + names = [s.name for s in REJECTED] + assert len(names) == len(set(names)) + + +@pytest.mark.corpus +class TestNeutralisedAttacks: + """Attacks defused by sanitization rather than rejection.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "filename", + [ + "../../../etc/passwd.jpg", + "..\\..\\windows\\system32\\evil.jpg", + "/absolute/path/photo.jpg", + "....//....//photo.jpg", + ], + ) + async def test_sanitized_name_cannot_escape_a_directory( + self, filename, mock_upload_file + ): + validator = FileValidator() + file = mock_upload_file(filename=filename, content=_jpeg()) + + await validator.validate_image_file(file) + + # Leading dots may survive as literal characters; with no + # separator left they cannot traverse. + _assert_cannot_escape(file.filename) + + @pytest.mark.asyncio + async def test_control_characters_are_stripped(self, mock_upload_file): + validator = FileValidator() + file = mock_upload_file( + filename="photo\x00\x07\x1b.jpg", content=_jpeg() + ) + + await validator.validate_image_file(file) + + assert all(ord(c) >= 32 for c in file.filename) diff --git a/tests/fuzz/test_fuzz_images.py b/tests/fuzz/test_fuzz_images.py index c479162..0afda9c 100644 --- a/tests/fuzz/test_fuzz_images.py +++ b/tests/fuzz/test_fuzz_images.py @@ -11,6 +11,7 @@ FileValidationError, ) from safeuploads.file_validator import FileValidator +from tests.conftest import JPEG_SOF0 class _MockFile: @@ -39,6 +40,7 @@ async def seek(self, offset): # JPEG header prefix _JPEG_HDR = ( b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" + + JPEG_SOF0 ) # PNG header prefix diff --git a/tests/inspectors/test_content_inspector.py b/tests/inspectors/test_content_inspector.py index 5e916c8..5b45532 100644 --- a/tests/inspectors/test_content_inspector.py +++ b/tests/inspectors/test_content_inspector.py @@ -8,6 +8,7 @@ from safeuploads.inspectors.content_inspector import ( ContentSecurityInspector, ) +from tests.conftest import JPEG_SOF0 def _sig(category: MalwareSignatureCategory) -> bytes: @@ -149,7 +150,12 @@ async def test_image_with_embedded_sig_rejected(self, mock_upload_file): b"JFIF\x00" b"\x01\x01\x00" b"\x00\x01\x00\x01" - b"\x00\x00" + b"\x00" * 50 + sig + b"\x00" * 50 + b"\xff\xd9" + b"\x00\x00" + + JPEG_SOF0 + + b"\x00" * 50 + + sig + + b"\x00" * 50 + + b"\xff\xd9" ) file = mock_upload_file(filename="bad.jpg", content=content) with pytest.raises(FileProcessingError) as exc: @@ -188,7 +194,12 @@ async def test_disabled_skips_scan(self, mock_upload_file): b"JFIF\x00" b"\x01\x01\x00" b"\x00\x01\x00\x01" - b"\x00\x00" + b"\x00" * 50 + sig + b"\x00" * 50 + b"\xff\xd9" + b"\x00\x00" + + JPEG_SOF0 + + b"\x00" * 50 + + sig + + b"\x00" * 50 + + b"\xff\xd9" ) file = mock_upload_file(filename="img.jpg", content=content) # Should pass — analysis disabled diff --git a/tests/inspectors/test_gzip_inspector.py b/tests/inspectors/test_gzip_inspector.py index b8051ab..f94ca77 100644 --- a/tests/inspectors/test_gzip_inspector.py +++ b/tests/inspectors/test_gzip_inspector.py @@ -5,14 +5,59 @@ import pytest +from safeuploads.audit import reset_correlation_id, set_correlation_id from safeuploads.config import FileSecurityConfig, SecurityLimits from safeuploads.exceptions import ( CompressionSecurityError, ErrorCode, FileProcessingError, + ResourceLimitError, ZipBombError, ) from safeuploads.inspectors.gzip_inspector import GzipContentInspector +from safeuploads.utils import ResourceMonitor + + +class TestGzipInspectionResourceLimits: + """A spent time budget aborts inflation mid-stream.""" + + def test_chunk_loop_aborts_on_time_limit(self, default_config): + """Test the per-chunk check surfaces ResourceLimitError.""" + inspector = GzipContentInspector(default_config) + payload = gzip.compress(b"x" * 1024) + + with ( + pytest.raises(ResourceLimitError), + ResourceMonitor(max_time_seconds=0.0) as monitor, + ): + inspector.inspect_gzip_content( + io.BytesIO(payload), len(payload), monitor + ) + + def test_inflation_timeout_without_monitor(self): + """Test the inspector bounds inflation on its own.""" + # A configuration-valid timeout, kept tiny; the 1-byte + # chunk size guarantees enough loop iterations to pass it. + config = FileSecurityConfig( + SecurityLimits( + gzip_analysis_timeout=0.001, + chunk_size=1, + enable_audit_logging=True, + ) + ) + inspector = GzipContentInspector(config) + payload = gzip.compress(b"x" * 65536) + + set_correlation_id("test-correlation-id") + try: + with pytest.raises(ZipBombError, match="timeout") as exc_info: + inspector.inspect_gzip_content( + io.BytesIO(payload), len(payload) + ) + assert exc_info.value.error_code == ErrorCode.ZIP_ANALYSIS_TIMEOUT + assert exc_info.value.compression_ratio is None + finally: + reset_correlation_id() class TestGzipContentInspector: diff --git a/tests/inspectors/test_zip_inspector.py b/tests/inspectors/test_zip_inspector.py index 3d0f87a..a470879 100644 --- a/tests/inspectors/test_zip_inspector.py +++ b/tests/inspectors/test_zip_inspector.py @@ -9,9 +9,167 @@ from safeuploads.exceptions import ( ErrorCode, FileProcessingError, + ResourceLimitError, ZipContentError, ) from safeuploads.inspectors.zip_inspector import ZipContentInspector +from safeuploads.utils import ResourceMonitor + + +class TestDangerousEntryExtensions: + """Entry names are checked against the dangerous categories.""" + + @pytest.mark.parametrize( + "entry_name", + [ + "payload.exe", + "shell.php", + "hook.ps1", + ], + ) + def test_dangerous_entry_rejected(self, default_config, entry_name): + """Test executable and script entries are rejected.""" + inspector = ZipContentInspector(default_config) + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr(entry_name, b"harmless looking bytes") + + with pytest.raises(ZipContentError) as exc_info: + inspector.inspect_zip_content(io.BytesIO(zip_buffer.getvalue())) + + assert "Dangerous entry extension" in str(exc_info.value) + + @pytest.mark.parametrize( + "entry_name", + [ + "inject.dll", + "settings.ini", + ], + ) + def test_system_file_entry_allowed_by_default( + self, default_config, entry_name + ): + """Test SYSTEM_FILES is off by default.""" + inspector = ZipContentInspector(default_config) + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr(entry_name, b"harmless looking bytes") + + inspector.inspect_zip_content(io.BytesIO(zip_buffer.getvalue())) + + def test_system_file_entry_rejected_when_opted_in(self): + """Test adding SYSTEM_FILES restores the stricter check.""" + config = FileSecurityConfig( + SecurityLimits( + blocked_zip_entry_categories=frozenset( + {"EXECUTABLE_FILES", "SCRIPT_FILES", "SYSTEM_FILES"} + ) + ) + ) + inspector = ZipContentInspector(config) + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr("settings.ini", b"harmless looking bytes") + + with pytest.raises(ZipContentError) as exc_info: + inspector.inspect_zip_content(io.BytesIO(zip_buffer.getvalue())) + + assert "SYSTEM_FILES" in str(exc_info.value) + + def test_check_disabled_when_no_categories_blocked(self): + """Test an empty category set skips the check entirely.""" + config = FileSecurityConfig( + SecurityLimits(blocked_zip_entry_categories=frozenset()) + ) + inspector = ZipContentInspector(config) + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr("payload.exe", b"harmless looking bytes") + + inspector.inspect_zip_content(io.BytesIO(zip_buffer.getvalue())) + + def test_disguised_double_extension_rejected(self, default_config): + """Test a dangerous extension hidden mid-name is caught.""" + inspector = ZipContentInspector(default_config) + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr("invoice.php.txt", b"just some text") + + with pytest.raises(ZipContentError) as exc_info: + inspector.inspect_zip_content(io.BytesIO(zip_buffer.getvalue())) + + assert ".php" in str(exc_info.value) + + def test_dangerous_entry_rejected_without_content_scan(self): + """Test the check is metadata-level, not content-gated.""" + config = FileSecurityConfig() + config.limits = SecurityLimits(scan_zip_content=False) + inspector = ZipContentInspector(config) + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr("payload.exe", b"harmless looking bytes") + + with pytest.raises(ZipContentError): + inspector.inspect_zip_content(io.BytesIO(zip_buffer.getvalue())) + + def test_benign_entry_accepted(self, default_config): + """Test ordinary document entries still pass.""" + inspector = ZipContentInspector(default_config) + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr("notes.txt", b"hello") + zf.writestr("track.gpx", b"") + + inspector.inspect_zip_content(io.BytesIO(zip_buffer.getvalue())) + + +class TestZipInspectionResourceLimits: + """A spent time budget aborts inspection mid-scan.""" + + def test_entry_loop_aborts_on_time_limit(self, default_config): + """Test the per-entry check surfaces ResourceLimitError.""" + inspector = ZipContentInspector(default_config) + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr("notes.txt", b"hello") + + with ( + pytest.raises(ResourceLimitError), + ResourceMonitor(max_time_seconds=0.0) as monitor, + ): + inspector.inspect_zip_content( + io.BytesIO(zip_buffer.getvalue()), monitor + ) + + def test_recursive_scan_aborts_on_time_limit(self): + """Test nested inspection surfaces ResourceLimitError.""" + config = FileSecurityConfig() + config.limits = SecurityLimits(allow_nested_archives=True) + inspector = ZipContentInspector(config) + + inner = io.BytesIO() + with zipfile.ZipFile(inner, "w") as zf: + zf.writestr("notes.txt", b"hello") + + outer = io.BytesIO() + with zipfile.ZipFile(outer, "w") as zf: + zf.writestr("nested.zip", inner.getvalue()) + + with ( + pytest.raises(ResourceLimitError), + ResourceMonitor(max_time_seconds=0.0) as monitor, + ): + inspector.inspect_nested_archives( + io.BytesIO(outer.getvalue()), monitor=monitor + ) class TestZipContentInspector: @@ -431,7 +589,7 @@ def test_skip_content_scan_when_disabled(self): zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, "w") as zf: - zf.writestr("file.bin", pe_content) + zf.writestr("file.dat", pe_content) # Should not raise when content scanning disabled inspector.inspect_zip_content(io.BytesIO(zip_buffer.getvalue())) @@ -678,7 +836,7 @@ def test_script_pattern_decode_error(self, default_config): with zipfile.ZipFile(zip_buffer, "w") as zf: # Pure binary content with no ASCII patterns # Use invalid UTF-8 sequences - zf.writestr("data.bin", b"\xff\xfe\xfd\xfc\xfb\xfa\xf9\xf8") + zf.writestr("data.dat", b"\xff\xfe\xfd\xfc\xfb\xfa\xf9\xf8") # Should handle decode errors gracefully without raising # The decode error is caught and logged but doesn't cause failure @@ -971,21 +1129,28 @@ def test_null_byte_in_entry_filename_detected( threats = inspector._inspect_zip_entry(bad_info, None) assert any("Null byte" in t for t in threats) - def test_script_pattern_decode_exception_silenced( + def test_script_pattern_scan_handles_undecodable_bytes( self, ): config = FileSecurityConfig() inspector = ZipContentInspector(config) - class _BadBytes: - def decode(self, *args, **kwargs): - raise RuntimeError("decode failed") - - # Passes a non-bytes object whose .decode() raises; - # covers the except Exception branch (lines 488-490) - result = inspector._contains_script_patterns(_BadBytes(), "file.txt") + # Invalid UTF-8 with no script markers must not match; + # the scan runs over raw bytes and never decodes. + result = inspector._contains_script_patterns( + b"\xff\xfe\xfd\xfc\xfb\xfa" + ) assert result is False + def test_script_pattern_scan_matches_in_binary_noise(self): + config = FileSecurityConfig() + inspector = ZipContentInspector(config) + + result = inspector._contains_script_patterns( + b"\xff\xfe= 1 + assert threat_records[0].audit_filename == "evil.zip" + + @pytest.mark.asyncio + async def test_gzip_threat_audit_event_names_the_file( + self, mock_upload_file, caplog + ): + """Test a gzip bomb audit event carries the filename.""" + import gzip + import io + + from safeuploads.config import ( + FileSecurityConfig, + SecurityLimits, + ) + from safeuploads.file_validator import FileValidator + + config = FileSecurityConfig( + SecurityLimits( + enable_audit_logging=True, + max_compression_ratio=2, + ) + ) + validator = FileValidator(config=config) + + buf = io.BytesIO() + with gzip.GzipFile(fileobj=buf, mode="wb") as gz: + gz.write(b"a" * 1_000_000) + + file = mock_upload_file(filename="bomb.gz", content=buf.getvalue()) + + with ( + caplog.at_level(logging.DEBUG, logger="safeuploads.audit"), + pytest.raises(ZipBombError), + ): + await validator.validate_gzip_file(file) + + threat_records = [ + r + for r in caplog.records + if r.name == "safeuploads.audit" and "threat_detected" in r.message + ] + assert len(threat_records) >= 1 + assert threat_records[0].audit_filename == "bomb.gz" @pytest.mark.asyncio async def test_zip_bomb_emits_audit_threat(self, mock_upload_file, caplog): @@ -470,3 +672,15 @@ def test_log_extra_with_no_base_dict(self): extra = log_extra() assert extra["correlation_id"] == "cid-abc" reset_correlation_id() + + def test_log_extra_escapes_correlation_id(self): + """Test log extras cannot carry raw line separators.""" + from safeuploads.audit import log_extra + + set_correlation_id("cid\nWARNING forged") + try: + extra = log_extra() + finally: + reset_correlation_id() + + assert extra["correlation_id"] == "cid\\u000aWARNING forged" diff --git a/tests/test_config.py b/tests/test_config.py index 71d0117..eaf43a6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -98,6 +98,14 @@ def test_allowed_image_extensions(self): assert ".jpeg" in config.ALLOWED_IMAGE_EXTENSIONS assert ".png" in config.ALLOWED_IMAGE_EXTENSIONS + def test_activity_xml_roots_are_immutable(self): + """Test the XML root mapping cannot be mutated.""" + config = FileSecurityConfig() + + assert config.ACTIVITY_XML_ROOTS[".gpx"] == "gpx" + with pytest.raises(TypeError): + config.ACTIVITY_XML_ROOTS[".svg"] = "svg" # type: ignore[index] + def test_allowed_zip_extensions(self): """Test allowed ZIP extensions.""" config = FileSecurityConfig() @@ -331,6 +339,23 @@ def test_limits_are_not_shared_between_instances(self): assert a.limits is not b.limits + def test_constructor_accepts_limits(self): + """Limits can be supplied without touching class state.""" + limits = SecurityLimits(max_image_size=4096) + config = FileSecurityConfig(limits) + + assert config.limits.max_image_size == 4096 + assert FileSecurityConfig.limits.max_image_size != 4096 + + def test_constructor_copies_supplied_limits(self): + """The caller's limits object must not be aliased.""" + limits = SecurityLimits(max_image_size=4096) + config = FileSecurityConfig(limits) + + config.limits.max_image_size = 1 + + assert limits.max_image_size == 4096 + def test_limit_mutation_does_not_leak_across_instances(self): """Mutating one instance's limits must not affect another.""" a = FileSecurityConfig() diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py index cc277fe..e9ae704 100644 --- a/tests/test_config_validation.py +++ b/tests/test_config_validation.py @@ -135,6 +135,180 @@ def test_nonpositive_sanitized_name_length_generates_error( error_types = [e.error_type for e in errors if e.severity == "error"] assert "invalid_name_length" in error_types + def test_nonpositive_image_pixels_generates_error(self, monkeypatch): + """ + Test that a non-positive image pixel limit errors. + + Args: + monkeypatch: pytest monkeypatch fixture. + """ + monkeypatch.setattr( + FileSecurityConfig, + "limits", + SecurityLimits(max_image_pixels=0), + ) + errors = FileSecurityConfig.validate_configuration() + error_types = [e.error_type for e in errors if e.severity == "error"] + assert "invalid_pixel_limit" in error_types + + def test_nonpositive_xml_element_cap_generates_error(self, monkeypatch): + """ + Test that a non-positive XML element cap errors. + + Args: + monkeypatch: pytest monkeypatch fixture. + """ + monkeypatch.setattr( + FileSecurityConfig, + "limits", + SecurityLimits(max_xml_elements=0), + ) + errors = FileSecurityConfig.validate_configuration() + error_types = [e.error_type for e in errors if e.severity == "error"] + assert "invalid_xml_element_limit" in error_types + + @pytest.mark.parametrize("scan_size", [0, -1]) + def test_nonpositive_content_scan_size_generates_error( + self, monkeypatch, scan_size + ): + """Test that a non-positive content scan limit errors.""" + monkeypatch.setattr( + FileSecurityConfig, + "limits", + SecurityLimits(content_scan_max_size=scan_size), + ) + errors = FileSecurityConfig.validate_configuration() + error_types = [e.error_type for e in errors if e.severity == "error"] + assert "invalid_content_scan_size" in error_types + + def test_nonpositive_gzip_timeout_generates_error(self, monkeypatch): + """ + Test that a non-positive gzip timeout errors. + + Args: + monkeypatch: pytest monkeypatch fixture. + """ + monkeypatch.setattr( + FileSecurityConfig, + "limits", + SecurityLimits(gzip_analysis_timeout=0.0), + ) + errors = FileSecurityConfig.validate_configuration() + error_types = [e.error_type for e in errors if e.severity == "error"] + assert "invalid_timeout" in error_types + + def test_unknown_zip_entry_category_generates_error(self): + """Test that a misspelled threat category errors.""" + config = FileSecurityConfig( + SecurityLimits( + blocked_zip_entry_categories=frozenset( + {"EXECUTABLE_FILES", "SCRIPT_FILE"} + ) + ) + ) + errors = config.validate_instance() + error_types = [e.error_type for e in errors if e.severity == "error"] + assert "unknown_zip_entry_category" in error_types + + def test_gzip_timeout_below_size_limit_warns(self): + """Test a timeout too short for the size limit warns.""" + config = FileSecurityConfig( + SecurityLimits( + gzip_analysis_timeout=1.0, + max_uncompressed_size=1024 * 1024 * 1024, + ) + ) + errors = config.validate_instance() + warnings = [e.error_type for e in errors if e.severity == "warning"] + assert "gzip_timeout_below_size_limit" in warnings + + def test_default_gzip_timeout_covers_size_limit(self): + """Test the shipped defaults do not warn against each other.""" + errors = FileSecurityConfig().validate_instance() + assert not [ + e + for e in errors + if e.error_type == "gzip_timeout_below_size_limit" + ] + + def test_known_zip_entry_categories_accepted(self): + """Test that valid category names pass validation.""" + config = FileSecurityConfig( + SecurityLimits( + blocked_zip_entry_categories=frozenset({"SYSTEM_FILES"}) + ) + ) + errors = config.validate_instance() + error_types = [e.error_type for e in errors if e.severity == "error"] + assert "unknown_zip_entry_category" not in error_types + + def test_missing_temp_dir_generates_error(self): + """Test that a temp_dir which is not a directory errors.""" + config = FileSecurityConfig( + SecurityLimits(temp_dir="/nonexistent/safeuploads-temp") + ) + errors = config.validate_instance() + error_types = [e.error_type for e in errors if e.severity == "error"] + assert "invalid_temp_dir" in error_types + + def test_existing_temp_dir_accepted(self, tmp_path): + """ + Test that an existing temp_dir passes validation. + + Args: + tmp_path: pytest temporary directory fixture. + """ + config = FileSecurityConfig(SecurityLimits(temp_dir=str(tmp_path))) + errors = config.validate_instance() + error_types = [e.error_type for e in errors if e.severity == "error"] + assert "invalid_temp_dir" not in error_types + + def test_customised_memory_budget_without_enforcement_is_informational( + self, + ): + """Test a tuned but unenforced memory budget is surfaced.""" + config = FileSecurityConfig( + SecurityLimits(max_validation_memory_mb=128) + ) + notices = [ + e.error_type + for e in config.validate_instance() + if e.severity == "info" + ] + assert "memory_limit_not_enforced" in notices + + def test_customised_memory_budget_passes_strict_validation(self): + """Test the notice does not fail strict validation.""" + config = FileSecurityConfig( + SecurityLimits(max_validation_memory_mb=128) + ) + config.validate_and_report_instance(strict=True) + + def test_customised_memory_budget_with_enforcement_is_quiet(self): + """Test opting in to enforcement clears the notice.""" + config = FileSecurityConfig( + SecurityLimits( + max_validation_memory_mb=128, + enforce_memory_limit=True, + ) + ) + notices = [ + e.error_type + for e in config.validate_instance() + if e.severity == "info" + ] + assert "memory_limit_not_enforced" not in notices + + def test_default_memory_budget_does_not_warn(self): + """Test an untouched budget is not flagged.""" + config = FileSecurityConfig() + notices = [ + e.error_type + for e in config.validate_instance() + if e.severity == "info" + ] + assert "memory_limit_not_enforced" not in notices + class TestMimeConfigurationValidation: """Tests for _validate_mime_configurations validation branches.""" diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 8d024ad..26cf928 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -47,15 +47,12 @@ def test_error_code_constants_exist(self): # File size errors assert hasattr(ErrorCode, "FILE_TOO_LARGE") assert hasattr(ErrorCode, "FILE_EMPTY") - assert hasattr(ErrorCode, "FILE_SIZE_UNKNOWN") # MIME type errors assert hasattr(ErrorCode, "MIME_TYPE_INVALID") assert hasattr(ErrorCode, "MIME_TYPE_MISMATCH") - assert hasattr(ErrorCode, "MIME_DETECTION_FAILED") # File signature errors - assert hasattr(ErrorCode, "FILE_SIGNATURE_INVALID") assert hasattr(ErrorCode, "FILE_SIGNATURE_MISSING") assert hasattr(ErrorCode, "FILE_SIGNATURE_MISMATCH") @@ -64,13 +61,11 @@ def test_error_code_constants_exist(self): assert hasattr(ErrorCode, "ZIP_CONTENT_THREAT") assert hasattr(ErrorCode, "COMPRESSION_RATIO_EXCEEDED") assert hasattr(ErrorCode, "ZIP_TOO_MANY_ENTRIES") - assert hasattr(ErrorCode, "ZIP_INVALID_STRUCTURE") assert hasattr(ErrorCode, "ZIP_CORRUPT") # Processing errors assert hasattr(ErrorCode, "PROCESSING_ERROR") assert hasattr(ErrorCode, "IO_ERROR") - assert hasattr(ErrorCode, "MEMORY_ERROR") def test_error_codes_are_strings(self): """Verify error codes are string values.""" diff --git a/tests/test_file_validator.py b/tests/test_file_validator.py index 6e967b6..4421d4a 100644 --- a/tests/test_file_validator.py +++ b/tests/test_file_validator.py @@ -2,6 +2,7 @@ import asyncio import io +import tempfile import pytest @@ -14,6 +15,7 @@ FileProcessingError, FileSignatureError, FileSizeError, + ImageSecurityError, MimeTypeError, ResourceLimitError, UnicodeSecurityError, @@ -22,6 +24,8 @@ ZipContentError, ) from safeuploads.file_validator import FileValidator +from safeuploads.utils import safe_label +from tests.conftest import JPEG_SOF0 class TestFileValidatorInitialization: @@ -960,6 +964,70 @@ async def test_raises_file_size_error_when_limit_exceeded_during_stream( max_file_size=1 * 1024, # 1 KB limit ) + @pytest.mark.asyncio + async def test_temp_dir_used_for_spilled_uploads( + self, mock_upload_file, monkeypatch, tmp_path + ): + """ + Test the configured temp_dir reaches the spooled file. + + Args: + mock_upload_file: File factory fixture. + monkeypatch: pytest monkeypatch fixture. + tmp_path: pytest temporary directory fixture. + """ + captured = {} + real_spooled = tempfile.SpooledTemporaryFile + + def _spy(*args, **kwargs): + captured.update(kwargs) + return real_spooled(*args, **kwargs) + + monkeypatch.setattr(tempfile, "SpooledTemporaryFile", _spy) + + config = FileSecurityConfig( + SecurityLimits(temp_dir=str(tmp_path), max_memory_buffer_size=8) + ) + validator = FileValidator(config=config) + file = mock_upload_file(filename="a.zip", content=b"x" * 4096) + + temp, _ = await validator._stream_to_temp_file( + file, max_file_size=1024 * 1024 + ) + temp.close() + + assert captured["dir"] == str(tmp_path) + + @pytest.mark.asyncio + async def test_temp_dir_defaults_to_system_location( + self, mock_upload_file, monkeypatch + ): + """ + Test an unset temp_dir leaves the system default in place. + + Args: + mock_upload_file: File factory fixture. + monkeypatch: pytest monkeypatch fixture. + """ + captured = {} + real_spooled = tempfile.SpooledTemporaryFile + + def _spy(*args, **kwargs): + captured.update(kwargs) + return real_spooled(*args, **kwargs) + + monkeypatch.setattr(tempfile, "SpooledTemporaryFile", _spy) + + validator = FileValidator() + file = mock_upload_file(filename="a.zip", content=b"x" * 32) + + temp, _ = await validator._stream_to_temp_file( + file, max_file_size=1024 * 1024 + ) + temp.close() + + assert captured["dir"] is None + class TestResourceMonitorIntegration: """Test resource monitoring in validate methods.""" @@ -1247,6 +1315,30 @@ def test_sanitize_whitespace_only_name_part(self): assert result.startswith("file_") assert result.endswith(".jpg") + @pytest.mark.parametrize( + "raw", + [ + "a\x85b.jpg", # C1 next-line + "a\x9bb.jpg", # C1 control sequence introducer + "a\u2028b.jpg", # Line separator + "a\u2029b.jpg", # Paragraph separator + ], + ) + def test_sanitize_strips_non_c0_line_breakers(self, raw): + """ + Test that code points beyond C0 cannot break a log line. + + Args: + raw: Filename carrying a log-breaking code point. + + Returns: + None + """ + validator = FileValidator() + result = validator._sanitize_filename(raw) + assert result == "ab.jpg" + assert safe_label(result) == result + class TestValidateActivityFile: """Test activity file validation (GPX, TCX, FIT).""" @@ -1445,7 +1537,11 @@ async def test_polyglot_after_header_is_detected(self, mock_upload_file): # Valid JPEG header, 9 KB filler, then a ZIP (GIFAR) # polyglot signature well past the 8 KB read window. jpeg = ( - b"\xff\xd8\xff\xe0" + b"\x00" * 9000 + b"PK\x03\x04" + b"\xff\xd9" + b"\xff\xd8" + + JPEG_SOF0 + + b"\x00" * 9000 + + b"PK\x03\x04" + + b"\xff\xd9" ) file = mock_upload_file(filename="poly.jpg", content=jpeg) with pytest.raises(FileProcessingError, match="Content analysis"): @@ -1455,11 +1551,131 @@ async def test_polyglot_after_header_is_detected(self, mock_upload_file): async def test_clean_large_image_passes(self, mock_upload_file): """A clean image larger than 8 KB passes analysis.""" validator = self._content_analysis_validator() - jpeg = b"\xff\xd8\xff\xe0" + b"\x00" * 9000 + b"\xff\xd9" + jpeg = b"\xff\xd8" + JPEG_SOF0 + b"\x00" * 9000 + b"\xff\xd9" file = mock_upload_file(filename="clean.jpg", content=jpeg) await validator.validate_image_file(file) +def _png_with_dimensions(width: int, height: int) -> bytes: + """Build a PNG whose IHDR declares the given dimensions.""" + return ( + b"\x89PNG\r\n\x1a\n" + + b"\x00\x00\x00\x0d" + + b"IHDR" + + width.to_bytes(4, "big") + + height.to_bytes(4, "big") + + b"\x08\x02\x00\x00\x00" + + b"\x00\x00\x00\x00" + ) + + +def _jpeg_with_dimensions(width: int, height: int) -> bytes: + """Build a JPEG whose SOF0 declares the given dimensions.""" + return ( + b"\xff\xd8" + b"\xff\xc0\x00\x11\x08" + + height.to_bytes(2, "big") + + width.to_bytes(2, "big") + + b"\x03\x01\x11\x00\x02\x11\x01\x03\x11\x01" + + b"\xff\xd9" + ) + + +class TestImageDimensionValidation: + """Decoded pixel count is bounded independently of byte size.""" + + @pytest.mark.asyncio + async def test_png_pixel_bomb_rejected(self, mock_upload_file): + """A tiny PNG declaring huge dimensions is rejected.""" + validator = FileValidator() + # ~40 bytes on the wire, ~3.6 GB once decoded. + content = _png_with_dimensions(30000, 30000) + file = mock_upload_file(filename="bomb.png", content=content) + + with pytest.raises(ImageSecurityError) as exc_info: + await validator.validate_image_file(file) + + assert exc_info.value.error_code == ErrorCode.IMAGE_DIMENSIONS_EXCEEDED + assert exc_info.value.width == 30000 + assert exc_info.value.height == 30000 + + @pytest.mark.asyncio + async def test_jpeg_pixel_bomb_rejected(self, mock_upload_file): + """A tiny JPEG declaring huge dimensions is rejected.""" + validator = FileValidator() + content = _jpeg_with_dimensions(20000, 20000) + file = mock_upload_file(filename="bomb.jpg", content=content) + + with pytest.raises(ImageSecurityError) as exc_info: + await validator.validate_image_file(file) + + assert exc_info.value.error_code == ErrorCode.IMAGE_DIMENSIONS_EXCEEDED + + @pytest.mark.asyncio + async def test_within_pixel_limit_passes(self, mock_upload_file): + """A normally sized image is accepted.""" + validator = FileValidator() + content = _png_with_dimensions(1920, 1080) + file = mock_upload_file(filename="photo.png", content=content) + + await validator.validate_image_file(file) + + @pytest.mark.asyncio + async def test_custom_pixel_limit_enforced(self, mock_upload_file): + """A tightened max_image_pixels is honoured.""" + config = FileSecurityConfig(SecurityLimits(max_image_pixels=1000)) + validator = FileValidator(config=config) + content = _png_with_dimensions(100, 100) + file = mock_upload_file(filename="photo.png", content=content) + + with pytest.raises(ImageSecurityError) as exc_info: + await validator.validate_image_file(file) + + assert exc_info.value.max_pixels == 1000 + + @pytest.mark.asyncio + async def test_zero_dimensions_rejected(self, mock_upload_file): + """An image declaring a zero dimension is rejected.""" + validator = FileValidator() + content = _png_with_dimensions(0, 100) + file = mock_upload_file(filename="empty.png", content=content) + + with pytest.raises(ImageSecurityError) as exc_info: + await validator.validate_image_file(file) + + assert ( + exc_info.value.error_code == ErrorCode.IMAGE_DIMENSIONS_UNREADABLE + ) + + @pytest.mark.asyncio + async def test_missing_frame_header_rejected(self, mock_upload_file): + """A JPEG with no frame header fails closed.""" + validator = FileValidator() + content = ( + b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00" + b"\x00\x01\x00\x01\x00\x00\xff\xd9" + ) + file = mock_upload_file(filename="headerless.jpg", content=content) + + with pytest.raises(ImageSecurityError) as exc_info: + await validator.validate_image_file(file) + + assert ( + exc_info.value.error_code == ErrorCode.IMAGE_DIMENSIONS_UNREADABLE + ) + + @pytest.mark.asyncio + async def test_frame_header_past_sample_window(self, mock_upload_file): + """A frame header behind a large EXIF block is still found.""" + validator = FileValidator() + # APP0 segment large enough to push SOF0 past the 8 KB sample. + app0 = b"\xff\xe0" + (9000).to_bytes(2, "big") + b"\x00" * 8998 + content = b"\xff\xd8" + app0 + JPEG_SOF0 + b"\xff\xd9" + file = mock_upload_file(filename="bigexif.jpg", content=content) + + await validator.validate_image_file(file) + + class TestConcurrentValidation: """Validation is offloaded to threads and safe under load.""" diff --git a/tests/test_performance.py b/tests/test_performance.py index a6666e7..c74710f 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -15,6 +15,7 @@ from safeuploads import FileValidator from safeuploads.config import FileSecurityConfig, SecurityLimits +from tests.conftest import JPEG_SOF0 def create_test_image(size_kb: int) -> bytes: @@ -56,10 +57,13 @@ def create_test_image(size_kb: int) -> bytes: # Add padding to reach desired size target_size = size_kb * 1024 - padding_size = max(0, target_size - len(jpeg_header) - len(jpeg_footer)) + padding_size = max( + 0, + target_size - len(jpeg_header) - len(JPEG_SOF0) - len(jpeg_footer), + ) padding = b"\x00" * padding_size - return jpeg_header + padding + jpeg_footer + return jpeg_header + JPEG_SOF0 + padding + jpeg_footer def create_test_zip(num_files: int, file_size_kb: int = 1) -> bytes: diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..6b36c1c --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,45 @@ +"""Contract tests for the package's public API surface.""" + +import safeuploads + + +class TestPublicExports: + """Every advertised name must be importable and unique.""" + + def test_all_names_are_resolvable(self): + missing = [ + name + for name in safeuploads.__all__ + if not hasattr(safeuploads, name) + ] + assert missing == [] + + def test_all_has_no_duplicates(self): + assert len(safeuploads.__all__) == len(set(safeuploads.__all__)) + + def test_framework_agnostic_protocols_exported(self): + # A caller integrating a non-FastAPI framework needs both + # protocols from the top-level package. + assert "SeekableFile" in safeuploads.__all__ + assert "UploadFileProtocol" in safeuploads.__all__ + + def test_correlation_id_helpers_exported(self): + for name in ( + "get_correlation_id", + "set_correlation_id", + "reset_correlation_id", + ): + assert name in safeuploads.__all__ + + def test_upload_file_protocol_is_runtime_checkable(self): + class _Upload: + filename = "a.jpg" + size = 3 + + async def read(self, size: int = -1) -> bytes: + return b"" + + async def seek(self, offset: int) -> int: + return offset + + assert isinstance(_Upload(), safeuploads.UploadFileProtocol) diff --git a/tests/test_utils.py b/tests/test_utils.py index 9b5b9c0..775bbb9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -8,7 +8,228 @@ ErrorCode, ResourceLimitError, ) -from safeuploads.utils import ResourceMonitor +from safeuploads.utils import ( + ResourceMonitor, + find_embedded_signature, + find_text_pattern, + parse_image_dimensions, + safe_label, + strip_unsafe_chars, +) +from tests.conftest import JPEG_SOF0 + + +class TestSafeLabel: + """Untrusted text is escaped before it reaches a log.""" + + def test_plain_text_unchanged(self): + """Test ordinary filenames pass through untouched.""" + assert safe_label("holiday-photo.jpg") == "holiday-photo.jpg" + + def test_non_ascii_preserved(self): + """Test legitimate non-ASCII names stay readable.""" + assert safe_label("caf\u00e9.jpg") == "caf\u00e9.jpg" + + def test_empty_value(self): + """Test empty input yields empty output.""" + assert safe_label("") == "" + + @pytest.mark.parametrize( + ("raw", "expected"), + [ + ("a\nb", "a\\u000ab"), + ("a\rb", "a\\u000db"), + ("a\u202eb", "a\\u202eb"), + ("a\u2028b", "a\\u2028b"), + ("a\x00b", "a\\u0000b"), + ], + ) + def test_control_characters_escaped(self, raw, expected): + """Test newline and format characters cannot break a line.""" + assert safe_label(raw) == expected + + def test_forged_log_line_neutralised(self): + """Test a crafted filename cannot inject a log record.""" + result = safe_label("ok.jpg\nWARNING forged entry") + assert "\n" not in result + + def test_truncates_long_values(self): + """Test oversized input is bounded and marked.""" + result = safe_label("a" * 400, max_length=16) + assert result == "a" * 16 + "..." + + def test_exact_length_is_not_marked_truncated(self): + """Test the boundary case keeps the value intact.""" + assert safe_label("a" * 16, max_length=16) == "a" * 16 + + def test_default_length_bound(self): + """Test the documented default bound is applied.""" + assert safe_label("a" * 300) == "a" * 256 + "..." + assert safe_label("a" * 256) == "a" * 256 + + +class TestStripUnsafeChars: + """Log-breaking code points are removed, not escaped.""" + + def test_plain_text_unchanged(self): + """Test ordinary filenames pass through untouched.""" + assert strip_unsafe_chars("holiday-photo.jpg") == "holiday-photo.jpg" + + def test_non_ascii_preserved(self): + """Test legitimate non-ASCII names stay readable.""" + assert strip_unsafe_chars("caf\u00e9.jpg") == "caf\u00e9.jpg" + + def test_empty_value(self): + """Test empty input yields empty output.""" + assert strip_unsafe_chars("") == "" + + @pytest.mark.parametrize( + "raw", + [ + "a\x00b.jpg", # C0 null + "a\nb.jpg", # C0 newline + "a\x7fb.jpg", # Delete + "a\x85b.jpg", # C1 next-line + "a\x9bb.jpg", # C1 control sequence introducer + "a\u2028b.jpg", # Line separator + "a\u2029b.jpg", # Paragraph separator + "a\u200bb.jpg", # Zero-width space (format) + ], + ) + def test_unsafe_code_points_removed(self, raw): + """Test every log-breaking category is stripped.""" + assert strip_unsafe_chars(raw) == "ab.jpg" + + +class TestFindTextPattern: + """Pattern scanning runs over raw bytes.""" + + def test_match_is_case_insensitive(self): + """Test uppercase content matches a lower-case pattern.""" + assert find_text_pattern(b"XX", (" bytes: + """Build a PNG header declaring the given dimensions.""" + return ( + b"\x89PNG\r\n\x1a\n" + + b"\x00\x00\x00\x0d" + + b"IHDR" + + width.to_bytes(4, "big") + + height.to_bytes(4, "big") + ) + + +class TestParseImageDimensions: + """Tests for PNG/JPEG dimension extraction.""" + + def test_png_dimensions(self): + """Test PNG IHDR dimensions are read.""" + assert parse_image_dimensions(_png(1920, 1080)) == (1920, 1080) + + def test_png_truncated_returns_none(self): + """Test truncated PNG header yields no dimensions.""" + assert parse_image_dimensions(_png(10, 10)[:20]) is None + + def test_png_without_ihdr_returns_none(self): + """Test PNG whose first chunk is not IHDR is rejected.""" + content = ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\x0d" + b"IDAT" + b"\x00" * 8 + ) + assert parse_image_dimensions(content) is None + + def test_jpeg_dimensions(self): + """Test JPEG SOF0 dimensions are read.""" + assert parse_image_dimensions(b"\xff\xd8" + JPEG_SOF0) == (16, 16) + + def test_jpeg_skips_app_segment(self): + """Test segments before the frame header are skipped.""" + app0 = b"\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" + content = b"\xff\xd8" + app0 + JPEG_SOF0 + assert parse_image_dimensions(content) == (16, 16) + + def test_jpeg_tolerates_fill_bytes(self): + """Test 0xFF padding before a marker is skipped.""" + content = b"\xff\xd8" + b"\xff" * 4 + JPEG_SOF0 + assert parse_image_dimensions(content) == (16, 16) + + def test_jpeg_skips_standalone_marker(self): + """Test standalone markers carry no length field.""" + content = b"\xff\xd8" + b"\xff\xd0" + JPEG_SOF0 + assert parse_image_dimensions(content) == (16, 16) + + def test_jpeg_desynchronised_returns_none(self): + """Test a non-marker byte where a segment must start.""" + content = b"\xff\xd8" + b"\x00\x11\x22\x33" + JPEG_SOF0 + assert parse_image_dimensions(content) is None + + @pytest.mark.parametrize("marker", [b"\xff\xd9", b"\xff\xda"]) + def test_jpeg_scan_end_before_frame(self, marker): + """Test EOI or SOS before any frame header.""" + content = b"\xff\xd8" + marker + JPEG_SOF0 + assert parse_image_dimensions(content) is None + + def test_jpeg_invalid_segment_length(self): + """Test a segment length below the minimum is rejected.""" + content = b"\xff\xd8" + b"\xff\xe0\x00\x01" + JPEG_SOF0 + assert parse_image_dimensions(content) is None + + def test_jpeg_truncated_frame_header(self): + """Test a frame header cut short yields no dimensions.""" + content = b"\xff\xd8" + JPEG_SOF0[:6] + b"\x00\x00" + assert parse_image_dimensions(content) is None + + def test_unsupported_format_returns_none(self): + """Test a non-PNG, non-JPEG payload yields no dimensions.""" + assert parse_image_dimensions(b"GIF89a" + b"\x00" * 32) is None class TestResourceMonitorInit: @@ -26,6 +247,20 @@ def test_custom_values(self): assert monitor.max_time_seconds == 5.0 assert monitor.max_memory_bytes == 128 * 1024 * 1024 + def test_check_enforces_time_budget(self): + """Test check() raises once the time budget is spent.""" + with ( + pytest.raises(ResourceLimitError) as exc_info, + ResourceMonitor(max_time_seconds=0.0) as monitor, + ): + monitor.check() + assert exc_info.value.error_code == ErrorCode.RESOURCE_TIME_EXCEEDED + + def test_check_passes_within_budget(self): + """Test check() is a no-op while within limits.""" + with ResourceMonitor(max_time_seconds=30.0) as monitor: + monitor.check() + class TestResourceMonitorTime: """Test wall-clock time monitoring.""" @@ -205,6 +440,7 @@ def _fake_rss() -> int: ResourceMonitor( max_time_seconds=30.0, max_memory_mb=512, + enforce_memory=True, ), ): pass # Immediate exit @@ -213,6 +449,87 @@ def _fake_rss() -> int: assert exc_info.value.memory_bytes is not None assert "memory limit" in str(exc_info.value).lower() + def test_memory_overrun_warns_when_not_enforced(self, monkeypatch, caplog): + """ + Test the default posture reports but does not fail. + + Args: + monkeypatch: pytest monkeypatch fixture. + caplog: pytest log capture fixture. + """ + _calls = {"n": 0} + + def _fake_rss() -> int: + _calls["n"] += 1 + if _calls["n"] == 1: + return 100 * 1024 * 1024 + return 700 * 1024 * 1024 + + monkeypatch.setattr( + ResourceMonitor, "_get_peak_rss_bytes", staticmethod(_fake_rss) + ) + + with ( + caplog.at_level("WARNING", logger="safeuploads.utils"), + ResourceMonitor(max_time_seconds=30.0, max_memory_mb=512), + ): + pass + + assert "not enforced" in caplog.text + + def test_check_skips_memory_when_not_enforced(self, monkeypatch): + """ + Test check() does not sample memory unless enforcing. + + Args: + monkeypatch: pytest monkeypatch fixture. + """ + _calls = {"n": 0} + + def _fake_rss() -> int: + _calls["n"] += 1 + return 100 * 1024 * 1024 + + monkeypatch.setattr( + ResourceMonitor, "_get_peak_rss_bytes", staticmethod(_fake_rss) + ) + + with ResourceMonitor(max_time_seconds=30.0) as monitor: + baseline = _calls["n"] + monitor.check() + assert _calls["n"] == baseline + + def test_check_samples_memory_when_enforced(self, monkeypatch): + """ + Test check() enforces the memory budget when enabled. + + Args: + monkeypatch: pytest monkeypatch fixture. + """ + _calls = {"n": 0} + + def _fake_rss() -> int: + _calls["n"] += 1 + if _calls["n"] == 1: + return 100 * 1024 * 1024 + return 700 * 1024 * 1024 + + monkeypatch.setattr( + ResourceMonitor, "_get_peak_rss_bytes", staticmethod(_fake_rss) + ) + + with ( + pytest.raises(ResourceLimitError) as exc_info, + ResourceMonitor( + max_time_seconds=30.0, + max_memory_mb=512, + enforce_memory=True, + ) as monitor, + ): + monitor.check() + + assert exc_info.value.error_code == ErrorCode.RESOURCE_MEMORY_EXCEEDED + def test_check_memory_passes_within_limit(self): """Test that check_memory does not raise within the limit.""" with ResourceMonitor( @@ -239,7 +556,9 @@ def _fake_rss() -> int: ResourceMonitor, "_get_peak_rss_bytes", staticmethod(_fake_rss) ) - monitor = ResourceMonitor(max_time_seconds=30.0, max_memory_mb=512) + monitor = ResourceMonitor( + max_time_seconds=30.0, max_memory_mb=512, enforce_memory=True + ) monitor.__enter__() with pytest.raises(ResourceLimitError) as exc_info: monitor.check_memory() diff --git a/tests/test_validate_activity_file.py b/tests/test_validate_activity_file.py index f7eb405..4152ee1 100644 --- a/tests/test_validate_activity_file.py +++ b/tests/test_validate_activity_file.py @@ -135,6 +135,12 @@ async def test_validate_activity_file_exe_extension_raises( class TestValidateActivityFileSizeErrors: """Tests for file size validation failures.""" + def test_mock_upload_file_preserves_explicit_zero_size( + self, mock_upload_file + ): + f = mock_upload_file("track.gpx", _GPX_CONTENT, size=0) + assert f.size == 0 + async def test_validate_activity_file_empty_file_raises( self, mock_upload_file ): @@ -213,6 +219,47 @@ async def test_validate_activity_file_malformed_xml_raises( await validator.validate_activity_file(f) +class TestValidateActivityFileContentAnalysis: + """Tests for optional deep content analysis.""" + + async def test_clean_gpx_passes_when_content_analysis_enabled( + self, mock_upload_file + ): + config = FileSecurityConfig( + SecurityLimits(enable_content_analysis=True) + ) + validator = FileValidator(config=config) + f = mock_upload_file("track.gpx", _GPX_CONTENT) + + await validator.validate_activity_file(f) + + async def test_script_in_gpx_is_rejected(self, mock_upload_file): + config = FileSecurityConfig( + SecurityLimits(enable_content_analysis=True) + ) + validator = FileValidator(config=config) + content = ( + b'' + b'' + ) + f = mock_upload_file("track.gpx", content) + + with pytest.raises(FileProcessingError, match="Content analysis"): + await validator.validate_activity_file(f) + + async def test_executable_signature_in_fit_is_rejected( + self, mock_upload_file + ): + config = FileSecurityConfig( + SecurityLimits(enable_content_analysis=True) + ) + validator = FileValidator(config=config) + f = mock_upload_file("activity.fit", _FIT_CONTENT + b"MZpayload") + + with pytest.raises(FileProcessingError, match="Content analysis"): + await validator.validate_activity_file(f) + + class TestValidateActivityFileExceptionWrapping: """Tests that unexpected errors are wrapped in FileProcessingError.""" diff --git a/tests/validators/test_compression_validator.py b/tests/validators/test_compression_validator.py index 6730cdd..adba87e 100644 --- a/tests/validators/test_compression_validator.py +++ b/tests/validators/test_compression_validator.py @@ -11,13 +11,60 @@ CompressionSecurityError, ErrorCode, FileProcessingError, + ResourceLimitError, ZipBombError, ) +from safeuploads.utils import ResourceMonitor from safeuploads.validators.compression_validator import ( CompressionSecurityValidator, ) +class TestCompressionResourceLimits: + """A spent time budget aborts analysis mid-scan.""" + + @staticmethod + def _archive() -> bytes: + """Build a small multi-entry archive.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as zf: + zf.writestr("a.txt", b"a" * 512) + zf.writestr("b.txt", b"b" * 512) + return buffer.getvalue() + + def test_entry_loop_aborts_on_time_limit(self, default_config): + """Test the per-entry check surfaces ResourceLimitError.""" + validator = CompressionSecurityValidator(default_config) + payload = self._archive() + + with ( + pytest.raises(ResourceLimitError), + ResourceMonitor(max_time_seconds=0.0) as monitor, + ): + validator.validate_zip_compression_ratio( + io.BytesIO(payload), len(payload), monitor + ) + + def test_decompression_check_aborts_on_time_limit(self): + """Test strict verification surfaces ResourceLimitError.""" + config = FileSecurityConfig() + config.limits = SecurityLimits( + verify_zip_decompression=True, + chunk_size=16, + ) + validator = CompressionSecurityValidator(config) + payload = self._archive() + + with ( + pytest.raises(ResourceLimitError), + ResourceMonitor(max_time_seconds=0.0) as monitor, + zipfile.ZipFile(io.BytesIO(payload), "r") as zip_file, + ): + validator._verify_entries_decompress( + zip_file, zip_file.infolist(), monitor + ) + + class TestCompressionSecurityValidator: """Test suite for CompressionSecurityValidator.""" @@ -44,22 +91,6 @@ def test_validate_normal_zip(self, default_config, create_zip_file): io.BytesIO(zip_bytes), len(zip_bytes) ) - def test_validate_method_delegates_correctly( - self, default_config, create_zip_file - ): - """ - Test that validate() method delegates to - validate_zip_compression_ratio(). - """ - validator = CompressionSecurityValidator(default_config) - zip_bytes = create_zip_file() - - # Both methods should work identically - validator.validate(io.BytesIO(zip_bytes), len(zip_bytes)) - validator.validate_zip_compression_ratio( - io.BytesIO(zip_bytes), len(zip_bytes) - ) - def test_reject_corrupted_zip(self, default_config): """Test rejection of corrupted ZIP files.""" validator = CompressionSecurityValidator(default_config) @@ -722,12 +753,13 @@ def test_overall_compression_ratio_exceeded(self) -> None: error_msg = str(exc_info.value).lower() assert "overall compression ratio" in error_msg - def test_complexity_attack_entry_count(self): - """Test rejection when entries exceed recursive limit.""" - config = FileSecurityConfig() - config.limits = SecurityLimits( - max_zip_entries=100000, - max_total_entries_recursive=5, + def test_flat_entry_count_uses_max_zip_entries(self): + """Test the flat entry cap is the only one that applies.""" + config = FileSecurityConfig( + SecurityLimits( + max_zip_entries=5, + max_total_entries_recursive=100000, + ) ) validator = CompressionSecurityValidator(config) @@ -741,7 +773,7 @@ def test_complexity_attack_entry_count(self): validator.validate_zip_compression_ratio( io.BytesIO(zip_bytes), len(zip_bytes) ) - assert exc_info.value.error_code == ErrorCode.ZIP_COMPLEXITY_ATTACK + assert exc_info.value.error_code == ErrorCode.ZIP_TOO_MANY_ENTRIES class TestCompressionValidatorNestedAllowed: diff --git a/tests/validators/test_extension_validator.py b/tests/validators/test_extension_validator.py index eab6978..78f1f44 100644 --- a/tests/validators/test_extension_validator.py +++ b/tests/validators/test_extension_validator.py @@ -126,17 +126,6 @@ def test_case_insensitive_compound_extension(self, default_config): with pytest.raises(ExtensionSecurityError): validator.validate_extensions("archive.Tar.Gz") - def test_validate_method_delegates_correctly(self, default_config): - """Test that validate() method delegates to validate_extensions().""" - validator = ExtensionSecurityValidator(default_config) - - # Should not raise for safe extension - validator.validate("safe.txt") - - # Should raise for dangerous extension - with pytest.raises(ExtensionSecurityError): - validator.validate("dangerous.exe") - def test_all_parts_checked_for_dangerous_extensions(self, default_config): """Test that all extension parts are checked.""" validator = ExtensionSecurityValidator(default_config) diff --git a/tests/validators/test_unicode_validator.py b/tests/validators/test_unicode_validator.py index 41cc38e..6f0778a 100644 --- a/tests/validators/test_unicode_validator.py +++ b/tests/validators/test_unicode_validator.py @@ -98,15 +98,6 @@ def test_unicode_normalization_nfc(self, default_config): assert "\u0301" not in result # No combining character assert "é" in result # Has composed é - def test_validate_method_delegates_to_validate_unicode_security( - self, default_config - ): - """Test that validate() method delegates correctly.""" - validator = UnicodeSecurityValidator(default_config) - filename = "test.txt" - result = validator.validate(filename) - assert result == filename - def test_dangerous_char_position_tracking(self, default_config): """Test that character positions are correctly tracked.""" validator = UnicodeSecurityValidator(default_config) diff --git a/tests/validators/test_windows_validator.py b/tests/validators/test_windows_validator.py index d9ffdc9..35d3998 100644 --- a/tests/validators/test_windows_validator.py +++ b/tests/validators/test_windows_validator.py @@ -137,17 +137,6 @@ def test_filename_containing_but_not_matching_reserved( validator.validate_windows_reserved_names("context.log") validator.validate_windows_reserved_names("acon.txt") - def test_validate_method_delegates_correctly(self, default_config): - """Test that validate() method delegates correctly.""" - validator = WindowsSecurityValidator(default_config) - - # Should not raise for safe filename - validator.validate("normal.txt") - - # Should raise for reserved name - with pytest.raises(WindowsReservedNameError): - validator.validate("CON.txt") - def test_error_includes_filename(self, default_config): """Test that error includes the problematic filename.""" validator = WindowsSecurityValidator(default_config) diff --git a/tests/validators/test_xml_validator.py b/tests/validators/test_xml_validator.py index 54b365b..02c4536 100644 --- a/tests/validators/test_xml_validator.py +++ b/tests/validators/test_xml_validator.py @@ -4,7 +4,8 @@ import pytest -from safeuploads.exceptions import FileProcessingError +from safeuploads.config import FileSecurityConfig, SecurityLimits +from safeuploads.exceptions import ErrorCode, FileProcessingError from safeuploads.validators.xml_validator import XmlSecurityValidator @@ -100,17 +101,10 @@ def test_reject_non_xml_content(self, default_config): with pytest.raises(FileProcessingError): validator.validate_xml_safety(file_obj) - def test_validate_delegates_to_validate_xml_safety(self, default_config): - """Test validate() delegates correctly.""" - validator = XmlSecurityValidator(default_config) - valid_xml = b"" - file_obj = io.BytesIO(valid_xml) - validator.validate(file_obj) - def test_file_position_reset_after_validation(self, default_config): """Test file position is reset after validation.""" validator = XmlSecurityValidator(default_config) - valid_xml = b"" + valid_xml = b"" file_obj = io.BytesIO(valid_xml) file_obj.seek(5) validator.validate_xml_safety(file_obj) @@ -119,7 +113,7 @@ def test_file_position_reset_after_validation(self, default_config): def test_xml_with_bom(self, default_config): """Test XML with byte order mark passes.""" validator = XmlSecurityValidator(default_config) - bom_xml = b'\xef\xbb\xbf' + bom_xml = b'\xef\xbb\xbf' file_obj = io.BytesIO(bom_xml) validator.validate_xml_safety(file_obj) @@ -144,9 +138,9 @@ def _raise_entities(*a, **kw): "entity", None, None, None, None, None ) - monkeypatch.setattr(_mod.DefusedET, "parse", _raise_entities) + monkeypatch.setattr(_mod.DefusedET, "iterparse", _raise_entities) with pytest.raises(FileProcessingError, match="entity"): - validator.validate_xml_safety(io.BytesIO(b"")) + validator.validate_xml_safety(io.BytesIO(b"")) def test_external_ref_forbidden_raises(self, default_config, monkeypatch): from defusedxml import ElementTree as DefusedET @@ -158,9 +152,9 @@ def test_external_ref_forbidden_raises(self, default_config, monkeypatch): def _raise_ext(*a, **kw): raise DefusedET.ExternalReferenceForbidden("ref", None, None, None) - monkeypatch.setattr(_mod.DefusedET, "parse", _raise_ext) + monkeypatch.setattr(_mod.DefusedET, "iterparse", _raise_ext) with pytest.raises(FileProcessingError, match="external"): - validator.validate_xml_safety(io.BytesIO(b"")) + validator.validate_xml_safety(io.BytesIO(b"")) def test_unexpected_exception_raises(self, default_config, monkeypatch): import safeuploads.validators.xml_validator as _mod @@ -170,6 +164,81 @@ def test_unexpected_exception_raises(self, default_config, monkeypatch): def _raise_generic(*a, **kw): raise OSError("disk failure") - monkeypatch.setattr(_mod.DefusedET, "parse", _raise_generic) + monkeypatch.setattr(_mod.DefusedET, "iterparse", _raise_generic) with pytest.raises(FileProcessingError, match="failed"): - validator.validate_xml_safety(io.BytesIO(b"")) + validator.validate_xml_safety(io.BytesIO(b"")) + + +class TestXmlRootElementValidation: + """The document root must match the activity format.""" + + def test_arbitrary_xml_root_rejected(self, default_config): + """Test a non-activity document is rejected.""" + validator = XmlSecurityValidator(default_config) + payload = b"" + + with pytest.raises(FileProcessingError) as exc_info: + validator.validate_xml_safety(io.BytesIO(payload)) + + assert exc_info.value.error_code == ErrorCode.XML_INVALID_ROOT + + def test_namespaced_gpx_root_accepted(self, default_config): + """Test the namespace prefix is stripped before matching.""" + validator = XmlSecurityValidator(default_config) + payload = ( + b'' + ) + validator.validate_xml_safety(io.BytesIO(payload), "gpx") + + def test_tcx_content_rejected_under_gpx_extension(self, default_config): + """Test the root must match the specific extension.""" + validator = XmlSecurityValidator(default_config) + payload = b"" + + with pytest.raises(FileProcessingError) as exc_info: + validator.validate_xml_safety(io.BytesIO(payload), "gpx") + + assert exc_info.value.error_code == ErrorCode.XML_INVALID_ROOT + + def test_tcx_root_is_case_insensitive(self, default_config): + """Test root matching ignores case.""" + validator = XmlSecurityValidator(default_config) + payload = b"" + validator.validate_xml_safety(io.BytesIO(payload)) + + def test_document_without_root_rejected(self, default_config, monkeypatch): + """Test a parse yielding no elements is rejected.""" + import safeuploads.validators.xml_validator as _mod + + validator = XmlSecurityValidator(default_config) + monkeypatch.setattr( + _mod.DefusedET, "iterparse", lambda *a, **kw: iter(()) + ) + + with pytest.raises(FileProcessingError) as exc_info: + validator.validate_xml_safety(io.BytesIO(b"")) + + assert exc_info.value.error_code == ErrorCode.XML_INVALID_ROOT + + +class TestXmlElementCap: + """A flat document cannot amplify a bounded upload.""" + + def test_element_cap_enforced(self): + """Test exceeding max_xml_elements is rejected.""" + config = FileSecurityConfig(SecurityLimits(max_xml_elements=10)) + validator = XmlSecurityValidator(config) + payload = b"" + b"" * 50 + b"" + + with pytest.raises(FileProcessingError) as exc_info: + validator.validate_xml_safety(io.BytesIO(payload)) + + assert exc_info.value.error_code == ErrorCode.XML_TOO_MANY_ELEMENTS + + def test_document_within_cap_passes(self): + """Test a document under the cap is accepted.""" + config = FileSecurityConfig(SecurityLimits(max_xml_elements=100)) + validator = XmlSecurityValidator(config) + payload = b"" + b"" * 50 + b"" + + validator.validate_xml_safety(io.BytesIO(payload)) diff --git a/uv.lock b/uv.lock index 2606b5f..4436749 100644 --- a/uv.lock +++ b/uv.lock @@ -1,9 +1,11 @@ version = 1 revision = 3 -requires-python = ">=3.13" +requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version < '3.15'", + "python_full_version == '3.14.*'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", ] [options] @@ -34,6 +36,7 @@ version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ @@ -118,6 +121,32 @@ version = "3.4.9" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, @@ -187,6 +216,36 @@ version = "7.15.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, + { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, + { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, + { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, @@ -235,6 +294,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, ] +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "defusedxml" version = "0.7.1" @@ -336,6 +400,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/c4/d528d58d9c348cf66ff6fd9f83719ab3e23a65cd071bf80d90510d39d79a/hypothesis-6.157.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7da3d279d21938d233a665b2b2fe24c6467de302948d1480491c44fc4f4c66f4", size = 1290441, upload-time = "2026-07-20T14:10:12.004Z" }, { url = "https://files.pythonhosted.org/packages/54/f5/179d6f92a6b2a5d695ce5c90b55f91921ff024d8b9b12aa38bf84d27a576/hypothesis-6.157.1-cp310-abi3-win32.whl", hash = "sha256:7da995675e3c58aa989cd9559340937c5e7a36abcc8fdc5461f63581f4a3cb3b", size = 636948, upload-time = "2026-07-20T14:09:33.961Z" }, { url = "https://files.pythonhosted.org/packages/23/3a/396dbcc9d79e5c2490a7695cfe7d56f2bd58c8db55dc9df15febd084fa6a/hypothesis-6.157.1-cp310-abi3-win_amd64.whl", hash = "sha256:e0f62dc347b97df897f897cf5244fa84f189507c81b8313081447ccc77864200", size = 642732, upload-time = "2026-07-20T14:09:56.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8e/101297bb001c178d61501e7efc9a7d4df0709e013b1c2fb3d95d77ea0a8c/hypothesis-6.157.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:e407d3c6945d5dd63c6594a08311b250a05a8d9687819c04cc29d6f451b88cb1", size = 750342, upload-time = "2026-07-20T14:09:16.589Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b4/3a093601e2ef8beb9ab33de4b3057103e597fb8c1d71bd5807f86d472eef/hypothesis-6.157.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:79964a5d38aa0451a3b27b6b58cadd5b5e79407cd77ed5dff6b4c1efa939a091", size = 745090, upload-time = "2026-07-20T14:10:08.812Z" }, + { url = "https://files.pythonhosted.org/packages/32/f6/3b87d53e45ab589f4a9f4fdb2f563a490ce41895d0bc9449fd7cde20537c/hypothesis-6.157.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6a91879b8b30789506733c9f4487743b44f4b9ad07b609e6ab7a6240ef057a5", size = 1072620, upload-time = "2026-07-20T14:09:19.076Z" }, + { url = "https://files.pythonhosted.org/packages/24/e7/d29a0a160f43caee6a3b9a5e24e82b5a24e431ef6ac7f8550932dc2e9899/hypothesis-6.157.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7c45f1a955c930fa71df861237bc6db80e7e9c257051d069c8ef59914f088ec", size = 1123814, upload-time = "2026-07-20T14:09:39.504Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/d563ec371d5bb9da8ca041a05c7e3108240e5e42f9ba9bf501ff0d8b118d/hypothesis-6.157.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:514b557299947b1c68cb2b841f6c7c0f4893eaab0081df370b2a04db34f5dbb5", size = 1247523, upload-time = "2026-07-20T14:09:09.374Z" }, + { url = "https://files.pythonhosted.org/packages/65/6e/516f57ba3bcfd36303381144fbd6f60be57a4ec61020392af8133bc243fc/hypothesis-6.157.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7518035104ebfdf8f3438937e50c2a3eff6aa21e26590653bec9f9a5b2c7b4ff", size = 1290802, upload-time = "2026-07-20T14:09:20.311Z" }, + { url = "https://files.pythonhosted.org/packages/fa/65/7093ae6ccbf006f75adb98e151c7e4460f5015c11d65d64347c77bb280e5/hypothesis-6.157.1-cp311-cp311-win_amd64.whl", hash = "sha256:8323a0f79793c351dc2cae1e47d0b4a3cdcc93d2b7397219c1afba4191f0cdd6", size = 642555, upload-time = "2026-07-20T14:10:20.663Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2d/750f5073499a61092bbe18bde939647f460cd8a83e22662a5b8dced4a77f/hypothesis-6.157.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:00047e7b366f6844b4d6fe797d610270d095cd7471a537f256b9077ab661e6a8", size = 750681, upload-time = "2026-07-20T14:09:24.124Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b2/431250ee8978c422e2ef99c24bc48329371fc9056b3800e60db5f20faae7/hypothesis-6.157.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d4d983473c710618ea65860652693c692382eadbacd9a066508e7febf7836ea8", size = 743238, upload-time = "2026-07-20T14:09:35.471Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4f/2579839bf22ea7ee18fee95ed03b2687d967c4002098360f25104f3e91b8/hypothesis-6.157.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:073b7361bbe24c3d1865f4bcba1b42d4b402abf7b2cccaa77dcd65cacf99335b", size = 1071567, upload-time = "2026-07-20T14:09:52.188Z" }, + { url = "https://files.pythonhosted.org/packages/41/00/b8594a357af7bdcccc0fc3d6cceaf53ae9661552996fa94528ee5abb6a1a/hypothesis-6.157.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:026b9354ac4f251018c9ca9bc20da92d3ae1afd605682dc655d0dbcaa78ea005", size = 1122778, upload-time = "2026-07-20T14:09:38.206Z" }, + { url = "https://files.pythonhosted.org/packages/28/23/8215ff473a8b986cc138c80ccead07ab0ddeb09e5d3b3fa43885aa51c46e/hypothesis-6.157.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a905e9a208d12d0e9c9538ac10e721c1bc5893d5a643124e8ba73e43121da079", size = 1245818, upload-time = "2026-07-20T14:09:07.237Z" }, + { url = "https://files.pythonhosted.org/packages/71/61/35145430f30d53461a5535c1aa52d0721c2f5e896c8c443fb6a910a267b2/hypothesis-6.157.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9cfb9ca5a0747e759aaa7f8eb6866b9186c4766d76ad761ef545ad502d16cce1", size = 1289401, upload-time = "2026-07-20T14:09:22.908Z" }, + { url = "https://files.pythonhosted.org/packages/77/ee/11d026f3a3f142236e32574a963985770a60e0448dd94025ed641b3c6d82/hypothesis-6.157.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aae63ff2e480209a9b4bc1a87800ba9a902c65597d4bb4e93cad070c1b068f5", size = 640053, upload-time = "2026-07-20T14:09:40.861Z" }, { url = "https://files.pythonhosted.org/packages/62/9c/8173ef621af943d54623c6a4ca082cc0825d4202545c6fad7b86549432c9/hypothesis-6.157.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f39b03615afd67fa89742dead1093a48628cb76469505782b83e3db06617eaa9", size = 751045, upload-time = "2026-07-20T14:09:55.137Z" }, { url = "https://files.pythonhosted.org/packages/cd/72/a8fdef2057fbd30bf160dffc64936414be0721e800fb016bf4fca3ebb12e/hypothesis-6.157.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7822852d34545be3730d9286e0254ea04d8acc0d162b01024574ce1f9b07db4d", size = 743558, upload-time = "2026-07-20T14:09:11.628Z" }, { url = "https://files.pythonhosted.org/packages/be/4b/03945f713197ae933e82ed9c08a2679ecad63fc8b2c3f1efb0f313e1a455/hypothesis-6.157.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa3b13f0ecfceab36d0649b15bd715f404b93b7c630f1a7dc538c08f3006922e", size = 1071773, upload-time = "2026-07-20T14:10:26.215Z" }, @@ -358,6 +436,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/6e/87cda614eebb3c2014d3c1f73f2ad06f971e8424b683eab705af2662ece3/hypothesis-6.157.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:eadae326795e89a0b54b51ad69e63c963397dae872b9a080bec1982be20744c9", size = 1245045, upload-time = "2026-07-20T14:10:02.704Z" }, { url = "https://files.pythonhosted.org/packages/8d/46/ac41389596c91381b764faf58591e1e6227cc9f03c1c651863e8dac1cd92/hypothesis-6.157.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12806f82065632e521cf5719f470c5e81c8c83fb1ad68c1a7b70c4675be2452b", size = 1288799, upload-time = "2026-07-20T14:09:28.711Z" }, { url = "https://files.pythonhosted.org/packages/82/be/e0982c636695fdf5219e60bbd8fe86c3428001f49440569b0f7c479e0088/hypothesis-6.157.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2ae7c0a0030953a1b11f6032fdf4c6cda2f6a501050864fcd70a069ab04032c6", size = 640186, upload-time = "2026-07-20T14:09:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/7faedcaceb202cfb634434b14f2d55ce390065426ec5a8054af3ef0755db/hypothesis-6.157.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab9bb8116424a863e2ea5aa07b90214d3a35236444352671d36ec3bafce91588", size = 751009, upload-time = "2026-07-20T14:09:45.107Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/15192bd90c03fa55253a64da5041be493f338f67eca9c7e9006ecf8c885b/hypothesis-6.157.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab4f818bd3de0bf520f4c36f5006cc5c9a3e54c42db672359d0e05ed76f5b9d", size = 745811, upload-time = "2026-07-20T14:09:58.063Z" }, + { url = "https://files.pythonhosted.org/packages/00/2c/2d7b3f1340c71b826ea2dbc89a526d3a16c6d96651aa23da13d1946b77e1/hypothesis-6.157.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf6a63b0d293d3f1ae9da9d210e86c163caf9a6b806c4281be3d3444ff788c23", size = 1072891, upload-time = "2026-07-20T14:09:14.186Z" }, + { url = "https://files.pythonhosted.org/packages/c9/fc/34a702d85445ed86762570d818af16c5e116095977bf79d75ab155ddb381/hypothesis-6.157.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b340c3bb1ae10858c168f231a319e10f45824ec601948e41fc5835cd134073", size = 1124458, upload-time = "2026-07-20T14:09:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/bb/5e/ce4264fc58aa5c885c4a6e0e84498953721120fd41be5e4d8395b88877b4/hypothesis-6.157.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b6eb11b715d52005e8622248242c0f854f771828ef87e7e32438a7ed167f9442", size = 643128, upload-time = "2026-07-20T14:09:42.219Z" }, ] [[package]] @@ -390,12 +473,98 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "libcst" +version = "1.8.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml", marker = "python_full_version != '3.13.*'" }, + { name = "pyyaml-ft", marker = "python_full_version == '3.13.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/cd/337df968b38d94c5aabd3e1b10630f047a2b345f6e1d4456bd9fe7417537/libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b", size = 891354, upload-time = "2025-11-03T22:33:30.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/15/95c2ecadc0fb4af8a7057ac2012a4c0ad5921b9ef1ace6c20006b56d3b5f/libcst-1.8.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3649a813660fbffd7bc24d3f810b1f75ac98bd40d9d6f56d1f0ee38579021073", size = 2211289, upload-time = "2025-11-03T22:32:04.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/c3/7e1107acd5ed15cf60cc07c7bb64498a33042dc4821874aea3ec4942f3cd/libcst-1.8.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0cbe17067055829607c5ba4afa46bfa4d0dd554c0b5a583546e690b7367a29b6", size = 2092927, upload-time = "2025-11-03T22:32:06.209Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ff/0d2be87f67e2841a4a37d35505e74b65991d30693295c46fc0380ace0454/libcst-1.8.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:59a7e388c57d21d63722018978a8ddba7b176e3a99bd34b9b84a576ed53f2978", size = 2237002, upload-time = "2025-11-03T22:32:07.559Z" }, + { url = "https://files.pythonhosted.org/packages/69/99/8c4a1b35c7894ccd7d33eae01ac8967122f43da41325223181ca7e4738fe/libcst-1.8.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b6c1248cc62952a3a005792b10cdef2a4e130847be9c74f33a7d617486f7e532", size = 2301048, upload-time = "2025-11-03T22:32:08.869Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8b/d1aa811eacf936cccfb386ae0585aa530ea1221ccf528d67144e041f5915/libcst-1.8.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6421a930b028c5ef4a943b32a5a78b7f1bf15138214525a2088f11acbb7d3d64", size = 2300675, upload-time = "2025-11-03T22:32:10.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/6b/7b65cd41f25a10c1fef2389ddc5c2b2cc23dc4d648083fa3e1aa7e0eeac2/libcst-1.8.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6d8b67874f2188399a71a71731e1ba2d1a2c3173b7565d1cc7ffb32e8fbaba5b", size = 2407934, upload-time = "2025-11-03T22:32:11.856Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/401cfff374bb3b785adfad78f05225225767ee190997176b2a9da9ed9460/libcst-1.8.6-cp311-cp311-win_amd64.whl", hash = "sha256:b0d8c364c44ae343937f474b2e492c1040df96d94530377c2f9263fb77096e4f", size = 2119247, upload-time = "2025-11-03T22:32:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/085f59eaa044b6ff6bc42148a5449df2b7f0ba567307de7782fe85c39ee2/libcst-1.8.6-cp311-cp311-win_arm64.whl", hash = "sha256:5dcaaebc835dfe5755bc85f9b186fb7e2895dda78e805e577fef1011d51d5a5c", size = 2001774, upload-time = "2025-11-03T22:32:14.647Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3c/93365c17da3d42b055a8edb0e1e99f1c60c776471db6c9b7f1ddf6a44b28/libcst-1.8.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c13d5bd3d8414a129e9dccaf0e5785108a4441e9b266e1e5e9d1f82d1b943c9", size = 2206166, upload-time = "2025-11-03T22:32:16.012Z" }, + { url = "https://files.pythonhosted.org/packages/1d/cb/7530940e6ac50c6dd6022349721074e19309eb6aa296e942ede2213c1a19/libcst-1.8.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1472eeafd67cdb22544e59cf3bfc25d23dc94058a68cf41f6654ff4fcb92e09", size = 2083726, upload-time = "2025-11-03T22:32:17.312Z" }, + { url = "https://files.pythonhosted.org/packages/1b/cf/7e5eaa8c8f2c54913160671575351d129170db757bb5e4b7faffed022271/libcst-1.8.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:089c58e75cb142ec33738a1a4ea7760a28b40c078ab2fd26b270dac7d2633a4d", size = 2235755, upload-time = "2025-11-03T22:32:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/55/54/570ec2b0e9a3de0af9922e3bb1b69a5429beefbc753a7ea770a27ad308bd/libcst-1.8.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c9d7aeafb1b07d25a964b148c0dda9451efb47bbbf67756e16eeae65004b0eb5", size = 2301473, upload-time = "2025-11-03T22:32:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/11/4c/163457d1717cd12181c421a4cca493454bcabd143fc7e53313bc6a4ad82a/libcst-1.8.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:207481197afd328aa91d02670c15b48d0256e676ce1ad4bafb6dc2b593cc58f1", size = 2298899, upload-time = "2025-11-03T22:32:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/35/1d/317ddef3669883619ef3d3395ea583305f353ef4ad87d7a5ac1c39be38e3/libcst-1.8.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:375965f34cc6f09f5f809244d3ff9bd4f6cb6699f571121cebce53622e7e0b86", size = 2408239, upload-time = "2025-11-03T22:32:23.275Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a1/f47d8cccf74e212dd6044b9d6dbc223636508da99acff1d54786653196bc/libcst-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:da95b38693b989eaa8d32e452e8261cfa77fe5babfef1d8d2ac25af8c4aa7e6d", size = 2119660, upload-time = "2025-11-03T22:32:24.822Z" }, + { url = "https://files.pythonhosted.org/packages/19/d0/dd313bf6a7942cdf951828f07ecc1a7695263f385065edc75ef3016a3cb5/libcst-1.8.6-cp312-cp312-win_arm64.whl", hash = "sha256:bff00e1c766658adbd09a175267f8b2f7616e5ee70ce45db3d7c4ce6d9f6bec7", size = 1999824, upload-time = "2025-11-03T22:32:26.131Z" }, + { url = "https://files.pythonhosted.org/packages/90/01/723cd467ec267e712480c772aacc5aa73f82370c9665162fd12c41b0065b/libcst-1.8.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7445479ebe7d1aff0ee094ab5a1c7718e1ad78d33e3241e1a1ec65dcdbc22ffb", size = 2206386, upload-time = "2025-11-03T22:32:27.422Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/b944944f910f24c094f9b083f76f61e3985af5a376f5342a21e01e2d1a81/libcst-1.8.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fc3fef8a2c983e7abf5d633e1884c5dd6fa0dcb8f6e32035abd3d3803a3a196", size = 2083945, upload-time = "2025-11-03T22:32:28.847Z" }, + { url = "https://files.pythonhosted.org/packages/36/a1/bd1b2b2b7f153d82301cdaddba787f4a9fc781816df6bdb295ca5f88b7cf/libcst-1.8.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1a3a5e4ee870907aa85a4076c914ae69066715a2741b821d9bf16f9579de1105", size = 2235818, upload-time = "2025-11-03T22:32:30.504Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ab/f5433988acc3b4d188c4bb154e57837df9488cc9ab551267cdeabd3bb5e7/libcst-1.8.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6609291c41f7ad0bac570bfca5af8fea1f4a27987d30a1fa8b67fe5e67e6c78d", size = 2301289, upload-time = "2025-11-03T22:32:31.812Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/89f4ba7a6f1ac274eec9903a9e9174890d2198266eee8c00bc27eb45ecf7/libcst-1.8.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25eaeae6567091443b5374b4c7d33a33636a2d58f5eda02135e96fc6c8807786", size = 2299230, upload-time = "2025-11-03T22:32:33.242Z" }, + { url = "https://files.pythonhosted.org/packages/f2/36/0aa693bc24cce163a942df49d36bf47a7ed614a0cd5598eee2623bc31913/libcst-1.8.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04030ea4d39d69a65873b1d4d877def1c3951a7ada1824242539e399b8763d30", size = 2408519, upload-time = "2025-11-03T22:32:34.678Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/6dd055b5f15afa640fb3304b2ee9df8b7f72e79513814dbd0a78638f4a0e/libcst-1.8.6-cp313-cp313-win_amd64.whl", hash = "sha256:8066f1b70f21a2961e96bedf48649f27dfd5ea68be5cd1bed3742b047f14acde", size = 2119853, upload-time = "2025-11-03T22:32:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ed/5ddb2a22f0b0abdd6dcffa40621ada1feaf252a15e5b2733a0a85dfd0429/libcst-1.8.6-cp313-cp313-win_arm64.whl", hash = "sha256:c188d06b583900e662cd791a3f962a8c96d3dfc9b36ea315be39e0a4c4792ebf", size = 1999808, upload-time = "2025-11-03T22:32:38.1Z" }, + { url = "https://files.pythonhosted.org/packages/25/d3/72b2de2c40b97e1ef4a1a1db4e5e52163fc7e7740ffef3846d30bc0096b5/libcst-1.8.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c41c76e034a1094afed7057023b1d8967f968782433f7299cd170eaa01ec033e", size = 2190553, upload-time = "2025-11-03T22:32:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/0d/20/983b7b210ccc3ad94a82db54230e92599c4a11b9cfc7ce3bc97c1d2df75c/libcst-1.8.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5432e785322aba3170352f6e72b32bea58d28abd141ac37cc9b0bf6b7c778f58", size = 2074717, upload-time = "2025-11-03T22:32:41.373Z" }, + { url = "https://files.pythonhosted.org/packages/13/f2/9e01678fedc772e09672ed99930de7355757035780d65d59266fcee212b8/libcst-1.8.6-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:85b7025795b796dea5284d290ff69de5089fc8e989b25d6f6f15b6800be7167f", size = 2225834, upload-time = "2025-11-03T22:32:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/4a/0d/7bed847b5c8c365e9f1953da274edc87577042bee5a5af21fba63276e756/libcst-1.8.6-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:536567441182a62fb706e7aa954aca034827b19746832205953b2c725d254a93", size = 2287107, upload-time = "2025-11-03T22:32:44.549Z" }, + { url = "https://files.pythonhosted.org/packages/02/f0/7e51fa84ade26c518bfbe7e2e4758b56d86a114c72d60309ac0d350426c4/libcst-1.8.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f04d3672bde1704f383a19e8f8331521abdbc1ed13abb349325a02ac56e5012", size = 2288672, upload-time = "2025-11-03T22:32:45.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cd/15762659a3f5799d36aab1bc2b7e732672722e249d7800e3c5f943b41250/libcst-1.8.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f04febcd70e1e67917be7de513c8d4749d2e09206798558d7fe632134426ea4", size = 2392661, upload-time = "2025-11-03T22:32:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6b/b7f9246c323910fcbe021241500f82e357521495dcfe419004dbb272c7cb/libcst-1.8.6-cp313-cp313t-win_amd64.whl", hash = "sha256:1dc3b897c8b0f7323412da3f4ad12b16b909150efc42238e19cbf19b561cc330", size = 2105068, upload-time = "2025-11-03T22:32:49.145Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0b/4fd40607bc4807ec2b93b054594373d7fa3d31bb983789901afcb9bcebe9/libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42", size = 1985181, upload-time = "2025-11-03T22:32:50.597Z" }, + { url = "https://files.pythonhosted.org/packages/3a/60/4105441989e321f7ad0fd28ffccb83eb6aac0b7cfb0366dab855dcccfbe5/libcst-1.8.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b188e626ce61de5ad1f95161b8557beb39253de4ec74fc9b1f25593324a0279c", size = 2204202, upload-time = "2025-11-03T22:32:52.311Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/51a6f285c3a183e50cfe5269d4a533c21625aac2c8de5cdf2d41f079320d/libcst-1.8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87e74f7d7dfcba9efa91127081e22331d7c42515f0a0ac6e81d4cf2c3ed14661", size = 2083581, upload-time = "2025-11-03T22:32:54.269Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/921b1c19b638860af76cdb28bc81d430056592910b9478eea49e31a7f47a/libcst-1.8.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a926a4b42015ee24ddfc8ae940c97bd99483d286b315b3ce82f3bafd9f53474", size = 2236495, upload-time = "2025-11-03T22:32:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/12/a8/b00592f9bede618cbb3df6ffe802fc65f1d1c03d48a10d353b108057d09c/libcst-1.8.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fbb7f569e69fd9e89d9d9caa57ca42c577c28ed05062f96a8c207594e75b8", size = 2301466, upload-time = "2025-11-03T22:32:57.337Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/790d9002f31580fefd0aec2f373a0f5da99070e04c5e8b1c995d0104f303/libcst-1.8.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08bd63a8ce674be431260649e70fca1d43f1554f1591eac657f403ff8ef82c7a", size = 2300264, upload-time = "2025-11-03T22:32:58.852Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/dc3f10e65bab461be5de57850d2910a02c24c3ddb0da28f0e6e4133c3487/libcst-1.8.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e00e275d4ba95d4963431ea3e409aa407566a74ee2bf309a402f84fc744abe47", size = 2408572, upload-time = "2025-11-03T22:33:00.552Z" }, + { url = "https://files.pythonhosted.org/packages/20/3b/35645157a7590891038b077db170d6dd04335cd2e82a63bdaa78c3297dfe/libcst-1.8.6-cp314-cp314-win_amd64.whl", hash = "sha256:fea5c7fa26556eedf277d4f72779c5ede45ac3018650721edd77fd37ccd4a2d4", size = 2193917, upload-time = "2025-11-03T22:33:02.354Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a2/1034a9ba7d3e82f2c2afaad84ba5180f601aed676d92b76325797ad60951/libcst-1.8.6-cp314-cp314-win_arm64.whl", hash = "sha256:bb9b4077bdf8857b2483879cbbf70f1073bc255b057ec5aac8a70d901bb838e9", size = 2078748, upload-time = "2025-11-03T22:33:03.707Z" }, + { url = "https://files.pythonhosted.org/packages/95/a1/30bc61e8719f721a5562f77695e6154e9092d1bdf467aa35d0806dcd6cea/libcst-1.8.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:55ec021a296960c92e5a33b8d93e8ad4182b0eab657021f45262510a58223de1", size = 2188980, upload-time = "2025-11-03T22:33:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/2c/14/c660204532407c5628e3b615015a902ed2d0b884b77714a6bdbe73350910/libcst-1.8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ba9ab2b012fbd53b36cafd8f4440a6b60e7e487cd8b87428e57336b7f38409a4", size = 2074828, upload-time = "2025-11-03T22:33:06.864Z" }, + { url = "https://files.pythonhosted.org/packages/82/e2/c497c354943dff644749f177ee9737b09ed811b8fc842b05709a40fe0d1b/libcst-1.8.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c0a0cc80aebd8aa15609dd4d330611cbc05e9b4216bcaeabba7189f99ef07c28", size = 2225568, upload-time = "2025-11-03T22:33:08.354Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/45999676d07bd6d0eefa28109b4f97124db114e92f9e108de42ba46a8028/libcst-1.8.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:42a4f68121e2e9c29f49c97f6154e8527cd31021809cc4a941c7270aa64f41aa", size = 2286523, upload-time = "2025-11-03T22:33:10.206Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6c/517d8bf57d9f811862f4125358caaf8cd3320a01291b3af08f7b50719db4/libcst-1.8.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a434c521fadaf9680788b50d5c21f4048fa85ed19d7d70bd40549fbaeeecab1", size = 2288044, upload-time = "2025-11-03T22:33:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/24d7d49478ffb61207f229239879845da40a374965874f5ee60f96b02ddb/libcst-1.8.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a65f844d813ab4ef351443badffa0ae358f98821561d19e18b3190f59e71996", size = 2392605, upload-time = "2025-11-03T22:33:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/39/c3/829092ead738b71e96a4e96896c96f276976e5a8a58b4473ed813d7c962b/libcst-1.8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb14bc4d4d83a57062fed2c5da93ecb426ff65b0dc02ddf3481040f5f074a82", size = 2181581, upload-time = "2025-11-03T22:33:14.514Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/5d6a790a02eb0d9d36c4aed4f41b277497e6178900b2fa29c35353aa45ed/libcst-1.8.6-cp314-cp314t-win_arm64.whl", hash = "sha256:819c8081e2948635cab60c603e1bbdceccdfe19104a242530ad38a36222cb88f", size = 2065000, upload-time = "2025-11-03T22:33:16.257Z" }, +] + [[package]] name = "librt" version = "0.13.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/89/25/a6498964cfeec270c468cffdc118f69c29b412593610d55fa1327ca51ff4/librt-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082", size = 148029, upload-time = "2026-07-08T12:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/dc86d1bffd8e0c2818bace29d9f7783cfbb8e0673bf3673b5bbd5bbe0420/librt-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14", size = 153036, upload-time = "2026-07-08T12:24:47.257Z" }, + { url = "https://files.pythonhosted.org/packages/29/3f/b923826660f02f286186cd9303d52bb05ced0a13708edc104dc8480920e3/librt-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79", size = 493062, upload-time = "2026-07-08T12:24:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/6c0980a9c9b1302cb68d108906697b89eceb55889bb1dcf77c109aa56ca5/librt-0.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176", size = 485510, upload-time = "2026-07-08T12:24:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/32/81/795ae3b9df5dd94079fb807e38191855e023e8c6249014ae6bc3f0d9a490/librt-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89", size = 515909, upload-time = "2026-07-08T12:24:51.135Z" }, + { url = "https://files.pythonhosted.org/packages/20/e5/182de15abce8907108a6fdb41487de65beb5099b74dc5841b19b099168db/librt-0.13.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f", size = 508620, upload-time = "2026-07-08T12:24:52.358Z" }, + { url = "https://files.pythonhosted.org/packages/32/03/33978d32db76e1f66377e8f78e42a2ca3c162143331677d1f50bbad36cfb/librt-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d", size = 530363, upload-time = "2026-07-08T12:24:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f5/b291fbd2d00f7d8287bcbf67b5aa0c6afed4bc26cef23e079629c47a2c04/librt-0.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd", size = 534209, upload-time = "2026-07-08T12:24:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/6f41f17939d191bc21609f220da8509316bc62797f078545fe83be522e78/librt-0.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588", size = 514254, upload-time = "2026-07-08T12:24:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/2e4befa5410a7443019c14abccc94ff619797171f6b72013635fb87f31d7/librt-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1", size = 557611, upload-time = "2026-07-08T12:24:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/ab/54/8b69f81448417adbc040a2185f4e2eece1e1994b7dcfaeed4662b30f98a5/librt-0.13.0-cp311-cp311-win32.whl", hash = "sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21", size = 104906, upload-time = "2026-07-08T12:24:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/f4aaf37b50f2fde12c8c663b83fdd499cdc24f957f19543d7414bfcc9e25/librt-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b", size = 125852, upload-time = "2026-07-08T12:25:00.065Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/bf1820e6feeabc2f218c24450ec0c995d6a91e8ba0fd3caf042c9e8adb2a/librt-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c", size = 111832, upload-time = "2026-07-08T12:25:01.148Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, @@ -439,6 +608,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + [[package]] name = "markdown" version = "3.10.2" @@ -448,12 +629,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + [[package]] name = "markupsafe" version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, @@ -500,6 +720,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mergedeep" version = "1.3.4" @@ -628,6 +869,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl", hash = "sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d", size = 104951, upload-time = "2026-06-19T10:41:07.378Z" }, ] +[[package]] +name = "mutmut" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "coverage" }, + { name = "libcst" }, + { name = "pytest" }, + { name = "setproctitle" }, + { name = "textual" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/b0/ebcae42b90b07756b7aa10c4176835f436332e6c1cb28bc35bae83462382/mutmut-3.6.0.tar.gz", hash = "sha256:bcbd3e4d0d2d4edf3dfb42955417279a8866a3dbbcb87d619f2f3fd0ac7fafda", size = 51538, upload-time = "2026-06-06T07:44:51.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/5a/a0caa3f9db407b5d12c311bd4c87aa67fdd6e3f329377149e303108a1c51/mutmut-3.6.0-py3-none-any.whl", hash = "sha256:a9f5b8dcf6cbf9496769d7cf8bdbba37a0ec709ad98f88d103238b62f10bdf37", size = 47770, upload-time = "2026-06-06T07:44:50.038Z" }, +] + [[package]] name = "mypy" version = "2.3.0" @@ -641,6 +899,20 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, + { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, @@ -744,6 +1016,36 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, @@ -789,6 +1091,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] @@ -835,6 +1153,7 @@ version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ @@ -846,7 +1165,7 @@ name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage" }, + { name = "coverage", extra = ["toml"] }, { name = "pluggy" }, { name = "pytest" }, ] @@ -891,6 +1210,25 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, @@ -933,6 +1271,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] +[[package]] +name = "pyyaml-ft" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/eb/5a0d575de784f9a1f94e2b1288c6886f13f34185e13117ed530f32b6f8a8/pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab", size = 141057, upload-time = "2025-06-10T15:32:15.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/ba/a067369fe61a2e57fb38732562927d5bae088c73cb9bb5438736a9555b29/pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6", size = 187027, upload-time = "2025-06-10T15:31:48.722Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c5/a3d2020ce5ccfc6aede0d45bcb870298652ac0cf199f67714d250e0cdf39/pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69", size = 176146, upload-time = "2025-06-10T15:31:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bb/23a9739291086ca0d3189eac7cd92b4d00e9fdc77d722ab610c35f9a82ba/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0", size = 746792, upload-time = "2025-06-10T15:31:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c2/e8825f4ff725b7e560d62a3609e31d735318068e1079539ebfde397ea03e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42", size = 786772, upload-time = "2025-06-10T15:31:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/35/be/58a4dcae8854f2fdca9b28d9495298fd5571a50d8430b1c3033ec95d2d0e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b", size = 778723, upload-time = "2025-06-10T15:31:56.093Z" }, + { url = "https://files.pythonhosted.org/packages/86/ed/fed0da92b5d5d7340a082e3802d84c6dc9d5fa142954404c41a544c1cb92/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254", size = 758478, upload-time = "2025-06-10T15:31:58.314Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/ac02afe286275980ecb2dcdc0156617389b7e0c0a3fcdedf155c67be2b80/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8", size = 799159, upload-time = "2025-06-10T15:31:59.675Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ac/c492a9da2e39abdff4c3094ec54acac9747743f36428281fb186a03fab76/pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96", size = 158779, upload-time = "2025-06-10T15:32:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9b/41998df3298960d7c67653669f37710fa2d568a5fc933ea24a6df60acaf6/pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb", size = 191331, upload-time = "2025-06-10T15:32:02.602Z" }, + { url = "https://files.pythonhosted.org/packages/0f/16/2710c252ee04cbd74d9562ebba709e5a284faeb8ada88fcda548c9191b47/pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1", size = 182879, upload-time = "2025-06-10T15:32:04.466Z" }, + { url = "https://files.pythonhosted.org/packages/9a/40/ae8163519d937fa7bfa457b6f78439cc6831a7c2b170e4f612f7eda71815/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49", size = 811277, upload-time = "2025-06-10T15:32:06.214Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/28d82dbff7f87b96f0eeac79b7d972a96b4980c1e445eb6a857ba91eda00/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b", size = 831650, upload-time = "2025-06-10T15:32:08.076Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/161c4566facac7d75a9e182295c223060373d4116dead9cc53a265de60b9/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a", size = 815755, upload-time = "2025-06-10T15:32:09.435Z" }, + { url = "https://files.pythonhosted.org/packages/05/10/f42c48fa5153204f42eaa945e8d1fd7c10d6296841dcb2447bf7da1be5c4/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e", size = 810403, upload-time = "2025-06-10T15:32:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/e369064aa51009eb9245399fd8ad2c562bd0bcd392a00be44b2a824ded7c/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255", size = 835581, upload-time = "2025-06-10T15:32:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579, upload-time = "2025-06-10T15:32:14.34Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -948,6 +1310,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "ruff" version = "0.15.22" @@ -975,7 +1350,7 @@ wheels = [ [[package]] name = "safeuploads" -version = "1.1.0" +version = "2.0.0" source = { editable = "." } dependencies = [ { name = "defusedxml" }, @@ -1003,6 +1378,9 @@ docs = [ { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, ] +mutation = [ + { name = "mutmut" }, +] [package.metadata] requires-dist = [ @@ -1028,6 +1406,78 @@ docs = [ { name = "mkdocs-material", specifier = ">=9.6.0" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=0.29.0" }, ] +mutation = [{ name = "mutmut", specifier = ">=3.2,<4.0" }] + +[[package]] +name = "setproctitle" +version = "1.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/48/49393a96a2eef1ab418b17475fb92b8fcfad83d099e678751b05472e69de/setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e", size = 27002, upload-time = "2025-09-05T12:51:25.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/cd/1b7ba5cad635510720ce19d7122154df96a2387d2a74217be552887c93e5/setproctitle-1.3.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a600eeb4145fb0ee6c287cb82a2884bd4ec5bbb076921e287039dcc7b7cc6dd0", size = 18085, upload-time = "2025-09-05T12:49:22.183Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1a/b2da0a620490aae355f9d72072ac13e901a9fec809a6a24fc6493a8f3c35/setproctitle-1.3.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:97a090fed480471bb175689859532709e28c085087e344bca45cf318034f70c4", size = 13097, upload-time = "2025-09-05T12:49:23.322Z" }, + { url = "https://files.pythonhosted.org/packages/18/2e/bd03ff02432a181c1787f6fc2a678f53b7dacdd5ded69c318fe1619556e8/setproctitle-1.3.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1607b963e7b53e24ec8a2cb4e0ab3ae591d7c6bf0a160feef0551da63452b37f", size = 32191, upload-time = "2025-09-05T12:49:24.567Z" }, + { url = "https://files.pythonhosted.org/packages/28/78/1e62fc0937a8549f2220445ed2175daacee9b6764c7963b16148119b016d/setproctitle-1.3.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a20fb1a3974e2dab857870cf874b325b8705605cb7e7e8bcbb915bca896f52a9", size = 33203, upload-time = "2025-09-05T12:49:25.871Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3c/65edc65db3fa3df400cf13b05e9d41a3c77517b4839ce873aa6b4043184f/setproctitle-1.3.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8d961bba676e07d77665204f36cffaa260f526e7b32d07ab3df6a2c1dfb44ba", size = 34963, upload-time = "2025-09-05T12:49:27.044Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/89157e3de997973e306e44152522385f428e16f92f3cf113461489e1e2ee/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:db0fd964fbd3a9f8999b502f65bd2e20883fdb5b1fae3a424e66db9a793ed307", size = 32398, upload-time = "2025-09-05T12:49:28.909Z" }, + { url = "https://files.pythonhosted.org/packages/4a/18/77a765a339ddf046844cb4513353d8e9dcd8183da9cdba6e078713e6b0b2/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:db116850fcf7cca19492030f8d3b4b6e231278e8fe097a043957d22ce1bdf3ee", size = 33657, upload-time = "2025-09-05T12:49:30.323Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/f0b6205c64d74d2a24a58644a38ec77bdbaa6afc13747e75973bf8904932/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:316664d8b24a5c91ee244460bdaf7a74a707adaa9e14fbe0dc0a53168bb9aba1", size = 31836, upload-time = "2025-09-05T12:49:32.309Z" }, + { url = "https://files.pythonhosted.org/packages/ba/51/e1277f9ba302f1a250bbd3eedbbee747a244b3cc682eb58fb9733968f6d8/setproctitle-1.3.7-cp311-cp311-win32.whl", hash = "sha256:b74774ca471c86c09b9d5037c8451fff06bb82cd320d26ae5a01c758088c0d5d", size = 12556, upload-time = "2025-09-05T12:49:33.529Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7b/822a23f17e9003dfdee92cd72758441ca2a3680388da813a371b716fb07f/setproctitle-1.3.7-cp311-cp311-win_amd64.whl", hash = "sha256:acb9097213a8dd3410ed9f0dc147840e45ca9797785272928d4be3f0e69e3be4", size = 13243, upload-time = "2025-09-05T12:49:34.553Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f0/2dc88e842077719d7384d86cc47403e5102810492b33680e7dadcee64cd8/setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2dc99aec591ab6126e636b11035a70991bc1ab7a261da428491a40b84376654e", size = 18049, upload-time = "2025-09-05T12:49:36.241Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b4/50940504466689cda65680c9e9a1e518e5750c10490639fa687489ac7013/setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdd8aa571b7aa39840fdbea620e308a19691ff595c3a10231e9ee830339dd798", size = 13079, upload-time = "2025-09-05T12:49:38.088Z" }, + { url = "https://files.pythonhosted.org/packages/d0/99/71630546b9395b095f4082be41165d1078204d1696c2d9baade3de3202d0/setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2906b6c7959cdb75f46159bf0acd8cc9906cf1361c9e1ded0d065fe8f9039629", size = 32932, upload-time = "2025-09-05T12:49:39.271Z" }, + { url = "https://files.pythonhosted.org/packages/50/22/cee06af4ffcfb0e8aba047bd44f5262e644199ae7527ae2c1f672b86495c/setproctitle-1.3.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6915964a6dda07920a1159321dcd6d94fc7fc526f815ca08a8063aeca3c204f1", size = 33736, upload-time = "2025-09-05T12:49:40.565Z" }, + { url = "https://files.pythonhosted.org/packages/5c/00/a5949a8bb06ef5e7df214fc393bb2fb6aedf0479b17214e57750dfdd0f24/setproctitle-1.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff72899861c765bd4021d1ff1c68d60edc129711a2fdba77f9cb69ef726a8b6", size = 35605, upload-time = "2025-09-05T12:49:42.362Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3a/50caca532a9343828e3bf5778c7a84d6c737a249b1796d50dd680290594d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7cb05bd446687ff816a3aaaf831047fc4c364feff7ada94a66024f1367b448c", size = 33143, upload-time = "2025-09-05T12:49:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ca/14/b843a251296ce55e2e17c017d6b9f11ce0d3d070e9265de4ecad948b913d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3a57b9a00de8cae7e2a1f7b9f0c2ac7b69372159e16a7708aa2f38f9e5cc987a", size = 34434, upload-time = "2025-09-05T12:49:45.31Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b7/06145c238c0a6d2c4bc881f8be230bb9f36d2bf51aff7bddcb796d5eed67/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d8828b356114f6b308b04afe398ed93803d7fca4a955dd3abe84430e28d33739", size = 32795, upload-time = "2025-09-05T12:49:46.419Z" }, + { url = "https://files.pythonhosted.org/packages/ef/dc/ef76a81fac9bf27b84ed23df19c1f67391a753eed6e3c2254ebcb5133f56/setproctitle-1.3.7-cp312-cp312-win32.whl", hash = "sha256:b0304f905efc845829ac2bc791ddebb976db2885f6171f4a3de678d7ee3f7c9f", size = 12552, upload-time = "2025-09-05T12:49:47.635Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5b/a9fe517912cd6e28cf43a212b80cb679ff179a91b623138a99796d7d18a0/setproctitle-1.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:9888ceb4faea3116cf02a920ff00bfbc8cc899743e4b4ac914b03625bdc3c300", size = 13247, upload-time = "2025-09-05T12:49:49.16Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2f/fcedcade3b307a391b6e17c774c6261a7166aed641aee00ed2aad96c63ce/setproctitle-1.3.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3736b2a423146b5e62230502e47e08e68282ff3b69bcfe08a322bee73407922", size = 18047, upload-time = "2025-09-05T12:49:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/23/ae/afc141ca9631350d0a80b8f287aac79a76f26b6af28fd8bf92dae70dc2c5/setproctitle-1.3.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3384e682b158d569e85a51cfbde2afd1ab57ecf93ea6651fe198d0ba451196ee", size = 13073, upload-time = "2025-09-05T12:49:51.46Z" }, + { url = "https://files.pythonhosted.org/packages/87/ed/0a4f00315bc02510395b95eec3d4aa77c07192ee79f0baae77ea7b9603d8/setproctitle-1.3.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0564a936ea687cd24dffcea35903e2a20962aa6ac20e61dd3a207652401492dd", size = 33284, upload-time = "2025-09-05T12:49:52.741Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e4/adf3c4c0a2173cb7920dc9df710bcc67e9bcdbf377e243b7a962dc31a51a/setproctitle-1.3.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5d1cb3f81531f0eb40e13246b679a1bdb58762b170303463cb06ecc296f26d0", size = 34104, upload-time = "2025-09-05T12:49:54.416Z" }, + { url = "https://files.pythonhosted.org/packages/52/4f/6daf66394152756664257180439d37047aa9a1cfaa5e4f5ed35e93d1dc06/setproctitle-1.3.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a7d159e7345f343b44330cbba9194169b8590cb13dae940da47aa36a72aa9929", size = 35982, upload-time = "2025-09-05T12:49:56.295Z" }, + { url = "https://files.pythonhosted.org/packages/1b/62/f2c0595403cf915db031f346b0e3b2c0096050e90e0be658a64f44f4278a/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0b5074649797fd07c72ca1f6bff0406f4a42e1194faac03ecaab765ce605866f", size = 33150, upload-time = "2025-09-05T12:49:58.025Z" }, + { url = "https://files.pythonhosted.org/packages/a0/29/10dd41cde849fb2f9b626c846b7ea30c99c81a18a5037a45cc4ba33c19a7/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61e96febced3f61b766115381d97a21a6265a0f29188a791f6df7ed777aef698", size = 34463, upload-time = "2025-09-05T12:49:59.424Z" }, + { url = "https://files.pythonhosted.org/packages/71/3c/cedd8eccfaf15fb73a2c20525b68c9477518917c9437737fa0fda91e378f/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:047138279f9463f06b858e579cc79580fbf7a04554d24e6bddf8fe5dddbe3d4c", size = 32848, upload-time = "2025-09-05T12:50:01.107Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3e/0a0e27d1c9926fecccfd1f91796c244416c70bf6bca448d988638faea81d/setproctitle-1.3.7-cp313-cp313-win32.whl", hash = "sha256:7f47accafac7fe6535ba8ba9efd59df9d84a6214565108d0ebb1199119c9cbbd", size = 12544, upload-time = "2025-09-05T12:50:15.81Z" }, + { url = "https://files.pythonhosted.org/packages/36/1b/6bf4cb7acbbd5c846ede1c3f4d6b4ee52744d402e43546826da065ff2ab7/setproctitle-1.3.7-cp313-cp313-win_amd64.whl", hash = "sha256:fe5ca35aeec6dc50cabab9bf2d12fbc9067eede7ff4fe92b8f5b99d92e21263f", size = 13235, upload-time = "2025-09-05T12:50:16.89Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a4/d588d3497d4714750e3eaf269e9e8985449203d82b16b933c39bd3fc52a1/setproctitle-1.3.7-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:10e92915c4b3086b1586933a36faf4f92f903c5554f3c34102d18c7d3f5378e9", size = 18058, upload-time = "2025-09-05T12:50:02.501Z" }, + { url = "https://files.pythonhosted.org/packages/05/77/7637f7682322a7244e07c373881c7e982567e2cb1dd2f31bd31481e45500/setproctitle-1.3.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:de879e9c2eab637f34b1a14c4da1e030c12658cdc69ee1b3e5be81b380163ce5", size = 13072, upload-time = "2025-09-05T12:50:03.601Z" }, + { url = "https://files.pythonhosted.org/packages/52/09/f366eca0973cfbac1470068d1313fa3fe3de4a594683385204ec7f1c4101/setproctitle-1.3.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c18246d88e227a5b16248687514f95642505000442165f4b7db354d39d0e4c29", size = 34490, upload-time = "2025-09-05T12:50:04.948Z" }, + { url = "https://files.pythonhosted.org/packages/71/36/611fc2ed149fdea17c3677e1d0df30d8186eef9562acc248682b91312706/setproctitle-1.3.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7081f193dab22df2c36f9fc6d113f3793f83c27891af8fe30c64d89d9a37e152", size = 35267, upload-time = "2025-09-05T12:50:06.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/a4/64e77d0671446bd5a5554387b69e1efd915274686844bea733714c828813/setproctitle-1.3.7-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cc9b901ce129350637426a89cfd650066a4adc6899e47822e2478a74023ff7c", size = 37376, upload-time = "2025-09-05T12:50:07.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/ad9c664fe524fb4a4b2d3663661a5c63453ce851736171e454fa2cdec35c/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80e177eff2d1ec172188d0d7fd9694f8e43d3aab76a6f5f929bee7bf7894e98b", size = 33963, upload-time = "2025-09-05T12:50:09.056Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a36de7caf2d90c4c28678da1466b47495cbbad43badb4e982d8db8167ed4/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:23e520776c445478a67ee71b2a3c1ffdafbe1f9f677239e03d7e2cc635954e18", size = 35550, upload-time = "2025-09-05T12:50:10.791Z" }, + { url = "https://files.pythonhosted.org/packages/dd/68/17e8aea0ed5ebc17fbf03ed2562bfab277c280e3625850c38d92a7b5fcd9/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5fa1953126a3b9bd47049d58c51b9dac72e78ed120459bd3aceb1bacee72357c", size = 33727, upload-time = "2025-09-05T12:50:12.032Z" }, + { url = "https://files.pythonhosted.org/packages/b2/33/90a3bf43fe3a2242b4618aa799c672270250b5780667898f30663fd94993/setproctitle-1.3.7-cp313-cp313t-win32.whl", hash = "sha256:4a5e212bf438a4dbeece763f4962ad472c6008ff6702e230b4f16a037e2f6f29", size = 12549, upload-time = "2025-09-05T12:50:13.074Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0e/50d1f07f3032e1f23d814ad6462bc0a138f369967c72494286b8a5228e40/setproctitle-1.3.7-cp313-cp313t-win_amd64.whl", hash = "sha256:cf2727b733e90b4f874bac53e3092aa0413fe1ea6d4f153f01207e6ce65034d9", size = 13243, upload-time = "2025-09-05T12:50:14.146Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/43ac3a98414f91d1b86a276bc2f799ad0b4b010e08497a95750d5bc42803/setproctitle-1.3.7-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:80c36c6a87ff72eabf621d0c79b66f3bdd0ecc79e873c1e9f0651ee8bf215c63", size = 18052, upload-time = "2025-09-05T12:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2c/dc258600a25e1a1f04948073826bebc55e18dbd99dc65a576277a82146fa/setproctitle-1.3.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b53602371a52b91c80aaf578b5ada29d311d12b8a69c0c17fbc35b76a1fd4f2e", size = 13071, upload-time = "2025-09-05T12:50:19.061Z" }, + { url = "https://files.pythonhosted.org/packages/ab/26/8e3bb082992f19823d831f3d62a89409deb6092e72fc6940962983ffc94f/setproctitle-1.3.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fcb966a6c57cf07cc9448321a08f3be6b11b7635be502669bc1d8745115d7e7f", size = 33180, upload-time = "2025-09-05T12:50:20.395Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/ae692a20276d1159dd0cf77b0bcf92cbb954b965655eb4a69672099bb214/setproctitle-1.3.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46178672599b940368d769474fe13ecef1b587d58bb438ea72b9987f74c56ea5", size = 34043, upload-time = "2025-09-05T12:50:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/34/b2/6a092076324dd4dac1a6d38482bedebbff5cf34ef29f58585ec76e47bc9d/setproctitle-1.3.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f9e9e3ff135cbcc3edd2f4cf29b139f4aca040d931573102742db70ff428c17", size = 35892, upload-time = "2025-09-05T12:50:23.937Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1a/8836b9f28cee32859ac36c3df85aa03e1ff4598d23ea17ca2e96b5845a8f/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14c7eba8d90c93b0e79c01f0bd92a37b61983c27d6d7d5a3b5defd599113d60e", size = 32898, upload-time = "2025-09-05T12:50:25.617Z" }, + { url = "https://files.pythonhosted.org/packages/ef/22/8fabdc24baf42defb599714799d8445fe3ae987ec425a26ec8e80ea38f8e/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e64e98077fb30b6cf98073d6c439cd91deb8ebbf8fc62d9dbf52bd38b0c6ac0", size = 34308, upload-time = "2025-09-05T12:50:26.827Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/b9bee9de6c8cdcb3b3a6cb0b3e773afdb86bbbc1665a3bfa424a4294fda2/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b91387cc0f02a00ac95dcd93f066242d3cca10ff9e6153de7ee07069c6f0f7c8", size = 32536, upload-time = "2025-09-05T12:50:28.5Z" }, + { url = "https://files.pythonhosted.org/packages/37/0c/75e5f2685a5e3eda0b39a8b158d6d8895d6daf3ba86dec9e3ba021510272/setproctitle-1.3.7-cp314-cp314-win32.whl", hash = "sha256:52b054a61c99d1b72fba58b7f5486e04b20fefc6961cd76722b424c187f362ed", size = 12731, upload-time = "2025-09-05T12:50:43.955Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/acddbce90d1361e1786e1fb421bc25baeb0c22ef244ee5d0176511769ec8/setproctitle-1.3.7-cp314-cp314-win_amd64.whl", hash = "sha256:5818e4080ac04da1851b3ec71e8a0f64e3748bf9849045180566d8b736702416", size = 13464, upload-time = "2025-09-05T12:50:45.057Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/20886c8ff2e6d85e3cabadab6aab9bb90acaf1a5cfcb04d633f8d61b2626/setproctitle-1.3.7-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6fc87caf9e323ac426910306c3e5d3205cd9f8dcac06d233fcafe9337f0928a3", size = 18062, upload-time = "2025-09-05T12:50:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/9a/60/26dfc5f198715f1343b95c2f7a1c16ae9ffa45bd89ffd45a60ed258d24ea/setproctitle-1.3.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6134c63853d87a4897ba7d5cc0e16abfa687f6c66fc09f262bb70d67718f2309", size = 13075, upload-time = "2025-09-05T12:50:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/9c/980b01f50d51345dd513047e3ba9e96468134b9181319093e61db1c47188/setproctitle-1.3.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1403d2abfd32790b6369916e2313dffbe87d6b11dca5bbd898981bcde48e7a2b", size = 34744, upload-time = "2025-09-05T12:50:32.777Z" }, + { url = "https://files.pythonhosted.org/packages/86/b4/82cd0c86e6d1c4538e1a7eb908c7517721513b801dff4ba3f98ef816a240/setproctitle-1.3.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7c5bfe4228ea22373e3025965d1a4116097e555ee3436044f5c954a5e63ac45", size = 35589, upload-time = "2025-09-05T12:50:34.13Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4f/9f6b2a7417fd45673037554021c888b31247f7594ff4bd2239918c5cd6d0/setproctitle-1.3.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:585edf25e54e21a94ccb0fe81ad32b9196b69ebc4fc25f81da81fb8a50cca9e4", size = 37698, upload-time = "2025-09-05T12:50:35.524Z" }, + { url = "https://files.pythonhosted.org/packages/20/92/927b7d4744aac214d149c892cb5fa6dc6f49cfa040cb2b0a844acd63dcaf/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:96c38cdeef9036eb2724c2210e8d0b93224e709af68c435d46a4733a3675fee1", size = 34201, upload-time = "2025-09-05T12:50:36.697Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0c/fd4901db5ba4b9d9013e62f61d9c18d52290497f956745cd3e91b0d80f90/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:45e3ef48350abb49cf937d0a8ba15e42cee1e5ae13ca41a77c66d1abc27a5070", size = 35801, upload-time = "2025-09-05T12:50:38.314Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e3/54b496ac724e60e61cc3447f02690105901ca6d90da0377dffe49ff99fc7/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1fae595d032b30dab4d659bece20debd202229fce12b55abab978b7f30783d73", size = 33958, upload-time = "2025-09-05T12:50:39.841Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/c84bb045ebf8c6fdc7f7532319e86f8380d14bbd3084e6348df56bdfe6fd/setproctitle-1.3.7-cp314-cp314t-win32.whl", hash = "sha256:02432f26f5d1329ab22279ff863c83589894977063f59e6c4b4845804a08f8c2", size = 12745, upload-time = "2025-09-05T12:50:41.377Z" }, + { url = "https://files.pythonhosted.org/packages/08/b6/3a5a4f9952972791a9114ac01dfc123f0df79903577a3e0a7a404a695586/setproctitle-1.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:cbc388e3d86da1f766d8fc2e12682e446064c01cea9f88a88647cfe7c011de6a", size = 13469, upload-time = "2025-09-05T12:50:42.67Z" }, + { url = "https://files.pythonhosted.org/packages/c3/5b/5e1c117ac84e3cefcf8d7a7f6b2461795a87e20869da065a5c087149060b/setproctitle-1.3.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b1cac6a4b0252b8811d60b6d8d0f157c0fdfed379ac89c25a914e6346cf355a1", size = 12587, upload-time = "2025-09-05T12:51:21.195Z" }, + { url = "https://files.pythonhosted.org/packages/73/02/b9eadc226195dcfa90eed37afe56b5dd6fa2f0e5220ab8b7867b8862b926/setproctitle-1.3.7-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f1704c9e041f2b1dc38f5be4552e141e1432fba3dd52c72eeffd5bc2db04dc65", size = 14286, upload-time = "2025-09-05T12:51:22.61Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/1be1d2a53c2a91ec48fa2ff4a409b395f836798adf194d99de9c059419ea/setproctitle-1.3.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b08b61976ffa548bd5349ce54404bf6b2d51bd74d4f1b241ed1b0f25bce09c3a", size = 13282, upload-time = "2025-09-05T12:51:24.094Z" }, +] [[package]] name = "six" @@ -1053,12 +1503,84 @@ version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "types-defusedxml" version = "0.7.0.20260504" @@ -1089,6 +1611,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" @@ -1104,6 +1635,12 @@ version = "6.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },