diff --git a/declearn/communication3/server/__init__.py b/declearn/communication3/server/__init__.py index 70c7fe3a8d303a1bb0427750ffed4f1c6177acc6..4097c1cff0bf904d72aa96cb26b7c580e7d3cea5 100644 --- a/declearn/communication3/server/__init__.py +++ b/declearn/communication3/server/__init__.py @@ -17,4 +17,5 @@ """DecLearn v3 experimental network server endpoint code.""" +from ._files import FileAuthError, FileExpiredError, FilesManager from ._peers import PeerPolicy, PeersManager diff --git a/declearn/communication3/server/_files.py b/declearn/communication3/server/_files.py new file mode 100644 index 0000000000000000000000000000000000000000..309f1d401f775b8ae67582242e376abe5fd93d70 --- /dev/null +++ b/declearn/communication3/server/_files.py @@ -0,0 +1,377 @@ +# coding: utf-8 + +# Copyright 2023 Inria (Institut National de Recherche en Informatique +# et Automatique) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Component to manage binary files storage for a communication server.""" + +import datetime +import os +import secrets +from typing import Dict, Iterator, Optional, Tuple, Union + +from declearn.communication3.server._utils import create_secret_token + +__all__ = [ + "FilesManager", + "FileAuthError", + "FileExpiredError", +] + + +class FileAuthError(Exception): + """Custom exception denoting wrong authentication to access a SavedFile.""" + + +class FileExpiredError(Exception): + """Custom exception denoting that a SavedFile has expired.""" + + +class SavedFile: + """Class to handle a saved binary file on a communication server. + + This class is merely a backend tool, to be used by `FilesManager`. + It may be refactored or replaced in the future, notably using some + database to store information on files' location, secret tokens, + metadata and access logs. + """ + + # backend class with private attributes; + # pylint: disable=too-many-instance-attributes + + def __init__( + self, + path: str, + access_token: str, + delete_token: str, + max_accesses: Optional[int] = None, + max_keeptime: Optional[datetime.timedelta] = None, + ) -> None: + """Instantiate the SavedFile handler. + + Parameters + ---------- + path: + Path to the file being watched and managed. + access_token: + Secret token required to open the file and read its contents. + delete_token: + Secret token required to perform non-automated file deletion. + max_accesses: + Optional maximum number of accesses, beyond which to disable + any new file access, and enable automatic deletion. + max_keeptime: + Optional maximum storage duration, beyond which to disable + any new file access, and enable automatic deletion. + """ + # arguments serve modularity; pylint: disable=too-many-arguments + self._path = path + self._access_token = access_token + self._delete_token = delete_token + self._max_accesses = max_accesses + self._num_accesses = 0 + self._srt_datetime = datetime.datetime.now() + self._max_keeptime = max_keeptime + self._n_open_reads = 0 + self._delete_next = False + + def access( + self, + token: str, + ) -> Iterator[bytes]: + """Access the contents from the file. + + Parameters + ---------- + token: + Secret token enabling to access this file. + + Yields + ------ + rows: + Rows of bytes content, iteratively read from the file. + + Raises + ------ + FileAuthError + If the provided token does not grant access rights. + + Notes + ----- + - File expiration and access token verification are performed + on call, to prevent race conditions from streaming from the + returned generator. + - File opening, on the other hand, is delegated to the first + iteration on the returned generator. Similarly, reading is + performed iteratively. Fail-safes should prevent the file + from being deleted while being accessed. + - Upon yielding the final chunk of content, if the file has + expired and no other access is open, it is automatically + deleted from disk. + """ + iterator = self._access(token) + next(iterator) # trigger access validation code + return iterator + + def _access( + self, + token: str, + ) -> Iterator[bytes]: + """Backend code for the `access` method. + + This method is merely here to enable triggering file expiration + and token verification right on `access` call, while preserving + the iterative nature of the file's contents streaming. + """ + # Verify that the access is authorized. + if self.expired: + raise FileExpiredError("Cannot access expired file.") + if not secrets.compare_digest(token, self._access_token): + raise FileAuthError("Incorrect access token.") + # Increment counters on total number of accesses and current opens. + self._num_accesses += 1 + self._n_open_reads += 1 + yield b"" # enable triggering the code above right away in `access` + # Yield the file's contents; auto-delete at close time. + with open(self._path, "rb") as file: + yield from file + self._n_open_reads -= 1 + self.auto_delete() + + def delete( + self, + token: str, + ) -> None: + """Delete this file from disk. + + Parameters + ---------- + token: + Secret token enabling to remove this file. + + Raises + ------ + FileAuthError + If the provided token does not grant deletion rights. + + Notes + ----- + - If the file is currently being accessed, delay its deletion + to the end of these accesses, and refuse any new access. + - If the file has already been removed from disk, still verify + the input token, and silently pass or raise based on that. + In other words, do not let the caller know that the file has + already been deleted. + """ + if not secrets.compare_digest(token, self._delete_token): + raise FileAuthError("Incorrect deletion token.") + if self._n_open_reads: + self._delete_next = True + elif os.path.isfile(self._path): + os.remove(self._path) + + @property + def expired( + self, + ) -> bool: + """Whether this file has expired - as a property evaluated on call.""" + if self._delete_next or not os.path.isfile(self._path): + return True + if self._max_accesses is not None: + if self._num_accesses >= self._max_accesses: + return True + if self._max_keeptime is not None: + keeptime = datetime.datetime.now() - self._srt_datetime + if keeptime >= self._max_keeptime: + return True + return False + + def auto_delete( + self, + ) -> None: + """Automatically delete this file from disk if it has expired.""" + if self.expired: + self.delete(self._delete_token) + + +class FilesManager: + """Component to manage binary files storage for a communication server.""" + + def __init__( + self, + folder: str, + ) -> None: + """Instantiate the stored files manager. + + Parameters + ---------- + folder: + Path to the root folder where to create stored files. + """ + os.makedirs(folder, exist_ok=True) + self.folder = folder + self._files = {} # type: Dict[str, SavedFile] + + def create_file( + self, + buffer: Union[bytes, bytearray], + prefix: Optional[str] = None, + max_accesses: Optional[int] = None, + max_keeptime: Optional[datetime.timedelta] = None, + ) -> Tuple[str, str, str]: + """Add a file to the manager. + + Parameters + ---------- + buffer: + Byte content (or stream) of the file to create and manage. + prefix: + Optional prefix to used in the registered name of the file. + max_accesses: + Optional maximum number of accesses to the file, leading to + its automatic expiration and removal. + max_keeptime: + Optional maximum duration of storage of the file, leading to + its automatic expiration and removal. + + Returns + ------- + name: + Registered name of the saved file. + access_token: + Secret token enabling to access the saved file. + delete_token: + Secret token enabling to remove the saved file. + """ + # Select a public name that is not already in use. + while ( + name := f"{prefix or 'file'}_{secrets.token_urlsafe()}" + ) in self._files: + pass + # Select a secret path that is not already in use. + while os.path.isfile( + path := os.path.join(self.folder, str(secrets.token_hex())) + ): + pass + # Write down the file. + with open(path, "wb") as file: + file.write(buffer) + # Create random secret tokens. + access_token = create_secret_token() + delete_token = create_secret_token() + # Store a reference to the managed file, and return access information. + self._files[name] = SavedFile( + path=path, + access_token=access_token, + delete_token=delete_token, + max_accesses=max_accesses, + max_keeptime=max_keeptime, + ) + return name, access_token, delete_token + + def access_file( + self, + name: str, + token: str, + ) -> Iterator[bytes]: + """Access a saved file. + + Parameters + ---------- + name: + Registered name of the file to access. + token: + Authentication token to access the file. + + Yields + ------ + stream: + Bytes content from the file, as a generator streaming + the file one row as a time. + + Raises + ------ + FileAuthError + If the authentication token is not the proper one. + FileExpiredError + If the file has expired and can no longer be accessed. + FileNotFoundError + If the file name does not match a managed resource. + + Notes + ----- + - File expiration and access token verification are performed + on call. + - File opening and reading are performed iteratively, based on + actions performed on the returned generator. + - Until the entire file has been read, it will not be removed + from disk, even if its deletion is asked. In the latter case, + deletion will be delayed until all pre-existing accesses are + closed. + """ + if name not in self._files: + raise FileNotFoundError(f"No file resource with name {name}.") + return self._files[name].access(token) + + def delete_file( + self, + name: str, + token: str, + deref: bool = False, + ) -> None: + """Delete a saved file. + + Parameters + ---------- + name: + Registered name of the file to delete. + token: + Authentication token to delete the file. + deref: + Whether to de-reference the file, so that new access attempts + will raise `FileNotFoundError` rather than `FileExpiredError`. + + Raises + ------ + FileAuthError + If the authentication token is not the proper one. + FileNotFoundError + If the file name does not match a managed resource. + + Notes + ----- + - If the file has already been deleted but is still referenced by + this manager, this function will pass or raise all the same. + - If the file is currently being accessed, its deletion will be + delayed to the termination of these accesses, and no new access + will be allowed. + """ + if name not in self._files: + raise FileNotFoundError(f"No file resource with name {name}.") + self._files[name].delete(token) + if deref: + self._files.pop(name) + + def remove_expired_references( + self, + ) -> None: + """Remove references to managed files that have expired. + + Delete expired files from disk upon being de-referenced. + """ + expired = [name for name, fobj in self._files.items() if fobj.expired] + for name in expired: + fobj = self._files.pop(name) + fobj.auto_delete() diff --git a/declearn/communication3/server/_peers.py b/declearn/communication3/server/_peers.py index 73eda459e9bafb62cc647b0ef2b84f00960d9698..f2054de63c1459769bf6ed472bdab2ecd481913d 100644 --- a/declearn/communication3/server/_peers.py +++ b/declearn/communication3/server/_peers.py @@ -20,9 +20,9 @@ import copy import dataclasses import secrets - from typing import Dict, Optional, Set, Tuple +from declearn.communication3.server._utils import create_secret_token __all__ = [ "PeersManager", @@ -30,14 +30,6 @@ __all__ = [ ] -DEFAULT_TOKEN_NBYTES = 64 # Default number of bytes in user auth tokens. - - -def create_secret_token() -> str: - """Create a random secret string token.""" - return secrets.token_hex(nbytes=DEFAULT_TOKEN_NBYTES) - - MOCK_TOKEN = create_secret_token() diff --git a/declearn/communication3/server/_utils.py b/declearn/communication3/server/_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..8f58750dff91a362d36fab1e3c27f2c19bbba959 --- /dev/null +++ b/declearn/communication3/server/_utils.py @@ -0,0 +1,32 @@ +# coding: utf-8 + +# Copyright 2023 Inria (Institut National de Recherche en Informatique +# et Automatique) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared utils for network communication components.""" + +import secrets + +__all__ = [ + "create_secret_token", +] + + +DEFAULT_TOKEN_NBYTES = 64 # Default number of bytes in user auth tokens. + + +def create_secret_token() -> str: + """Create a random secret string token.""" + return secrets.token_hex(nbytes=DEFAULT_TOKEN_NBYTES) diff --git a/test/communication3/server/test_files.py b/test/communication3/server/test_files.py new file mode 100644 index 0000000000000000000000000000000000000000..024a4c2be272ee1f5449eca60506842be00231dc --- /dev/null +++ b/test/communication3/server/test_files.py @@ -0,0 +1,262 @@ +# coding: utf-8 + +# Copyright 2023 Inria (Institut National de Recherche en Informatique +# et Automatique) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for the FilesManager communication server component.""" + +import datetime +import os +import tempfile +import time +from typing import Iterator, Optional +from unittest import mock + +import pytest + +from declearn.communication3.server import ( + FileAuthError, + FileExpiredError, + FilesManager, +) + + +@pytest.fixture(name="folder") +def folder_fixture() -> Iterator[str]: + """Provide with a temporary folder.""" + with tempfile.TemporaryDirectory() as folder: + yield folder + + +class TestFilesManager: + """Unit tests for the FilesManager communication server component.""" + + @pytest.mark.parametrize( + "prefix", [None, "prefix"], ids=["noprefix", "prefixed"] + ) + def test_create_file( + self, + folder: str, + prefix: Optional[str], + ) -> None: + """Test that `create_file` properly creates a file.""" + # Setup a FilesManager and some mock file content. + manager = FilesManager(folder) + content = mock.create_autospec(bytes, instance=True) + # Call the method, mocking file opening. + with mock.patch("builtins.open", mock.mock_open()) as patch_open: + name, access_token, delete_token = manager.create_file( + content, prefix=prefix + ) + # Verify that a file was opened and that outputs match expectations. + patch_open.assert_called_once() + patch_open.return_value.write.assert_called_once_with(content) + assert isinstance(name, str) + if prefix: + assert name.startswith(prefix) + assert isinstance(access_token, str) + assert isinstance(delete_token, str) + assert access_token != delete_token + + def test_access_file( + self, + folder: str, + ) -> None: + """Test that `access_files` properly streams the file.""" + # Setup a FilesManager and create a file. + manager = FilesManager(folder) + content = b"placeholder" + name, access_token, _ = manager.create_file(content) + # Verify that the file can properly be accessed. + outputs = b"".join(manager.access_file(name, access_token)) + assert outputs == content + + def test_access_file_with_wrong_token( + self, + folder: str, + ) -> None: + """Test that `access_files` raises on wrong input token.""" + # Setup a FilesManager and create a file. + manager = FilesManager(folder) + content = b"placeholder" + name, _, delete_token = manager.create_file(content) + # Verify that the expected error is raised with a wrongful token. + with pytest.raises(FileAuthError): + manager.access_file(name, delete_token) + + def test_access_file_with_wrong_name( + self, + folder: str, + ) -> None: + """Test that `access_files` raises on wrong input name.""" + # Setup a FilesManager with no file. + manager = FilesManager(folder) + # Verify that the expected error is raised with a wrongful name. + with pytest.raises(FileNotFoundError): + manager.access_file("fake-name", "fake-token") + + def test_access_expired_file_too_many_accesses( + self, + folder: str, + ) -> None: + """Test that files can expire based on specified number of accesses.""" + # Setup a FilesManager with a single-access file. + manager = FilesManager(folder) + content = b"placeholder" + name, access_token, _ = manager.create_file(content, max_accesses=1) + # Test that the file can be accessed once and only once. + assert b"".join(manager.access_file(name, access_token)) == content + with pytest.raises(FileExpiredError): + manager.access_file(name, access_token) + + def test_access_expired_file_too_long_timekeep( + self, + folder: str, + ) -> None: + """Test that files can expire based on specified storage duration.""" + # Setup a FilesManager with a 0.2-second storage duration. + manager = FilesManager(folder) + content = b"placeholder" + name, access_token, _ = manager.create_file( + content, max_keeptime=datetime.timedelta(milliseconds=200) + ) + # Test that the file can be accessed prior to the limit. + assert b"".join(manager.access_file(name, access_token)) == content + # Test that the file cannot be accessed past the limit. + time.sleep(0.2) + with pytest.raises(FileExpiredError): + manager.access_file(name, access_token) + + def test_delete_file( + self, + folder: str, + ) -> None: + """Test that `delete_file` properly deletes a stored file.""" + # Setup a FilesManager with a single file. + manager = FilesManager(folder) + content = b"placeholder" + name, _, delete_token = manager.create_file(content) + # Test that the file can be deleted (and is effectively removed). + assert len(os.listdir(folder)) == 1 + manager.delete_file(name, delete_token) + assert len(os.listdir(folder)) == 0 + + def test_delete_file_with_wrong_token( + self, + folder: str, + ) -> None: + """Test that `delete_files` raises on wrong input token.""" + # Setup a FilesManager and create a file. + manager = FilesManager(folder) + content = b"placeholder" + name, access_token, _ = manager.create_file(content) + # Verify that the expected error is raised with a wrongful token. + with pytest.raises(FileAuthError): + manager.delete_file(name, access_token) + + def test_delete_file_with_wrong_name( + self, + folder: str, + ) -> None: + """Test that `delete_files` raises on wrong input name.""" + # Setup a FilesManager with no file. + manager = FilesManager(folder) + # Verify that the expected error is raised with a wrongful name. + with pytest.raises(FileNotFoundError): + manager.delete_file("fake-name", "fake-token") + + def test_delete_file_redundant_calls_no_deref( + self, + folder: str, + ) -> None: + """Test that redundant calls to `delete_file` pass quietly.""" + # Setup a FilesManager with a single file. + manager = FilesManager(folder) + content = b"placeholder" + name, _, delete_token = manager.create_file(content) + # Test that the file can be deleted twice - i.e. that deletion + # is accepted even for deprecated/no-longer-existing files. + manager.delete_file(name, delete_token) + manager.delete_file(name, delete_token) + + def test_delete_file_redundant_calls_with_deref( + self, + folder: str, + ) -> None: + """Test that redundant calls to `delete_file` raise if `deref=True`.""" + # Setup a FilesManager with a single file. + manager = FilesManager(folder) + content = b"placeholder" + name, _, delete_token = manager.create_file(content) + # Test that the file can only be deleted once - due to its + # dereferencing, that make it unknown to the manager. + manager.delete_file(name, delete_token, deref=True) + with pytest.raises(FileNotFoundError): + manager.delete_file(name, delete_token) + + def test_delete_file_during_access( + self, + folder: str, + ) -> None: + """Test that `delete_file` and `access_file` play along nicely.""" + # Setup a FilesManager with a single file. + manager = FilesManager(folder) + content = b"placeholder" + name, access_token, delete_token = manager.create_file(content) + # Open an access to the file, then trigger its deletion. + fstream = manager.access_file(name, access_token) + manager.delete_file(name, delete_token) + # Verify that new accesses are refused, due to the planned deletion. + with pytest.raises(FileExpiredError): + manager.access_file(name, access_token) + # Verify that the file exists and the pre-existing stream works. + assert len(os.listdir(folder)) == 1 + assert b"".join(fstream) == content + # Verify that the file is deleted once the open reader is done. + assert len(os.listdir(folder)) == 0 + + def test_remove_expired_references( + self, + folder: str, + ) -> None: + """Test that `remove_expired_references` properly collects files.""" + # Setup a FilesManager with four files: + # - a is bound to remain in-place + # - b is deleted after creation + # - c may expire but is left unaccessed + # - d expires right away due to zero-seconds storage duration + manager = FilesManager(folder) + content = b"placeholder" + file_a = manager.create_file(content) + file_b = manager.create_file(content) + file_c = manager.create_file(content, max_accesses=1) + file_d = manager.create_file( + content, max_keeptime=datetime.timedelta(0) + ) + assert len(os.listdir(folder)) == 4 + manager.delete_file(name=file_b[0], token=file_b[2]) + assert len(os.listdir(folder)) == 3 + # Call the method. + manager.remove_expired_references() + # Verify that only two files remain (a and c). + assert len(os.listdir(folder)) == 2 + # Verify that a and c are still referenced. + manager.access_file(name=file_a[0], token=file_a[1]) + manager.access_file(name=file_c[0], token=file_c[1]) + # Verify that b and d are no longer referenced. + with pytest.raises(FileNotFoundError): + manager.access_file(name=file_b[0], token=file_b[1]) + with pytest.raises(FileNotFoundError): + manager.access_file(name=file_d[0], token=file_d[1])