diff --git a/earthsnake/path.py b/earthsnake/path.py new file mode 100644 index 0000000..ba2d608 --- /dev/null +++ b/earthsnake/path.py @@ -0,0 +1,70 @@ +"""Path handling""" + +from .identity import Identity +from .types import ALPHA_LOWER, ALPHA_UPPER, DIGIT + +PATH_PUNCTUATION = "/'()-._~!$&+,:=@%" +PATH_CHARACTER = ALPHA_LOWER + ALPHA_UPPER + DIGIT + PATH_PUNCTUATION + + +class Path: + """A document path""" + + _SEGMENT_PATTERN = f'/[{PATH_CHARACTER}]+' + _PATTERN = f'^({_SEGMENT_PATTERN})+$' + + def __init__(self, path: str) -> None: + self.validate(path, allow_ephemeral=True) + self.path = path + + @staticmethod + def validate(path: str, allow_ephemeral: bool = False) -> None: + """Validate a path""" + + if not 2 <= len(path) <= 512: + raise ValueError('Path length must be between 2 and 512') + + if not path.startswith('/'): + raise ValueError('Paths must start with a /') + + if path.endswith('/'): + raise ValueError('Paths must not end with a /') + + if path.startswith('/@'): + raise ValueError('Paths must not start with /@') + + if '//' in path: + raise ValueError('Paths must not contain //') + + if path.count('!') > 1: + raise ValueError('Only one ! is allowed in paths') + + if '!' in path and not allow_ephemeral: + raise ValueError('Only ephemeral paths may contain !') + + @property + def is_shared(self) -> bool: + """Check if the path is shared""" + + return '~' not in self.path + + @property + def is_ephemeral(self) -> bool: + """Check if the path is ephemeral""" + + return '!' in self.path + + def can_write(self, author: Identity) -> bool: + """Check if a specific author has write access over a document""" + + if self.is_shared: + return True + + segments = self.path.split('/') + + for segment in segments: + for allowed_author in segment.split('~'): + if Identity.valid_address(allowed_author) and str(author) == allowed_author: + return True + + return False diff --git a/earthsnake/types.py b/earthsnake/types.py index e873e28..27c696e 100644 --- a/earthsnake/types.py +++ b/earthsnake/types.py @@ -2,6 +2,7 @@ """ ALPHA_LOWER = 'abcdefghijklmnopqrstuvwxyz' +ALPHA_UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' DIGIT = '0123456789' B32_CHAR = ALPHA_LOWER + '234567' ALPHA_LOWER_OR_DIGIT = ALPHA_LOWER + DIGIT diff --git a/tests/test_path.py b/tests/test_path.py new file mode 100644 index 0000000..89f2531 --- /dev/null +++ b/tests/test_path.py @@ -0,0 +1,95 @@ +"""Tests for path handling +""" + +import pytest + +from earthsnake.identity import Identity +from earthsnake.path import Path + + +@pytest.mark.parametrize( + 'path,error_msg', + [ + pytest.param('/', 'Path length must be between 2 and 512', id='short'), + pytest.param('/' + 'a' * 512, 'Path length must be between 2 and 512', id='long'), + pytest.param('badstart', 'Paths must start with a /', id='no_slash_start'), + pytest.param('/@atstart', 'Paths must not start with /@', id='slashat_start'), + pytest.param('/double//slash', 'Paths must not contain //', id='double_slash'), + pytest.param('/multiple!excl!', 'Only one ! is allowed in paths', id='multi_excl'), + pytest.param('/test/', 'Paths must not end with a /', id='trailing_slash'), + ], +) +def test_invalid(path: str, error_msg: str) -> None: + """Test if validation fails for invalid paths""" + + with pytest.raises(ValueError) as ctx: + Path.validate(path) + + assert error_msg in str(ctx.value) + + +def test_invalid_non_ephemeral() -> None: + """Test validating ephemeral paths when ephemeral is not allowed""" + + with pytest.raises(ValueError) as ctx: + Path.validate('/ephemeral!') + + assert 'Only ephemeral paths may contain !' in str(ctx.value) + + +def test_validate_ephemeral() -> None: + """Test validation of ephemeral paths""" + + assert Path.validate('/ephemeral!', allow_ephemeral=True) is None + + +def test_is_ephemeral() -> None: + """Test the is_ephemeral property""" + + assert Path('/ephemeral!').is_ephemeral + assert not Path('/non-ephemeral').is_ephemeral + + +def test_is_shared() -> None: + """Test the is_shared property""" + + assert Path('/test').is_shared + assert not Path('/test/~').is_shared + + +@pytest.mark.parametrize( + 'author,path,allowed', + [ + pytest.param( + '@test.bcz76z52y5dlpohtkmpuj3jsdcvfmebzpcgfmtmhu4u7hlexzreya', + '/test', + True, + id='shared', + ), + pytest.param( + '@test.bcz76z52y5dlpohtkmpuj3jsdcvfmebzpcgfmtmhu4u7hlexzreya', + '/test/~@test.bcz76z52y5dlpohtkmpuj3jsdcvfmebzpcgfmtmhu4u7hlexzreya', + True, + id='allowed', + ), + pytest.param( + '@test.bcz76z52y5dlpohtkmpuj3jsdcvfmebzpcgfmtmhu4u7hlexzreya', + '/test/~@abob.bcz76z52y5dlpohtkmpuj3jsdcvfmebzpcgfmtmhu4u7hlexzreya', + False, + id='someone_else', + ), + pytest.param( + '@test.bcz76z52y5dlpohtkmpuj3jsdcvfmebzpcgfmtmhu4u7hlexzreya', + '/test/~', + False, + id='strictly_forbidden', + ), + ], +) +def test_can_write(author: str, path: str, allowed: bool) -> None: + """Test the can_write method""" + + path_obj = Path(path) + identity = Identity.from_address(author) + + assert path_obj.can_write(identity) is allowed