diff --git a/changedetectionio/flask_app.py b/changedetectionio/flask_app.py index c98e2a79a..639bd0a14 100644 --- a/changedetectionio/flask_app.py +++ b/changedetectionio/flask_app.py @@ -216,13 +216,37 @@ csrf = CSRFProtect() csrf.init_app(app) notification_debug_log = [] -# Locale for correct presentation of prices etc +# Locale for correct presentation of prices etc. +# +# Deliberately NOT locale.LC_ALL - LC_COLLATE must stay in the "C" locale. +# +# elementpath implements the XPath string functions on top of locale.strxfrm: +# +# def contains(self, a, b): return self.strxfrm(b) in self.strxfrm(a) +# +# Under LC_COLLATE=C, strxfrm() is the identity function and that substring test means what it +# says. Under any real locale it returns a binary collation key, and a substring of a collation +# key is not the collation key of the substring - so contains(), starts-with(), ends-with() and +# substring-before/after() silently return false for EVERY input. Every xPath filter using +# contains() then matches nothing and the watch reports "no filters were found" on a page whose +# HTML plainly contains the target (#4437). +# +# That stayed hidden until the image actually generated its locales: before then this call raised +# locale.Error, we logged a warning and stayed in C. Once en_US.UTF-8 existed the call succeeded +# and took LC_COLLATE with it. Setting the presentation categories individually keeps what this +# block is for - 1234567 still renders as "1,234,567" - without touching collation. +# +# Per XPath 3.1 the default collation is codepoint and must not consult LC_COLLATE at all, so +# this is arguably an elementpath bug; html_tools.xpath_filter() pins the collation explicitly as +# well, so a filter is correct even if an operator sets LC_COLLATE themselves. default_locale = locale.getdefaultlocale() logger.info(f"System locale default is {default_locale}") -try: - locale.setlocale(locale.LC_ALL, default_locale) -except locale.Error: - logger.warning(f"Unable to set locale {default_locale}, locale is not installed maybe?") +for _category in (locale.LC_CTYPE, locale.LC_NUMERIC, locale.LC_MONETARY, locale.LC_TIME): + try: + locale.setlocale(_category, default_locale) + except locale.Error: + logger.warning(f"Unable to set locale {default_locale} for category {_category}, " + f"locale is not installed maybe?") watch_api = Api(app, decorators=[csrf.exempt]) diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py index 3bdb9e983..f08e5ddd8 100644 --- a/changedetectionio/forms.py +++ b/changedetectionio/forms.py @@ -697,14 +697,18 @@ class ValidateCSSJSONXPATHInput(object): raise ValidationError("XPath not permitted in this field!") from lxml import etree, html import elementpath - from changedetectionio.html_tools import get_safe_xpath3_parser, lxml_guard, lxml_html_parser + from changedetectionio.html_tools import get_safe_xpath3_parser, lxml_guard, lxml_html_parser, \ + XPATH_CODEPOINT_COLLATION line = line.replace('xpath:', '') try: # Runs on a Flask request thread - must share the worker's lxml lock. with lxml_guard(): tree = html.fromstring("", parser=lxml_html_parser()) - elementpath.select(tree, line.strip(), parser=get_safe_xpath3_parser()) + # Same collation the filter will actually run under, so validation + # cannot accept an expression that then behaves differently at check time. + elementpath.select(tree, line.strip(), parser=get_safe_xpath3_parser(), + default_collation=XPATH_CODEPOINT_COLLATION) except elementpath.ElementPathError as e: message = field.gettext('\'%(expression)s\' is not a valid XPath expression. (%(error)s)') raise ValidationError(message % {'expression': line, 'error': str(e)}) diff --git a/changedetectionio/html_tools.py b/changedetectionio/html_tools.py index d40f17e17..e3b34f49c 100644 --- a/changedetectionio/html_tools.py +++ b/changedetectionio/html_tools.py @@ -164,6 +164,25 @@ _DEFAULT_UNSAFE_XPATH3_FUNCTIONS = [ ] +# XPath 3.1 says the default collation is Unicode codepoint collation. elementpath instead leaves +# its collation functions pointing at locale.strxfrm / locale.strcoll, so the process-wide +# LC_COLLATE decides what contains() means: +# +# def contains(self, a, b): return self.strxfrm(b) in self.strxfrm(a) +# +# With LC_COLLATE=C, strxfrm() is the identity and that is an ordinary substring test. With +# LC_COLLATE=en_US.UTF-8 it returns a binary collation key, and a substring of a collation key is +# not the collation key of the substring - so contains(), starts-with(), ends-with() and +# substring-before/after() return false for every input, and a filter that matches 67 elements +# matches 0 (#4437). Name tests, axes and '=' are unaffected, which is what made it look like the +# page had changed layout. +# +# flask_app.py keeps LC_COLLATE in "C" for this reason, but a filter must not depend on a distant +# module's locale bookkeeping, nor on what an operator puts in LANG/LC_ALL. Pinning the collation +# per evaluation makes the filter mean the same thing in every deployment. +XPATH_CODEPOINT_COLLATION = 'http://www.w3.org/2005/xpath-functions/collation/codepoint' + + def get_safe_xpath3_parser(): """Return an XPath3Parser subclass with filesystem/environment access functions removed. @@ -383,7 +402,9 @@ def xpath_filter(xpath_filter, html_content, append_pretty_line_formatting=False # This allows //title to match elements in the default namespace namespaces[''] = tree.nsmap[None] - r = elementpath.select(tree, xpath_filter.strip(), namespaces=namespaces, parser=get_safe_xpath3_parser()) + r = elementpath.select(tree, xpath_filter.strip(), namespaces=namespaces, + parser=get_safe_xpath3_parser(), + default_collation=XPATH_CODEPOINT_COLLATION) #@note: //title/text() now works with default namespaces (fixed by registering '' prefix) #@note: //title/text() wont work where
Only $9.99 per month