safe-s3-storage 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- safe_s3_storage-0.1.0/.github/dependabot.yml +6 -0
- safe_s3_storage-0.1.0/.github/workflows/workflow.yml +19 -0
- safe_s3_storage-0.1.0/.gitignore +7 -0
- safe_s3_storage-0.1.0/Justfile +25 -0
- safe_s3_storage-0.1.0/LICENSE +21 -0
- safe_s3_storage-0.1.0/PKG-INFO +34 -0
- safe_s3_storage-0.1.0/README.md +10 -0
- safe_s3_storage-0.1.0/pyproject.toml +77 -0
- safe_s3_storage-0.1.0/safe_s3_storage/__init__.py +17 -0
- safe_s3_storage-0.1.0/safe_s3_storage/exceptions.py +33 -0
- safe_s3_storage-0.1.0/safe_s3_storage/file_validator.py +108 -0
- safe_s3_storage-0.1.0/safe_s3_storage/kaspersky_scan_engine.py +55 -0
- safe_s3_storage-0.1.0/safe_s3_storage/s3_base.py +10 -0
- safe_s3_storage-0.1.0/safe_s3_storage/s3_upload.py +43 -0
- safe_s3_storage-0.1.0/tests/__init__.py +0 -0
- safe_s3_storage-0.1.0/tests/conftest.py +22 -0
- safe_s3_storage-0.1.0/tests/test_exceptions.py +15 -0
- safe_s3_storage-0.1.0/tests/test_file_validator.py +146 -0
- safe_s3_storage-0.1.0/tests/test_s3_upload.py +67 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: CI Pipeline
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
pull_request:
|
|
8
|
+
branches:
|
|
9
|
+
- main
|
|
10
|
+
release:
|
|
11
|
+
types:
|
|
12
|
+
- published
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
ci:
|
|
16
|
+
uses: community-of-python/community-workflow/.github/workflows/preset.yml@main
|
|
17
|
+
with:
|
|
18
|
+
python-version: '["3.12","3.13"]'
|
|
19
|
+
secrets: inherit
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
default: install lint test
|
|
2
|
+
|
|
3
|
+
install:
|
|
4
|
+
uv lock --upgrade
|
|
5
|
+
uv sync --frozen --all-groups
|
|
6
|
+
|
|
7
|
+
lint:
|
|
8
|
+
uv run --group lint auto-typing-final .
|
|
9
|
+
uv run --group lint ruff check
|
|
10
|
+
uv run --group lint ruff format
|
|
11
|
+
uv run --group lint mypy .
|
|
12
|
+
|
|
13
|
+
lint-ci:
|
|
14
|
+
uv run --group lint auto-typing-final .
|
|
15
|
+
uv run --group lint ruff format --check
|
|
16
|
+
uv run --group lint ruff check --no-fix
|
|
17
|
+
uv run --group lint mypy .
|
|
18
|
+
|
|
19
|
+
test *args:
|
|
20
|
+
uv run pytest {{ args }}
|
|
21
|
+
|
|
22
|
+
publish:
|
|
23
|
+
rm -rf dist
|
|
24
|
+
uv build
|
|
25
|
+
uv publish --token $PYPI_TOKEN
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Community of python
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: safe-s3-storage
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: S3 safe storage
|
|
5
|
+
Author: community-of-python
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: antivirus,kaspersky,s3,upload
|
|
8
|
+
Classifier: Natural Language :: English
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Classifier: Topic :: Communications :: File Sharing
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
|
+
Requires-Dist: aioboto3
|
|
16
|
+
Requires-Dist: httpx
|
|
17
|
+
Requires-Dist: puremagic
|
|
18
|
+
Requires-Dist: pydantic
|
|
19
|
+
Requires-Dist: pyvips
|
|
20
|
+
Requires-Dist: pyvips-binary
|
|
21
|
+
Requires-Dist: stamina
|
|
22
|
+
Requires-Dist: types-aioboto3[s3]
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# safe-s3-storage
|
|
26
|
+
|
|
27
|
+
S3 tools for uploading files to S3 safely (antivirus check, etc) as well as downloading and deleting files.
|
|
28
|
+
|
|
29
|
+
## How To Use
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
uv add safe-s3-storage
|
|
33
|
+
poetry add safe-s3-storage
|
|
34
|
+
```
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "safe-s3-storage"
|
|
3
|
+
description = "S3 safe storage"
|
|
4
|
+
readme = "README.md"
|
|
5
|
+
keywords = ["s3", "kaspersky", "antivirus", "upload"]
|
|
6
|
+
classifiers = [
|
|
7
|
+
"Natural Language :: English",
|
|
8
|
+
"Programming Language :: Python :: 3.12",
|
|
9
|
+
"Programming Language :: Python :: 3.13",
|
|
10
|
+
"Topic :: Software Development :: Libraries",
|
|
11
|
+
"Topic :: Communications :: File Sharing",
|
|
12
|
+
"Typing :: Typed",
|
|
13
|
+
]
|
|
14
|
+
authors = [{ name = "community-of-python" }]
|
|
15
|
+
dynamic = ["version"]
|
|
16
|
+
requires-python = ">=3.12"
|
|
17
|
+
dependencies = [
|
|
18
|
+
"httpx",
|
|
19
|
+
"aioboto3",
|
|
20
|
+
"types-aioboto3[s3]",
|
|
21
|
+
"stamina",
|
|
22
|
+
"pydantic",
|
|
23
|
+
"pyvips",
|
|
24
|
+
"pyvips-binary",
|
|
25
|
+
"puremagic",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = ["anyio", "faker", "pytest", "pytest-cov"]
|
|
30
|
+
lint = [{ include-group = "dev" }, "auto-typing-final", "mypy", "ruff"]
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
34
|
+
build-backend = "hatchling.build"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.version]
|
|
37
|
+
source = "vcs"
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
fix = true
|
|
41
|
+
unsafe-fixes = true
|
|
42
|
+
line-length = 120
|
|
43
|
+
|
|
44
|
+
[tool.ruff.format]
|
|
45
|
+
docstring-code-format = true
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint]
|
|
48
|
+
select = ["ALL"]
|
|
49
|
+
ignore = [
|
|
50
|
+
"EM",
|
|
51
|
+
"FBT",
|
|
52
|
+
"TRY003",
|
|
53
|
+
"D1",
|
|
54
|
+
"D203",
|
|
55
|
+
"D213",
|
|
56
|
+
"G004",
|
|
57
|
+
"FA",
|
|
58
|
+
"COM812",
|
|
59
|
+
"ISC001",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint.isort]
|
|
63
|
+
no-lines-before = ["standard-library", "local-folder"]
|
|
64
|
+
known-third-party = []
|
|
65
|
+
known-local-folder = []
|
|
66
|
+
lines-after-imports = 2
|
|
67
|
+
|
|
68
|
+
[tool.ruff.lint.extend-per-file-ignores]
|
|
69
|
+
"tests/*.py" = ["S101", "S311"]
|
|
70
|
+
|
|
71
|
+
[tool.pytest.ini_options]
|
|
72
|
+
addopts = "--cov=."
|
|
73
|
+
|
|
74
|
+
[tool.coverage.report]
|
|
75
|
+
skip_covered = true
|
|
76
|
+
show_missing = true
|
|
77
|
+
exclude_also = ["if typing.TYPE_CHECKING:"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from safe_s3_storage import exceptions
|
|
2
|
+
from safe_s3_storage.file_validator import FileValidator, ImageConversionFormat, ValidatedFile
|
|
3
|
+
from safe_s3_storage.kaspersky_scan_engine import KasperskyScanEngineClient
|
|
4
|
+
from safe_s3_storage.s3_base import BaseS3Service
|
|
5
|
+
from safe_s3_storage.s3_upload import SafeS3FilesUploader, UploadedFile
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"BaseS3Service",
|
|
10
|
+
"FileValidator",
|
|
11
|
+
"ImageConversionFormat",
|
|
12
|
+
"KasperskyScanEngineClient",
|
|
13
|
+
"SafeS3FilesUploader",
|
|
14
|
+
"UploadedFile",
|
|
15
|
+
"ValidatedFile",
|
|
16
|
+
"exceptions",
|
|
17
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclasses.dataclass
|
|
5
|
+
class BaseError(Exception):
|
|
6
|
+
def __str__(self) -> str:
|
|
7
|
+
return self.__repr__().replace(self.__class__.__name__, "")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclasses.dataclass
|
|
11
|
+
class KasperskyScanEngineThreatDetectedError(BaseError):
|
|
12
|
+
response: bytes
|
|
13
|
+
file_name: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclasses.dataclass
|
|
17
|
+
class NotAllowedMimeTypeError(BaseError):
|
|
18
|
+
file_name: str
|
|
19
|
+
mime_type: str
|
|
20
|
+
allowed_mime_types: list[str]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclasses.dataclass
|
|
24
|
+
class TooLargeFileError(BaseError):
|
|
25
|
+
file_name: str
|
|
26
|
+
mime_type: str
|
|
27
|
+
max_size: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclasses.dataclass
|
|
31
|
+
class FailedToConvertImageError(BaseError):
|
|
32
|
+
file_name: str
|
|
33
|
+
mime_type: str
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import enum
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
import puremagic
|
|
6
|
+
import pyvips # type: ignore[import-untyped]
|
|
7
|
+
|
|
8
|
+
from safe_s3_storage import exceptions
|
|
9
|
+
from safe_s3_storage.kaspersky_scan_engine import KasperskyScanEngineClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
|
|
13
|
+
class ValidatedFile:
|
|
14
|
+
file_name: str
|
|
15
|
+
file_content: bytes
|
|
16
|
+
file_size: int
|
|
17
|
+
mime_type: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_image(mime_type: str) -> bool:
|
|
21
|
+
return mime_type.startswith("image/")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ImageConversionFormat(enum.Enum):
|
|
25
|
+
# mime type, file extension
|
|
26
|
+
jpeg = ("image/jpeg", "jpg")
|
|
27
|
+
webp = ("image/webp", "webp")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _split_file_base_name_and_extensions(file_name: str) -> tuple[str, str | None]:
|
|
31
|
+
split_result: typing.Final = file_name.rsplit(".", 1) or [file_name]
|
|
32
|
+
return split_result[0], None if len(split_result) == 1 else split_result[1]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
|
|
36
|
+
class FileValidator:
|
|
37
|
+
kaspersky_scan_engine: KasperskyScanEngineClient | None = None
|
|
38
|
+
allowed_mime_types: list[str]
|
|
39
|
+
scan_images_with_antivirus: bool = True
|
|
40
|
+
max_file_size_bytes: int = 10 * 1024 * 1024 # 10 MB
|
|
41
|
+
max_image_size_bytes: int = 50 * 1024 * 1024 # 50 MB
|
|
42
|
+
image_conversion_format: ImageConversionFormat = ImageConversionFormat.webp
|
|
43
|
+
image_quality: int = 85
|
|
44
|
+
|
|
45
|
+
def _validate_mime_type(self, *, file_name: str, file_content: bytes) -> str:
|
|
46
|
+
try:
|
|
47
|
+
mime_type = puremagic.from_string(file_content, mime=True)
|
|
48
|
+
except puremagic.PureError:
|
|
49
|
+
# unlike python-magic, puremagic doesn't recognize if text is binary
|
|
50
|
+
try:
|
|
51
|
+
file_content.decode()
|
|
52
|
+
except UnicodeDecodeError:
|
|
53
|
+
mime_type = "application/octet-stream"
|
|
54
|
+
else:
|
|
55
|
+
mime_type = "text/plain"
|
|
56
|
+
if mime_type in self.allowed_mime_types:
|
|
57
|
+
return mime_type
|
|
58
|
+
raise exceptions.NotAllowedMimeTypeError(
|
|
59
|
+
file_name=file_name, mime_type=mime_type, allowed_mime_types=self.allowed_mime_types
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def _validate_file_size(self, *, file_name: str, file_content: bytes, mime_type: str) -> int:
|
|
63
|
+
content_size: typing.Final = len(file_content)
|
|
64
|
+
max_size: typing.Final = self.max_image_size_bytes if _is_image(mime_type) else self.max_file_size_bytes
|
|
65
|
+
if content_size > max_size:
|
|
66
|
+
raise exceptions.TooLargeFileError(file_name=file_name, mime_type=mime_type, max_size=max_size)
|
|
67
|
+
return content_size
|
|
68
|
+
|
|
69
|
+
def _convert_image(self, validated_file: ValidatedFile) -> ValidatedFile:
|
|
70
|
+
if not _is_image(validated_file.mime_type):
|
|
71
|
+
return validated_file
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
pyvips_image: typing.Final[pyvips.Image] = pyvips.Image.new_from_buffer(
|
|
75
|
+
validated_file.file_content, options=""
|
|
76
|
+
)
|
|
77
|
+
new_file_content: typing.Final = typing.cast(
|
|
78
|
+
"bytes",
|
|
79
|
+
pyvips_image.write_to_buffer(f".{self.image_conversion_format.value[1]}", Q=self.image_quality),
|
|
80
|
+
)
|
|
81
|
+
except pyvips.Error as pyvips_error:
|
|
82
|
+
raise exceptions.FailedToConvertImageError(
|
|
83
|
+
file_name=validated_file.file_name, mime_type=validated_file.mime_type
|
|
84
|
+
) from pyvips_error
|
|
85
|
+
|
|
86
|
+
file_base_name, _file_extension = _split_file_base_name_and_extensions(validated_file.file_name)
|
|
87
|
+
return ValidatedFile(
|
|
88
|
+
file_name=f"{file_base_name}.{self.image_conversion_format.value[1]}",
|
|
89
|
+
file_content=new_file_content,
|
|
90
|
+
file_size=len(new_file_content),
|
|
91
|
+
mime_type=self.image_conversion_format.value[0],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
async def validate_file(self, *, file_name: str, file_content: bytes) -> ValidatedFile:
|
|
95
|
+
mime_type: typing.Final = self._validate_mime_type(file_name=file_name, file_content=file_content)
|
|
96
|
+
file_size: typing.Final = self._validate_file_size(
|
|
97
|
+
file_name=file_name, file_content=file_content, mime_type=mime_type
|
|
98
|
+
)
|
|
99
|
+
validated_file: typing.Final = self._convert_image(
|
|
100
|
+
ValidatedFile(file_name=file_name, file_content=file_content, mime_type=mime_type, file_size=file_size)
|
|
101
|
+
)
|
|
102
|
+
if self.kaspersky_scan_engine:
|
|
103
|
+
is_image: typing.Final = _is_image(validated_file.mime_type)
|
|
104
|
+
if (is_image and self.scan_images_with_antivirus) or not is_image:
|
|
105
|
+
await self.kaspersky_scan_engine.scan_memory(
|
|
106
|
+
file_name=validated_file.file_name, file_content=validated_file.file_content
|
|
107
|
+
)
|
|
108
|
+
return validated_file
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import dataclasses
|
|
3
|
+
import enum
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pydantic
|
|
8
|
+
import stamina
|
|
9
|
+
|
|
10
|
+
from safe_s3_storage.exceptions import KasperskyScanEngineThreatDetectedError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class KasperskyScanEngineRequest(pydantic.BaseModel):
|
|
14
|
+
timeout: str
|
|
15
|
+
object: str
|
|
16
|
+
name: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# https://support.kaspersky.ru/scan-engine/2.1/193001
|
|
20
|
+
class KasperskyScanEngineScanResult(enum.StrEnum):
|
|
21
|
+
CLEAN = enum.auto()
|
|
22
|
+
DETECT = enum.auto()
|
|
23
|
+
DISINFECTED = enum.auto()
|
|
24
|
+
DELETED = enum.auto()
|
|
25
|
+
NON_SCANNED = enum.auto()
|
|
26
|
+
SERVER_ERROR = enum.auto()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class KasperskyScanEngineResponse(pydantic.BaseModel):
|
|
30
|
+
scanResult: KasperskyScanEngineScanResult # noqa: N815
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
|
|
34
|
+
class KasperskyScanEngineClient:
|
|
35
|
+
httpx_client: httpx.AsyncClient
|
|
36
|
+
service_url: str
|
|
37
|
+
client_name: str
|
|
38
|
+
timeout_ms: int = 10000
|
|
39
|
+
max_retries: int = 3
|
|
40
|
+
|
|
41
|
+
async def _send_scan_memory_request(self, payload: dict[str, typing.Any]) -> bytes:
|
|
42
|
+
response: typing.Final = await self.httpx_client.post(url=self.service_url, json=payload)
|
|
43
|
+
response.raise_for_status()
|
|
44
|
+
return response.content
|
|
45
|
+
|
|
46
|
+
async def scan_memory(self, *, file_name: str, file_content: bytes) -> None:
|
|
47
|
+
payload: typing.Final = KasperskyScanEngineRequest(
|
|
48
|
+
timeout=str(self.timeout_ms), object=base64.b64encode(file_content).decode(), name=self.client_name
|
|
49
|
+
).model_dump(mode="json")
|
|
50
|
+
response: typing.Final = await stamina.retry(on=httpx.HTTPError, attempts=self.max_retries)(
|
|
51
|
+
self._send_scan_memory_request
|
|
52
|
+
)(payload)
|
|
53
|
+
validated_response: typing.Final = KasperskyScanEngineResponse.model_validate_json(response)
|
|
54
|
+
if validated_response.scanResult == KasperskyScanEngineScanResult.DETECT:
|
|
55
|
+
raise KasperskyScanEngineThreatDetectedError(response=response, file_name=file_name)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
import botocore
|
|
5
|
+
import botocore.exceptions
|
|
6
|
+
import stamina
|
|
7
|
+
|
|
8
|
+
from safe_s3_storage.file_validator import FileValidator, ValidatedFile
|
|
9
|
+
from safe_s3_storage.s3_base import BaseS3Service
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
|
|
13
|
+
class UploadedFile(ValidatedFile):
|
|
14
|
+
s3_path: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
|
|
18
|
+
class SafeS3FilesUploader(BaseS3Service):
|
|
19
|
+
file_validator: FileValidator
|
|
20
|
+
s3_key_generator: typing.Callable[[ValidatedFile], str] = lambda file_context: file_context.file_name
|
|
21
|
+
s3_metadata_generator: typing.Callable[[ValidatedFile], typing.Mapping[str, str]] = lambda _file_context: {}
|
|
22
|
+
|
|
23
|
+
async def upload_file(self, *, file_name: str, file_content: bytes) -> UploadedFile:
|
|
24
|
+
validated_file: typing.Final = await self.file_validator.validate_file(
|
|
25
|
+
file_name=file_name, file_content=file_content
|
|
26
|
+
)
|
|
27
|
+
s3_key: typing.Final = self.s3_key_generator(validated_file)
|
|
28
|
+
|
|
29
|
+
await stamina.retry(on=botocore.exceptions.BotoCoreError, attempts=self.max_retries)(self.s3_client.put_object)(
|
|
30
|
+
Body=validated_file.file_content,
|
|
31
|
+
Bucket=self.bucket_name,
|
|
32
|
+
Key=s3_key,
|
|
33
|
+
ContentType=validated_file.mime_type,
|
|
34
|
+
Metadata=self.s3_metadata_generator(validated_file),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
return UploadedFile(
|
|
38
|
+
file_name=validated_file.file_name,
|
|
39
|
+
file_content=validated_file.file_content,
|
|
40
|
+
file_size=validated_file.file_size,
|
|
41
|
+
mime_type=validated_file.mime_type,
|
|
42
|
+
s3_path=f"{self.bucket_name}/{s3_key}",
|
|
43
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
import faker
|
|
4
|
+
import pytest
|
|
5
|
+
import stamina
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture(scope="session", autouse=True)
|
|
9
|
+
def anyio_backend() -> str:
|
|
10
|
+
return "asyncio"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(scope="session", autouse=True)
|
|
14
|
+
def deactivate_retries() -> None:
|
|
15
|
+
stamina.set_active(False)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
MIME_OCTET_STREAM: typing.Final = "application/octet-stream"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def generate_binary_content(faker: faker.Faker) -> bytes:
|
|
22
|
+
return faker.binary(length=faker.pyint(min_value=10, max_value=100))
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
import faker
|
|
4
|
+
|
|
5
|
+
from safe_s3_storage.exceptions import KasperskyScanEngineThreatDetectedError
|
|
6
|
+
from tests.conftest import generate_binary_content
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_exception_str(faker: faker.Faker) -> None:
|
|
10
|
+
response: typing.Final = generate_binary_content(faker)
|
|
11
|
+
file_name: typing.Final = faker.file_name()
|
|
12
|
+
assert (
|
|
13
|
+
str(KasperskyScanEngineThreatDetectedError(response=response, file_name=file_name))
|
|
14
|
+
== f"({response=}, {file_name=})"
|
|
15
|
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
import faker
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from safe_s3_storage import exceptions
|
|
9
|
+
from safe_s3_storage.file_validator import FileValidator, ImageConversionFormat
|
|
10
|
+
from safe_s3_storage.kaspersky_scan_engine import (
|
|
11
|
+
KasperskyScanEngineClient,
|
|
12
|
+
KasperskyScanEngineResponse,
|
|
13
|
+
KasperskyScanEngineScanResult,
|
|
14
|
+
)
|
|
15
|
+
from tests.conftest import MIME_OCTET_STREAM, generate_binary_content
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def png_file() -> bytes:
|
|
20
|
+
return (
|
|
21
|
+
b"\x89PNG\r\n\x1a\n" # PNG signature
|
|
22
|
+
b"\x00\x00\x00\r" # IHDR chunk length
|
|
23
|
+
b"IHDR" # IHDR chunk type
|
|
24
|
+
b"\x00\x00\x00\x01" # width: 1
|
|
25
|
+
b"\x00\x00\x00\x01" # height: 1
|
|
26
|
+
b"\x08" # bit depth: 8
|
|
27
|
+
b"\x06" # color type: RGBA
|
|
28
|
+
b"\x00" # compression method
|
|
29
|
+
b"\x00" # filter method
|
|
30
|
+
b"\x00" # interlace method
|
|
31
|
+
b"\x1f\x15\xc4\x89" # CRC for IHDR
|
|
32
|
+
b"\x00\x00\x00\x0a" # IDAT chunk length
|
|
33
|
+
b"IDAT" # IDAT chunk type
|
|
34
|
+
b"\x78\x9c\x63\x60\x00\x00\x00\x02\x00\x01" # compressed image data (deflate)
|
|
35
|
+
b"\x5d\xc6\x2d\xb4" # CRC for IDAT
|
|
36
|
+
b"\x00\x00\x00\x00" # IEND chunk length
|
|
37
|
+
b"IEND" # IEND chunk type
|
|
38
|
+
b"\xae\x42\x60\x82" # CRC for IEND
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_mocked_kaspersky_scan_engine_client(*, faker: faker.Faker, ok_response: bool) -> KasperskyScanEngineClient:
|
|
43
|
+
if ok_response:
|
|
44
|
+
all_scan_results: typing.Final[list[KasperskyScanEngineScanResult]] = list(KasperskyScanEngineScanResult)
|
|
45
|
+
all_scan_results.remove(KasperskyScanEngineScanResult.DETECT)
|
|
46
|
+
scan_result = random.choice(all_scan_results)
|
|
47
|
+
else:
|
|
48
|
+
scan_result = KasperskyScanEngineScanResult.DETECT
|
|
49
|
+
|
|
50
|
+
scan_response: typing.Final = KasperskyScanEngineResponse(scanResult=scan_result)
|
|
51
|
+
return KasperskyScanEngineClient(
|
|
52
|
+
service_url=faker.url(schemes=["http"]),
|
|
53
|
+
client_name=faker.pystr(),
|
|
54
|
+
httpx_client=httpx.AsyncClient(
|
|
55
|
+
transport=httpx.MockTransport(lambda _: httpx.Response(200, json=scan_response.model_dump(mode="json")))
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestFileValidator:
|
|
61
|
+
async def test_fails_to_validate_mime_type(self, faker: faker.Faker) -> None:
|
|
62
|
+
with pytest.raises(exceptions.NotAllowedMimeTypeError):
|
|
63
|
+
await FileValidator(allowed_mime_types=["image/jpeg"]).validate_file(
|
|
64
|
+
file_name=faker.file_name(), file_content=generate_binary_content(faker)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
async def test_fails_to_validate_file_size(self, faker: faker.Faker) -> None:
|
|
68
|
+
with pytest.raises(exceptions.TooLargeFileError):
|
|
69
|
+
await FileValidator(allowed_mime_types=[MIME_OCTET_STREAM], max_file_size_bytes=0).validate_file(
|
|
70
|
+
file_name=faker.file_name(), file_content=generate_binary_content(faker)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def test_fails_to_validate_image_size(self, faker: faker.Faker, png_file: bytes) -> None:
|
|
74
|
+
with pytest.raises(exceptions.TooLargeFileError):
|
|
75
|
+
await FileValidator(allowed_mime_types=["image/png"], max_image_size_bytes=0).validate_file(
|
|
76
|
+
file_name=faker.file_name(), file_content=png_file
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
async def test_fails_to_convert_image(self, faker: faker.Faker, png_file: bytes) -> None:
|
|
80
|
+
with pytest.raises(exceptions.FailedToConvertImageError):
|
|
81
|
+
await FileValidator(allowed_mime_types=["image/png"]).validate_file(
|
|
82
|
+
file_name=faker.file_name(), file_content=png_file[:50]
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@pytest.mark.parametrize("image_conversion_format", list(ImageConversionFormat))
|
|
86
|
+
async def test_ok_image(
|
|
87
|
+
self, faker: faker.Faker, png_file: bytes, image_conversion_format: ImageConversionFormat
|
|
88
|
+
) -> None:
|
|
89
|
+
file_base_name: typing.Final = faker.pystr()
|
|
90
|
+
|
|
91
|
+
validated_file: typing.Final = await FileValidator(
|
|
92
|
+
allowed_mime_types=["image/png"], image_conversion_format=image_conversion_format
|
|
93
|
+
).validate_file(file_name=f"{file_base_name}.{faker.file_extension()}", file_content=png_file)
|
|
94
|
+
|
|
95
|
+
assert validated_file.file_name == f"{file_base_name}.{image_conversion_format.value[1]}"
|
|
96
|
+
assert validated_file.file_content != png_file
|
|
97
|
+
assert validated_file.file_size == len(validated_file.file_content)
|
|
98
|
+
assert validated_file.mime_type == image_conversion_format.value[0]
|
|
99
|
+
|
|
100
|
+
@pytest.mark.parametrize("binary", [True, False])
|
|
101
|
+
async def test_ok_not_image(self, faker: faker.Faker, binary: bool) -> None:
|
|
102
|
+
file_name: typing.Final = faker.file_name()
|
|
103
|
+
file_content: typing.Final = generate_binary_content(faker) if binary else faker.pystr().encode()
|
|
104
|
+
|
|
105
|
+
validated_file: typing.Final = await FileValidator(
|
|
106
|
+
allowed_mime_types=[MIME_OCTET_STREAM if binary else "text/plain"]
|
|
107
|
+
).validate_file(file_name=file_name, file_content=file_content)
|
|
108
|
+
|
|
109
|
+
assert validated_file.file_name == file_name
|
|
110
|
+
assert validated_file.file_content == file_content
|
|
111
|
+
assert validated_file.file_size == len(file_content)
|
|
112
|
+
assert validated_file.mime_type == MIME_OCTET_STREAM if binary else "text/plain"
|
|
113
|
+
|
|
114
|
+
@pytest.mark.parametrize("ok_response", [True, False])
|
|
115
|
+
async def test_antivirus_skips_images(self, faker: faker.Faker, png_file: bytes, ok_response: bool) -> None:
|
|
116
|
+
await FileValidator(
|
|
117
|
+
kaspersky_scan_engine=get_mocked_kaspersky_scan_engine_client(faker=faker, ok_response=ok_response),
|
|
118
|
+
scan_images_with_antivirus=False,
|
|
119
|
+
allowed_mime_types=["image/png"],
|
|
120
|
+
).validate_file(file_name=faker.file_name(), file_content=png_file)
|
|
121
|
+
|
|
122
|
+
async def test_antivirus_fails_on_files(self, faker: faker.Faker) -> None:
|
|
123
|
+
with pytest.raises(exceptions.KasperskyScanEngineThreatDetectedError):
|
|
124
|
+
await FileValidator(
|
|
125
|
+
kaspersky_scan_engine=get_mocked_kaspersky_scan_engine_client(faker=faker, ok_response=False),
|
|
126
|
+
allowed_mime_types=[MIME_OCTET_STREAM],
|
|
127
|
+
).validate_file(file_name=faker.file_name(), file_content=generate_binary_content(faker))
|
|
128
|
+
|
|
129
|
+
async def test_antivirus_fails_on_images(self, faker: faker.Faker, png_file: bytes) -> None:
|
|
130
|
+
with pytest.raises(exceptions.KasperskyScanEngineThreatDetectedError):
|
|
131
|
+
await FileValidator(
|
|
132
|
+
kaspersky_scan_engine=get_mocked_kaspersky_scan_engine_client(faker=faker, ok_response=False),
|
|
133
|
+
allowed_mime_types=["image/png"],
|
|
134
|
+
).validate_file(file_name=faker.file_name(), file_content=png_file)
|
|
135
|
+
|
|
136
|
+
async def test_antivirus_passes_on_files(self, faker: faker.Faker) -> None:
|
|
137
|
+
await FileValidator(
|
|
138
|
+
kaspersky_scan_engine=get_mocked_kaspersky_scan_engine_client(faker=faker, ok_response=True),
|
|
139
|
+
allowed_mime_types=[MIME_OCTET_STREAM],
|
|
140
|
+
).validate_file(file_name=faker.file_name(), file_content=generate_binary_content(faker))
|
|
141
|
+
|
|
142
|
+
async def test_antivirus_passes_on_images(self, faker: faker.Faker, png_file: bytes) -> None:
|
|
143
|
+
await FileValidator(
|
|
144
|
+
kaspersky_scan_engine=get_mocked_kaspersky_scan_engine_client(faker=faker, ok_response=True),
|
|
145
|
+
allowed_mime_types=["image/png"],
|
|
146
|
+
).validate_file(file_name=faker.file_name(), file_content=png_file)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
from unittest import mock
|
|
3
|
+
|
|
4
|
+
import faker
|
|
5
|
+
|
|
6
|
+
from safe_s3_storage.file_validator import FileValidator
|
|
7
|
+
from safe_s3_storage.s3_upload import SafeS3FilesUploader, UploadedFile
|
|
8
|
+
from tests.conftest import MIME_OCTET_STREAM, generate_binary_content
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestSafeS3FilesUploader:
|
|
12
|
+
async def test_ok_with_defaults(self, faker: faker.Faker) -> None:
|
|
13
|
+
s3_client_mock: typing.Final = mock.AsyncMock()
|
|
14
|
+
file_name: typing.Final = faker.file_name()
|
|
15
|
+
bucket_name: typing.Final = faker.pystr()
|
|
16
|
+
file_content: typing.Final = generate_binary_content(faker)
|
|
17
|
+
|
|
18
|
+
uploaded_file: typing.Final = await SafeS3FilesUploader(
|
|
19
|
+
file_validator=FileValidator(allowed_mime_types=[MIME_OCTET_STREAM]),
|
|
20
|
+
s3_client=s3_client_mock,
|
|
21
|
+
bucket_name=bucket_name,
|
|
22
|
+
).upload_file(file_name=file_name, file_content=file_content)
|
|
23
|
+
|
|
24
|
+
assert uploaded_file == UploadedFile(
|
|
25
|
+
file_content=file_content,
|
|
26
|
+
file_name=file_name,
|
|
27
|
+
file_size=len(file_content),
|
|
28
|
+
mime_type=MIME_OCTET_STREAM,
|
|
29
|
+
s3_path=f"{bucket_name}/{file_name}",
|
|
30
|
+
)
|
|
31
|
+
s3_client_mock.put_object.assert_called_once_with(
|
|
32
|
+
Body=file_content,
|
|
33
|
+
Bucket=bucket_name,
|
|
34
|
+
Key=file_name,
|
|
35
|
+
ContentType=MIME_OCTET_STREAM,
|
|
36
|
+
Metadata={},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
async def test_ok_with_custom_key_generator(self, faker: faker.Faker) -> None:
|
|
40
|
+
s3_client_mock: typing.Final = mock.AsyncMock()
|
|
41
|
+
file_name: typing.Final = faker.file_name()
|
|
42
|
+
file_name_prefix: typing.Final = faker.pystr()
|
|
43
|
+
bucket_name: typing.Final = faker.pystr()
|
|
44
|
+
|
|
45
|
+
uploaded_file: typing.Final = await SafeS3FilesUploader(
|
|
46
|
+
file_validator=FileValidator(allowed_mime_types=[MIME_OCTET_STREAM]),
|
|
47
|
+
s3_client=s3_client_mock,
|
|
48
|
+
bucket_name=bucket_name,
|
|
49
|
+
s3_key_generator=lambda file_context: file_name_prefix + file_context.file_name,
|
|
50
|
+
).upload_file(file_name=file_name, file_content=generate_binary_content(faker))
|
|
51
|
+
|
|
52
|
+
assert uploaded_file.s3_path == f"{bucket_name}/{file_name_prefix}{file_name}"
|
|
53
|
+
assert s3_client_mock.put_object.mock_calls[0].kwargs["Key"] == file_name_prefix + file_name
|
|
54
|
+
|
|
55
|
+
async def test_ok_with_custom_metadata_generator(self, faker: faker.Faker) -> None:
|
|
56
|
+
s3_client_mock: typing.Final = mock.AsyncMock()
|
|
57
|
+
file_name: typing.Final = faker.file_name()
|
|
58
|
+
file_original_name_key: typing.Final = faker.pystr()
|
|
59
|
+
|
|
60
|
+
await SafeS3FilesUploader(
|
|
61
|
+
file_validator=FileValidator(allowed_mime_types=[MIME_OCTET_STREAM]),
|
|
62
|
+
s3_client=s3_client_mock,
|
|
63
|
+
bucket_name=faker.pystr(),
|
|
64
|
+
s3_metadata_generator=lambda file_context: {file_original_name_key: file_context.file_name},
|
|
65
|
+
).upload_file(file_name=file_name, file_content=generate_binary_content(faker))
|
|
66
|
+
|
|
67
|
+
assert s3_client_mock.put_object.mock_calls[0].kwargs["Metadata"] == {file_original_name_key: file_name}
|