Coverage for aiocoap/cli/fileserver.py: 72%
219 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-16 16:09 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-16 16:09 +0000
1# SPDX-FileCopyrightText: Christian Amsüss and the aiocoap contributors
2#
3# SPDX-License-Identifier: MIT
5"""A simple file server that serves the contents of a given directory in a
6read-only fashion via CoAP. It provides directory listings, and guesses the
7media type of files it serves.
9It follows the conventions set out for the [kitchen-sink fileserver],
10optionally with write support, with some caveats:
12* There are some time-of-check / time-of-use race conditions around the
13 handling of ETags, which could probably only be resolved if heavy file system
14 locking were used. Some of these races are a consequence of this server
15 implementing atomic writes through renames.
17 As long as no other processes access the working area, and aiocoap is run
18 single threaded, the races should not be visible to CoAP users.
20* ETags are constructed based on information in the file's (or directory's)
21 `stat` output -- this avoids reaing the whole file on overwrites etc.
23 This means that forcing the MTime to stay constant across a change would
24 confuse clients.
26* While GET requests on files are served block by block (reading only what is
27 being requested), PUT operations are spooled in memory rather than on the
28 file system.
30* Directory creation and deletion is not supported at the moment.
32[kitchen-sink fileserver]: https://www.ietf.org/archive/id/draft-amsuess-core-coap-kitchensink-00.html#name-coap
33"""
35import argparse
36import asyncio
37from pathlib import Path
38import logging
39from stat import S_ISREG, S_ISDIR
40import mimetypes
41import tempfile
42import hashlib
44import aiocoap
45import aiocoap.error as error
46import aiocoap.numbers.codes as codes
47from aiocoap.resource import Resource
48from aiocoap.util.cli import AsyncCLIDaemon
49from aiocoap.cli.common import (add_server_arguments,
50 server_context_from_arguments, extract_server_arguments)
51from aiocoap.resourcedirectory.client.register import Registerer
52from ..util.asyncio import py38args
54class InvalidPathError(error.ConstructionRenderableError):
55 code = codes.BAD_REQUEST
57class TrailingSlashMissingError(error.ConstructionRenderableError):
58 code = codes.BAD_REQUEST
59 message = "Error: Not a file (add trailing slash)"
61class AbundantTrailingSlashError(error.ConstructionRenderableError):
62 code = codes.BAD_REQUEST
63 message = "Error: Not a directory (strip the trailing slash)"
65class NoSuchFile(error.NotFound): # just for the better error msg
66 message = "Error: File not found!"
68class PreconditionFailed(error.ConstructionRenderableError):
69 code = codes.PRECONDITION_FAILED
71class FileServer(Resource, aiocoap.interfaces.ObservableResource):
72 # Resource is only used to give the nice render_xxx methods
74 def __init__(self, root, log, *, write=False):
75 super().__init__()
76 self.root = root
77 self.log = log
78 self.write = write
80 self._observations = {} # path -> [last_stat, [callbacks]]
82 # While we don't have a .well-known/core resource that would need this, we
83 # still allow registration at an RD and thus need something in here.
84 #
85 # As we can't possibly register all files in here, we're just registering a
86 # single link to the index.
87 def get_resources_as_linkheader(self):
88 # Resource type indicates draft-amsuess-core-coap-kitchensink-00 file
89 # service, might use registered name later
90 return '</>;ct=40;rt="tag:chrysn@fsfe.org,2022:fileserver"'
92 async def check_files_for_refreshes(self):
93 while True:
94 await asyncio.sleep(10)
96 for path, data in list(self._observations.items()):
97 last_stat, callbacks = data
98 if last_stat is None:
99 continue # this hit before the original response even triggered
100 try:
101 new_stat = path.stat()
102 except Exception:
103 new_stat = False
104 def relevant(s):
105 return (s.st_ino, s.st_dev, s.st_size, s.st_mtime, s.st_ctime)
106 if relevant(new_stat) != relevant(last_stat):
107 self.log.info("New stat for %s", path)
108 data[0] = new_stat
109 for cb in callbacks:
110 cb()
112 def request_to_localpath(self, request):
113 path = request.opt.uri_path
114 if any('/' in p or p in ('.', '..') for p in path):
115 raise InvalidPathError()
117 return self.root / "/".join(path)
119 async def needs_blockwise_assembly(self, request):
120 if request.code != codes.GET:
121 return True
122 if not request.opt.uri_path or request.opt.uri_path[-1] == '' or \
123 request.opt.uri_path == ('.well-known', 'core'):
124 return True
125 # Only GETs to non-directory access handle it explicitly
126 return False
128 @staticmethod
129 def hash_stat(stat):
130 # The subset that the author expects to (possibly) change if the file changes
131 data = (stat.st_mtime_ns, stat.st_ctime_ns, stat.st_size)
132 return hashlib.sha256(repr(data).encode('ascii')).digest()[:8]
134 async def render_get(self, request):
135 if request.opt.uri_path == ('.well-known', 'core'):
136 return aiocoap.Message(
137 payload=str(self.get_resources_as_linkheader()).encode('utf8'),
138 content_format=40
139 )
141 path = self.request_to_localpath(request)
142 try:
143 st = path.stat()
144 except FileNotFoundError:
145 raise NoSuchFile()
147 etag = self.hash_stat(st)
149 if etag in request.opt.etags:
150 response = aiocoap.Message(code=codes.VALID)
151 else:
152 if S_ISDIR(st.st_mode):
153 response = await self.render_get_dir(request, path)
154 elif S_ISREG(st.st_mode):
155 response = await self.render_get_file(request, path)
157 response.opt.etag = etag
158 return response
160 async def render_put(self, request):
161 if not self.write:
162 return aiocoap.Message(code=codes.FORBIDDEN)
164 if not request.opt.uri_path or not request.opt.uri_path[-1]:
165 # Attempting to write to a directory
166 return aiocoap.Message(code=codes.BAD_REQUEST)
168 path = self.request_to_localpath(request)
170 if request.opt.if_none_match:
171 # FIXME: This is locally a race condition; files could be created
172 # in the "x" mode, but then how would writes to them be made
173 # atomic?
174 if path.exists():
175 raise PreconditionFailed()
177 if request.opt.if_match and b"" not in request.opt.if_match:
178 # FIXME: This is locally a race condition; not sure how to prevent
179 # that.
180 try:
181 st = path.stat()
182 except FileNotFoundError:
183 # Absent file in particular doesn't have the expected ETag
184 raise PreconditionFailed()
185 if self.hash_stat(st) not in request.opt.if_match:
186 raise PreconditionFailed()
188 # Is there a way to get both "Use umask for file creation (or the
189 # existing file's permissions)" logic *and* atomic file creation on
190 # portable UNIX? If not, all we could do would be emulate the logic of
191 # just opening the file (by interpreting umask and the existing file's
192 # permissions), and that fails horrobly if there are ACLs in place that
193 # bites rsync in https://bugzilla.samba.org/show_bug.cgi?id=9377.
194 #
195 # If there is not, secure temporary file creation is as good as
196 # anything else.
197 with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as spool:
198 spool.write(request.payload)
199 temppath = Path(spool.name)
200 try:
201 temppath.rename(path)
202 except Exception:
203 temppath.unlink()
204 raise
206 st = path.stat()
207 etag = self.hash_stat(st)
209 return aiocoap.Message(code=codes.CHANGED, etag=etag)
211 async def render_delete(self, request):
212 if not self.write:
213 return aiocoap.Message(code=codes.FORBIDDEN)
215 if not request.opt.uri_path or not request.opt.uri_path[-1]:
216 # Deleting directories is not supported as they can't be created
217 return aiocoap.Message(code=codes.BAD_REQUEST)
219 path = self.request_to_localpath(request)
221 if request.opt.if_match and b"" not in request.opt.if_match:
222 # FIXME: This is locally a race condition; not sure how to prevent
223 # that.
224 try:
225 st = path.stat()
226 except FileNotFoundError:
227 # Absent file in particular doesn't have the expected ETag
228 raise NoSuchFile()
229 if self.hash_stat(st) not in request.opt.if_match:
230 raise PreconditionFailed()
232 try:
233 path.unlink()
234 except FileNotFoundError:
235 raise NoSuchFile()
237 return aiocoap.Message(code=codes.DELETED)
239 async def render_get_dir(self, request, path):
240 if request.opt.uri_path and request.opt.uri_path[-1] != '':
241 raise TrailingSlashMissingError()
243 self.log.info("Serving directory %s", path)
245 response = ""
246 for f in path.iterdir():
247 rel = f.relative_to(self.root)
248 if f.is_dir():
249 response += "</%s/>;ct=40," % rel
250 else:
251 response += "</%s>," % rel
252 return aiocoap.Message(payload=response[:-1].encode('utf8'), content_format=40)
254 async def render_get_file(self, request, path):
255 if request.opt.uri_path and request.opt.uri_path[-1] == '':
256 raise AbundantTrailingSlashError()
258 self.log.info("Serving file %s", path)
260 block_in = request.opt.block2 or aiocoap.optiontypes.BlockOption.BlockwiseTuple(0, 0, 6)
262 with path.open('rb') as f:
263 f.seek(block_in.start)
264 data = f.read(block_in.size + 1)
266 if path in self._observations and self._observations[path][0] is None:
267 # FIXME this is not *completely* precise, as it might mean that in
268 # a (Observation 1 established, check loop run, file modified,
269 # observation 2 established) situation, observation 2 could receive
270 # a needless update on the next check, but it's simple and errs on
271 # the side of caution.
272 self._observations[path][0] = path.stat()
274 guessed_type, _ = mimetypes.guess_type(str(path))
276 block_out = aiocoap.optiontypes.BlockOption.BlockwiseTuple(block_in.block_number, len(data) > block_in.size, block_in.size_exponent)
277 content_format = None
278 if guessed_type is not None:
279 try:
280 content_format = aiocoap.numbers.ContentFormat.by_media_type(guessed_type)
281 except KeyError:
282 if guessed_type and guessed_type.startswith('text/'):
283 content_format = aiocoap.numbers.ContentFormat.TEXT
284 return aiocoap.Message(
285 payload=data[:block_in.size],
286 block2=block_out,
287 content_format=content_format,
288 observe=request.opt.observe
289 )
291 async def add_observation(self, request, serverobservation):
292 path = self.request_to_localpath(request)
294 # the actual observable flag will only be set on files anyway, the
295 # library will cancel the file observation accordingly if the requested
296 # thing is not actually a file -- so it can be done unconditionally here
298 last_stat, callbacks = self._observations.setdefault(path, [None, []])
299 cb = serverobservation.trigger
300 callbacks.append(cb)
301 serverobservation.accept(lambda self=self, path=path, cb=cb: self._observations[path][1].remove(cb))
303class FileServerProgram(AsyncCLIDaemon):
304 async def start(self):
305 logging.basicConfig()
307 self.registerer = None
309 p = self.build_parser()
311 opts = p.parse_args()
312 server_opts = extract_server_arguments(opts)
314 await self.start_with_options(**vars(opts), server_opts=server_opts)
316 @staticmethod
317 def build_parser():
318 p = argparse.ArgumentParser(description=__doc__)
319 p.add_argument("-v", "--verbose", help="Be more verbose (repeat to debug)", action='count', dest="verbosity", default=0)
320 p.add_argument("--register", help="Register with a Resource directory", metavar='RD-URI', nargs='?', default=False)
321 p.add_argument("--write", help="Allow writes by any user", action='store_true')
322 p.add_argument("path", help="Root directory of the server", nargs="?", default=".", type=Path)
324 add_server_arguments(p)
326 return p
328 async def start_with_options(self, path, verbosity=0, register=False,
329 server_opts=None, write=False):
330 log = logging.getLogger('fileserver')
331 coaplog = logging.getLogger('coap-server')
333 if verbosity == 1:
334 log.setLevel(logging.INFO)
335 elif verbosity == 2:
336 log.setLevel(logging.DEBUG)
337 coaplog.setLevel(logging.INFO)
338 elif verbosity >= 3:
339 log.setLevel(logging.DEBUG)
340 coaplog.setLevel(logging.DEBUG)
342 server = FileServer(path, log, write=write)
343 if server_opts is None:
344 self.context = await aiocoap.Context.create_server_context(server)
345 else:
346 self.context = await server_context_from_arguments(server, server_opts)
348 self.refreshes = asyncio.create_task(
349 server.check_files_for_refreshes(),
350 **py38args(name="Refresh on %r" % (path,))
351 )
353 if register is not False:
354 if register is not None and register.count('/') != 2:
355 log.warn("Resource directory does not look like a host-only CoAP URI")
357 self.registerer = Registerer(self.context, rd=register, lt=60)
359 if verbosity == 2:
360 self.registerer.log.setLevel(logging.INFO)
361 elif verbosity >= 3:
362 self.registerer.log.setLevel(logging.DEBUG)
364 async def shutdown(self):
365 if self.registerer is not None:
366 await self.registerer.shutdown()
367 self.refreshes.cancel()
368 await self.context.shutdown()
370# used by doc/aiocoap_index.py
371build_parser = FileServerProgram.build_parser
373if __name__ == "__main__":
374 FileServerProgram.sync_main()