Repository: marshmallow-code/marshmallow

Issue:
`fields.Url` does not accept `file` URLs without host
# Steps to reproduce

Run this test case:
```
import marshmallow as mm
class LOL(mm.Schema):
    url = mm.fields.Url(schemes={'file'})

LOL().load(dict(url="file:///var/storage/somefile.zip"))
```

# Expected result
```
{'url': 'file:///var/storage/somefile.zip'}
```

# Actual behavior
```
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/nisimond/work/blastarea/lolwut/lib/python3.11/site-packages/marshmallow/schema.py", line 756, in loads
    return self.load(data, many=many, partial=partial, unknown=unknown)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/nisimond/work/blastarea/lolwut/lib/python3.11/site-packages/marshmallow/schema.py", line 722, in load
    return self._do_load(
           ^^^^^^^^^^^^^^
  File "/Users/nisimond/work/blastarea/lolwut/lib/python3.11/site-packages/marshmallow/schema.py", line 909, in _do_load
    raise exc
marshmallow.exceptions.ValidationError: {'url': ['Not a valid URL.']}
```

# Workaround

This works:
```
LOL().load(dict(url="file://localhost/var/storage/somefile.zip"))
```
...but the `hostname` portion is entirely optional with `file` URLs.

Discussion:
sloria: we generally go the other way--merge into older release line then forward-port. but tbh it doesn't matter that much. happy to backport this if you don't have time to switch it
0xDEC0DE: Done.

Patch:
diff --git a/AUTHORS.rst b/AUTHORS.rst
index dc645c3c..a43a00f0 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -176,3 +176,4 @@ Contributors (chronological)
 - Peter C `@somethingnew2-0 <https://github.com/somethingnew2-0>`_
 - Marcel Jackwerth `@mrcljx` <https://github.com/mrcljx>`_
 - Fares Abubaker `@Fares-Abubaker <https://github.com/Fares-Abubaker>`_
+- Nicolas Simonds `@0xDEC0DE <https://github.com/0xDEC0DE>`_
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 3037a01c..500aeea2 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -17,6 +17,8 @@ Bug fixes:
 - Correctly handle multiple `@post_load <marshmallow.post_load>` methods where one method appends to
   the data and another passes ``pass_original=True`` (:issue:`1755`).
   Thanks :user:`ghostwheel42` for reporting.
+- ``URL`` fields now properly validate ``file`` paths (:issue:`2249`).
+  Thanks :user:`0xDEC0DE` for reporting and fixing.
 
 Documentation:
 
@@ -25,8 +27,8 @@ Documentation:
 
 Deprecations:
 
-- The ``ordered`` `class Meta <marshmallow.Schema.Meta>` option is deprecated (:issue:`2146`, :pr:`2762`). 
-  Field order is already preserved by default. Set `marshmallow.Schema.dict_class` to `collections.OrderedDict` 
+- The ``ordered`` `class Meta <marshmallow.Schema.Meta>` option is deprecated (:issue:`2146`, :pr:`2762`).
+  Field order is already preserved by default. Set `marshmallow.Schema.dict_class` to `collections.OrderedDict`
   to maintain the previous behavior.
 
 3.25.1 (2025-01-11)
diff --git a/src/marshmallow/validate.py b/src/marshmallow/validate.py
index 98e181ac..d56912a3 100644
--- a/src/marshmallow/validate.py
+++ b/src/marshmallow/validate.py
@@ -216,6 +216,7 @@ class URL(Validator):
             raise ValidationError(message)
 
         # Check first if the scheme is valid
+        scheme = None
         if "://" in value:
             scheme = value.split("://")[0].lower()
             if scheme not in self.schemes:
@@ -225,7 +226,14 @@ class URL(Validator):
             relative=self.relative, absolute=self.absolute, require_tld=self.require_tld
         )
 
-        if not regex.search(value):
+        # Hostname is optional for file URLS. If absent it means `localhost`.
+        # Fill it in for the validation if needed
+        if scheme == "file" and value.startswith("file:///"):
+            matched = regex.search(value.replace("file:///", "file://localhost/", 1))
+        else:
+            matched = regex.search(value)
+
+        if not matched:
             raise ValidationError(message)
 
         return value


Test patch:
diff --git a/tests/test_validate.py b/tests/test_validate.py
index 866276de..783e9006 100644
--- a/tests/test_validate.py
+++ b/tests/test_validate.py
@@ -205,6 +205,40 @@ def test_url_custom_scheme():
     assert validator(url) == url
 
 
+@pytest.mark.parametrize(
+    "valid_url",
+    (
+        "file:///tmp/tmp1234",
+        "file://localhost/tmp/tmp1234",
+        "file:///C:/Users/test/file.txt",
+        "file://localhost/C:/Program%20Files/file.exe",
+        "file:///home/user/documents/test.pdf",
+        "file:///tmp/test%20file.txt",
+        "file:///",
+        "file://localhost/",
+    ),
+)
+def test_url_accepts_valid_file_urls(valid_url):
+    validator = validate.URL(schemes={"file"})
+    assert validator(valid_url) == valid_url
+
+
+@pytest.mark.parametrize(
+    "invalid_url",
+    (
+        "file://",
+        "file:/tmp/file.txt",
+        "file:tmp/file.txt",
+        "file://hostname/path",
+        "file:///tmp/test file.txt",
+    ),
+)
+def test_url_rejects_invalid_file_urls(invalid_url):
+    validator = validate.URL(schemes={"file"})
+    with pytest.raises(ValidationError, match="Not a valid URL."):
+        assert validator(invalid_url)
+
+
 def test_url_relative_and_custom_schemes():
     validator = validate.URL(relative=True)
     # By default, ws not allowed