fluidattacks_core_git 12.0.0__tar.gz → 12.1.1__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.
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/.gitignore +4 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/PKG-INFO +2 -2
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/download_repo.py +27 -7
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/import-linter.cfg +1 -11
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/pyproject.toml +3 -2
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/tests/test_git.py +76 -1
- fluidattacks_core_git-12.1.1/tests/test_remote.py +106 -0
- fluidattacks_core_git-12.1.1/tests/test_upload_repo.py +559 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/uv.lock +20 -5
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/.envrc +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/build/default.nix +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/build/filter.nix +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/flake.lock +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/flake.nix +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/__init__.py +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/classes.py +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/clone.py +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/codecommit_utils.py +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/constants.py +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/delete_files.py +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/download_file.py +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/https_utils.py +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/py.typed +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/remote.py +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/show_file.py +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/ssh_utils.py +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/upload_repo.py +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/utils.py +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/warp.py +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/mypy.ini +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/ruff.toml +0 -0
- {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: fluidattacks_core_git
|
|
3
|
-
Version: 12.
|
|
3
|
+
Version: 12.1.1
|
|
4
4
|
Summary: Fluid Attacks Core Git Library
|
|
5
5
|
Author-email: Development <development@fluidattacks.com>
|
|
6
6
|
License: MPL-2.0
|
|
@@ -16,7 +16,7 @@ Requires-Dist: aiohttp>=3.10.0
|
|
|
16
16
|
Requires-Dist: anyio>=4.7.0
|
|
17
17
|
Requires-Dist: boto3>=1.34
|
|
18
18
|
Requires-Dist: botocore>=1.40.18
|
|
19
|
-
Requires-Dist: fluidattacks-core-http>=12.
|
|
19
|
+
Requires-Dist: fluidattacks-core-http>=12.1.0
|
|
20
20
|
Requires-Dist: gitpython>=3.1.41
|
|
21
21
|
Requires-Dist: pathspec>=0.12.1
|
|
22
22
|
Requires-Dist: urllib3>=2.6.1
|
{fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/download_repo.py
RENAMED
|
@@ -46,13 +46,7 @@ def remove_symlinks_in_directory(directory: str) -> None:
|
|
|
46
46
|
Path(file_path).unlink(missing_ok=True)
|
|
47
47
|
|
|
48
48
|
|
|
49
|
-
def
|
|
50
|
-
try:
|
|
51
|
-
Path.cwd()
|
|
52
|
-
except OSError:
|
|
53
|
-
LOGGER.exception("Failed to get the working directory: %s", repo_path)
|
|
54
|
-
os.chdir(repo_path)
|
|
55
|
-
|
|
49
|
+
def _mark_safe_directory(repo_path: str) -> None:
|
|
56
50
|
try:
|
|
57
51
|
Git().execute(
|
|
58
52
|
[
|
|
@@ -67,6 +61,16 @@ def _reset_repo_sync(repo_path: str) -> bool:
|
|
|
67
61
|
except GitError:
|
|
68
62
|
LOGGER.exception("Failed to add safe directory %s", repo_path)
|
|
69
63
|
|
|
64
|
+
|
|
65
|
+
def _reset_repo_sync(repo_path: str) -> bool:
|
|
66
|
+
try:
|
|
67
|
+
Path.cwd()
|
|
68
|
+
except OSError:
|
|
69
|
+
LOGGER.exception("Failed to get the working directory: %s", repo_path)
|
|
70
|
+
os.chdir(repo_path)
|
|
71
|
+
|
|
72
|
+
_mark_safe_directory(repo_path)
|
|
73
|
+
|
|
70
74
|
try:
|
|
71
75
|
repo = Repo(repo_path)
|
|
72
76
|
repo.git.reset("--hard", "HEAD")
|
|
@@ -114,7 +118,16 @@ async def download_repo_from_s3(
|
|
|
114
118
|
git_ignore: list[str] | None = None,
|
|
115
119
|
*,
|
|
116
120
|
download_buffer_size: int = DEFAULT_DOWNLOAD_BUFFER_SIZE,
|
|
121
|
+
checkout: bool = True,
|
|
117
122
|
) -> bool:
|
|
123
|
+
"""Download the repository archived in s3 at `download_url`.
|
|
124
|
+
|
|
125
|
+
The archive holds only `.git`, so the working tree is restored with a
|
|
126
|
+
`git reset --hard`. Pass `checkout=False` to keep just the history: peak
|
|
127
|
+
disk stays at the size of the archive plus `.git`, which is what a
|
|
128
|
+
consumer that walks commits needs. `git_ignore` prunes working-tree files
|
|
129
|
+
and has no effect on a history-only download.
|
|
130
|
+
"""
|
|
118
131
|
await anyio.Path(destination_path.parent).mkdir(parents=True, exist_ok=True)
|
|
119
132
|
with tempfile.TemporaryDirectory(prefix="fluidattacks_", ignore_cleanup_errors=True) as tmpdir:
|
|
120
133
|
tmp_path = Path(tmpdir)
|
|
@@ -139,6 +152,13 @@ async def download_repo_from_s3(
|
|
|
139
152
|
)
|
|
140
153
|
return False
|
|
141
154
|
|
|
155
|
+
if not checkout:
|
|
156
|
+
# Nothing was extracted but `.git`, so there is no working tree to
|
|
157
|
+
# reset or prune. Callers still read the repository through git, which
|
|
158
|
+
# refuses a directory it considers foreign.
|
|
159
|
+
await anyio.to_thread.run_sync(_mark_safe_directory, str(destination_path))
|
|
160
|
+
return True
|
|
161
|
+
|
|
142
162
|
if not await reset_repo(str(destination_path)):
|
|
143
163
|
shutil.rmtree(destination_path, ignore_errors=True)
|
|
144
164
|
return False
|
|
@@ -1,17 +1,7 @@
|
|
|
1
1
|
[importlinter]
|
|
2
|
-
root_package = fluidattacks_core
|
|
2
|
+
root_package = fluidattacks_core.git
|
|
3
3
|
include_external_packages = True
|
|
4
4
|
|
|
5
|
-
[importlinter:contract:portion]
|
|
6
|
-
name = Single Portion
|
|
7
|
-
type = layers
|
|
8
|
-
containers =
|
|
9
|
-
fluidattacks_core
|
|
10
|
-
exhaustive = true
|
|
11
|
-
layers =
|
|
12
|
-
git
|
|
13
|
-
http
|
|
14
|
-
|
|
15
5
|
[importlinter:contract:dag]
|
|
16
6
|
name = Direct Acyclic Graph
|
|
17
7
|
type = layers
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fluidattacks_core_git"
|
|
3
|
-
version = "12.
|
|
3
|
+
version = "12.1.1"
|
|
4
4
|
description = "Fluid Attacks Core Git Library"
|
|
5
5
|
authors = [{ name = "Development", email = "development@fluidattacks.com" }]
|
|
6
6
|
license = { text = "MPL-2.0" }
|
|
@@ -19,7 +19,7 @@ dependencies = [
|
|
|
19
19
|
"anyio>=4.7.0",
|
|
20
20
|
"boto3>=1.34",
|
|
21
21
|
"botocore>=1.40.18",
|
|
22
|
-
"fluidattacks-core-http>=12.
|
|
22
|
+
"fluidattacks-core-http>=12.1.0",
|
|
23
23
|
"GitPython>=3.1.41",
|
|
24
24
|
"pathspec>=0.12.1",
|
|
25
25
|
"urllib3>=2.6.1",
|
|
@@ -32,6 +32,7 @@ dev = [
|
|
|
32
32
|
"deptry>=0.23.1",
|
|
33
33
|
"import-linter==2.11",
|
|
34
34
|
"mypy==1.19.1",
|
|
35
|
+
"pytest-asyncio>=1.1.0",
|
|
35
36
|
"pytest-cov>=7.0.0",
|
|
36
37
|
"pytest==9.0.3",
|
|
37
38
|
"ruff==0.15.0",
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import tarfile
|
|
1
3
|
import tempfile
|
|
2
4
|
from pathlib import Path
|
|
3
5
|
|
|
@@ -6,7 +8,7 @@ from git.repo import Repo
|
|
|
6
8
|
from pathspec import PathSpec
|
|
7
9
|
from pathspec import util as pathspec_util
|
|
8
10
|
|
|
9
|
-
from fluidattacks_core.git import rebase
|
|
11
|
+
from fluidattacks_core.git import download_repo, download_repo_from_s3, rebase
|
|
10
12
|
from fluidattacks_core.git.delete_files import delete_out_of_scope_files
|
|
11
13
|
from fluidattacks_core.git.download_repo import remove_symlinks_in_directory
|
|
12
14
|
|
|
@@ -171,3 +173,76 @@ def test_rebase_preserves_ascii_renamed_path() -> None:
|
|
|
171
173
|
assert result.path == renamed
|
|
172
174
|
assert result.line == 1
|
|
173
175
|
assert result.rev == rev_b
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _archive_git_dir(repo_path: Path, archive_path: Path, name: str) -> None:
|
|
179
|
+
# Mimics `_create_tar_archive`: the archive uploaded to s3 carries `.git`
|
|
180
|
+
# and no tracked file.
|
|
181
|
+
with tarfile.open(archive_path, "w:gz") as tar_handler:
|
|
182
|
+
tar_handler.add(str(repo_path / ".git"), arcname=f"{name}/.git", recursive=True)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _committed_repo(path: Path, tracked: str) -> str:
|
|
186
|
+
repo = _init_repo(path)
|
|
187
|
+
(path / tracked).write_text("content\n", encoding="utf-8")
|
|
188
|
+
repo.index.add([tracked])
|
|
189
|
+
revision = repo.index.commit("add tracked file").hexsha
|
|
190
|
+
repo.close()
|
|
191
|
+
return revision
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@pytest.fixture
|
|
195
|
+
def s3_archive(
|
|
196
|
+
tmp_path: Path,
|
|
197
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
198
|
+
) -> str:
|
|
199
|
+
# `git config --global` writes to $HOME, so keep it inside the test.
|
|
200
|
+
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
201
|
+
(tmp_path / "home").mkdir()
|
|
202
|
+
|
|
203
|
+
source = tmp_path / "source"
|
|
204
|
+
source.mkdir()
|
|
205
|
+
revision = _committed_repo(source, "tracked.txt")
|
|
206
|
+
archive = tmp_path / "repo.tar.gz"
|
|
207
|
+
_archive_git_dir(source, archive, "source")
|
|
208
|
+
shutil.rmtree(source)
|
|
209
|
+
|
|
210
|
+
async def _fake_download(
|
|
211
|
+
*,
|
|
212
|
+
url: str, # noqa: ARG001
|
|
213
|
+
destination_path: str,
|
|
214
|
+
download_buffer_size: int, # noqa: ARG001
|
|
215
|
+
) -> bool:
|
|
216
|
+
shutil.copyfile(archive, destination_path)
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
monkeypatch.setattr(download_repo, "download_file", _fake_download)
|
|
220
|
+
return revision
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@pytest.mark.asyncio
|
|
224
|
+
@pytest.mark.usefixtures("s3_archive")
|
|
225
|
+
async def test_download_repo_from_s3_restores_working_tree(tmp_path: Path) -> None:
|
|
226
|
+
destination = tmp_path / "groups" / "group" / "root"
|
|
227
|
+
|
|
228
|
+
assert await download_repo_from_s3("https://s3/repo.tar.gz", destination)
|
|
229
|
+
|
|
230
|
+
assert (destination / "tracked.txt").is_file()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@pytest.mark.asyncio
|
|
234
|
+
async def test_download_repo_from_s3_without_checkout_keeps_only_history(
|
|
235
|
+
tmp_path: Path,
|
|
236
|
+
s3_archive: str,
|
|
237
|
+
) -> None:
|
|
238
|
+
destination = tmp_path / "groups" / "group" / "root"
|
|
239
|
+
|
|
240
|
+
assert await download_repo_from_s3("https://s3/repo.tar.gz", destination, checkout=False)
|
|
241
|
+
|
|
242
|
+
# The point of the flag: the history is complete and readable, and not one
|
|
243
|
+
# byte of the working tree was written.
|
|
244
|
+
repo = Repo(destination)
|
|
245
|
+
assert repo.head.commit.hexsha == s3_archive
|
|
246
|
+
repo.close()
|
|
247
|
+
assert not (destination / "tracked.txt").exists()
|
|
248
|
+
assert [entry.name for entry in destination.iterdir()] == [".git"]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from unittest.mock import AsyncMock, patch
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from fluidattacks_core.git.remote import ls_remote
|
|
6
|
+
|
|
7
|
+
_SSH_PATH = "fluidattacks_core.git.remote.call_ssh_ls_remote"
|
|
8
|
+
_CODECOMMIT_PATH = "fluidattacks_core.git.remote.call_codecommit_ls_remote"
|
|
9
|
+
_HTTPS_PATH = "fluidattacks_core.git.remote.call_https_ls_remote"
|
|
10
|
+
|
|
11
|
+
_SHA = "0" * 40
|
|
12
|
+
_CREDENTIAL = "a-token"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.mark.asyncio
|
|
16
|
+
async def test_ls_remote_with_a_credential_key_goes_over_ssh() -> None:
|
|
17
|
+
with (
|
|
18
|
+
patch(_SSH_PATH, AsyncMock(return_value=(_SHA, None))) as ssh,
|
|
19
|
+
patch(_CODECOMMIT_PATH, AsyncMock()) as codecommit,
|
|
20
|
+
patch(_HTTPS_PATH, AsyncMock()) as https,
|
|
21
|
+
):
|
|
22
|
+
sha, error = await ls_remote(
|
|
23
|
+
repo_url="git@github.com:fluidattacks/universe",
|
|
24
|
+
repo_branch="main",
|
|
25
|
+
credential_key="a-key",
|
|
26
|
+
arn="an-arn",
|
|
27
|
+
org_external_id="an-id",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
assert (sha, error) == (_SHA, None)
|
|
31
|
+
ssh.assert_awaited_once_with("git@github.com:fluidattacks/universe", "a-key", "main")
|
|
32
|
+
codecommit.assert_not_awaited()
|
|
33
|
+
https.assert_not_awaited()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.asyncio
|
|
37
|
+
async def test_ls_remote_with_an_arn_and_an_external_id_goes_over_codecommit() -> None:
|
|
38
|
+
with (
|
|
39
|
+
patch(_SSH_PATH, AsyncMock()) as ssh,
|
|
40
|
+
patch(_CODECOMMIT_PATH, AsyncMock(return_value=(_SHA, None))) as codecommit,
|
|
41
|
+
patch(_HTTPS_PATH, AsyncMock()) as https,
|
|
42
|
+
):
|
|
43
|
+
sha, error = await ls_remote(
|
|
44
|
+
repo_url="codecommit://a-repo",
|
|
45
|
+
repo_branch="main",
|
|
46
|
+
arn="an-arn",
|
|
47
|
+
org_external_id="an-id",
|
|
48
|
+
follow_redirects=True,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
assert (sha, error) == (_SHA, None)
|
|
52
|
+
codecommit.assert_awaited_once_with(
|
|
53
|
+
"codecommit://a-repo",
|
|
54
|
+
"an-arn",
|
|
55
|
+
"main",
|
|
56
|
+
org_external_id="an-id",
|
|
57
|
+
follow_redirects=True,
|
|
58
|
+
)
|
|
59
|
+
ssh.assert_not_awaited()
|
|
60
|
+
https.assert_not_awaited()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.asyncio
|
|
64
|
+
async def test_ls_remote_without_ssh_or_codecommit_inputs_goes_over_https() -> None:
|
|
65
|
+
with (
|
|
66
|
+
patch(_SSH_PATH, AsyncMock()) as ssh,
|
|
67
|
+
patch(_CODECOMMIT_PATH, AsyncMock()) as codecommit,
|
|
68
|
+
patch(_HTTPS_PATH, AsyncMock(return_value=(_SHA, None))) as https,
|
|
69
|
+
):
|
|
70
|
+
sha, error = await ls_remote(
|
|
71
|
+
repo_url="https://github.com/fluidattacks/universe",
|
|
72
|
+
repo_branch="main",
|
|
73
|
+
token=_CREDENTIAL,
|
|
74
|
+
provider="GITHUB",
|
|
75
|
+
is_pat=True,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
assert (sha, error) == (_SHA, None)
|
|
79
|
+
https.assert_awaited_once_with(
|
|
80
|
+
repo_url="https://github.com/fluidattacks/universe",
|
|
81
|
+
user=None,
|
|
82
|
+
password=None,
|
|
83
|
+
token=_CREDENTIAL,
|
|
84
|
+
branch="main",
|
|
85
|
+
provider="GITHUB",
|
|
86
|
+
is_pat=True,
|
|
87
|
+
follow_redirects=False,
|
|
88
|
+
)
|
|
89
|
+
ssh.assert_not_awaited()
|
|
90
|
+
codecommit.assert_not_awaited()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.asyncio
|
|
94
|
+
async def test_ls_remote_forwards_the_error_from_the_transport() -> None:
|
|
95
|
+
with (
|
|
96
|
+
patch(_SSH_PATH, AsyncMock()),
|
|
97
|
+
patch(_CODECOMMIT_PATH, AsyncMock()),
|
|
98
|
+
patch(_HTTPS_PATH, AsyncMock(return_value=(None, "Repository not found"))),
|
|
99
|
+
):
|
|
100
|
+
sha, error = await ls_remote(
|
|
101
|
+
repo_url="https://github.com/fluidattacks/nope",
|
|
102
|
+
repo_branch="main",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
assert sha is None
|
|
106
|
+
assert error == "Repository not found"
|
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import tarfile
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from fluidattacks_core.git.constants import MAX_S3_PARTS, MULTIPART_PART_SIZE
|
|
10
|
+
from fluidattacks_core.git.upload_repo import (
|
|
11
|
+
MultipartCallbacks,
|
|
12
|
+
PartUploadConfig,
|
|
13
|
+
UploadPartResult,
|
|
14
|
+
_complete_with_retry,
|
|
15
|
+
_compute_part_size,
|
|
16
|
+
_create_tar_archive,
|
|
17
|
+
_do_part_upload,
|
|
18
|
+
_file_chunks,
|
|
19
|
+
_initiate_with_retry,
|
|
20
|
+
_upload_all_parts,
|
|
21
|
+
_upload_part_with_retry,
|
|
22
|
+
upload_repo_to_s3,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_SLEEP_PATH = "fluidattacks_core.git.upload_repo.anyio.sleep"
|
|
26
|
+
_PART_RETRY_PATH = "fluidattacks_core.git.upload_repo._upload_part_with_retry"
|
|
27
|
+
_SESSION_PATH = "fluidattacks_core.git.upload_repo.aiohttp.ClientSession"
|
|
28
|
+
_THRESHOLD_PATH = "fluidattacks_core.git.upload_repo.MULTIPART_THRESHOLD"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fake_git_repo(base: Path) -> Path:
|
|
32
|
+
repo = base / "repo"
|
|
33
|
+
git_dir = repo / ".git"
|
|
34
|
+
git_dir.mkdir(parents=True)
|
|
35
|
+
(git_dir / "HEAD").write_text("ref: refs/heads/main\n")
|
|
36
|
+
return repo
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _fake_file(base: Path, content: bytes = b"x" * 512) -> Path:
|
|
40
|
+
path = base / "data.bin"
|
|
41
|
+
path.write_bytes(content)
|
|
42
|
+
return path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _make_mock_session(status: int = 200, etag: str = '"etag-value"') -> MagicMock:
|
|
46
|
+
mock_response = MagicMock()
|
|
47
|
+
mock_response.status = status
|
|
48
|
+
mock_response.headers = {"ETag": etag}
|
|
49
|
+
mock_response.text = AsyncMock(return_value="error body")
|
|
50
|
+
|
|
51
|
+
mock_put_cm = MagicMock()
|
|
52
|
+
mock_put_cm.__aenter__ = AsyncMock(return_value=mock_response)
|
|
53
|
+
mock_put_cm.__aexit__ = AsyncMock(return_value=None)
|
|
54
|
+
|
|
55
|
+
mock_session = MagicMock()
|
|
56
|
+
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
|
57
|
+
mock_session.__aexit__ = AsyncMock(return_value=None)
|
|
58
|
+
mock_session.put = MagicMock(return_value=mock_put_cm)
|
|
59
|
+
|
|
60
|
+
return mock_session
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.asyncio
|
|
64
|
+
async def test_create_tar_archive_produces_valid_gzip_with_git_entries() -> None:
|
|
65
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
66
|
+
repo_path = _fake_git_repo(Path(tmp))
|
|
67
|
+
output = Path(tmp) / "archive.tar.gz"
|
|
68
|
+
|
|
69
|
+
result = await _create_tar_archive(repo_path, output, "my-repo")
|
|
70
|
+
|
|
71
|
+
assert result is True
|
|
72
|
+
assert output.exists()
|
|
73
|
+
with tarfile.open(output, "r:gz") as tf:
|
|
74
|
+
names = tf.getnames()
|
|
75
|
+
assert all(name.startswith("my-repo/.git") for name in names)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.mark.asyncio
|
|
79
|
+
async def test_create_tar_archive_returns_false_when_git_dir_missing() -> None:
|
|
80
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
81
|
+
repo_path = Path(tmp) / "no-git-here"
|
|
82
|
+
repo_path.mkdir()
|
|
83
|
+
output = Path(tmp) / "archive.tar.gz"
|
|
84
|
+
|
|
85
|
+
result = await _create_tar_archive(repo_path, output, "my-repo")
|
|
86
|
+
|
|
87
|
+
assert result is False
|
|
88
|
+
assert not output.exists()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest.mark.asyncio
|
|
92
|
+
async def test_create_tar_archive_returns_false_on_io_error() -> None:
|
|
93
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
94
|
+
repo_path = _fake_git_repo(Path(tmp))
|
|
95
|
+
invalid_output = Path(tmp) / "nonexistent_dir" / "archive.tar.gz"
|
|
96
|
+
|
|
97
|
+
result = await _create_tar_archive(repo_path, invalid_output, "my-repo")
|
|
98
|
+
|
|
99
|
+
assert result is False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.mark.asyncio
|
|
103
|
+
async def test_file_chunks_yields_complete_file_content() -> None:
|
|
104
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
105
|
+
content = b"abcdefgh" * 10_000
|
|
106
|
+
file_path = Path(tmp) / "test.bin"
|
|
107
|
+
file_path.write_bytes(content)
|
|
108
|
+
|
|
109
|
+
received = [chunk async for chunk in _file_chunks(file_path)]
|
|
110
|
+
|
|
111
|
+
assert b"".join(received) == content
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pytest.mark.asyncio
|
|
115
|
+
async def test_file_chunks_empty_file_yields_nothing() -> None:
|
|
116
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
117
|
+
file_path = Path(tmp) / "empty.bin"
|
|
118
|
+
file_path.write_bytes(b"")
|
|
119
|
+
|
|
120
|
+
received = [chunk async for chunk in _file_chunks(file_path)]
|
|
121
|
+
|
|
122
|
+
assert received == []
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pytest.mark.asyncio
|
|
126
|
+
async def test_do_part_upload_returns_etag_on_200() -> None:
|
|
127
|
+
mock_session = _make_mock_session(status=200, etag='"abc-etag"')
|
|
128
|
+
|
|
129
|
+
result = await _do_part_upload(mock_session, "https://example.com/part", b"data")
|
|
130
|
+
|
|
131
|
+
assert result == '"abc-etag"'
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@pytest.mark.asyncio
|
|
135
|
+
async def test_do_part_upload_preserves_etag_quotes_for_s3_completion() -> None:
|
|
136
|
+
mock_session = _make_mock_session(status=200, etag='"quoted-etag"')
|
|
137
|
+
|
|
138
|
+
result = await _do_part_upload(mock_session, "https://example.com/part", b"data")
|
|
139
|
+
|
|
140
|
+
assert result == '"quoted-etag"'
|
|
141
|
+
assert result is not None
|
|
142
|
+
assert result.startswith('"')
|
|
143
|
+
assert result.endswith('"')
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@pytest.mark.asyncio
|
|
147
|
+
async def test_do_part_upload_returns_none_on_non_200_status() -> None:
|
|
148
|
+
mock_session = _make_mock_session(status=403, etag="")
|
|
149
|
+
|
|
150
|
+
result = await _do_part_upload(mock_session, "https://example.com/part", b"data")
|
|
151
|
+
|
|
152
|
+
assert result is None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@pytest.mark.asyncio
|
|
156
|
+
async def test_initiate_with_retry_returns_upload_id_on_first_attempt() -> None:
|
|
157
|
+
initiate = AsyncMock(return_value="upload-id-123")
|
|
158
|
+
|
|
159
|
+
result = await _initiate_with_retry(initiate)
|
|
160
|
+
|
|
161
|
+
assert result == "upload-id-123"
|
|
162
|
+
assert initiate.call_count == 1
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@pytest.mark.asyncio
|
|
166
|
+
async def test_initiate_with_retry_retries_after_none_and_succeeds() -> None:
|
|
167
|
+
initiate = AsyncMock(side_effect=[None, None, "upload-id-ok"])
|
|
168
|
+
|
|
169
|
+
with patch(_SLEEP_PATH, new_callable=AsyncMock):
|
|
170
|
+
result = await _initiate_with_retry(initiate)
|
|
171
|
+
|
|
172
|
+
assert result == "upload-id-ok"
|
|
173
|
+
assert initiate.call_count == 3
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@pytest.mark.asyncio
|
|
177
|
+
async def test_initiate_with_retry_returns_none_after_all_attempts_fail() -> None:
|
|
178
|
+
initiate = AsyncMock(return_value=None)
|
|
179
|
+
|
|
180
|
+
with patch(_SLEEP_PATH, new_callable=AsyncMock):
|
|
181
|
+
result = await _initiate_with_retry(initiate)
|
|
182
|
+
|
|
183
|
+
assert result is None
|
|
184
|
+
assert initiate.call_count == 3
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@pytest.mark.asyncio
|
|
188
|
+
async def test_complete_with_retry_returns_true_on_first_success() -> None:
|
|
189
|
+
complete = AsyncMock(return_value=True)
|
|
190
|
+
|
|
191
|
+
result = await _complete_with_retry("uid", [], complete)
|
|
192
|
+
|
|
193
|
+
assert result is True
|
|
194
|
+
assert complete.call_count == 1
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@pytest.mark.asyncio
|
|
198
|
+
async def test_complete_with_retry_retries_on_false_then_succeeds() -> None:
|
|
199
|
+
complete = AsyncMock(side_effect=[False, False, True])
|
|
200
|
+
|
|
201
|
+
with patch(_SLEEP_PATH, new_callable=AsyncMock):
|
|
202
|
+
result = await _complete_with_retry("uid", [], complete)
|
|
203
|
+
|
|
204
|
+
assert result is True
|
|
205
|
+
assert complete.call_count == 3
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@pytest.mark.asyncio
|
|
209
|
+
async def test_complete_with_retry_returns_false_after_all_attempts_fail() -> None:
|
|
210
|
+
complete = AsyncMock(return_value=False)
|
|
211
|
+
|
|
212
|
+
with patch(_SLEEP_PATH, new_callable=AsyncMock):
|
|
213
|
+
result = await _complete_with_retry("uid", [], complete)
|
|
214
|
+
|
|
215
|
+
assert result is False
|
|
216
|
+
assert complete.call_count == 3
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@pytest.mark.asyncio
|
|
220
|
+
async def test_upload_part_with_retry_returns_result_on_first_success() -> None:
|
|
221
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
222
|
+
file_path = _fake_file(Path(tmp))
|
|
223
|
+
config = PartUploadConfig(
|
|
224
|
+
file_path=file_path,
|
|
225
|
+
part_number=2,
|
|
226
|
+
start_byte=0,
|
|
227
|
+
end_byte=512,
|
|
228
|
+
upload_id="upload-id",
|
|
229
|
+
)
|
|
230
|
+
get_part_url = AsyncMock(return_value="https://s3.example.com/part")
|
|
231
|
+
|
|
232
|
+
with (
|
|
233
|
+
patch(_SESSION_PATH, return_value=_make_mock_session()),
|
|
234
|
+
patch(
|
|
235
|
+
"fluidattacks_core.git.upload_repo._do_part_upload",
|
|
236
|
+
new_callable=AsyncMock,
|
|
237
|
+
return_value="part-etag",
|
|
238
|
+
),
|
|
239
|
+
):
|
|
240
|
+
result = await _upload_part_with_retry(config, get_part_url)
|
|
241
|
+
|
|
242
|
+
assert result is not None
|
|
243
|
+
assert result.part_number == 2
|
|
244
|
+
assert result.etag == "part-etag"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@pytest.mark.asyncio
|
|
248
|
+
async def test_upload_part_with_retry_succeeds_on_second_attempt() -> None:
|
|
249
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
250
|
+
file_path = _fake_file(Path(tmp))
|
|
251
|
+
config = PartUploadConfig(
|
|
252
|
+
file_path=file_path,
|
|
253
|
+
part_number=1,
|
|
254
|
+
start_byte=0,
|
|
255
|
+
end_byte=512,
|
|
256
|
+
upload_id="upload-id",
|
|
257
|
+
)
|
|
258
|
+
get_part_url = AsyncMock(return_value="https://s3.example.com/part")
|
|
259
|
+
|
|
260
|
+
with (
|
|
261
|
+
patch(_SESSION_PATH, return_value=_make_mock_session()),
|
|
262
|
+
patch(
|
|
263
|
+
"fluidattacks_core.git.upload_repo._do_part_upload",
|
|
264
|
+
new_callable=AsyncMock,
|
|
265
|
+
side_effect=[None, "recovered-etag"],
|
|
266
|
+
),
|
|
267
|
+
patch(_SLEEP_PATH, new_callable=AsyncMock),
|
|
268
|
+
):
|
|
269
|
+
result = await _upload_part_with_retry(config, get_part_url)
|
|
270
|
+
|
|
271
|
+
assert result is not None
|
|
272
|
+
assert result.etag == "recovered-etag"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@pytest.mark.asyncio
|
|
276
|
+
async def test_upload_part_with_retry_returns_none_after_all_attempts_exhausted() -> None:
|
|
277
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
278
|
+
file_path = _fake_file(Path(tmp))
|
|
279
|
+
config = PartUploadConfig(
|
|
280
|
+
file_path=file_path,
|
|
281
|
+
part_number=1,
|
|
282
|
+
start_byte=0,
|
|
283
|
+
end_byte=512,
|
|
284
|
+
upload_id="upload-id",
|
|
285
|
+
)
|
|
286
|
+
get_part_url = AsyncMock(return_value="https://s3.example.com/part")
|
|
287
|
+
|
|
288
|
+
with (
|
|
289
|
+
patch(_SESSION_PATH, return_value=_make_mock_session()),
|
|
290
|
+
patch(
|
|
291
|
+
"fluidattacks_core.git.upload_repo._do_part_upload",
|
|
292
|
+
new_callable=AsyncMock,
|
|
293
|
+
return_value=None,
|
|
294
|
+
),
|
|
295
|
+
patch(_SLEEP_PATH, new_callable=AsyncMock),
|
|
296
|
+
):
|
|
297
|
+
result = await _upload_part_with_retry(config, get_part_url)
|
|
298
|
+
|
|
299
|
+
assert result is None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@pytest.mark.asyncio
|
|
303
|
+
async def test_upload_part_with_retry_skips_attempt_when_presigned_url_empty() -> None:
|
|
304
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
305
|
+
file_path = _fake_file(Path(tmp))
|
|
306
|
+
config = PartUploadConfig(
|
|
307
|
+
file_path=file_path,
|
|
308
|
+
part_number=1,
|
|
309
|
+
start_byte=0,
|
|
310
|
+
end_byte=512,
|
|
311
|
+
upload_id="upload-id",
|
|
312
|
+
)
|
|
313
|
+
get_part_url = AsyncMock(return_value="")
|
|
314
|
+
|
|
315
|
+
with (
|
|
316
|
+
patch(_SESSION_PATH, return_value=_make_mock_session()),
|
|
317
|
+
patch(_SLEEP_PATH, new_callable=AsyncMock),
|
|
318
|
+
):
|
|
319
|
+
result = await _upload_part_with_retry(config, get_part_url)
|
|
320
|
+
|
|
321
|
+
assert result is None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@pytest.mark.asyncio
|
|
325
|
+
async def test_upload_all_parts_collects_results_indexed_by_part_number() -> None:
|
|
326
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
327
|
+
file_path = _fake_file(Path(tmp), content=b"x" * 100)
|
|
328
|
+
|
|
329
|
+
def _make_result(config: PartUploadConfig, _: object) -> UploadPartResult:
|
|
330
|
+
return UploadPartResult(
|
|
331
|
+
part_number=config.part_number, etag=f"etag-{config.part_number}"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
with patch(_PART_RETRY_PATH, new_callable=AsyncMock, side_effect=_make_result):
|
|
335
|
+
result = await _upload_all_parts(
|
|
336
|
+
file_path=file_path,
|
|
337
|
+
file_size=100,
|
|
338
|
+
upload_id="uid",
|
|
339
|
+
get_part_url=AsyncMock(return_value="url"),
|
|
340
|
+
part_size=100,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
assert result is not None
|
|
344
|
+
assert result[0].part_number == 1
|
|
345
|
+
assert result[0].etag == "etag-1"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@pytest.mark.asyncio
|
|
349
|
+
async def test_upload_all_parts_returns_none_when_any_part_fails() -> None:
|
|
350
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
351
|
+
file_path = _fake_file(Path(tmp), content=b"x" * 100)
|
|
352
|
+
|
|
353
|
+
with patch(_PART_RETRY_PATH, new_callable=AsyncMock, return_value=None):
|
|
354
|
+
result = await _upload_all_parts(
|
|
355
|
+
file_path=file_path,
|
|
356
|
+
file_size=100,
|
|
357
|
+
upload_id="uid",
|
|
358
|
+
get_part_url=AsyncMock(return_value="url"),
|
|
359
|
+
part_size=100,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
assert result is None
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@pytest.mark.asyncio
|
|
366
|
+
async def test_upload_repo_to_s3_returns_false_for_nonexistent_path() -> None:
|
|
367
|
+
result = await upload_repo_to_s3(
|
|
368
|
+
repo_path=Path("/does/not/exist"),
|
|
369
|
+
archive_name="test",
|
|
370
|
+
upload_url="https://example.com",
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
assert result is False
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@pytest.mark.asyncio
|
|
377
|
+
async def test_upload_repo_to_s3_returns_false_when_upload_url_missing_for_small_file() -> None:
|
|
378
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
379
|
+
repo_path = _fake_git_repo(Path(tmp))
|
|
380
|
+
|
|
381
|
+
result = await upload_repo_to_s3(repo_path=repo_path, archive_name="test")
|
|
382
|
+
|
|
383
|
+
assert result is False
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@pytest.mark.asyncio
|
|
387
|
+
async def test_upload_repo_to_s3_returns_false_when_multipart_missing_for_large_file() -> None:
|
|
388
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
389
|
+
repo_path = _fake_git_repo(Path(tmp))
|
|
390
|
+
|
|
391
|
+
with patch(_THRESHOLD_PATH, 0):
|
|
392
|
+
result = await upload_repo_to_s3(
|
|
393
|
+
repo_path=repo_path,
|
|
394
|
+
archive_name="test",
|
|
395
|
+
upload_url="https://example.com",
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
assert result is False
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@pytest.mark.asyncio
|
|
402
|
+
async def test_upload_repo_to_s3_single_part_returns_true_on_200() -> None:
|
|
403
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
404
|
+
repo_path = _fake_git_repo(Path(tmp))
|
|
405
|
+
|
|
406
|
+
with patch(_SESSION_PATH, return_value=_make_mock_session(status=200)):
|
|
407
|
+
result = await upload_repo_to_s3(
|
|
408
|
+
repo_path=repo_path,
|
|
409
|
+
archive_name="test-repo",
|
|
410
|
+
upload_url="https://s3.example.com/upload",
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
assert result is True
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@pytest.mark.asyncio
|
|
417
|
+
async def test_upload_repo_to_s3_single_part_returns_false_on_non_200() -> None:
|
|
418
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
419
|
+
repo_path = _fake_git_repo(Path(tmp))
|
|
420
|
+
|
|
421
|
+
with patch(_SESSION_PATH, return_value=_make_mock_session(status=500)):
|
|
422
|
+
result = await upload_repo_to_s3(
|
|
423
|
+
repo_path=repo_path,
|
|
424
|
+
archive_name="test-repo",
|
|
425
|
+
upload_url="https://s3.example.com/upload",
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
assert result is False
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@pytest.mark.asyncio
|
|
432
|
+
async def test_upload_repo_to_s3_multipart_path_returns_true_on_success() -> None:
|
|
433
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
434
|
+
repo_path = _fake_git_repo(Path(tmp))
|
|
435
|
+
|
|
436
|
+
multipart = MultipartCallbacks(
|
|
437
|
+
initiate_upload=AsyncMock(return_value="upload-id"),
|
|
438
|
+
get_part_url=AsyncMock(return_value="https://s3.example.com/part"),
|
|
439
|
+
complete_upload=AsyncMock(return_value=True),
|
|
440
|
+
abort_upload=AsyncMock(),
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
with (
|
|
444
|
+
patch(_THRESHOLD_PATH, 0),
|
|
445
|
+
patch(
|
|
446
|
+
_PART_RETRY_PATH,
|
|
447
|
+
new_callable=AsyncMock,
|
|
448
|
+
return_value=UploadPartResult(part_number=1, etag="etag-1"),
|
|
449
|
+
),
|
|
450
|
+
):
|
|
451
|
+
result = await upload_repo_to_s3(
|
|
452
|
+
repo_path=repo_path,
|
|
453
|
+
archive_name="test-repo",
|
|
454
|
+
multipart=multipart,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
assert result is True
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@pytest.mark.asyncio
|
|
461
|
+
async def test_upload_repo_to_s3_multipart_calls_abort_when_parts_fail() -> None:
|
|
462
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
463
|
+
repo_path = _fake_git_repo(Path(tmp))
|
|
464
|
+
|
|
465
|
+
abort_mock = AsyncMock()
|
|
466
|
+
multipart = MultipartCallbacks(
|
|
467
|
+
initiate_upload=AsyncMock(return_value="upload-id"),
|
|
468
|
+
get_part_url=AsyncMock(return_value="https://s3.example.com/part"),
|
|
469
|
+
complete_upload=AsyncMock(return_value=True),
|
|
470
|
+
abort_upload=abort_mock,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
with (
|
|
474
|
+
patch(_THRESHOLD_PATH, 0),
|
|
475
|
+
patch(_PART_RETRY_PATH, new_callable=AsyncMock, return_value=None),
|
|
476
|
+
):
|
|
477
|
+
result = await upload_repo_to_s3(
|
|
478
|
+
repo_path=repo_path,
|
|
479
|
+
archive_name="test-repo",
|
|
480
|
+
multipart=multipart,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
assert result is False
|
|
484
|
+
abort_mock.assert_called_once_with("upload-id")
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@pytest.mark.asyncio
|
|
488
|
+
async def test_upload_repo_to_s3_multipart_calls_abort_when_complete_fails() -> None:
|
|
489
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
490
|
+
repo_path = _fake_git_repo(Path(tmp))
|
|
491
|
+
|
|
492
|
+
abort_mock = AsyncMock()
|
|
493
|
+
multipart = MultipartCallbacks(
|
|
494
|
+
initiate_upload=AsyncMock(return_value="upload-id"),
|
|
495
|
+
get_part_url=AsyncMock(return_value="https://s3.example.com/part"),
|
|
496
|
+
complete_upload=AsyncMock(return_value=False),
|
|
497
|
+
abort_upload=abort_mock,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
with (
|
|
501
|
+
patch(_THRESHOLD_PATH, 0),
|
|
502
|
+
patch(
|
|
503
|
+
_PART_RETRY_PATH,
|
|
504
|
+
new_callable=AsyncMock,
|
|
505
|
+
return_value=UploadPartResult(part_number=1, etag="etag-1"),
|
|
506
|
+
),
|
|
507
|
+
patch(_SLEEP_PATH, new_callable=AsyncMock),
|
|
508
|
+
):
|
|
509
|
+
result = await upload_repo_to_s3(
|
|
510
|
+
repo_path=repo_path,
|
|
511
|
+
archive_name="test-repo",
|
|
512
|
+
multipart=multipart,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
assert result is False
|
|
516
|
+
abort_mock.assert_called_once_with("upload-id")
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@pytest.mark.asyncio
|
|
520
|
+
async def test_upload_repo_to_s3_multipart_aborts_on_unexpected_exception() -> None:
|
|
521
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
522
|
+
repo_path = _fake_git_repo(Path(tmp))
|
|
523
|
+
|
|
524
|
+
abort_mock = AsyncMock()
|
|
525
|
+
multipart = MultipartCallbacks(
|
|
526
|
+
initiate_upload=AsyncMock(return_value="upload-id"),
|
|
527
|
+
get_part_url=AsyncMock(return_value="https://s3.example.com/part"),
|
|
528
|
+
complete_upload=AsyncMock(return_value=True),
|
|
529
|
+
abort_upload=abort_mock,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
with (
|
|
533
|
+
pytest.raises(ValueError, match="unexpected callback error"),
|
|
534
|
+
patch(_THRESHOLD_PATH, 0),
|
|
535
|
+
patch(
|
|
536
|
+
"fluidattacks_core.git.upload_repo._upload_all_parts",
|
|
537
|
+
new_callable=AsyncMock,
|
|
538
|
+
side_effect=ValueError("unexpected callback error"),
|
|
539
|
+
),
|
|
540
|
+
):
|
|
541
|
+
await upload_repo_to_s3(
|
|
542
|
+
repo_path=repo_path,
|
|
543
|
+
archive_name="test-repo",
|
|
544
|
+
multipart=multipart,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
abort_mock.assert_called_once_with("upload-id")
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def test_compute_part_size_returns_default_for_small_files() -> None:
|
|
551
|
+
small_file = MULTIPART_PART_SIZE * 100
|
|
552
|
+
assert _compute_part_size(small_file) == MULTIPART_PART_SIZE
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def test_compute_part_size_scales_up_for_files_exceeding_10000_parts() -> None:
|
|
556
|
+
huge_file = MULTIPART_PART_SIZE * (MAX_S3_PARTS + 1)
|
|
557
|
+
result = _compute_part_size(huge_file)
|
|
558
|
+
assert result > MULTIPART_PART_SIZE
|
|
559
|
+
assert math.ceil(huge_file / result) <= MAX_S3_PARTS
|
|
@@ -432,7 +432,7 @@ wheels = [
|
|
|
432
432
|
|
|
433
433
|
[[package]]
|
|
434
434
|
name = "fluidattacks-core-git"
|
|
435
|
-
version = "12.
|
|
435
|
+
version = "12.1.1"
|
|
436
436
|
source = { editable = "." }
|
|
437
437
|
dependencies = [
|
|
438
438
|
{ name = "aiofiles" },
|
|
@@ -454,6 +454,7 @@ dev = [
|
|
|
454
454
|
{ name = "import-linter" },
|
|
455
455
|
{ name = "mypy" },
|
|
456
456
|
{ name = "pytest" },
|
|
457
|
+
{ name = "pytest-asyncio" },
|
|
457
458
|
{ name = "pytest-cov" },
|
|
458
459
|
{ name = "ruff" },
|
|
459
460
|
{ name = "types-aiofiles" },
|
|
@@ -466,7 +467,7 @@ requires-dist = [
|
|
|
466
467
|
{ name = "anyio", specifier = ">=4.7.0" },
|
|
467
468
|
{ name = "boto3", specifier = ">=1.34" },
|
|
468
469
|
{ name = "botocore", specifier = ">=1.40.18" },
|
|
469
|
-
{ name = "fluidattacks-core-http", specifier = ">=12.
|
|
470
|
+
{ name = "fluidattacks-core-http", specifier = ">=12.1.0" },
|
|
470
471
|
{ name = "gitpython", specifier = ">=3.1.41" },
|
|
471
472
|
{ name = "pathspec", specifier = ">=0.12.1" },
|
|
472
473
|
{ name = "urllib3", specifier = ">=2.6.1" },
|
|
@@ -480,6 +481,7 @@ dev = [
|
|
|
480
481
|
{ name = "import-linter", specifier = "==2.11" },
|
|
481
482
|
{ name = "mypy", specifier = "==1.19.1" },
|
|
482
483
|
{ name = "pytest", specifier = "==9.0.3" },
|
|
484
|
+
{ name = "pytest-asyncio", specifier = ">=1.1.0" },
|
|
483
485
|
{ name = "pytest-cov", specifier = ">=7.0.0" },
|
|
484
486
|
{ name = "ruff", specifier = "==0.15.0" },
|
|
485
487
|
{ name = "types-aiofiles", specifier = "==24.1.0.20240626" },
|
|
@@ -487,16 +489,16 @@ dev = [
|
|
|
487
489
|
|
|
488
490
|
[[package]]
|
|
489
491
|
name = "fluidattacks-core-http"
|
|
490
|
-
version = "12.
|
|
492
|
+
version = "12.1.0"
|
|
491
493
|
source = { registry = "https://pypi.org/simple" }
|
|
492
494
|
dependencies = [
|
|
493
495
|
{ name = "aiohttp" },
|
|
494
496
|
{ name = "certifi" },
|
|
495
497
|
{ name = "urllib3" },
|
|
496
498
|
]
|
|
497
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
499
|
+
sdist = { url = "https://files.pythonhosted.org/packages/6c/a2/822f0a6d06cffea448cb7be1d731c028a0df6d5e657700fabd749f176f8d/fluidattacks_core_http-12.1.0.tar.gz", hash = "sha256:b3ef49420da9a3bd24e050b0b5b7a6666b1a0e7b308e97a59e871d80e27aa222", size = 89677, upload-time = "2026-08-27T19:19:54.15Z" }
|
|
498
500
|
wheels = [
|
|
499
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
501
|
+
{ url = "https://files.pythonhosted.org/packages/c0/cd/1beca25745de6058a43ee5c417c064c42ac645990fbdfd739ec4a2fe2f33/fluidattacks_core_http-12.1.0-py3-none-any.whl", hash = "sha256:6f15e283f8ebed0fcf73cb9cd2be268f1d891c1e3e9635b6cd2323c1084f014c", size = 4403, upload-time = "2026-08-27T19:19:54.855Z" },
|
|
500
502
|
]
|
|
501
503
|
|
|
502
504
|
[[package]]
|
|
@@ -1331,6 +1333,19 @@ wheels = [
|
|
|
1331
1333
|
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
|
1332
1334
|
]
|
|
1333
1335
|
|
|
1336
|
+
[[package]]
|
|
1337
|
+
name = "pytest-asyncio"
|
|
1338
|
+
version = "1.4.0"
|
|
1339
|
+
source = { registry = "https://pypi.org/simple" }
|
|
1340
|
+
dependencies = [
|
|
1341
|
+
{ name = "pytest" },
|
|
1342
|
+
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
|
1343
|
+
]
|
|
1344
|
+
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
|
1345
|
+
wheels = [
|
|
1346
|
+
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
|
1347
|
+
]
|
|
1348
|
+
|
|
1334
1349
|
[[package]]
|
|
1335
1350
|
name = "pytest-cov"
|
|
1336
1351
|
version = "7.1.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/__init__.py
RENAMED
|
File without changes
|
{fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/classes.py
RENAMED
|
File without changes
|
{fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/clone.py
RENAMED
|
File without changes
|
|
File without changes
|
{fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/constants.py
RENAMED
|
File without changes
|
{fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/delete_files.py
RENAMED
|
File without changes
|
{fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/download_file.py
RENAMED
|
File without changes
|
{fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/https_utils.py
RENAMED
|
File without changes
|
{fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/py.typed
RENAMED
|
File without changes
|
{fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/remote.py
RENAMED
|
File without changes
|
{fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/show_file.py
RENAMED
|
File without changes
|
{fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/ssh_utils.py
RENAMED
|
File without changes
|
{fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/upload_repo.py
RENAMED
|
File without changes
|
{fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|