fluidattacks_core_git 12.0.0__py3-none-any.whl
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/__init__.py +382 -0
- fluidattacks_core/git/classes.py +29 -0
- fluidattacks_core/git/clone.py +243 -0
- fluidattacks_core/git/codecommit_utils.py +129 -0
- fluidattacks_core/git/constants.py +7 -0
- fluidattacks_core/git/delete_files.py +47 -0
- fluidattacks_core/git/download_file.py +47 -0
- fluidattacks_core/git/download_repo.py +148 -0
- fluidattacks_core/git/https_utils.py +230 -0
- fluidattacks_core/git/py.typed +0 -0
- fluidattacks_core/git/remote.py +45 -0
- fluidattacks_core/git/show_file.py +408 -0
- fluidattacks_core/git/ssh_utils.py +214 -0
- fluidattacks_core/git/upload_repo.py +338 -0
- fluidattacks_core/git/utils.py +143 -0
- fluidattacks_core/git/warp.py +184 -0
- fluidattacks_core_git-12.0.0.dist-info/METADATA +22 -0
- fluidattacks_core_git-12.0.0.dist-info/RECORD +19 -0
- fluidattacks_core_git-12.0.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import io
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import tarfile
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import aiofiles
|
|
12
|
+
|
|
13
|
+
from fluidattacks_core.git.ssh_utils import (
|
|
14
|
+
parse_ssh_url,
|
|
15
|
+
ssh_git_env_context,
|
|
16
|
+
)
|
|
17
|
+
from fluidattacks_core.git.utils import format_url, get_https_git_config_args, run_git
|
|
18
|
+
|
|
19
|
+
LOGGER = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
COMMIT_SHA_PATTERN = re.compile(r"^[0-9a-fA-F]{7,40}\Z")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_commit_sha(ref: str) -> bool:
|
|
25
|
+
return bool(COMMIT_SHA_PATTERN.fullmatch(ref))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _ref_for_fetch(ref: str) -> str:
|
|
29
|
+
if ref.startswith("origin/"):
|
|
30
|
+
return ref.removeprefix("origin/")
|
|
31
|
+
return ref
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _show_spec_for_ref(path: str) -> str:
|
|
35
|
+
return f"FETCH_HEAD:{path}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _run_git(
|
|
39
|
+
env: dict[str, str],
|
|
40
|
+
config_args: list[str],
|
|
41
|
+
args: list[str],
|
|
42
|
+
*,
|
|
43
|
+
cwd: str | None = None,
|
|
44
|
+
) -> tuple[bytes, bytes, int]:
|
|
45
|
+
try:
|
|
46
|
+
return await run_git(*config_args, *args, env=env, cwd=cwd, timeout=30.0)
|
|
47
|
+
except asyncio.exceptions.TimeoutError:
|
|
48
|
+
return b"", b"git command timed out", -1
|
|
49
|
+
except OSError as exc:
|
|
50
|
+
return b"", str(exc).encode(), -1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _GitResult:
|
|
54
|
+
__slots__ = ("_error", "_stdout")
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
error: str | None = None,
|
|
59
|
+
stdout: bytes | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self._error = error
|
|
62
|
+
self._stdout = stdout
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_ok(self) -> bool:
|
|
66
|
+
return self._error is None
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def error(self) -> str | None:
|
|
70
|
+
return self._error
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def stdout(self) -> bytes | None:
|
|
74
|
+
return self._stdout
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
async def from_git(
|
|
78
|
+
cls,
|
|
79
|
+
env: dict[str, str],
|
|
80
|
+
config_args: list[str],
|
|
81
|
+
args: list[str],
|
|
82
|
+
*,
|
|
83
|
+
cwd: str | None = None,
|
|
84
|
+
) -> "_GitResult":
|
|
85
|
+
stdout, stderr, code = await _run_git(env, config_args, args, cwd=cwd)
|
|
86
|
+
if code != 0:
|
|
87
|
+
return cls(stderr.decode("utf-8"))
|
|
88
|
+
return cls(stdout=stdout)
|
|
89
|
+
|
|
90
|
+
async def and_then(
|
|
91
|
+
self,
|
|
92
|
+
env: dict[str, str],
|
|
93
|
+
config_args: list[str],
|
|
94
|
+
args: list[str],
|
|
95
|
+
*,
|
|
96
|
+
cwd: str | None = None,
|
|
97
|
+
) -> "_GitResult":
|
|
98
|
+
if not self.is_ok:
|
|
99
|
+
return self
|
|
100
|
+
return await _GitResult.from_git(env, config_args, args, cwd=cwd)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def _run_git_sequence(
|
|
104
|
+
env: dict[str, str],
|
|
105
|
+
config_args: list[str],
|
|
106
|
+
steps: list[tuple[list[str], str | None]],
|
|
107
|
+
) -> _GitResult:
|
|
108
|
+
result = await _GitResult.from_git(env, config_args, steps[0][0], cwd=steps[0][1])
|
|
109
|
+
for args, cwd in steps[1:]:
|
|
110
|
+
result = await result.and_then(env, config_args, args, cwd=cwd)
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def _show_via_fetch_show( # noqa: PLR0913
|
|
115
|
+
env: dict[str, str],
|
|
116
|
+
config_args: list[str],
|
|
117
|
+
repo_url: str,
|
|
118
|
+
ref_fetch: str,
|
|
119
|
+
show_spec: str,
|
|
120
|
+
work_dir: str,
|
|
121
|
+
) -> tuple[bytes | None, str | None]:
|
|
122
|
+
fetch_args = ["fetch"]
|
|
123
|
+
if not _is_commit_sha(ref_fetch):
|
|
124
|
+
fetch_args.extend(["--depth", "1"])
|
|
125
|
+
fetch_args.extend(["origin", ref_fetch])
|
|
126
|
+
|
|
127
|
+
steps: list[tuple[list[str], str | None]] = [
|
|
128
|
+
(["init"], work_dir),
|
|
129
|
+
(["remote", "add", "origin", repo_url], work_dir),
|
|
130
|
+
(fetch_args, work_dir),
|
|
131
|
+
(["show", show_spec], work_dir),
|
|
132
|
+
]
|
|
133
|
+
result = await _run_git_sequence(env, config_args, steps)
|
|
134
|
+
if not result.is_ok:
|
|
135
|
+
return None, result.error
|
|
136
|
+
return result.stdout, None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def _show_via_archive_remote(
|
|
140
|
+
env: dict[str, str],
|
|
141
|
+
config_args: list[str],
|
|
142
|
+
repo_url: str,
|
|
143
|
+
ref_fetch: str,
|
|
144
|
+
path: str,
|
|
145
|
+
) -> tuple[bytes | None, str | None]:
|
|
146
|
+
stdout, stderr, code = await _run_git(
|
|
147
|
+
env,
|
|
148
|
+
config_args,
|
|
149
|
+
["archive", f"--remote={repo_url}", ref_fetch, "--", path],
|
|
150
|
+
cwd=None,
|
|
151
|
+
)
|
|
152
|
+
if code != 0:
|
|
153
|
+
return None, stderr.decode("utf-8")
|
|
154
|
+
if len(stdout) == 0:
|
|
155
|
+
return None, "git archive produced empty output"
|
|
156
|
+
try:
|
|
157
|
+
with tarfile.open(fileobj=io.BytesIO(stdout), mode="r:") as tar:
|
|
158
|
+
for member in tar:
|
|
159
|
+
if (
|
|
160
|
+
member.isfile()
|
|
161
|
+
and (extracted := tar.extractfile(member))
|
|
162
|
+
and extracted is not None
|
|
163
|
+
):
|
|
164
|
+
return extracted.read(), None
|
|
165
|
+
return None, "no file in archive"
|
|
166
|
+
except (tarfile.TarError, OSError) as exc:
|
|
167
|
+
return None, str(exc)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _path_contained_in(base: Path, candidate: Path) -> bool:
|
|
171
|
+
try:
|
|
172
|
+
return candidate.resolve().is_relative_to(base.resolve())
|
|
173
|
+
except (ValueError, OSError):
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _sparse_checkout_directory(path: str) -> str:
|
|
178
|
+
"""Directory to pass to sparse-checkout set in cone mode.
|
|
179
|
+
|
|
180
|
+
Cone mode only accepts directories and includes all files under them.
|
|
181
|
+
For a file path like src/main.py we must set the parent directory (src)
|
|
182
|
+
so the file is actually checked out.
|
|
183
|
+
"""
|
|
184
|
+
parent = Path(path).parent
|
|
185
|
+
return "." if not parent.parts else str(parent)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def _show_via_sparse_checkout( # noqa: PLR0913
|
|
189
|
+
env: dict[str, str],
|
|
190
|
+
config_args: list[str],
|
|
191
|
+
repo_url: str,
|
|
192
|
+
ref_fetch: str,
|
|
193
|
+
path: str,
|
|
194
|
+
temp_dir: str,
|
|
195
|
+
) -> tuple[bytes | None, str | None]:
|
|
196
|
+
sparse_dir = f"{temp_dir}/{uuid.uuid4()}"
|
|
197
|
+
sparse_dir_path = _sparse_checkout_directory(path)
|
|
198
|
+
|
|
199
|
+
fetch_args = ["fetch"]
|
|
200
|
+
if not _is_commit_sha(ref_fetch):
|
|
201
|
+
fetch_args.extend(["--depth", "1"])
|
|
202
|
+
fetch_args.extend(["origin", ref_fetch])
|
|
203
|
+
|
|
204
|
+
steps: list[tuple[list[str], str | None]] = [
|
|
205
|
+
(
|
|
206
|
+
[
|
|
207
|
+
"clone",
|
|
208
|
+
"--no-checkout",
|
|
209
|
+
"--depth",
|
|
210
|
+
"1",
|
|
211
|
+
repo_url,
|
|
212
|
+
Path(sparse_dir).name,
|
|
213
|
+
],
|
|
214
|
+
temp_dir,
|
|
215
|
+
),
|
|
216
|
+
(fetch_args, sparse_dir),
|
|
217
|
+
(["sparse-checkout", "init", "--cone"], sparse_dir),
|
|
218
|
+
(["sparse-checkout", "set", sparse_dir_path], sparse_dir),
|
|
219
|
+
(["checkout", "FETCH_HEAD"], sparse_dir),
|
|
220
|
+
]
|
|
221
|
+
try:
|
|
222
|
+
result = await _run_git_sequence(env, config_args, steps)
|
|
223
|
+
if not result.is_ok:
|
|
224
|
+
return None, result.error
|
|
225
|
+
|
|
226
|
+
base = Path(sparse_dir)
|
|
227
|
+
file_path = base / path
|
|
228
|
+
if not _path_contained_in(base, file_path):
|
|
229
|
+
return None, "path escapes repository"
|
|
230
|
+
try:
|
|
231
|
+
async with aiofiles.open(file_path, "rb") as f:
|
|
232
|
+
return await f.read(), None
|
|
233
|
+
except OSError as exc:
|
|
234
|
+
return None, str(exc)
|
|
235
|
+
finally:
|
|
236
|
+
shutil.rmtree(sparse_dir, ignore_errors=True)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
async def _try_show_strategies( # noqa: PLR0913
|
|
240
|
+
env: dict[str, str],
|
|
241
|
+
config_args: list[str],
|
|
242
|
+
repo_url: str,
|
|
243
|
+
ref_fetch: str,
|
|
244
|
+
show_spec: str,
|
|
245
|
+
path: str,
|
|
246
|
+
work_dir: str,
|
|
247
|
+
temp_dir: str,
|
|
248
|
+
) -> tuple[bytes | None, str | None]:
|
|
249
|
+
content, err = await _show_via_fetch_show(
|
|
250
|
+
env,
|
|
251
|
+
config_args,
|
|
252
|
+
repo_url,
|
|
253
|
+
ref_fetch,
|
|
254
|
+
show_spec,
|
|
255
|
+
work_dir,
|
|
256
|
+
)
|
|
257
|
+
if content is not None:
|
|
258
|
+
return content, None
|
|
259
|
+
last_error = err or "fetch+show failed"
|
|
260
|
+
LOGGER.debug(
|
|
261
|
+
"show_file fallback: fetch+show failed, trying archive --remote",
|
|
262
|
+
extra={"extra": {"error": last_error}},
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
content, err = await _show_via_archive_remote(
|
|
266
|
+
env,
|
|
267
|
+
config_args,
|
|
268
|
+
repo_url,
|
|
269
|
+
ref_fetch,
|
|
270
|
+
path,
|
|
271
|
+
)
|
|
272
|
+
if content is not None:
|
|
273
|
+
return content, None
|
|
274
|
+
last_error = err or "archive --remote failed"
|
|
275
|
+
LOGGER.debug(
|
|
276
|
+
"show_file fallback: archive --remote failed, trying sparse-checkout",
|
|
277
|
+
extra={"extra": {"error": last_error}},
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
content, err = await _show_via_sparse_checkout(
|
|
281
|
+
env,
|
|
282
|
+
config_args,
|
|
283
|
+
repo_url,
|
|
284
|
+
ref_fetch,
|
|
285
|
+
path,
|
|
286
|
+
temp_dir,
|
|
287
|
+
)
|
|
288
|
+
if content is not None:
|
|
289
|
+
return content, None
|
|
290
|
+
return None, err or "sparse-checkout failed"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def show_file_at_ref_ssh(
|
|
294
|
+
repo_url: str,
|
|
295
|
+
ref: str,
|
|
296
|
+
path: str,
|
|
297
|
+
temp_dir: str,
|
|
298
|
+
*,
|
|
299
|
+
credential_key: str,
|
|
300
|
+
) -> tuple[bytes | None, str | None]:
|
|
301
|
+
ref_fetch = _ref_for_fetch(ref)
|
|
302
|
+
show_spec = _show_spec_for_ref(path)
|
|
303
|
+
work_dir = f"{temp_dir}/{uuid.uuid4()}"
|
|
304
|
+
Path(work_dir).mkdir(parents=True, exist_ok=True) # noqa: ASYNC240
|
|
305
|
+
repo_url_parsed = parse_ssh_url(repo_url)
|
|
306
|
+
config_args: list[str] = []
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
with ssh_git_env_context(temp_dir, credential_key) as env:
|
|
310
|
+
return await _try_show_strategies(
|
|
311
|
+
env,
|
|
312
|
+
config_args,
|
|
313
|
+
repo_url_parsed,
|
|
314
|
+
ref_fetch,
|
|
315
|
+
show_spec,
|
|
316
|
+
path,
|
|
317
|
+
work_dir,
|
|
318
|
+
temp_dir,
|
|
319
|
+
)
|
|
320
|
+
finally:
|
|
321
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
async def show_file_at_ref_https( # noqa: PLR0913
|
|
325
|
+
repo_url: str,
|
|
326
|
+
ref: str,
|
|
327
|
+
path: str,
|
|
328
|
+
temp_dir: str,
|
|
329
|
+
*,
|
|
330
|
+
user: str | None = None,
|
|
331
|
+
password: str | None = None,
|
|
332
|
+
token: str | None = None,
|
|
333
|
+
provider: str | None = None,
|
|
334
|
+
is_pat: bool = False,
|
|
335
|
+
follow_redirects: bool = False,
|
|
336
|
+
) -> tuple[bytes | None, str | None]:
|
|
337
|
+
ref_fetch = _ref_for_fetch(ref)
|
|
338
|
+
show_spec = _show_spec_for_ref(path)
|
|
339
|
+
work_dir = f"{temp_dir}/{uuid.uuid4()}"
|
|
340
|
+
Path(work_dir).mkdir(parents=True, exist_ok=True) # noqa: ASYNC240
|
|
341
|
+
|
|
342
|
+
url = format_url(
|
|
343
|
+
repo_url=repo_url,
|
|
344
|
+
user=user,
|
|
345
|
+
password=password,
|
|
346
|
+
token=token,
|
|
347
|
+
provider=provider,
|
|
348
|
+
is_pat=is_pat,
|
|
349
|
+
)
|
|
350
|
+
config_args = get_https_git_config_args(
|
|
351
|
+
follow_redirects=follow_redirects,
|
|
352
|
+
is_pat=is_pat,
|
|
353
|
+
token=token,
|
|
354
|
+
disable_credential_prompt=token is not None or password is not None,
|
|
355
|
+
)
|
|
356
|
+
env = os.environ.copy()
|
|
357
|
+
if token is not None or password is not None:
|
|
358
|
+
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
return await _try_show_strategies(
|
|
362
|
+
env,
|
|
363
|
+
config_args,
|
|
364
|
+
url,
|
|
365
|
+
ref_fetch,
|
|
366
|
+
show_spec,
|
|
367
|
+
path,
|
|
368
|
+
work_dir,
|
|
369
|
+
temp_dir,
|
|
370
|
+
)
|
|
371
|
+
finally:
|
|
372
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
async def show_file_at_ref( # noqa: PLR0913
|
|
376
|
+
repo_url: str,
|
|
377
|
+
ref: str,
|
|
378
|
+
path: str,
|
|
379
|
+
temp_dir: str,
|
|
380
|
+
*,
|
|
381
|
+
credential_key: str | None = None,
|
|
382
|
+
user: str | None = None,
|
|
383
|
+
password: str | None = None,
|
|
384
|
+
token: str | None = None,
|
|
385
|
+
provider: str | None = None,
|
|
386
|
+
is_pat: bool = False,
|
|
387
|
+
follow_redirects: bool = False,
|
|
388
|
+
) -> tuple[bytes | None, str | None]:
|
|
389
|
+
if credential_key:
|
|
390
|
+
return await show_file_at_ref_ssh(
|
|
391
|
+
repo_url,
|
|
392
|
+
ref,
|
|
393
|
+
path,
|
|
394
|
+
temp_dir,
|
|
395
|
+
credential_key=credential_key,
|
|
396
|
+
)
|
|
397
|
+
return await show_file_at_ref_https(
|
|
398
|
+
repo_url,
|
|
399
|
+
ref,
|
|
400
|
+
path,
|
|
401
|
+
temp_dir,
|
|
402
|
+
user=user,
|
|
403
|
+
password=password,
|
|
404
|
+
token=token,
|
|
405
|
+
provider=provider,
|
|
406
|
+
is_pat=is_pat,
|
|
407
|
+
follow_redirects=follow_redirects,
|
|
408
|
+
)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import ipaddress
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Generator
|
|
8
|
+
from contextlib import contextmanager, suppress
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
from fluidattacks_core.http.validations import (
|
|
13
|
+
HTTPValidationError,
|
|
14
|
+
validate_local_request,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from fluidattacks_core.git.utils import run_git
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SSHHostValidationError(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _extract_hostname_from_ssh_url(url: str) -> str | None:
|
|
25
|
+
if "://" in url:
|
|
26
|
+
parsed = urlparse(url)
|
|
27
|
+
if parsed.hostname:
|
|
28
|
+
return parsed.hostname
|
|
29
|
+
if "@" in url and ":" in url:
|
|
30
|
+
host_part = url.split("@", maxsplit=1)[-1]
|
|
31
|
+
return host_part.split(":", maxsplit=1)[0]
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def validate_ssh_host(
|
|
36
|
+
url: str,
|
|
37
|
+
*,
|
|
38
|
+
allow_local_network: bool = False,
|
|
39
|
+
allow_localhost: bool = False,
|
|
40
|
+
) -> None:
|
|
41
|
+
hostname = _extract_hostname_from_ssh_url(parse_ssh_url(url))
|
|
42
|
+
if not hostname:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
loop = asyncio.get_running_loop()
|
|
46
|
+
infos = await loop.getaddrinfo(hostname, None)
|
|
47
|
+
|
|
48
|
+
seen: set[str] = set()
|
|
49
|
+
ips: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
|
|
50
|
+
for _family, _type, _proto, _canonname, sockaddr in infos:
|
|
51
|
+
addr = sockaddr[0]
|
|
52
|
+
if addr not in seen:
|
|
53
|
+
seen.add(addr)
|
|
54
|
+
ips.append(ipaddress.ip_address(addr))
|
|
55
|
+
|
|
56
|
+
if not ips:
|
|
57
|
+
msg = f"No IP addresses resolved for hostname: {hostname}"
|
|
58
|
+
raise SSHHostValidationError(msg)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
validate_local_request(
|
|
62
|
+
ips,
|
|
63
|
+
allow_local_network=allow_local_network,
|
|
64
|
+
allow_localhost=allow_localhost,
|
|
65
|
+
)
|
|
66
|
+
except HTTPValidationError as exc:
|
|
67
|
+
msg = f"SSH host validation failed for {hostname}: {exc}"
|
|
68
|
+
raise SSHHostValidationError(msg) from exc
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _add_ssh_scheme_to_url(url: str) -> str:
|
|
72
|
+
scheme: str = "ssh://"
|
|
73
|
+
if url.startswith(scheme):
|
|
74
|
+
return url
|
|
75
|
+
return scheme + url
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _url_has_port(url: str) -> bool:
|
|
79
|
+
parsed_url = urlparse(url)
|
|
80
|
+
try:
|
|
81
|
+
if parsed_url.port:
|
|
82
|
+
return True
|
|
83
|
+
except ValueError:
|
|
84
|
+
# Port could not be cast to integer value, or
|
|
85
|
+
# Port out of range 0-65535
|
|
86
|
+
return False
|
|
87
|
+
else:
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _set_default_ssh_port(url_with_scheme: str) -> str:
|
|
92
|
+
"""Add a default port placeholder to a URL that lacks an explicit port.
|
|
93
|
+
|
|
94
|
+
This function modifies URLs that use the SSH protocol format for Git
|
|
95
|
+
repositories. It adds a placeholder for the default port
|
|
96
|
+
(represented by ':/') after the hostname.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
url_with_scheme (str): The input URL, expected to be in the format
|
|
100
|
+
"ssh://git@hostname:path/to/repo.git"
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
str: The modified URL with the default port placeholder added,
|
|
104
|
+
in the format "ssh://git@hostname:/path/to/repo.git"
|
|
105
|
+
|
|
106
|
+
Examples:
|
|
107
|
+
"ssh://git@gitlab.com:fluidattacks/demo.git" becomes
|
|
108
|
+
"ssh://git@gitlab.com:/fluidattacks/demo.git"
|
|
109
|
+
|
|
110
|
+
Note:
|
|
111
|
+
This function modifies the URL only if all the following
|
|
112
|
+
conditions are met:
|
|
113
|
+
1. The URL starts with 'ssh://'.
|
|
114
|
+
2. The URL does not already contain a port.
|
|
115
|
+
3. The URL contains exactly two colons after the 'ssh://' scheme.
|
|
116
|
+
URLs not meeting these criteria are returned unchanged.
|
|
117
|
+
|
|
118
|
+
"""
|
|
119
|
+
has_ssh_scheme = url_with_scheme.startswith("ssh://")
|
|
120
|
+
|
|
121
|
+
# formatting is skipped if no ssh scheme or URL contains a port
|
|
122
|
+
if not has_ssh_scheme or _url_has_port(url_with_scheme):
|
|
123
|
+
return url_with_scheme
|
|
124
|
+
|
|
125
|
+
url_parts = url_with_scheme.split(":", 2)
|
|
126
|
+
if len(url_parts) < 3:
|
|
127
|
+
return url_with_scheme
|
|
128
|
+
|
|
129
|
+
return f"{url_parts[0]}:{url_parts[1]}:/{url_parts[2]}"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_ssh_url(url: str) -> str:
|
|
133
|
+
if "source.developers.google" in url or url.startswith("ssh://FLUID"):
|
|
134
|
+
return url
|
|
135
|
+
|
|
136
|
+
url_with_scheme = _add_ssh_scheme_to_url(url)
|
|
137
|
+
|
|
138
|
+
# url misses an explicit ssh port
|
|
139
|
+
return _set_default_ssh_port(url_with_scheme)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_ssh_git_env(ssh_key_path: str | Path) -> dict[str, str]:
|
|
143
|
+
return {
|
|
144
|
+
**os.environ.copy(),
|
|
145
|
+
"GIT_SSH_COMMAND": (
|
|
146
|
+
f"ssh -i {ssh_key_path}"
|
|
147
|
+
" -o UserKnownHostsFile=/dev/null"
|
|
148
|
+
" -o StrictHostKeyChecking=no"
|
|
149
|
+
" -o IdentitiesOnly=yes"
|
|
150
|
+
" -o HostkeyAlgorithms=+ssh-rsa"
|
|
151
|
+
" -o PubkeyAcceptedAlgorithms=+ssh-rsa"
|
|
152
|
+
),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def create_ssh_key_file(temp_dir: str, credential_key: str) -> str:
|
|
157
|
+
ssh_file_name: str = os.path.join(temp_dir, str(uuid.uuid4())) # noqa: PTH118
|
|
158
|
+
with open( # noqa: PTH123
|
|
159
|
+
os.open(ssh_file_name, os.O_CREAT | os.O_WRONLY, 0o400),
|
|
160
|
+
"w",
|
|
161
|
+
encoding="utf-8",
|
|
162
|
+
) as ssh_file:
|
|
163
|
+
ssh_file.write(base64.b64decode(credential_key).decode())
|
|
164
|
+
return ssh_file_name
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@contextmanager
|
|
168
|
+
def ssh_git_env_context(
|
|
169
|
+
temp_dir: str, credential_key: str
|
|
170
|
+
) -> Generator[dict[str, str], None, None]:
|
|
171
|
+
ssh_key_path = create_ssh_key_file(temp_dir, credential_key)
|
|
172
|
+
try:
|
|
173
|
+
yield get_ssh_git_env(ssh_key_path)
|
|
174
|
+
finally:
|
|
175
|
+
with suppress(OSError):
|
|
176
|
+
os.remove(ssh_key_path) # noqa: PTH107
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def ssh_ls_remote(
|
|
180
|
+
repo_url: str,
|
|
181
|
+
credential_key: str,
|
|
182
|
+
branch: str,
|
|
183
|
+
) -> tuple[str | None, str | None]:
|
|
184
|
+
raw_root_url = parse_ssh_url(repo_url)
|
|
185
|
+
with (
|
|
186
|
+
tempfile.TemporaryDirectory() as temp_dir,
|
|
187
|
+
ssh_git_env_context(temp_dir, credential_key) as env,
|
|
188
|
+
):
|
|
189
|
+
try:
|
|
190
|
+
stdout, stderr, returncode = await run_git(
|
|
191
|
+
"ls-remote",
|
|
192
|
+
"--",
|
|
193
|
+
raw_root_url,
|
|
194
|
+
branch,
|
|
195
|
+
env=env,
|
|
196
|
+
timeout=20,
|
|
197
|
+
)
|
|
198
|
+
except asyncio.exceptions.TimeoutError:
|
|
199
|
+
return None, "git ls-remote time out"
|
|
200
|
+
if returncode == 0:
|
|
201
|
+
return stdout.decode().split("\t")[0], None
|
|
202
|
+
return None, stderr.decode("utf-8")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def call_ssh_ls_remote(
|
|
206
|
+
repo_url: str,
|
|
207
|
+
credential_key: str,
|
|
208
|
+
branch: str,
|
|
209
|
+
) -> tuple[str | None, str | None]:
|
|
210
|
+
return await ssh_ls_remote(
|
|
211
|
+
repo_url=repo_url,
|
|
212
|
+
credential_key=credential_key,
|
|
213
|
+
branch=branch,
|
|
214
|
+
)
|