github2gerrit 0.1.5__py3-none-any.whl → 0.1.7__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.
- github2gerrit/cli.py +511 -271
- github2gerrit/commit_normalization.py +471 -0
- github2gerrit/config.py +32 -24
- github2gerrit/core.py +1092 -507
- github2gerrit/duplicate_detection.py +333 -217
- github2gerrit/external_api.py +518 -0
- github2gerrit/gerrit_rest.py +298 -0
- github2gerrit/gerrit_urls.py +353 -0
- github2gerrit/github_api.py +17 -95
- github2gerrit/gitutils.py +225 -41
- github2gerrit/models.py +3 -0
- github2gerrit/pr_content_filter.py +476 -0
- github2gerrit/similarity.py +458 -0
- github2gerrit/ssh_agent_setup.py +351 -0
- github2gerrit/ssh_common.py +244 -0
- github2gerrit/ssh_discovery.py +24 -67
- github2gerrit/utils.py +113 -0
- github2gerrit-0.1.7.dist-info/METADATA +798 -0
- github2gerrit-0.1.7.dist-info/RECORD +24 -0
- github2gerrit-0.1.5.dist-info/METADATA +0 -555
- github2gerrit-0.1.5.dist-info/RECORD +0 -15
- {github2gerrit-0.1.5.dist-info → github2gerrit-0.1.7.dist-info}/WHEEL +0 -0
- {github2gerrit-0.1.5.dist-info → github2gerrit-0.1.7.dist-info}/entry_points.txt +0 -0
- {github2gerrit-0.1.5.dist-info → github2gerrit-0.1.7.dist-info}/licenses/LICENSE +0 -0
- {github2gerrit-0.1.5.dist-info → github2gerrit-0.1.7.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,351 @@
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
2
|
+
# SPDX-FileCopyrightText: 2025 The Linux Foundation
|
3
|
+
|
4
|
+
"""
|
5
|
+
SSH agent-based authentication for github2gerrit.
|
6
|
+
|
7
|
+
This module provides functionality to use SSH agent for authentication
|
8
|
+
instead of writing private keys to disk, which is more secure and
|
9
|
+
avoids file permission issues in CI environments.
|
10
|
+
"""
|
11
|
+
|
12
|
+
from __future__ import annotations
|
13
|
+
|
14
|
+
import logging
|
15
|
+
import os
|
16
|
+
import shutil
|
17
|
+
import subprocess
|
18
|
+
from pathlib import Path
|
19
|
+
|
20
|
+
from .gitutils import CommandError
|
21
|
+
from .gitutils import run_cmd
|
22
|
+
from .ssh_common import augment_known_hosts_with_bracketed_entries
|
23
|
+
|
24
|
+
|
25
|
+
log = logging.getLogger(__name__)
|
26
|
+
|
27
|
+
|
28
|
+
class SSHAgentError(Exception):
|
29
|
+
"""Raised when SSH agent operations fail."""
|
30
|
+
|
31
|
+
|
32
|
+
# Error message constants to comply with TRY003
|
33
|
+
_MSG_PARSE_FAILED = "Failed to parse ssh-agent output"
|
34
|
+
_MSG_START_FAILED = "Failed to start SSH agent: {error}"
|
35
|
+
_MSG_NOT_STARTED = "SSH agent not started"
|
36
|
+
_MSG_ADD_FAILED = "ssh-add failed: {error}"
|
37
|
+
_MSG_ADD_TIMEOUT = "ssh-add timed out"
|
38
|
+
_MSG_ADD_KEY_FAILED = "Failed to add key to SSH agent: {error}"
|
39
|
+
_MSG_SETUP_HOSTS_FAILED = "Failed to setup known hosts: {error}"
|
40
|
+
_MSG_HOSTS_NOT_CONFIGURED = "Known hosts not configured"
|
41
|
+
_MSG_LIST_FAILED = "Failed to list keys: {error}"
|
42
|
+
_MSG_NO_KEYS_LOADED = "No keys were loaded into SSH agent"
|
43
|
+
_MSG_SSH_AGENT_NOT_FOUND = "ssh-agent not found in PATH"
|
44
|
+
_MSG_SSH_ADD_NOT_FOUND = "ssh-add not found in PATH"
|
45
|
+
|
46
|
+
|
47
|
+
class SSHAgentManager:
|
48
|
+
"""Manages SSH agent lifecycle and key loading for secure authentication."""
|
49
|
+
|
50
|
+
def __init__(self, workspace: Path):
|
51
|
+
"""Initialize SSH agent manager.
|
52
|
+
|
53
|
+
Args:
|
54
|
+
workspace: The workspace directory for storing temporary files
|
55
|
+
"""
|
56
|
+
self.workspace = workspace
|
57
|
+
self.agent_pid: int | None = None
|
58
|
+
self.auth_sock: str | None = None
|
59
|
+
self.known_hosts_path: Path | None = None
|
60
|
+
self._original_env: dict[str, str] = {}
|
61
|
+
|
62
|
+
def start_agent(self) -> None:
|
63
|
+
"""Start a new SSH agent process."""
|
64
|
+
try:
|
65
|
+
# Locate ssh-agent executable
|
66
|
+
ssh_agent_path = shutil.which("ssh-agent")
|
67
|
+
if not ssh_agent_path:
|
68
|
+
_raise_ssh_agent_not_found()
|
69
|
+
assert ssh_agent_path is not None # for mypy # noqa: S101
|
70
|
+
|
71
|
+
# Start ssh-agent and capture its output
|
72
|
+
result = run_cmd([ssh_agent_path, "-s"], timeout=10)
|
73
|
+
|
74
|
+
# Parse the ssh-agent output to get environment variables
|
75
|
+
for line in result.stdout.strip().split("\n"):
|
76
|
+
if line.startswith("SSH_AUTH_SOCK="):
|
77
|
+
# Format: SSH_AUTH_SOCK=/path/to/socket; export SSH_AUTH_SOCK;
|
78
|
+
value = line.split("=", 1)[1].split(";")[0].strip()
|
79
|
+
self.auth_sock = value
|
80
|
+
elif line.startswith("SSH_AGENT_PID="):
|
81
|
+
# Format: SSH_AGENT_PID=12345; export SSH_AGENT_PID;
|
82
|
+
value = line.split("=", 1)[1].split(";")[0].strip()
|
83
|
+
self.agent_pid = int(value)
|
84
|
+
|
85
|
+
if not self.auth_sock or not self.agent_pid:
|
86
|
+
_raise_parse_error()
|
87
|
+
|
88
|
+
# Store original environment
|
89
|
+
self._original_env = {
|
90
|
+
"SSH_AUTH_SOCK": os.environ.get("SSH_AUTH_SOCK", ""),
|
91
|
+
"SSH_AGENT_PID": os.environ.get("SSH_AGENT_PID", ""),
|
92
|
+
}
|
93
|
+
|
94
|
+
# Set environment variables for this process
|
95
|
+
if self.auth_sock:
|
96
|
+
os.environ["SSH_AUTH_SOCK"] = self.auth_sock
|
97
|
+
if self.agent_pid:
|
98
|
+
os.environ["SSH_AGENT_PID"] = str(self.agent_pid)
|
99
|
+
|
100
|
+
log.debug("Started SSH agent with PID %d, socket %s", self.agent_pid, self.auth_sock)
|
101
|
+
|
102
|
+
except Exception as exc:
|
103
|
+
raise SSHAgentError(_MSG_START_FAILED.format(error=exc)) from exc
|
104
|
+
|
105
|
+
def add_key(self, private_key_content: str) -> None:
|
106
|
+
"""Add a private key to the SSH agent.
|
107
|
+
|
108
|
+
Args:
|
109
|
+
private_key_content: The private key content as a string
|
110
|
+
"""
|
111
|
+
if not self.auth_sock:
|
112
|
+
raise SSHAgentError(_MSG_NOT_STARTED)
|
113
|
+
|
114
|
+
# Locate ssh-add executable
|
115
|
+
ssh_add_path = shutil.which("ssh-add")
|
116
|
+
if not ssh_add_path:
|
117
|
+
_raise_ssh_add_not_found()
|
118
|
+
assert ssh_add_path is not None # for mypy # noqa: S101
|
119
|
+
|
120
|
+
process = None
|
121
|
+
try:
|
122
|
+
# Use ssh-add with stdin to add the key
|
123
|
+
process = subprocess.Popen( # noqa: S603
|
124
|
+
[ssh_add_path, "-"],
|
125
|
+
stdin=subprocess.PIPE,
|
126
|
+
stdout=subprocess.PIPE,
|
127
|
+
stderr=subprocess.PIPE,
|
128
|
+
text=True,
|
129
|
+
env={
|
130
|
+
**os.environ,
|
131
|
+
"SSH_AUTH_SOCK": self.auth_sock,
|
132
|
+
"SSH_AGENT_PID": str(self.agent_pid),
|
133
|
+
},
|
134
|
+
)
|
135
|
+
|
136
|
+
stdout, stderr = process.communicate(input=private_key_content.strip() + "\n", timeout=10)
|
137
|
+
|
138
|
+
if process.returncode != 0:
|
139
|
+
_raise_add_key_error(stderr)
|
140
|
+
|
141
|
+
log.debug("Successfully added SSH key to agent")
|
142
|
+
|
143
|
+
except subprocess.TimeoutExpired as exc:
|
144
|
+
if process:
|
145
|
+
process.kill()
|
146
|
+
raise SSHAgentError(_MSG_ADD_TIMEOUT) from exc
|
147
|
+
except Exception as exc:
|
148
|
+
raise SSHAgentError(_MSG_ADD_KEY_FAILED.format(error=exc)) from exc
|
149
|
+
|
150
|
+
def setup_known_hosts(self, known_hosts_content: str) -> None:
|
151
|
+
"""Setup known hosts file.
|
152
|
+
|
153
|
+
Args:
|
154
|
+
known_hosts_content: The known hosts content
|
155
|
+
"""
|
156
|
+
try:
|
157
|
+
# Create tool-specific SSH directory
|
158
|
+
tool_ssh_dir = self.workspace / ".ssh-g2g"
|
159
|
+
tool_ssh_dir.mkdir(mode=0o700, exist_ok=True)
|
160
|
+
|
161
|
+
# Write known hosts file (normalize/augment with [host]:port entries)
|
162
|
+
self.known_hosts_path = tool_ssh_dir / "known_hosts"
|
163
|
+
host = (os.getenv("GERRIT_SERVER") or "").strip()
|
164
|
+
port = (os.getenv("GERRIT_SERVER_PORT") or "29418").strip()
|
165
|
+
try:
|
166
|
+
port_int = int(port)
|
167
|
+
except Exception:
|
168
|
+
port_int = 29418
|
169
|
+
|
170
|
+
# Use centralized augmentation logic
|
171
|
+
augmented_content = augment_known_hosts_with_bracketed_entries(known_hosts_content, host, port_int)
|
172
|
+
|
173
|
+
with open(self.known_hosts_path, "w", encoding="utf-8") as f:
|
174
|
+
f.write(augmented_content)
|
175
|
+
self.known_hosts_path.chmod(0o644)
|
176
|
+
|
177
|
+
log.debug("Known hosts written to %s", self.known_hosts_path)
|
178
|
+
|
179
|
+
except Exception as exc:
|
180
|
+
raise SSHAgentError(_MSG_SETUP_HOSTS_FAILED.format(error=exc)) from exc
|
181
|
+
|
182
|
+
def get_git_ssh_command(self) -> str:
|
183
|
+
"""Generate GIT_SSH_COMMAND for SSH agent-based authentication.
|
184
|
+
|
185
|
+
Returns:
|
186
|
+
SSH command string for git operations
|
187
|
+
"""
|
188
|
+
if not self.known_hosts_path:
|
189
|
+
raise SSHAgentError(_MSG_HOSTS_NOT_CONFIGURED)
|
190
|
+
|
191
|
+
ssh_options = [
|
192
|
+
"-F /dev/null",
|
193
|
+
f"-o UserKnownHostsFile={self.known_hosts_path}",
|
194
|
+
"-o IdentitiesOnly=no", # Allow SSH agent
|
195
|
+
"-o BatchMode=yes",
|
196
|
+
"-o PreferredAuthentications=publickey",
|
197
|
+
"-o StrictHostKeyChecking=yes",
|
198
|
+
"-o PasswordAuthentication=no",
|
199
|
+
"-o PubkeyAcceptedKeyTypes=+ssh-rsa",
|
200
|
+
"-o ConnectTimeout=10",
|
201
|
+
]
|
202
|
+
|
203
|
+
return f"ssh {' '.join(ssh_options)}"
|
204
|
+
|
205
|
+
def get_ssh_env(self) -> dict[str, str]:
|
206
|
+
"""Get environment variables for SSH operations.
|
207
|
+
|
208
|
+
Returns:
|
209
|
+
Dictionary of environment variables
|
210
|
+
"""
|
211
|
+
if not self.auth_sock:
|
212
|
+
raise SSHAgentError(_MSG_NOT_STARTED)
|
213
|
+
|
214
|
+
return {
|
215
|
+
"SSH_AUTH_SOCK": self.auth_sock,
|
216
|
+
"SSH_AGENT_PID": str(self.agent_pid),
|
217
|
+
}
|
218
|
+
|
219
|
+
def list_keys(self) -> str:
|
220
|
+
"""List keys currently loaded in the agent.
|
221
|
+
|
222
|
+
Returns:
|
223
|
+
Output from ssh-add -l
|
224
|
+
"""
|
225
|
+
if not self.auth_sock:
|
226
|
+
raise SSHAgentError(_MSG_NOT_STARTED)
|
227
|
+
|
228
|
+
try:
|
229
|
+
# Locate ssh-add executable
|
230
|
+
ssh_add_path = shutil.which("ssh-add")
|
231
|
+
if not ssh_add_path:
|
232
|
+
_raise_ssh_add_not_found()
|
233
|
+
assert ssh_add_path is not None # for mypy # noqa: S101
|
234
|
+
|
235
|
+
result = run_cmd(
|
236
|
+
[ssh_add_path, "-l"],
|
237
|
+
env={
|
238
|
+
**os.environ,
|
239
|
+
"SSH_AUTH_SOCK": self.auth_sock,
|
240
|
+
"SSH_AGENT_PID": str(self.agent_pid),
|
241
|
+
},
|
242
|
+
timeout=5,
|
243
|
+
)
|
244
|
+
except CommandError as exc:
|
245
|
+
if exc.returncode == 1:
|
246
|
+
return "No keys loaded"
|
247
|
+
raise SSHAgentError(_MSG_LIST_FAILED.format(error=exc)) from exc
|
248
|
+
except Exception as exc:
|
249
|
+
raise SSHAgentError(_MSG_LIST_FAILED.format(error=exc)) from exc
|
250
|
+
else:
|
251
|
+
return result.stdout
|
252
|
+
|
253
|
+
def cleanup(self) -> None:
|
254
|
+
"""Clean up SSH agent and temporary files."""
|
255
|
+
try:
|
256
|
+
# Kill SSH agent if we started it
|
257
|
+
if self.agent_pid:
|
258
|
+
try:
|
259
|
+
run_cmd(["/bin/kill", str(self.agent_pid)], timeout=5)
|
260
|
+
log.debug("SSH agent (PID %d) terminated", self.agent_pid)
|
261
|
+
except Exception as exc:
|
262
|
+
log.warning("Failed to kill SSH agent: %s", exc)
|
263
|
+
|
264
|
+
# Restore original environment
|
265
|
+
for key, value in self._original_env.items():
|
266
|
+
if value:
|
267
|
+
os.environ[key] = value
|
268
|
+
elif key in os.environ:
|
269
|
+
del os.environ[key]
|
270
|
+
|
271
|
+
# Clean up temporary files
|
272
|
+
tool_ssh_dir = self.workspace / ".ssh-g2g"
|
273
|
+
if tool_ssh_dir.exists():
|
274
|
+
import shutil
|
275
|
+
|
276
|
+
shutil.rmtree(tool_ssh_dir)
|
277
|
+
log.debug("Cleaned up temporary SSH directory: %s", tool_ssh_dir)
|
278
|
+
|
279
|
+
except Exception as exc:
|
280
|
+
log.warning("Failed to clean up SSH agent: %s", exc)
|
281
|
+
finally:
|
282
|
+
self.agent_pid = None
|
283
|
+
self.auth_sock = None
|
284
|
+
self.known_hosts_path = None
|
285
|
+
|
286
|
+
|
287
|
+
def setup_ssh_agent_auth(workspace: Path, private_key_content: str, known_hosts_content: str) -> SSHAgentManager:
|
288
|
+
"""Setup SSH agent-based authentication.
|
289
|
+
|
290
|
+
Args:
|
291
|
+
workspace: The workspace directory
|
292
|
+
private_key_content: SSH private key content
|
293
|
+
known_hosts_content: Known hosts content
|
294
|
+
|
295
|
+
Returns:
|
296
|
+
Configured SSHAgentManager instance
|
297
|
+
|
298
|
+
Raises:
|
299
|
+
SSHAgentError: If setup fails
|
300
|
+
"""
|
301
|
+
manager = SSHAgentManager(workspace)
|
302
|
+
|
303
|
+
try:
|
304
|
+
# Start SSH agent
|
305
|
+
manager.start_agent()
|
306
|
+
|
307
|
+
# Add the private key
|
308
|
+
manager.add_key(private_key_content)
|
309
|
+
|
310
|
+
# Setup known hosts
|
311
|
+
manager.setup_known_hosts(known_hosts_content)
|
312
|
+
|
313
|
+
# Verify key was added
|
314
|
+
keys_list = manager.list_keys()
|
315
|
+
if "No keys loaded" in keys_list:
|
316
|
+
_raise_no_keys_error()
|
317
|
+
|
318
|
+
log.info("SSH agent authentication configured successfully")
|
319
|
+
log.debug("Loaded keys: %s", keys_list)
|
320
|
+
|
321
|
+
except Exception:
|
322
|
+
# Clean up on failure
|
323
|
+
manager.cleanup()
|
324
|
+
raise
|
325
|
+
else:
|
326
|
+
return manager
|
327
|
+
|
328
|
+
|
329
|
+
def _raise_parse_error() -> None:
|
330
|
+
"""Raise SSH agent parse error."""
|
331
|
+
raise SSHAgentError(_MSG_PARSE_FAILED)
|
332
|
+
|
333
|
+
|
334
|
+
def _raise_add_key_error(stderr: str) -> None:
|
335
|
+
"""Raise SSH key addition error."""
|
336
|
+
raise SSHAgentError(_MSG_ADD_FAILED.format(error=stderr))
|
337
|
+
|
338
|
+
|
339
|
+
def _raise_ssh_agent_not_found() -> None:
|
340
|
+
"""Raise SSH agent not found error."""
|
341
|
+
raise SSHAgentError(_MSG_SSH_AGENT_NOT_FOUND)
|
342
|
+
|
343
|
+
|
344
|
+
def _raise_ssh_add_not_found() -> None:
|
345
|
+
"""Raise SSH add not found error."""
|
346
|
+
raise SSHAgentError(_MSG_SSH_ADD_NOT_FOUND)
|
347
|
+
|
348
|
+
|
349
|
+
def _raise_no_keys_error() -> None:
|
350
|
+
"""Raise no keys loaded error."""
|
351
|
+
raise SSHAgentError(_MSG_NO_KEYS_LOADED)
|
@@ -0,0 +1,244 @@
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
2
|
+
# SPDX-FileCopyrightText: 2025 The Linux Foundation
|
3
|
+
|
4
|
+
"""Common SSH utilities for consistent SSH configuration across modules.
|
5
|
+
|
6
|
+
This module provides shared SSH functionality to avoid duplication between
|
7
|
+
CLI and core SSH setup routines, ensuring consistent behavior and options.
|
8
|
+
"""
|
9
|
+
|
10
|
+
import logging
|
11
|
+
import os
|
12
|
+
from pathlib import Path
|
13
|
+
|
14
|
+
|
15
|
+
log = logging.getLogger(__name__)
|
16
|
+
|
17
|
+
|
18
|
+
def build_ssh_options(
|
19
|
+
key_path: str | Path | None = None,
|
20
|
+
known_hosts_path: str | Path | None = None,
|
21
|
+
identities_only: bool = True,
|
22
|
+
strict_host_checking: bool = True,
|
23
|
+
batch_mode: bool = True,
|
24
|
+
connect_timeout: int = 10,
|
25
|
+
additional_options: list[str] | None = None,
|
26
|
+
respect_user_ssh_config: bool | None = None,
|
27
|
+
) -> list[str]:
|
28
|
+
"""Build a list of SSH options for secure, isolated SSH configuration.
|
29
|
+
|
30
|
+
Args:
|
31
|
+
key_path: Path to SSH private key file
|
32
|
+
known_hosts_path: Path to known_hosts file
|
33
|
+
identities_only: Only use specified identities (prevents agent scanning)
|
34
|
+
strict_host_checking: Enable strict host key checking
|
35
|
+
batch_mode: Enable batch mode (non-interactive)
|
36
|
+
connect_timeout: Connection timeout in seconds
|
37
|
+
additional_options: Additional SSH options to include
|
38
|
+
respect_user_ssh_config: If True, respect user SSH config; if None, check G2G_RESPECT_USER_SSH env var
|
39
|
+
|
40
|
+
Returns:
|
41
|
+
List of SSH option strings suitable for ssh command line
|
42
|
+
"""
|
43
|
+
# Check if we should respect user SSH config
|
44
|
+
if respect_user_ssh_config is None:
|
45
|
+
respect_user_ssh_config = os.getenv("G2G_RESPECT_USER_SSH", "false").lower() in ("true", "1", "yes")
|
46
|
+
|
47
|
+
options = []
|
48
|
+
if not respect_user_ssh_config:
|
49
|
+
options.append("-F /dev/null") # Ignore user SSH config
|
50
|
+
|
51
|
+
if key_path:
|
52
|
+
options.append(f"-i {key_path}")
|
53
|
+
|
54
|
+
if known_hosts_path:
|
55
|
+
options.append(f"-o UserKnownHostsFile={known_hosts_path}")
|
56
|
+
|
57
|
+
if identities_only and not respect_user_ssh_config:
|
58
|
+
options.extend(
|
59
|
+
[
|
60
|
+
"-o IdentitiesOnly=yes", # Critical: prevents SSH agent scanning
|
61
|
+
"-o IdentityAgent=none",
|
62
|
+
]
|
63
|
+
)
|
64
|
+
|
65
|
+
if batch_mode:
|
66
|
+
options.append("-o BatchMode=yes")
|
67
|
+
|
68
|
+
options.extend(
|
69
|
+
[
|
70
|
+
"-o PreferredAuthentications=publickey",
|
71
|
+
"-o PasswordAuthentication=no",
|
72
|
+
"-o PubkeyAcceptedKeyTypes=+ssh-rsa",
|
73
|
+
f"-o ConnectTimeout={connect_timeout}",
|
74
|
+
]
|
75
|
+
)
|
76
|
+
|
77
|
+
if strict_host_checking:
|
78
|
+
options.append("-o StrictHostKeyChecking=yes")
|
79
|
+
|
80
|
+
if additional_options:
|
81
|
+
options.extend(additional_options)
|
82
|
+
|
83
|
+
return options
|
84
|
+
|
85
|
+
|
86
|
+
def build_git_ssh_command(
|
87
|
+
key_path: str | Path | None = None,
|
88
|
+
known_hosts_path: str | Path | None = None,
|
89
|
+
identities_only: bool = True,
|
90
|
+
strict_host_checking: bool = True,
|
91
|
+
batch_mode: bool = True,
|
92
|
+
connect_timeout: int = 10,
|
93
|
+
additional_options: list[str] | None = None,
|
94
|
+
respect_user_ssh_config: bool | None = None,
|
95
|
+
) -> str:
|
96
|
+
"""Build GIT_SSH_COMMAND for secure, isolated SSH configuration.
|
97
|
+
|
98
|
+
This prevents SSH from scanning the user's SSH agent or using
|
99
|
+
unintended keys by setting appropriate SSH options, unless
|
100
|
+
respect_user_ssh_config is True.
|
101
|
+
|
102
|
+
Args:
|
103
|
+
key_path: Path to SSH private key file
|
104
|
+
known_hosts_path: Path to known_hosts file
|
105
|
+
identities_only: Only use specified identities (prevents agent scanning)
|
106
|
+
strict_host_checking: Enable strict host key checking
|
107
|
+
batch_mode: Enable batch mode (non-interactive)
|
108
|
+
connect_timeout: Connection timeout in seconds
|
109
|
+
additional_options: Additional SSH options to include
|
110
|
+
respect_user_ssh_config: If True, respect user SSH config; if None, check G2G_RESPECT_USER_SSH env var
|
111
|
+
|
112
|
+
Returns:
|
113
|
+
Complete SSH command string suitable for GIT_SSH_COMMAND
|
114
|
+
"""
|
115
|
+
ssh_options = build_ssh_options(
|
116
|
+
key_path=key_path,
|
117
|
+
known_hosts_path=known_hosts_path,
|
118
|
+
identities_only=identities_only,
|
119
|
+
strict_host_checking=strict_host_checking,
|
120
|
+
batch_mode=batch_mode,
|
121
|
+
connect_timeout=connect_timeout,
|
122
|
+
additional_options=additional_options,
|
123
|
+
respect_user_ssh_config=respect_user_ssh_config,
|
124
|
+
)
|
125
|
+
|
126
|
+
ssh_cmd = f"ssh {' '.join(ssh_options)}"
|
127
|
+
|
128
|
+
# Log masked version for security
|
129
|
+
if key_path:
|
130
|
+
masked_cmd = ssh_cmd.replace(str(key_path), "[KEY_PATH]")
|
131
|
+
log.debug("Generated SSH command: %s", masked_cmd)
|
132
|
+
else:
|
133
|
+
log.debug("Generated SSH command: %s", ssh_cmd)
|
134
|
+
|
135
|
+
return ssh_cmd
|
136
|
+
|
137
|
+
|
138
|
+
def build_non_interactive_ssh_env() -> dict[str, str]:
|
139
|
+
"""Build environment variables for non-interactive SSH operations.
|
140
|
+
|
141
|
+
This creates an environment that prevents SSH from using agents,
|
142
|
+
asking for passwords, or displaying prompts.
|
143
|
+
|
144
|
+
Returns:
|
145
|
+
Dictionary of environment variables for non-interactive SSH
|
146
|
+
"""
|
147
|
+
return {
|
148
|
+
"SSH_AUTH_SOCK": "",
|
149
|
+
"SSH_AGENT_PID": "",
|
150
|
+
"SSH_ASKPASS": "/usr/bin/false",
|
151
|
+
"DISPLAY": "",
|
152
|
+
"SSH_ASKPASS_REQUIRE": "never",
|
153
|
+
}
|
154
|
+
|
155
|
+
|
156
|
+
def augment_known_hosts_with_bracketed_entries(
|
157
|
+
known_hosts_content: str,
|
158
|
+
hostname: str,
|
159
|
+
port: int = 22,
|
160
|
+
) -> str:
|
161
|
+
"""Augment known_hosts content with bracketed [host]:port entries for non-standard ports.
|
162
|
+
|
163
|
+
This function adds bracketed [host]:port variants for existing plain host entries
|
164
|
+
to satisfy StrictHostKeyChecking with non-standard SSH ports.
|
165
|
+
|
166
|
+
Args:
|
167
|
+
known_hosts_content: Original known_hosts content
|
168
|
+
hostname: Hostname to augment
|
169
|
+
port: SSH port (default 22)
|
170
|
+
|
171
|
+
Returns:
|
172
|
+
Augmented known_hosts content (always normalized to end with single newline)
|
173
|
+
"""
|
174
|
+
if not known_hosts_content.strip():
|
175
|
+
return known_hosts_content
|
176
|
+
|
177
|
+
original_lines = [ln.rstrip() for ln in known_hosts_content.strip().splitlines() if ln.strip()]
|
178
|
+
augmented = list(original_lines)
|
179
|
+
|
180
|
+
# Add bracketed [host]:port variants for non-standard ports if hostname provided
|
181
|
+
if hostname and original_lines:
|
182
|
+
bracket_prefix = f"[{hostname}]:{port} "
|
183
|
+
plain_prefix = f"{hostname} "
|
184
|
+
existing = set(augmented)
|
185
|
+
|
186
|
+
for ln in original_lines:
|
187
|
+
if ln.startswith(plain_prefix):
|
188
|
+
suffix = ln[len(plain_prefix) :]
|
189
|
+
candidate = bracket_prefix + suffix
|
190
|
+
if candidate not in existing:
|
191
|
+
augmented.append(candidate)
|
192
|
+
existing.add(candidate)
|
193
|
+
|
194
|
+
# Always normalize to strip + newline for consistency
|
195
|
+
return "\n".join(augmented).strip() + "\n"
|
196
|
+
|
197
|
+
|
198
|
+
def merge_known_hosts_content(
|
199
|
+
base_content: str,
|
200
|
+
additional_content: str,
|
201
|
+
) -> str:
|
202
|
+
"""Merge additional known_hosts content into base content without duplicates.
|
203
|
+
|
204
|
+
Args:
|
205
|
+
base_content: Original known_hosts content
|
206
|
+
additional_content: Additional content to merge
|
207
|
+
|
208
|
+
Returns:
|
209
|
+
Merged known_hosts content
|
210
|
+
"""
|
211
|
+
if not additional_content or not additional_content.strip():
|
212
|
+
return base_content
|
213
|
+
|
214
|
+
if not base_content or not base_content.strip():
|
215
|
+
return additional_content.strip() + "\n"
|
216
|
+
|
217
|
+
existing_lines = [ln for ln in base_content.splitlines() if ln.strip()]
|
218
|
+
existing_set = set(existing_lines)
|
219
|
+
|
220
|
+
for ln in additional_content.splitlines():
|
221
|
+
s = ln.strip()
|
222
|
+
if s and s not in existing_set:
|
223
|
+
existing_lines.append(s)
|
224
|
+
existing_set.add(s)
|
225
|
+
|
226
|
+
return "\n".join(existing_lines).strip() + "\n"
|
227
|
+
|
228
|
+
|
229
|
+
def augment_known_hosts(
|
230
|
+
known_hosts_path: Path,
|
231
|
+
hostname: str,
|
232
|
+
port: int = 22,
|
233
|
+
) -> None:
|
234
|
+
"""Augment known_hosts file with host key for the given hostname.
|
235
|
+
|
236
|
+
Args:
|
237
|
+
known_hosts_path: Path to known_hosts file
|
238
|
+
hostname: Hostname to add to known_hosts
|
239
|
+
port: SSH port (default 22)
|
240
|
+
"""
|
241
|
+
# This is a placeholder for known hosts augmentation logic
|
242
|
+
# The actual implementation would use ssh-keyscan or similar
|
243
|
+
# to fetch and add host keys to the known_hosts file
|
244
|
+
log.debug("Would augment known_hosts at %s with %s:%d", known_hosts_path, hostname, port)
|