Files
sabnzbd/tests/test_encoding.py
mnightingale 35d5355f49 Replace CherryPy with Uvicorn and Starlette (#3550)
* Migrate web interface from CherryPy to Uvicorn/Starlette

Squashed rebase of feature/uvicorn (34 commits) onto develop, reconciled
with ~3 months of intervening develop changes.

Replaces the CherryPy webserver and request handling with Uvicorn/Starlette
across the API, web interface, RSS, config pages and related modules.

Reconciliation with develop during the rebase:
- api.py: kept develop's security/behaviour fixes (orphan path-traversal
  guard, expanded log redaction incl. host_whitelist and
  remote_label_replacement, get_dconfig single-return, get_retryable_jobs,
  connections default, translated NNTP test errors) on top of the Starlette
  request/response rewrite.
- interface.py: ported the RSS route handlers to develop's DB-backed
  RSSRepository API (process_feed, rss_repository / find_job_by_url /
  clear_feed / clear_downloaded / flag_downloaded).
- misc.py: kept develop's hachoir-based get_media_duration.
- requirements.txt: dropped the CherryPy stack, adopted develop's newer pins.

Also applied ruff --fix (PEP 604 unions, builtin generics) to align with
develop's lint config.

Verified: ruff check, black --check, and the affected test suites
(9413 passed, 1 skipped) all pass.

* Update starlette/uvicorn versions

* Fix race issues in global rss state

* Fix test race in server shutdown

The uvicorn migration turned /shutdown (and the shutdown API) into fire-and-forget: it spawned shutdown_program() in a background thread and replied immediately, whereas develop ran it synchronously and only replied once halt() had persisted all state. Because the module-scoped test teardown doesn't wait for the process to exit, the next module's clean_cache_dir wiped the shared cache dir (and reused the fixed port) while the previous instance was still saving state and holding the port — producing the three intermittent failures (deleted sabnzbd.log → "File log disabled or not found"; un-persisted [sorters] → KeyError; stale instance → missing wizard .quoteBlock).

* Fix robots and description, add favicon

* Remove remains of http basic auth

* Setup Starlette once configuration is available, fix static file relative cwd and url_base config

* abort_and_show_error when webserver fails to start

* Guard stopping webserver that never started

* Delegate XFF handling to ProxyHeadersMiddleware

* Merged params at request.state.params instead of modifying private apis

* Both shutdown routes share implementation and do not block event loop

* Run sync handlers via run_in_threadpool and facilitate eventual migration to async

* Pool database connections

* Online backup of database due to WAL changes

* Fix exception on None request.client (test clients or unix sockets)

* Fix flakey tests due to process not fully shutting down

* Restore X-Frame-Options behaviour via middleware

* Fix set_config_default with multiple keywords

* Remove broken logging call

* Restore api logging functionality

* Cache-Control: no-store

* Login only via POST

* Remove 401 (basic-auth) and add 404 handling via redirect

* Fix crash when shutdown not an int

* Use BaseRedirectResponse helper

* Remove trailing slashes from wizard routes

* URL helper, absolute URLs everywhere, fixes issues with nested navigation

* Fix scheduler adding multiple daysofweek

* Restore CherryPy api behaviour merging body with query params (body wins)

* Clearer documentation of get_request_params and request_params

* First stage supporting gradual api async

* Fix rss ajax consuming flash

* Restore access log functionality

* Hostname check in middleware

* Request logging in middleware

* Param parsing in middleware

* Security checks in middleware

* secured_expose is now purely route registration

* Lookup api handler once per request

* Fix flakey alert dialogs

* Trigger restart via BackgroundTask

* Restore CherryPy first param wins and get/post consistency

* Remove dead code

* Secure cookies based on protocol the client used

* Fix various issues with port_is_free

1. port_is_free answered the wrong question. It connect-probed ("is something answering?") rather than bind-probed ("can I bind?"). A port could report free and then kill startup at uvicorn's bind().
2. The bind-all remap crossed address families. :: was mapped to 127.0.0.1, probing IPv4 for an IPv6 bind — a regression against portend, which maps :: → ::1.
3. The call sites passed the wrong host. browserhost is a client-reachable address; the thing that has to be bindable is web_host.
4. Errors were swallowed. A bare except OSError hid gaierror, so an unresolvable host reported "free".
5. find_free_port had a port-0 trap. Under a bind-probe, currentport=0 always succeeds and returned 0 — the old failure sentinel. Now guarded, and None instead of 0.
6. Ports 80/443 were misdiagnosed. EACCES was folded into "occupied", producing ten futile probes and a panic claiming another program held the port. PermissionError now propagates to a dedicated panic explaining the actual remedies.
7. The tests were largely tautological. Three tests covering one branch, an IPv6 test with no IPv6 in it, a timeout test that never engaged the timeout, TOCTOU-prone fixed-range probes, no SO_REUSEADDR on the helper listener, and nothing asserting the property that matters — that "free" implies bindable.
8. A portability bug I introduced, then fixed. I'd baked Linux SO_REUSEADDR overlap semantics into four assertions; macOS differs. Now platform-aware, with the IPv6 regression re-covered by checking the socket family directly.

* Claim the bind address for uvicorn on startup, resolves "49" in err handling from cherrypy

* Rename function BaseRedirectResponse to base_redirect_response

* Restore error response on change web directory

* Add missing typings

* Fix return type of retry job for future types

* A better fix for xdist compatibility - test overwrote db_path

* Secure session cookies (rss flash)

* Inline or remove some functions

* Retry job futuretype behaviour

* Sneak a worksteal fix in

* Test and fix retry_job futuretype behaviour
2026-08-11 13:27:51 +01:00

100 lines
4.6 KiB
Python

#!/usr/bin/python3 -OO
# Copyright 2007-2026 by The SABnzbd-Team (sabnzbd.org)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
"""
tests.test_misc - Testing functions in encoding.py
"""
import unicodedata
import sabnzbd.encoding as enc
class TestEncoding:
def test_correct_unknown_encoding(self):
# Windows encoding in bytes
assert "frènch_german_demö" == enc.correct_unknown_encoding(b"fr\xe8nch_german_dem\xf6")
# Windows encoding in string that's already UTF8
assert "demotöwers" == enc.correct_unknown_encoding("demot\udcf6wers")
def test_correct_unknown_encoding_nfc_normalization(self):
"""Verify that correct_unknown_encoding always returns NFC-normalized strings.
This is the fix for GitHub issues #1633 and #2858: par2 file metadata may
carry NFC filenames while macOS / some archivers produce NFD.
Both must end up identical after passing through this function so that
filename comparisons (e.g. quick_check_set) do not cause double-unpacking.
"""
# NFD form: 'e' + combining-grave (U+0300), 'o' + combining-diaeresis (U+0308)
nfd_bytes = "fre\u0300nch_german_demo\u0308".encode("utf-8")
# NFC form: precomposed è (U+00E8) and ö (U+00F6)
nfc_string = "frènch_german_demö" # U+00E8 / U+00F6
result_from_nfd = enc.correct_unknown_encoding(nfd_bytes)
result_from_nfc = enc.correct_unknown_encoding(nfc_string.encode("utf-8"))
# Both should produce the same NFC result
assert result_from_nfd == nfc_string
assert result_from_nfc == nfc_string
# Confirm the output is actually NFC, not NFD
assert unicodedata.is_normalized("NFC", result_from_nfd)
assert unicodedata.is_normalized("NFC", result_from_nfc)
# Confirm NFD input was different at the byte level before normalization
assert nfd_bytes != nfc_string.encode("utf-8")
def test_limit_encoded_length(self):
# Test with empty string
assert enc.limit_encoded_length("", 10) == "", "Empty string should return empty string"
# Test with string shorter than the limit
assert enc.limit_encoded_length("hello", 10) == "hello"
# Test with string equal to the limit
assert enc.limit_encoded_length("hello", 5) == "hello", "String equal to limit should return the same string"
# Test with string longer than the limit
assert enc.limit_encoded_length("hello world", 5) == "hello", "String longer than limit should be truncated"
# Test with UTF-8 characters
assert enc.limit_encoded_length("héllö wørld", 10) == "héllö w", "UTF-8 characters should be handled correctly"
# Test with emojis (multibyte characters)
assert enc.limit_encoded_length("😀😂🤣😃😄😅😆😉😊😋😎😍", 30) == "😀😂🤣😃😄😅😆"
# Test with invalid UTF-8 (single surrogate)
invalid_utf8 = b"\xed\xa0\x80".decode("latin-1")
limited_string = enc.limit_encoded_length(invalid_utf8, 3)
assert len(limited_string) == 1, "Invalid UTF-8 should be handled without raising an exception"
# Test with mixed valid and invalid UTF-8
mixed_string = "hello" + b"\xed\xa0\x80".decode("latin-1") + "world"
limited_string = enc.limit_encoded_length(mixed_string, 8)
assert "hello" in limited_string, "Valid part of mixed string should be present"
assert len(limited_string) <= 8, "Length of mixed string should be limited"
# Parametrized tests for various string and length combinations
test_cases = [
("", 5, ""),
("short", 10, "short"),
("longstring", 4, "long"),
("üöä", 4, "üö"), # Test for umlauts
("你好世界", 4, "你"), # Test for CJK characters
]
for input_string, max_len, expected in test_cases:
assert enc.limit_encoded_length(input_string, max_len) == expected