Repository: joke2k/django-environ

Issue:
Error with CACHE_URL passwords that end with '='
This is somewhat similar to #194. A cache password that ends with the `=` character raises an error when calling `env.cache`. Even escaping it (`%3D`) will not work.

It looks like the `cast` method works by trying to just convert to a value and if that fails with a `ValueError`, it assumes the value is string ([see code](https://github.com/joke2k/django-environ/blob/20a1b50/environ/environ.py#L27-L30)). However, a string that ends in a `=` will throw a `SyntaxError` instead of a `ValueError`:

    >>> import ast
    >>> ast.literal_eval('mypassword=')
    Traceback (most recent call last):
    ...
    SyntaxError: unexpected EOF while parsing

If you think the solution is just to catch a `SyntaxError` in addition to a `ValueError`, I'm happy to make a PR.

Normally I'd just change the password but on Azure the generated cache passwords always end with a `=` for some reason.

Comments:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 0e4bd83..335a267 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -17,12 +17,13 @@ Added
   `#314 <https://github.com/joke2k/django-environ/pull/314>`_.
 - Provided ability to use ``bytes`` or ``str`` as a default value for ``Env.bytes()``.
 
-
 Fixed
 +++++
-- Fixed links in the documentation
+- Fixed links in the documentation.
 - Use default option in ``Env.bytes()``
   `#206 <https://github.com/joke2k/django-environ/pull/206>`_.
+- Safely evaluate a string containing an invalid Python literal
+  `#200 <https://github.com/joke2k/django-environ/issues/200>`_.
 
 Changed
 +++++++
diff --git a/environ/environ.py b/environ/environ.py
index 0b97c5e..0267add 100644
--- a/environ/environ.py
+++ b/environ/environ.py
@@ -44,7 +44,7 @@ def _cast(value):
     # https://docs.python.org/3/library/ast.html#ast.literal_eval
     try:
         return ast.literal_eval(value)
-    except ValueError:
+    except (ValueError, SyntaxError):
         return value
 
 


Test patch:
diff --git a/tests/test_cache.py b/tests/test_cache.py
index 13077a5..42c72bb 100644
--- a/tests/test_cache.py
+++ b/tests/test_cache.py
@@ -149,3 +149,80 @@ def test_unknown_backend():
 def test_empty_url_is_mapped_to_empty_config():
     assert Env.cache_url_config('') == {}
     assert Env.cache_url_config(None) == {}
+
+
+@pytest.mark.parametrize(
+    'chars',
+    ['!', '$', '&', "'", '(', ')', '*', '+', ';', '=', '-', '.', '-v1.2']
+)
+def test_cache_url_password_using_sub_delims(monkeypatch, chars):
+    """Ensure CACHE_URL passwords may contains some unsafe characters.
+
+    See: https://github.com/joke2k/django-environ/issues/200 for details."""
+    url = 'rediss://enigma:secret{}@ondigitalocean.com:25061/2'.format(chars)
+    monkeypatch.setenv('CACHE_URL', url)
+    env = Env()
+
+    result = env.cache()
+    assert result['BACKEND'] == 'django_redis.cache.RedisCache'
+    assert result['LOCATION'] == url
+
+    result = env.cache_url_config(url)
+    assert result['BACKEND'] == 'django_redis.cache.RedisCache'
+    assert result['LOCATION'] == url
+
+    url = 'rediss://enigma:sec{}ret@ondigitalocean.com:25061/2'.format(chars)
+    monkeypatch.setenv('CACHE_URL', url)
+    env = Env()
+
+    result = env.cache()
+    assert result['BACKEND'] == 'django_redis.cache.RedisCache'
+    assert result['LOCATION'] == url
+
+    result = env.cache_url_config(url)
+    assert result['BACKEND'] == 'django_redis.cache.RedisCache'
+    assert result['LOCATION'] == url
+
+    url = 'rediss://enigma:{}secret@ondigitalocean.com:25061/2'.format(chars)
+    monkeypatch.setenv('CACHE_URL', url)
+    env = Env()
+
+    result = env.cache()
+    assert result['BACKEND'] == 'django_redis.cache.RedisCache'
+    assert result['LOCATION'] == url
+
+    result = env.cache_url_config(url)
+    assert result['BACKEND'] == 'django_redis.cache.RedisCache'
+    assert result['LOCATION'] == url
+
+
+@pytest.mark.parametrize(
+    'chars', ['%3A', '%2F', '%3F', '%23', '%5B', '%5D', '%40', '%2C']
+)
+def test_cache_url_password_using_gen_delims(monkeypatch, chars):
+    """Ensure CACHE_URL passwords may contains %-encoded characters.
+
+    See: https://github.com/joke2k/django-environ/issues/200 for details."""
+    url = 'rediss://enigma:secret{}@ondigitalocean.com:25061/2'.format(chars)
+    monkeypatch.setenv('CACHE_URL', url)
+    env = Env()
+
+    result = env.cache()
+    assert result['BACKEND'] == 'django_redis.cache.RedisCache'
+    assert result['LOCATION'] == url
+
+    url = 'rediss://enigma:sec{}ret@ondigitalocean.com:25061/2'.format(chars)
+    monkeypatch.setenv('CACHE_URL', url)
+    env = Env()
+
+    result = env.cache()
+    assert result['BACKEND'] == 'django_redis.cache.RedisCache'
+    assert result['LOCATION'] == url
+
+    url = 'rediss://enigma:{}secret@ondigitalocean.com:25061/2'.format(chars)
+    monkeypatch.setenv('CACHE_URL', url)
+    env = Env()
+
+    result = env.cache()
+    assert result['BACKEND'] == 'django_redis.cache.RedisCache'
+    assert result['LOCATION'] == url
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..523a72d
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,22 @@
+# This file is part of the django-environ.
+#
+# Copyright (c) 2021, Serghei Iakovlev <egrep@protonmail.ch>
+# Copyright (c) 2013-2021, Daniele Faraglia <daniele.faraglia@gmail.com>
+#
+# For the full copyright and license information, please view
+# the LICENSE.txt file that was distributed with this source code.
+
+import pytest
+from environ.environ import _cast
+
+
+@pytest.mark.parametrize(
+    'literal',
+    ['anything-', 'anything*', '*anything', 'anything.',
+     'anything.1', '(anything', 'anything-v1.2', 'anything-1.2', 'anything=']
+)
+def test_cast(literal):
+    """Safely evaluate a string containing an invalid Python literal.
+
+    See https://github.com/joke2k/django-environ/issues/200 for details."""
+    assert _cast(literal) == literal