We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
StaticRouteAsync leaves open files
When using static routes with a [`falcon.asgi.App`](https://falcon.readthedocs.io/en/stable/api/app.html#asgi-app), it seems that the `_AsyncFileReader` wrapper does not implement any `.close()` method, so files are left open.

On CPython, I wasn't able to demonstrate any practical impact of this bug as the file object in question is refcounted to 0 and garbage collected as soon as it goes out of scope. However, that isn't the case when running `uvicorn` on PyPy 3.7, as PyPy uses a different GC implementation.

Test case in point:
```
import io
import logging
import os.path
import unittest.mock

import falcon.asgi

logging.basicConfig(
    format='%(asctime)s [%(levelname)s] %(message)s', level=logging.INFO)


class DebugIO(io.BytesIO):

    @classmethod
    def open(cls, *args, **kwargs):
        return cls(b'Test data!\n')

    def close(self):
        logging.info(f'{self}.close()')
        super().close()


app = falcon.asgi.App()
app.add_static_route('/files', '/tmp')

debug = unittest.mock.patch('io.open', DebugIO.open)
debug.start()
```
StaticRouteAsync leaves open files
When using static routes with a [`falcon.asgi.App`](https://falcon.readthedocs.io/en/stable/api/app.html#asgi-app), it seems that the `_AsyncFileReader` wrapper does not implement any `.close()` method, so files are left open.

On CPython, I wasn't able to demonstrate any practical impact of this bug as the file object in question is refcounted to 0 and garbage collected as soon as it goes out of scope. However, that isn't the case when running `uvicorn` on PyPy 3.7, as PyPy uses a different GC implementation.

Test case in point:
```
import io
import logging
import os.path
import unittest.mock

import falcon.asgi

logging.basicConfig(
    format='%(asctime)s [%(levelname)s] %(message)s', level=logging.INFO)


class DebugIO(io.BytesIO):

    @classmethod
    def open(cls, *args, **kwargs):
        return cls(b'Test data!\n')

    def close(self):
        logging.info(f'{self}.close()')
        super().close()


app = falcon.asgi.App()
app.add_static_route('/files', '/tmp')

debug = unittest.mock.patch('io.open', DebugIO.open)
debug.start()
```

--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `falcon/routing/static.py`
Content:
```
1 from functools import partial
2 import io
3 import os
4 import pathlib
5 import re
6 
7 import falcon
8 from falcon.util.sync import get_running_loop
9 
10 
11 def _open_range(file_path, req_range):
12     """Open a file for a ranged request.
13 
14     Args:
15         file_path (str): Path to the file to open.
16         req_range (Optional[Tuple[int, int]]): Request.range value.
17     Returns:
18         tuple: Three-member tuple of (stream, content-length, content-range).
19             If req_range is ``None`` or ignored, content-range will be
20             ``None``; otherwise, the stream will be appropriately seeked and
21             possibly bounded, and the content-range will be a tuple of
22             (start, end, size).
23     """
24     fh = io.open(file_path, 'rb')
25     size = os.fstat(fh.fileno()).st_size
26     if req_range is None:
27         return fh, size, None
28 
29     start, end = req_range
30     if size == 0:
31         # NOTE(tipabu): Ignore Range headers for zero-byte files; just serve
32         #   the empty body since Content-Range can't be used to express a
33         #   zero-byte body.
34         return fh, 0, None
35 
36     if start < 0 and end == -1:
37         # NOTE(tipabu): Special case: only want the last N bytes.
38         start = max(start, -size)
39         fh.seek(start, os.SEEK_END)
40         # NOTE(vytas): Wrap in order to prevent sendfile from being used, as
41         #   its implementation was found to be buggy in many popular WSGI
42         #   servers for open files with a non-zero offset.
43         return _BoundedFile(fh, -start), -start, (size + start, size - 1, size)
44 
45     if start >= size:
46         fh.close()
47         raise falcon.HTTPRangeNotSatisfiable(size)
48 
49     fh.seek(start)
50     if end == -1:
51         # NOTE(vytas): Wrap in order to prevent sendfile from being used, as
52         #   its implementation was found to be buggy in many popular WSGI
53         #   servers for open files with a non-zero offset.
54         length = size - start
55         return _BoundedFile(fh, length), length, (start, size - 1, size)
56 
57     end = min(end, size - 1)
58     length = end - start + 1
59     return _BoundedFile(fh, length), length, (start, end, size)
60 
61 
62 class _BoundedFile:
63     """Wrap a file to only allow part of it to be read.
64 
65     Args:
66         fh: The file object to wrap. Should be opened in binary mode,
67             and already seeked to an appropriate position. The object must
68             expose a ``.close()`` method.
69         length (int): Number of bytes that may be read.
70     """
71 
72     def __init__(self, fh, length):
73         self.fh = fh
74         self.close = fh.close
75         self.remaining = length
76 
77     def read(self, size=-1):
78         """Read the underlying file object, within the specified bounds."""
79         if size < 0:
80             size = self.remaining
81         else:
82             size = min(size, self.remaining)
83         data = self.fh.read(size)
84         self.remaining -= len(data)
85         return data
86 
87 
88 class StaticRoute:
89     """Represents a static route.
90 
91     Args:
92         prefix (str): The path prefix to match for this route. If the
93             path in the requested URI starts with this string, the remainder
94             of the path will be appended to the source directory to
95             determine the file to serve. This is done in a secure manner
96             to prevent an attacker from requesting a file outside the
97             specified directory.
98 
99             Note that static routes are matched in LIFO order, and are only
100             attempted after checking dynamic routes and sinks.
101 
102         directory (Union[str, pathlib.Path]): The source directory from which to
103             serve files. Must be an absolute path.
104         downloadable (bool): Set to ``True`` to include a
105             Content-Disposition header in the response. The "filename"
106             directive is simply set to the name of the requested file.
107         fallback_filename (str): Fallback filename used when the requested file
108             is not found. Can be a relative path inside the prefix folder or
109             any valid absolute path.
110 
111             Note:
112                 If the fallback file is served instead of the requested file,
113                 the response Content-Type header, as well as the
114                 Content-Disposition header (provided it was requested with the
115                 `downloadable` parameter described above), are derived from the
116                 fallback filename, as opposed to the requested filename.
117     """
118 
119     # NOTE(kgriffs): Don't allow control characters and reserved chars
120     _DISALLOWED_CHARS_PATTERN = re.compile('[\x00-\x1f\x80-\x9f\ufffd~?<>:*|\'"]')
121 
122     # NOTE(kgriffs): If somehow an executable code exploit is triggerable, this
123     # minimizes how much can be included in the payload.
124     _MAX_NON_PREFIXED_LEN = 512
125 
126     def __init__(self, prefix, directory, downloadable=False, fallback_filename=None):
127         if not prefix.startswith('/'):
128             raise ValueError("prefix must start with '/'")
129 
130         # TODO(vgerak): Remove the check when py3.5 is dropped.
131         if isinstance(directory, pathlib.Path):
132             directory = str(directory)
133 
134         self._directory = os.path.normpath(directory)
135         if not os.path.isabs(self._directory):
136             raise ValueError('directory must be an absolute path')
137 
138         if fallback_filename is None:
139             self._fallback_filename = None
140         else:
141             self._fallback_filename = os.path.normpath(
142                 os.path.join(self._directory, fallback_filename)
143             )
144             if not os.path.isfile(self._fallback_filename):
145                 raise ValueError('fallback_filename is not a file')
146 
147         # NOTE(kgriffs): Ensure it ends with a path separator to ensure
148         # we only match on the complete segment. Don't raise an error
149         # because most people won't expect to have to append a slash.
150         if not prefix.endswith('/'):
151             prefix += '/'
152 
153         self._prefix = prefix
154         self._downloadable = downloadable
155 
156     def match(self, path):
157         """Check whether the given path matches this route."""
158         if self._fallback_filename is None:
159             return path.startswith(self._prefix)
160         return path.startswith(self._prefix) or path == self._prefix[:-1]
161 
162     def __call__(self, req, resp):
163         """Resource responder for this route."""
164 
165         without_prefix = req.path[len(self._prefix) :]
166 
167         # NOTE(kgriffs): Check surrounding whitespace and strip trailing
168         # periods, which are illegal on windows
169         # NOTE(CaselIT): An empty filename is allowed when fallback_filename is provided
170         if (
171             not (without_prefix or self._fallback_filename is not None)
172             or without_prefix.strip().rstrip('.') != without_prefix
173             or self._DISALLOWED_CHARS_PATTERN.search(without_prefix)
174             or '\\' in without_prefix
175             or '//' in without_prefix
176             or len(without_prefix) > self._MAX_NON_PREFIXED_LEN
177         ):
178 
179             raise falcon.HTTPNotFound()
180 
181         normalized = os.path.normpath(without_prefix)
182 
183         if normalized.startswith('../') or normalized.startswith('/'):
184             raise falcon.HTTPNotFound()
185 
186         file_path = os.path.join(self._directory, normalized)
187 
188         # NOTE(kgriffs): Final sanity-check just to be safe. This check
189         # should never succeed, but this should guard against us having
190         # overlooked something.
191         if '..' in file_path or not file_path.startswith(self._directory):
192             raise falcon.HTTPNotFound()
193 
194         req_range = req.range
195         if req.range_unit != 'bytes':
196             req_range = None
197         try:
198             stream, length, content_range = _open_range(file_path, req_range)
199             resp.set_stream(stream, length)
200         except IOError:
201             if self._fallback_filename is None:
202                 raise falcon.HTTPNotFound()
203             try:
204                 stream, length, content_range = _open_range(
205                     self._fallback_filename, req_range
206                 )
207                 resp.set_stream(stream, length)
208                 file_path = self._fallback_filename
209             except IOError:
210                 raise falcon.HTTPNotFound()
211 
212         suffix = os.path.splitext(file_path)[1]
213         resp.content_type = resp.options.static_media_types.get(
214             suffix, 'application/octet-stream'
215         )
216         resp.accept_ranges = 'bytes'
217 
218         if self._downloadable:
219             resp.downloadable_as = os.path.basename(file_path)
220         if content_range:
221             resp.status = falcon.HTTP_206
222             resp.content_range = content_range
223 
224 
225 class StaticRouteAsync(StaticRoute):
226     """Subclass of StaticRoute with modifications to support ASGI apps."""
227 
228     async def __call__(self, req, resp):
229         super().__call__(req, resp)
230 
231         # NOTE(kgriffs): Fixup resp.stream so that it is non-blocking
232         resp.stream = _AsyncFileReader(resp.stream)
233 
234 
235 class _AsyncFileReader:
236     """Adapts a standard file I/O object so that reads are non-blocking."""
237 
238     def __init__(self, file):
239         self._file = file
240         self._loop = get_running_loop()
241 
242     async def read(self, size=-1):
243         return await self._loop.run_in_executor(None, partial(self._file.read, size))
244
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/falcon/routing/static.py b/falcon/routing/static.py
--- a/falcon/routing/static.py
+++ b/falcon/routing/static.py
@@ -241,3 +241,6 @@
 
     async def read(self, size=-1):
         return await self._loop.run_in_executor(None, partial(self._file.read, size))
+
+    async def close(self):
+        await self._loop.run_in_executor(None, self._file.close)

{"golden_diff": "diff --git a/falcon/routing/static.py b/falcon/routing/static.py\n--- a/falcon/routing/static.py\n+++ b/falcon/routing/static.py\n@@ -241,3 +241,6 @@\n \n     async def read(self, size=-1):\n         return await self._loop.run_in_executor(None, partial(self._file.read, size))\n+\n+    async def close(self):\n+        await self._loop.run_in_executor(None, self._file.close)\n", "issue": "StaticRouteAsync leaves open files\nWhen using static routes with a [`falcon.asgi.App`](https://falcon.readthedocs.io/en/stable/api/app.html#asgi-app), it seems that the `_AsyncFileReader` wrapper does not implement any `.close()` method, so files are left open.\r\n\r\nOn CPython, I wasn't able to demonstrate any practical impact of this bug as the file object in question is refcounted to 0 and garbage collected as soon as it goes out of scope. However, that isn't the case when running `uvicorn` on PyPy 3.7, as PyPy uses a different GC implementation.\r\n\r\nTest case in point:\r\n```\r\nimport io\r\nimport logging\r\nimport os.path\r\nimport unittest.mock\r\n\r\nimport falcon.asgi\r\n\r\nlogging.basicConfig(\r\n    format='%(asctime)s [%(levelname)s] %(message)s', level=logging.INFO)\r\n\r\n\r\nclass DebugIO(io.BytesIO):\r\n\r\n    @classmethod\r\n    def open(cls, *args, **kwargs):\r\n        return cls(b'Test data!\\n')\r\n\r\n    def close(self):\r\n        logging.info(f'{self}.close()')\r\n        super().close()\r\n\r\n\r\napp = falcon.asgi.App()\r\napp.add_static_route('/files', '/tmp')\r\n\r\ndebug = unittest.mock.patch('io.open', DebugIO.open)\r\ndebug.start()\r\n```\nStaticRouteAsync leaves open files\nWhen using static routes with a [`falcon.asgi.App`](https://falcon.readthedocs.io/en/stable/api/app.html#asgi-app), it seems that the `_AsyncFileReader` wrapper does not implement any `.close()` method, so files are left open.\r\n\r\nOn CPython, I wasn't able to demonstrate any practical impact of this bug as the file object in question is refcounted to 0 and garbage collected as soon as it goes out of scope. However, that isn't the case when running `uvicorn` on PyPy 3.7, as PyPy uses a different GC implementation.\r\n\r\nTest case in point:\r\n```\r\nimport io\r\nimport logging\r\nimport os.path\r\nimport unittest.mock\r\n\r\nimport falcon.asgi\r\n\r\nlogging.basicConfig(\r\n    format='%(asctime)s [%(levelname)s] %(message)s', level=logging.INFO)\r\n\r\n\r\nclass DebugIO(io.BytesIO):\r\n\r\n    @classmethod\r\n    def open(cls, *args, **kwargs):\r\n        return cls(b'Test data!\\n')\r\n\r\n    def close(self):\r\n        logging.info(f'{self}.close()')\r\n        super().close()\r\n\r\n\r\napp = falcon.asgi.App()\r\napp.add_static_route('/files', '/tmp')\r\n\r\ndebug = unittest.mock.patch('io.open', DebugIO.open)\r\ndebug.start()\r\n```\n", "before_files": [{"content": "from functools import partial\nimport io\nimport os\nimport pathlib\nimport re\n\nimport falcon\nfrom falcon.util.sync import get_running_loop\n\n\ndef _open_range(file_path, req_range):\n    \"\"\"Open a file for a ranged request.\n\n    Args:\n        file_path (str): Path to the file to open.\n        req_range (Optional[Tuple[int, int]]): Request.range value.\n    Returns:\n        tuple: Three-member tuple of (stream, content-length, content-range).\n            If req_range is ``None`` or ignored, content-range will be\n            ``None``; otherwise, the stream will be appropriately seeked and\n            possibly bounded, and the content-range will be a tuple of\n            (start, end, size).\n    \"\"\"\n    fh = io.open(file_path, 'rb')\n    size = os.fstat(fh.fileno()).st_size\n    if req_range is None:\n        return fh, size, None\n\n    start, end = req_range\n    if size == 0:\n        # NOTE(tipabu): Ignore Range headers for zero-byte files; just serve\n        #   the empty body since Content-Range can't be used to express a\n        #   zero-byte body.\n        return fh, 0, None\n\n    if start < 0 and end == -1:\n        # NOTE(tipabu): Special case: only want the last N bytes.\n        start = max(start, -size)\n        fh.seek(start, os.SEEK_END)\n        # NOTE(vytas): Wrap in order to prevent sendfile from being used, as\n        #   its implementation was found to be buggy in many popular WSGI\n        #   servers for open files with a non-zero offset.\n        return _BoundedFile(fh, -start), -start, (size + start, size - 1, size)\n\n    if start >= size:\n        fh.close()\n        raise falcon.HTTPRangeNotSatisfiable(size)\n\n    fh.seek(start)\n    if end == -1:\n        # NOTE(vytas): Wrap in order to prevent sendfile from being used, as\n        #   its implementation was found to be buggy in many popular WSGI\n        #   servers for open files with a non-zero offset.\n        length = size - start\n        return _BoundedFile(fh, length), length, (start, size - 1, size)\n\n    end = min(end, size - 1)\n    length = end - start + 1\n    return _BoundedFile(fh, length), length, (start, end, size)\n\n\nclass _BoundedFile:\n    \"\"\"Wrap a file to only allow part of it to be read.\n\n    Args:\n        fh: The file object to wrap. Should be opened in binary mode,\n            and already seeked to an appropriate position. The object must\n            expose a ``.close()`` method.\n        length (int): Number of bytes that may be read.\n    \"\"\"\n\n    def __init__(self, fh, length):\n        self.fh = fh\n        self.close = fh.close\n        self.remaining = length\n\n    def read(self, size=-1):\n        \"\"\"Read the underlying file object, within the specified bounds.\"\"\"\n        if size < 0:\n            size = self.remaining\n        else:\n            size = min(size, self.remaining)\n        data = self.fh.read(size)\n        self.remaining -= len(data)\n        return data\n\n\nclass StaticRoute:\n    \"\"\"Represents a static route.\n\n    Args:\n        prefix (str): The path prefix to match for this route. If the\n            path in the requested URI starts with this string, the remainder\n            of the path will be appended to the source directory to\n            determine the file to serve. This is done in a secure manner\n            to prevent an attacker from requesting a file outside the\n            specified directory.\n\n            Note that static routes are matched in LIFO order, and are only\n            attempted after checking dynamic routes and sinks.\n\n        directory (Union[str, pathlib.Path]): The source directory from which to\n            serve files. Must be an absolute path.\n        downloadable (bool): Set to ``True`` to include a\n            Content-Disposition header in the response. The \"filename\"\n            directive is simply set to the name of the requested file.\n        fallback_filename (str): Fallback filename used when the requested file\n            is not found. Can be a relative path inside the prefix folder or\n            any valid absolute path.\n\n            Note:\n                If the fallback file is served instead of the requested file,\n                the response Content-Type header, as well as the\n                Content-Disposition header (provided it was requested with the\n                `downloadable` parameter described above), are derived from the\n                fallback filename, as opposed to the requested filename.\n    \"\"\"\n\n    # NOTE(kgriffs): Don't allow control characters and reserved chars\n    _DISALLOWED_CHARS_PATTERN = re.compile('[\\x00-\\x1f\\x80-\\x9f\\ufffd~?<>:*|\\'\"]')\n\n    # NOTE(kgriffs): If somehow an executable code exploit is triggerable, this\n    # minimizes how much can be included in the payload.\n    _MAX_NON_PREFIXED_LEN = 512\n\n    def __init__(self, prefix, directory, downloadable=False, fallback_filename=None):\n        if not prefix.startswith('/'):\n            raise ValueError(\"prefix must start with '/'\")\n\n        # TODO(vgerak): Remove the check when py3.5 is dropped.\n        if isinstance(directory, pathlib.Path):\n            directory = str(directory)\n\n        self._directory = os.path.normpath(directory)\n        if not os.path.isabs(self._directory):\n            raise ValueError('directory must be an absolute path')\n\n        if fallback_filename is None:\n            self._fallback_filename = None\n        else:\n            self._fallback_filename = os.path.normpath(\n                os.path.join(self._directory, fallback_filename)\n            )\n            if not os.path.isfile(self._fallback_filename):\n                raise ValueError('fallback_filename is not a file')\n\n        # NOTE(kgriffs): Ensure it ends with a path separator to ensure\n        # we only match on the complete segment. Don't raise an error\n        # because most people won't expect to have to append a slash.\n        if not prefix.endswith('/'):\n            prefix += '/'\n\n        self._prefix = prefix\n        self._downloadable = downloadable\n\n    def match(self, path):\n        \"\"\"Check whether the given path matches this route.\"\"\"\n        if self._fallback_filename is None:\n            return path.startswith(self._prefix)\n        return path.startswith(self._prefix) or path == self._prefix[:-1]\n\n    def __call__(self, req, resp):\n        \"\"\"Resource responder for this route.\"\"\"\n\n        without_prefix = req.path[len(self._prefix) :]\n\n        # NOTE(kgriffs): Check surrounding whitespace and strip trailing\n        # periods, which are illegal on windows\n        # NOTE(CaselIT): An empty filename is allowed when fallback_filename is provided\n        if (\n            not (without_prefix or self._fallback_filename is not None)\n            or without_prefix.strip().rstrip('.') != without_prefix\n            or self._DISALLOWED_CHARS_PATTERN.search(without_prefix)\n            or '\\\\' in without_prefix\n            or '//' in without_prefix\n            or len(without_prefix) > self._MAX_NON_PREFIXED_LEN\n        ):\n\n            raise falcon.HTTPNotFound()\n\n        normalized = os.path.normpath(without_prefix)\n\n        if normalized.startswith('../') or normalized.startswith('/'):\n            raise falcon.HTTPNotFound()\n\n        file_path = os.path.join(self._directory, normalized)\n\n        # NOTE(kgriffs): Final sanity-check just to be safe. This check\n        # should never succeed, but this should guard against us having\n        # overlooked something.\n        if '..' in file_path or not file_path.startswith(self._directory):\n            raise falcon.HTTPNotFound()\n\n        req_range = req.range\n        if req.range_unit != 'bytes':\n            req_range = None\n        try:\n            stream, length, content_range = _open_range(file_path, req_range)\n            resp.set_stream(stream, length)\n        except IOError:\n            if self._fallback_filename is None:\n                raise falcon.HTTPNotFound()\n            try:\n                stream, length, content_range = _open_range(\n                    self._fallback_filename, req_range\n                )\n                resp.set_stream(stream, length)\n                file_path = self._fallback_filename\n            except IOError:\n                raise falcon.HTTPNotFound()\n\n        suffix = os.path.splitext(file_path)[1]\n        resp.content_type = resp.options.static_media_types.get(\n            suffix, 'application/octet-stream'\n        )\n        resp.accept_ranges = 'bytes'\n\n        if self._downloadable:\n            resp.downloadable_as = os.path.basename(file_path)\n        if content_range:\n            resp.status = falcon.HTTP_206\n            resp.content_range = content_range\n\n\nclass StaticRouteAsync(StaticRoute):\n    \"\"\"Subclass of StaticRoute with modifications to support ASGI apps.\"\"\"\n\n    async def __call__(self, req, resp):\n        super().__call__(req, resp)\n\n        # NOTE(kgriffs): Fixup resp.stream so that it is non-blocking\n        resp.stream = _AsyncFileReader(resp.stream)\n\n\nclass _AsyncFileReader:\n    \"\"\"Adapts a standard file I/O object so that reads are non-blocking.\"\"\"\n\n    def __init__(self, file):\n        self._file = file\n        self._loop = get_running_loop()\n\n    async def read(self, size=-1):\n        return await self._loop.run_in_executor(None, partial(self._file.read, size))\n", "path": "falcon/routing/static.py"}], "after_files": [{"content": "from functools import partial\nimport io\nimport os\nimport pathlib\nimport re\n\nimport falcon\nfrom falcon.util.sync import get_running_loop\n\n\ndef _open_range(file_path, req_range):\n    \"\"\"Open a file for a ranged request.\n\n    Args:\n        file_path (str): Path to the file to open.\n        req_range (Optional[Tuple[int, int]]): Request.range value.\n    Returns:\n        tuple: Three-member tuple of (stream, content-length, content-range).\n            If req_range is ``None`` or ignored, content-range will be\n            ``None``; otherwise, the stream will be appropriately seeked and\n            possibly bounded, and the content-range will be a tuple of\n            (start, end, size).\n    \"\"\"\n    fh = io.open(file_path, 'rb')\n    size = os.fstat(fh.fileno()).st_size\n    if req_range is None:\n        return fh, size, None\n\n    start, end = req_range\n    if size == 0:\n        # NOTE(tipabu): Ignore Range headers for zero-byte files; just serve\n        #   the empty body since Content-Range can't be used to express a\n        #   zero-byte body.\n        return fh, 0, None\n\n    if start < 0 and end == -1:\n        # NOTE(tipabu): Special case: only want the last N bytes.\n        start = max(start, -size)\n        fh.seek(start, os.SEEK_END)\n        # NOTE(vytas): Wrap in order to prevent sendfile from being used, as\n        #   its implementation was found to be buggy in many popular WSGI\n        #   servers for open files with a non-zero offset.\n        return _BoundedFile(fh, -start), -start, (size + start, size - 1, size)\n\n    if start >= size:\n        fh.close()\n        raise falcon.HTTPRangeNotSatisfiable(size)\n\n    fh.seek(start)\n    if end == -1:\n        # NOTE(vytas): Wrap in order to prevent sendfile from being used, as\n        #   its implementation was found to be buggy in many popular WSGI\n        #   servers for open files with a non-zero offset.\n        length = size - start\n        return _BoundedFile(fh, length), length, (start, size - 1, size)\n\n    end = min(end, size - 1)\n    length = end - start + 1\n    return _BoundedFile(fh, length), length, (start, end, size)\n\n\nclass _BoundedFile:\n    \"\"\"Wrap a file to only allow part of it to be read.\n\n    Args:\n        fh: The file object to wrap. Should be opened in binary mode,\n            and already seeked to an appropriate position. The object must\n            expose a ``.close()`` method.\n        length (int): Number of bytes that may be read.\n    \"\"\"\n\n    def __init__(self, fh, length):\n        self.fh = fh\n        self.close = fh.close\n        self.remaining = length\n\n    def read(self, size=-1):\n        \"\"\"Read the underlying file object, within the specified bounds.\"\"\"\n        if size < 0:\n            size = self.remaining\n        else:\n            size = min(size, self.remaining)\n        data = self.fh.read(size)\n        self.remaining -= len(data)\n        return data\n\n\nclass StaticRoute:\n    \"\"\"Represents a static route.\n\n    Args:\n        prefix (str): The path prefix to match for this route. If the\n            path in the requested URI starts with this string, the remainder\n            of the path will be appended to the source directory to\n            determine the file to serve. This is done in a secure manner\n            to prevent an attacker from requesting a file outside the\n            specified directory.\n\n            Note that static routes are matched in LIFO order, and are only\n            attempted after checking dynamic routes and sinks.\n\n        directory (Union[str, pathlib.Path]): The source directory from which to\n            serve files. Must be an absolute path.\n        downloadable (bool): Set to ``True`` to include a\n            Content-Disposition header in the response. The \"filename\"\n            directive is simply set to the name of the requested file.\n        fallback_filename (str): Fallback filename used when the requested file\n            is not found. Can be a relative path inside the prefix folder or\n            any valid absolute path.\n\n            Note:\n                If the fallback file is served instead of the requested file,\n                the response Content-Type header, as well as the\n                Content-Disposition header (provided it was requested with the\n                `downloadable` parameter described above), are derived from the\n                fallback filename, as opposed to the requested filename.\n    \"\"\"\n\n    # NOTE(kgriffs): Don't allow control characters and reserved chars\n    _DISALLOWED_CHARS_PATTERN = re.compile('[\\x00-\\x1f\\x80-\\x9f\\ufffd~?<>:*|\\'\"]')\n\n    # NOTE(kgriffs): If somehow an executable code exploit is triggerable, this\n    # minimizes how much can be included in the payload.\n    _MAX_NON_PREFIXED_LEN = 512\n\n    def __init__(self, prefix, directory, downloadable=False, fallback_filename=None):\n        if not prefix.startswith('/'):\n            raise ValueError(\"prefix must start with '/'\")\n\n        # TODO(vgerak): Remove the check when py3.5 is dropped.\n        if isinstance(directory, pathlib.Path):\n            directory = str(directory)\n\n        self._directory = os.path.normpath(directory)\n        if not os.path.isabs(self._directory):\n            raise ValueError('directory must be an absolute path')\n\n        if fallback_filename is None:\n            self._fallback_filename = None\n        else:\n            self._fallback_filename = os.path.normpath(\n                os.path.join(self._directory, fallback_filename)\n            )\n            if not os.path.isfile(self._fallback_filename):\n                raise ValueError('fallback_filename is not a file')\n\n        # NOTE(kgriffs): Ensure it ends with a path separator to ensure\n        # we only match on the complete segment. Don't raise an error\n        # because most people won't expect to have to append a slash.\n        if not prefix.endswith('/'):\n            prefix += '/'\n\n        self._prefix = prefix\n        self._downloadable = downloadable\n\n    def match(self, path):\n        \"\"\"Check whether the given path matches this route.\"\"\"\n        if self._fallback_filename is None:\n            return path.startswith(self._prefix)\n        return path.startswith(self._prefix) or path == self._prefix[:-1]\n\n    def __call__(self, req, resp):\n        \"\"\"Resource responder for this route.\"\"\"\n\n        without_prefix = req.path[len(self._prefix) :]\n\n        # NOTE(kgriffs): Check surrounding whitespace and strip trailing\n        # periods, which are illegal on windows\n        # NOTE(CaselIT): An empty filename is allowed when fallback_filename is provided\n        if (\n            not (without_prefix or self._fallback_filename is not None)\n            or without_prefix.strip().rstrip('.') != without_prefix\n            or self._DISALLOWED_CHARS_PATTERN.search(without_prefix)\n            or '\\\\' in without_prefix\n            or '//' in without_prefix\n            or len(without_prefix) > self._MAX_NON_PREFIXED_LEN\n        ):\n\n            raise falcon.HTTPNotFound()\n\n        normalized = os.path.normpath(without_prefix)\n\n        if normalized.startswith('../') or normalized.startswith('/'):\n            raise falcon.HTTPNotFound()\n\n        file_path = os.path.join(self._directory, normalized)\n\n        # NOTE(kgriffs): Final sanity-check just to be safe. This check\n        # should never succeed, but this should guard against us having\n        # overlooked something.\n        if '..' in file_path or not file_path.startswith(self._directory):\n            raise falcon.HTTPNotFound()\n\n        req_range = req.range\n        if req.range_unit != 'bytes':\n            req_range = None\n        try:\n            stream, length, content_range = _open_range(file_path, req_range)\n            resp.set_stream(stream, length)\n        except IOError:\n            if self._fallback_filename is None:\n                raise falcon.HTTPNotFound()\n            try:\n                stream, length, content_range = _open_range(\n                    self._fallback_filename, req_range\n                )\n                resp.set_stream(stream, length)\n                file_path = self._fallback_filename\n            except IOError:\n                raise falcon.HTTPNotFound()\n\n        suffix = os.path.splitext(file_path)[1]\n        resp.content_type = resp.options.static_media_types.get(\n            suffix, 'application/octet-stream'\n        )\n        resp.accept_ranges = 'bytes'\n\n        if self._downloadable:\n            resp.downloadable_as = os.path.basename(file_path)\n        if content_range:\n            resp.status = falcon.HTTP_206\n            resp.content_range = content_range\n\n\nclass StaticRouteAsync(StaticRoute):\n    \"\"\"Subclass of StaticRoute with modifications to support ASGI apps.\"\"\"\n\n    async def __call__(self, req, resp):\n        super().__call__(req, resp)\n\n        # NOTE(kgriffs): Fixup resp.stream so that it is non-blocking\n        resp.stream = _AsyncFileReader(resp.stream)\n\n\nclass _AsyncFileReader:\n    \"\"\"Adapts a standard file I/O object so that reads are non-blocking.\"\"\"\n\n    def __init__(self, file):\n        self._file = file\n        self._loop = get_running_loop()\n\n    async def read(self, size=-1):\n        return await self._loop.run_in_executor(None, partial(self._file.read, size))\n\n    async def close(self):\n        await self._loop.run_in_executor(None, self._file.close)\n", "path": "falcon/routing/static.py"}]}