git-ftp 2.0.0.dev0__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.
- git_ftp-2.0.0.dev0.dist-info/METADATA +243 -0
- git_ftp-2.0.0.dev0.dist-info/RECORD +47 -0
- git_ftp-2.0.0.dev0.dist-info/WHEEL +4 -0
- git_ftp-2.0.0.dev0.dist-info/entry_points.txt +2 -0
- git_ftp-2.0.0.dev0.dist-info/licenses/LICENSE +674 -0
- gitftp/__init__.py +5 -0
- gitftp/__main__.py +7 -0
- gitftp/_version.py +24 -0
- gitftp/auth.py +209 -0
- gitftp/changeset.py +86 -0
- gitftp/cli/__init__.py +111 -0
- gitftp/cli/cmd_add_scope.py +23 -0
- gitftp/cli/cmd_catchup.py +19 -0
- gitftp/cli/cmd_download.py +19 -0
- gitftp/cli/cmd_help.py +30 -0
- gitftp/cli/cmd_init.py +19 -0
- gitftp/cli/cmd_log.py +31 -0
- gitftp/cli/cmd_pull.py +19 -0
- gitftp/cli/cmd_push.py +21 -0
- gitftp/cli/cmd_remove_scope.py +23 -0
- gitftp/cli/cmd_show.py +31 -0
- gitftp/cli/cmd_snapshot.py +20 -0
- gitftp/cli/cmd_unlock.py +19 -0
- gitftp/cli/cmd_version.py +19 -0
- gitftp/cli/group.py +68 -0
- gitftp/cli/options.py +208 -0
- gitftp/config.py +170 -0
- gitftp/deploy.py +341 -0
- gitftp/errors.py +86 -0
- gitftp/gitrepo.py +293 -0
- gitftp/hooks.py +30 -0
- gitftp/ignore.py +83 -0
- gitftp/include.py +94 -0
- gitftp/lock.py +94 -0
- gitftp/mirror.py +413 -0
- gitftp/options.py +72 -0
- gitftp/output.py +132 -0
- gitftp/session.py +212 -0
- gitftp/transfer.py +269 -0
- gitftp/transport/__init__.py +1 -0
- gitftp/transport/base.py +93 -0
- gitftp/transport/curlftp.py +389 -0
- gitftp/transport/listing.py +155 -0
- gitftp/transport/registry.py +73 -0
- gitftp/transport/sftp.py +442 -0
- gitftp/url.py +204 -0
- gitftp/version.py +35 -0
gitftp/transport/sftp.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""SFTP through paramiko.
|
|
2
|
+
|
|
3
|
+
Deliberate difference from upstream: host keys are verified against
|
|
4
|
+
``~/.ssh/known_hosts`` and ``/etc/ssh/ssh_known_hosts``; ``--insecure`` skips
|
|
5
|
+
the check. Authentication order: --key, ssh-agent, password.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import errno
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import posixpath
|
|
15
|
+
import socket
|
|
16
|
+
import stat as statmod
|
|
17
|
+
import threading
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from gitftp.auth import Credentials
|
|
23
|
+
from gitftp.errors import DownloadError, MissingArgumentError, UploadError
|
|
24
|
+
from gitftp.output import Output
|
|
25
|
+
from gitftp.transport.base import Entry, ProgressFn, RemoteNotFound, TransferCancelled, Transport
|
|
26
|
+
from gitftp.url import RemoteURL
|
|
27
|
+
|
|
28
|
+
DEFAULT_PORT = 22
|
|
29
|
+
SYSTEM_KNOWN_HOSTS = ["/etc/ssh/ssh_known_hosts"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check_available() -> None:
|
|
33
|
+
try:
|
|
34
|
+
import paramiko # noqa: F401
|
|
35
|
+
except ImportError as e:
|
|
36
|
+
raise DownloadError("paramiko is not available; sftp support is disabled.") from e
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DirCache:
|
|
40
|
+
"""Remembers created directories so parallel workers do not race on mkdir."""
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
self.lock = threading.Lock()
|
|
44
|
+
self.known: set[str] = set()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SftpOptions:
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
insecure: bool = False,
|
|
52
|
+
trace: Callable[[str], None] | None = None,
|
|
53
|
+
known_hosts_files: list[str] | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.insecure = insecure
|
|
56
|
+
self.trace = trace
|
|
57
|
+
self.known_hosts_files = known_hosts_files
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _TraceHandler(logging.Handler):
|
|
61
|
+
def __init__(self, fn: Callable[[str], None]) -> None:
|
|
62
|
+
super().__init__(logging.DEBUG)
|
|
63
|
+
self.fn = fn
|
|
64
|
+
|
|
65
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
66
|
+
self.fn("* " + record.getMessage())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SftpTransport(Transport):
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
url: RemoteURL,
|
|
73
|
+
creds: Credentials,
|
|
74
|
+
options: SftpOptions,
|
|
75
|
+
out: Output,
|
|
76
|
+
dircache: DirCache | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
super().__init__()
|
|
79
|
+
self.url = url
|
|
80
|
+
self.creds = creds
|
|
81
|
+
self.options = options
|
|
82
|
+
self.out = out
|
|
83
|
+
self.dircache = dircache or DirCache()
|
|
84
|
+
self._transport: Any = None
|
|
85
|
+
self._sftp: Any = None
|
|
86
|
+
self._loaded_key: Any = None
|
|
87
|
+
|
|
88
|
+
# -- lifecycle ---------------------------------------------------------
|
|
89
|
+
def open(self) -> None:
|
|
90
|
+
import paramiko
|
|
91
|
+
|
|
92
|
+
host = self.url.hostname
|
|
93
|
+
port = self.url.port or DEFAULT_PORT
|
|
94
|
+
# Load the private key before opening the socket: a locked key with no
|
|
95
|
+
# passphrase should fail fast without a pointless connection (and without
|
|
96
|
+
# leaving a half-open session on the server).
|
|
97
|
+
if self.creds.key:
|
|
98
|
+
self._loaded_key = self._load_private_key()
|
|
99
|
+
if self.options.trace is not None:
|
|
100
|
+
logger = logging.getLogger("paramiko.transport")
|
|
101
|
+
logger.setLevel(logging.DEBUG)
|
|
102
|
+
if not any(isinstance(h, _TraceHandler) for h in logger.handlers):
|
|
103
|
+
logger.addHandler(_TraceHandler(self.options.trace))
|
|
104
|
+
try:
|
|
105
|
+
sock = socket.create_connection((host, port), timeout=30)
|
|
106
|
+
except OSError as e:
|
|
107
|
+
raise DownloadError(
|
|
108
|
+
f"Can't access remote '{self.url.display()}'. Network down? Wrong URL? ({e})"
|
|
109
|
+
) from e
|
|
110
|
+
t = paramiko.Transport(sock)
|
|
111
|
+
expected = None
|
|
112
|
+
if not self.options.insecure:
|
|
113
|
+
expected = self._known_host_keys(host, port)
|
|
114
|
+
if expected:
|
|
115
|
+
opts = t.get_security_options()
|
|
116
|
+
preferred = [k for k in expected if k in opts.key_types]
|
|
117
|
+
opts.key_types = tuple(
|
|
118
|
+
preferred + [k for k in opts.key_types if k not in preferred]
|
|
119
|
+
)
|
|
120
|
+
try:
|
|
121
|
+
t.start_client(timeout=30)
|
|
122
|
+
except (paramiko.SSHException, OSError) as e:
|
|
123
|
+
t.close()
|
|
124
|
+
raise DownloadError(
|
|
125
|
+
f"Can't access remote '{self.url.display()}'. SSH handshake failed ({e})."
|
|
126
|
+
) from e
|
|
127
|
+
if not self.options.insecure:
|
|
128
|
+
self._verify_host_key(t, host, port, expected or {})
|
|
129
|
+
self._transport = t
|
|
130
|
+
try:
|
|
131
|
+
self._authenticate(t)
|
|
132
|
+
except BaseException:
|
|
133
|
+
t.close()
|
|
134
|
+
raise
|
|
135
|
+
sftp = paramiko.SFTPClient.from_transport(t)
|
|
136
|
+
if sftp is None: # pragma: no cover
|
|
137
|
+
t.close()
|
|
138
|
+
raise DownloadError("Could not open the SFTP subsystem.")
|
|
139
|
+
chan = sftp.get_channel()
|
|
140
|
+
if chan is not None:
|
|
141
|
+
chan.settimeout(60)
|
|
142
|
+
self._sftp = sftp
|
|
143
|
+
|
|
144
|
+
def close(self) -> None:
|
|
145
|
+
if self._sftp is not None:
|
|
146
|
+
with contextlib.suppress(Exception):
|
|
147
|
+
self._sftp.close()
|
|
148
|
+
self._sftp = None
|
|
149
|
+
if self._transport is not None:
|
|
150
|
+
with contextlib.suppress(Exception):
|
|
151
|
+
self._transport.close()
|
|
152
|
+
self._transport = None
|
|
153
|
+
|
|
154
|
+
# -- host keys ---------------------------------------------------------
|
|
155
|
+
def _known_hosts_paths(self) -> list[Path]:
|
|
156
|
+
if self.options.known_hosts_files is not None:
|
|
157
|
+
return [Path(p) for p in self.options.known_hosts_files]
|
|
158
|
+
paths = [Path.home() / ".ssh" / "known_hosts"]
|
|
159
|
+
paths += [Path(p) for p in SYSTEM_KNOWN_HOSTS]
|
|
160
|
+
return paths
|
|
161
|
+
|
|
162
|
+
def _known_host_keys(self, host: str, port: int) -> dict[str, Any]:
|
|
163
|
+
import paramiko
|
|
164
|
+
|
|
165
|
+
hostkeys = paramiko.HostKeys()
|
|
166
|
+
for path in self._known_hosts_paths():
|
|
167
|
+
if path.is_file():
|
|
168
|
+
try:
|
|
169
|
+
hostkeys.load(str(path))
|
|
170
|
+
except OSError:
|
|
171
|
+
continue
|
|
172
|
+
name = host if port == DEFAULT_PORT else f"[{host}]:{port}"
|
|
173
|
+
found = hostkeys.lookup(name)
|
|
174
|
+
if found is None and port != DEFAULT_PORT:
|
|
175
|
+
found = None
|
|
176
|
+
return dict(found) if found else {}
|
|
177
|
+
|
|
178
|
+
def _verify_host_key(self, t: Any, host: str, port: int, expected: dict[str, Any]) -> None:
|
|
179
|
+
key = t.get_remote_server_key()
|
|
180
|
+
name = host if port == DEFAULT_PORT else f"[{host}]:{port}"
|
|
181
|
+
hint = f"ssh-keyscan -p {port} {host} >> ~/.ssh/known_hosts"
|
|
182
|
+
if not expected:
|
|
183
|
+
t.close()
|
|
184
|
+
raise DownloadError(
|
|
185
|
+
f"Host key verification failed: '{name}' is not in known_hosts. "
|
|
186
|
+
f"Add it with '{hint}' or pass --insecure."
|
|
187
|
+
)
|
|
188
|
+
known = expected.get(key.get_name())
|
|
189
|
+
if known is None or known.asbytes() != key.asbytes():
|
|
190
|
+
t.close()
|
|
191
|
+
raise DownloadError(
|
|
192
|
+
f"Host key verification failed: the {key.get_name()} key of '{name}' does not "
|
|
193
|
+
"match known_hosts. If the server changed, update known_hosts "
|
|
194
|
+
f"({hint}) or pass --insecure."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# -- auth --------------------------------------------------------------
|
|
198
|
+
def _load_private_key(self) -> Any:
|
|
199
|
+
import paramiko
|
|
200
|
+
|
|
201
|
+
assert self.creds.key
|
|
202
|
+
path = self.creds.key
|
|
203
|
+
passphrase = self.creds.key_passphrase
|
|
204
|
+
classes: list[type[paramiko.PKey]] = [
|
|
205
|
+
paramiko.Ed25519Key,
|
|
206
|
+
paramiko.RSAKey,
|
|
207
|
+
paramiko.ECDSAKey,
|
|
208
|
+
]
|
|
209
|
+
for _attempt in range(2):
|
|
210
|
+
last: Exception | None = None
|
|
211
|
+
for cls in classes:
|
|
212
|
+
try:
|
|
213
|
+
return cls.from_private_key_file(path, password=passphrase or None)
|
|
214
|
+
except paramiko.PasswordRequiredException:
|
|
215
|
+
last = None
|
|
216
|
+
break
|
|
217
|
+
except paramiko.SSHException as e:
|
|
218
|
+
last = e
|
|
219
|
+
else:
|
|
220
|
+
raise DownloadError(f"Could not read private key '{path}': {last}")
|
|
221
|
+
# Needs a passphrase
|
|
222
|
+
if passphrase:
|
|
223
|
+
raise DownloadError(f"Wrong passphrase for private key '{path}'.")
|
|
224
|
+
if not self.out.stdin.isatty() and self.out._stdin is None:
|
|
225
|
+
raise MissingArgumentError(
|
|
226
|
+
f"Private key '{path}' is encrypted; give --key-passphrase."
|
|
227
|
+
)
|
|
228
|
+
passphrase = self.out.prompt_secret(f"Enter passphrase for {path}: ")
|
|
229
|
+
self.out.add_secret(passphrase)
|
|
230
|
+
raise DownloadError(f"Could not read private key '{path}'.")
|
|
231
|
+
|
|
232
|
+
def _authenticate(self, t: Any) -> None:
|
|
233
|
+
import paramiko
|
|
234
|
+
|
|
235
|
+
user = self.creds.user or os.environ.get("USER") or os.environ.get("USERNAME") or ""
|
|
236
|
+
if not user:
|
|
237
|
+
import getpass
|
|
238
|
+
|
|
239
|
+
user = getpass.getuser()
|
|
240
|
+
errors: list[str] = []
|
|
241
|
+
if self.creds.key:
|
|
242
|
+
key = self._loaded_key if self._loaded_key is not None else self._load_private_key()
|
|
243
|
+
try:
|
|
244
|
+
t.auth_publickey(user, key)
|
|
245
|
+
return
|
|
246
|
+
except paramiko.AuthenticationException as e:
|
|
247
|
+
errors.append(f"key: {e}")
|
|
248
|
+
if os.environ.get("SSH_AUTH_SOCK"):
|
|
249
|
+
try:
|
|
250
|
+
agent_keys = paramiko.Agent().get_keys()
|
|
251
|
+
except paramiko.SSHException:
|
|
252
|
+
agent_keys = ()
|
|
253
|
+
for key in agent_keys:
|
|
254
|
+
try:
|
|
255
|
+
t.auth_publickey(user, key)
|
|
256
|
+
return
|
|
257
|
+
except paramiko.AuthenticationException:
|
|
258
|
+
continue
|
|
259
|
+
if agent_keys:
|
|
260
|
+
errors.append("agent: no accepted key")
|
|
261
|
+
if self.creds.password is not None:
|
|
262
|
+
password = self.creds.password
|
|
263
|
+
try:
|
|
264
|
+
t.auth_password(user, password)
|
|
265
|
+
return
|
|
266
|
+
except paramiko.BadAuthenticationType as e:
|
|
267
|
+
errors.append(f"password: {e}")
|
|
268
|
+
except paramiko.AuthenticationException as e:
|
|
269
|
+
errors.append(f"password: {e}")
|
|
270
|
+
try:
|
|
271
|
+
|
|
272
|
+
def handler(_title: str, _instr: str, prompts: Any) -> list[str]:
|
|
273
|
+
return [password for _ in prompts]
|
|
274
|
+
|
|
275
|
+
t.auth_interactive(user, handler)
|
|
276
|
+
return
|
|
277
|
+
except paramiko.AuthenticationException:
|
|
278
|
+
pass
|
|
279
|
+
if not errors:
|
|
280
|
+
raise MissingArgumentError(
|
|
281
|
+
"No SFTP credentials: give a password, a key (--key) or run an ssh-agent."
|
|
282
|
+
)
|
|
283
|
+
raise UploadError(
|
|
284
|
+
f"Can't access remote '{self.url.display()}'. Failed to log in. "
|
|
285
|
+
"Correct user, password or key?"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# -- helpers -----------------------------------------------------------
|
|
289
|
+
def _path(self, rel: str) -> str:
|
|
290
|
+
return self.url.sftp_path(rel)
|
|
291
|
+
|
|
292
|
+
def _check_cancel(self) -> None:
|
|
293
|
+
if self.cancel.is_set():
|
|
294
|
+
raise TransferCancelled()
|
|
295
|
+
|
|
296
|
+
def _callback(self, progress: ProgressFn | None) -> Callable[[int, int], None]:
|
|
297
|
+
def cb(done: int, total: int) -> None:
|
|
298
|
+
if self.cancel.is_set():
|
|
299
|
+
raise TransferCancelled()
|
|
300
|
+
if progress is not None:
|
|
301
|
+
progress(done, total)
|
|
302
|
+
|
|
303
|
+
return cb
|
|
304
|
+
|
|
305
|
+
@staticmethod
|
|
306
|
+
def _is_enoent(e: OSError) -> bool:
|
|
307
|
+
return isinstance(e, FileNotFoundError) or e.errno == errno.ENOENT
|
|
308
|
+
|
|
309
|
+
# -- operations --------------------------------------------------------
|
|
310
|
+
def get(self, path: str) -> bytes:
|
|
311
|
+
try:
|
|
312
|
+
with self._sftp.open(self._path(path), "rb") as fh:
|
|
313
|
+
data: bytes = fh.read()
|
|
314
|
+
return data
|
|
315
|
+
except OSError as e:
|
|
316
|
+
if self._is_enoent(e):
|
|
317
|
+
raise RemoteNotFound(path) from e
|
|
318
|
+
raise DownloadError(f"Could not read '{path}': {e}") from e
|
|
319
|
+
|
|
320
|
+
def get_file(self, path: str, local: Path, *, progress: ProgressFn | None = None) -> None:
|
|
321
|
+
try:
|
|
322
|
+
with open(local, "wb") as fh:
|
|
323
|
+
self._sftp.getfo(self._path(path), fh, callback=self._callback(progress))
|
|
324
|
+
except OSError as e:
|
|
325
|
+
if self._is_enoent(e):
|
|
326
|
+
raise RemoteNotFound(path) from e
|
|
327
|
+
raise DownloadError(f"Could not download '{path}': {e}") from e
|
|
328
|
+
|
|
329
|
+
def put(
|
|
330
|
+
self, local: Path, remote: str, size: int, *, progress: ProgressFn | None = None
|
|
331
|
+
) -> None:
|
|
332
|
+
self.mkdir_p(posixpath.dirname(remote))
|
|
333
|
+
try:
|
|
334
|
+
with open(local, "rb") as fh:
|
|
335
|
+
self._sftp.putfo(
|
|
336
|
+
fh,
|
|
337
|
+
self._path(remote),
|
|
338
|
+
file_size=size,
|
|
339
|
+
callback=self._callback(progress),
|
|
340
|
+
confirm=False,
|
|
341
|
+
)
|
|
342
|
+
except OSError as e:
|
|
343
|
+
raise UploadError(f"Could not upload '{remote}': {e}") from e
|
|
344
|
+
|
|
345
|
+
def put_bytes(self, data: bytes, remote: str) -> None:
|
|
346
|
+
import io
|
|
347
|
+
|
|
348
|
+
self.mkdir_p(posixpath.dirname(remote))
|
|
349
|
+
try:
|
|
350
|
+
self._sftp.putfo(
|
|
351
|
+
io.BytesIO(data), self._path(remote), file_size=len(data), confirm=False
|
|
352
|
+
)
|
|
353
|
+
except OSError as e:
|
|
354
|
+
raise UploadError(f"Could not upload '{remote}': {e}") from e
|
|
355
|
+
|
|
356
|
+
def delete(self, path: str) -> None:
|
|
357
|
+
try:
|
|
358
|
+
self._sftp.remove(self._path(path))
|
|
359
|
+
except OSError as e:
|
|
360
|
+
if self._is_enoent(e):
|
|
361
|
+
return
|
|
362
|
+
raise UploadError(f"Could not delete '{path}': {e}") from e
|
|
363
|
+
|
|
364
|
+
def mkdir_p(self, directory: str) -> None:
|
|
365
|
+
base = self.url.sftp_path("")
|
|
366
|
+
base = "" if base == "." else base
|
|
367
|
+
full = posixpath.normpath(posixpath.join(base, directory.strip("/")))
|
|
368
|
+
if full in (".", "", "/"):
|
|
369
|
+
return
|
|
370
|
+
lead = "/" if full.startswith("/") else ""
|
|
371
|
+
acc = ""
|
|
372
|
+
for part in [p for p in full.split("/") if p]:
|
|
373
|
+
acc = f"{acc}/{part}" if acc else lead + part
|
|
374
|
+
with self.dircache.lock:
|
|
375
|
+
if acc in self.dircache.known:
|
|
376
|
+
continue
|
|
377
|
+
try:
|
|
378
|
+
st = self._sftp.stat(acc)
|
|
379
|
+
if not statmod.S_ISDIR(st.st_mode or 0):
|
|
380
|
+
raise UploadError(f"'{acc}' exists on the remote but is not a directory.")
|
|
381
|
+
except OSError as e:
|
|
382
|
+
if not self._is_enoent(e):
|
|
383
|
+
raise UploadError(f"Could not stat '{acc}': {e}") from e
|
|
384
|
+
try:
|
|
385
|
+
self._sftp.mkdir(acc)
|
|
386
|
+
except OSError as e2:
|
|
387
|
+
try:
|
|
388
|
+
self._sftp.stat(acc)
|
|
389
|
+
except OSError:
|
|
390
|
+
raise UploadError(f"Could not create directory '{acc}': {e2}") from e2
|
|
391
|
+
self.dircache.known.add(acc)
|
|
392
|
+
|
|
393
|
+
def stat(self, path: str) -> Entry | None:
|
|
394
|
+
try:
|
|
395
|
+
st = self._sftp.stat(self._path(path))
|
|
396
|
+
except OSError as e:
|
|
397
|
+
if self._is_enoent(e):
|
|
398
|
+
return None
|
|
399
|
+
raise DownloadError(f"Could not stat '{path}': {e}") from e
|
|
400
|
+
return Entry(
|
|
401
|
+
name=posixpath.basename(path),
|
|
402
|
+
is_dir=statmod.S_ISDIR(st.st_mode or 0),
|
|
403
|
+
size=st.st_size,
|
|
404
|
+
mtime=int(st.st_mtime) if st.st_mtime is not None else None,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
def exists(self, path: str) -> bool:
|
|
408
|
+
return self.stat(path) is not None
|
|
409
|
+
|
|
410
|
+
def list_dir(self, path: str) -> list[Entry]:
|
|
411
|
+
target = self._path(path.strip("/") + "/" if path.strip("/") else "")
|
|
412
|
+
if target.endswith("/") and len(target) > 1:
|
|
413
|
+
target = target.rstrip("/")
|
|
414
|
+
try:
|
|
415
|
+
attrs = self._sftp.listdir_attr(target)
|
|
416
|
+
except OSError as e:
|
|
417
|
+
if self._is_enoent(e):
|
|
418
|
+
raise RemoteNotFound(path) from e
|
|
419
|
+
raise DownloadError(f"Could not list '{path}': {e}") from e
|
|
420
|
+
entries = []
|
|
421
|
+
for a in attrs:
|
|
422
|
+
if a.filename in (".", ".."):
|
|
423
|
+
continue
|
|
424
|
+
mode = a.st_mode or 0
|
|
425
|
+
is_link = statmod.S_ISLNK(mode)
|
|
426
|
+
is_dir = statmod.S_ISDIR(mode)
|
|
427
|
+
if is_link:
|
|
428
|
+
child = posixpath.join(target, a.filename) if target != "." else a.filename
|
|
429
|
+
try:
|
|
430
|
+
is_dir = statmod.S_ISDIR(self._sftp.stat(child).st_mode or 0)
|
|
431
|
+
except OSError:
|
|
432
|
+
is_dir = False
|
|
433
|
+
entries.append(
|
|
434
|
+
Entry(
|
|
435
|
+
name=a.filename,
|
|
436
|
+
is_dir=is_dir,
|
|
437
|
+
size=a.st_size,
|
|
438
|
+
mtime=int(a.st_mtime) if a.st_mtime is not None else None,
|
|
439
|
+
is_link=is_link,
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
return entries
|
gitftp/url.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Remote URL parsing and rendering.
|
|
2
|
+
|
|
3
|
+
Fixes over upstream: ``user:pass@host`` userinfo is credentials (upstream took
|
|
4
|
+
``user`` for the host), ``host:2121/path`` without a scheme is accepted as
|
|
5
|
+
ftp (the built-in help promised it), and ``--remote-root`` replaces the path.
|
|
6
|
+
Credentials never appear in any rendered URL except :meth:`RemoteURL.display`,
|
|
7
|
+
which masks the password.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import enum
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from urllib.parse import quote, unquote
|
|
16
|
+
|
|
17
|
+
from gitftp.errors import MissingArgumentError, UnknownProtocolError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Scheme(str, enum.Enum):
|
|
21
|
+
FTP = "ftp"
|
|
22
|
+
FTPS = "ftps" # implicit TLS
|
|
23
|
+
FTPES = "ftpes" # explicit TLS (AUTH TLS)
|
|
24
|
+
SFTP = "sftp"
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def secure(self) -> bool:
|
|
28
|
+
return self in (Scheme.FTPS, Scheme.FTPES)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def curl_scheme(self) -> str:
|
|
32
|
+
"""libcurl has no ``ftpes``; explicit TLS is ``ftp://`` plus CURLOPT_USE_SSL."""
|
|
33
|
+
return "ftp" if self is Scheme.FTPES else self.value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
DEFAULT_SCHEME = Scheme.FTP
|
|
37
|
+
_SCHEME_RE = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*)://(.*)$", re.DOTALL)
|
|
38
|
+
_PORT_HOST_RE = re.compile(r"^[^/:]+:\d+$")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class RemoteURL:
|
|
43
|
+
scheme: Scheme
|
|
44
|
+
host: str # hostname, with ``:port`` when given
|
|
45
|
+
path: str = "" # "" or "dir/sub/" (no leading slash, exactly one trailing slash)
|
|
46
|
+
absolute: bool = False # the raw path began with a second '/' (``host//var/www``)
|
|
47
|
+
user: str | None = None
|
|
48
|
+
password: str | None = None
|
|
49
|
+
|
|
50
|
+
# -- normalisation -----------------------------------------------------
|
|
51
|
+
def set_path(self, raw: str) -> None:
|
|
52
|
+
raw = raw.replace("\\", "/")
|
|
53
|
+
self.absolute = raw.startswith("/")
|
|
54
|
+
stripped = raw.strip("/")
|
|
55
|
+
self.path = f"{stripped}/" if stripped else ""
|
|
56
|
+
|
|
57
|
+
def child(self, rel_dir: str) -> RemoteURL:
|
|
58
|
+
"""The URL of a subdirectory (used for submodules)."""
|
|
59
|
+
rel = rel_dir.strip("/")
|
|
60
|
+
return RemoteURL(
|
|
61
|
+
scheme=self.scheme,
|
|
62
|
+
host=self.host,
|
|
63
|
+
path=f"{self.path}{rel}/" if rel else self.path,
|
|
64
|
+
absolute=self.absolute,
|
|
65
|
+
user=self.user,
|
|
66
|
+
password=self.password,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def hostname(self) -> str:
|
|
71
|
+
host, _ = _split_host_port(self.host)
|
|
72
|
+
return host
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def port(self) -> int | None:
|
|
76
|
+
_, port = _split_host_port(self.host)
|
|
77
|
+
return port
|
|
78
|
+
|
|
79
|
+
# -- rendering ---------------------------------------------------------
|
|
80
|
+
def display(self) -> str:
|
|
81
|
+
"""For messages and hooks: ``ftp://user:***@host/path/``."""
|
|
82
|
+
cred = f"{self.user}:***@" if self.user else ""
|
|
83
|
+
lead = "/" if self.absolute else ""
|
|
84
|
+
return f"{self.scheme.value}://{cred}{self.host}/{lead}{self.path}"
|
|
85
|
+
|
|
86
|
+
def name(self) -> str:
|
|
87
|
+
"""``host/path/`` as used in "No changed files for ..." messages."""
|
|
88
|
+
lead = "/" if self.absolute else ""
|
|
89
|
+
return f"{self.host}/{lead}{self.path}"
|
|
90
|
+
|
|
91
|
+
def curl_base(self) -> str:
|
|
92
|
+
return f"{self.scheme.curl_scheme}://{self.host}"
|
|
93
|
+
|
|
94
|
+
def _curl_path(self, rel: str) -> str:
|
|
95
|
+
lead = "%2F" if self.absolute else ""
|
|
96
|
+
return lead + escape_path(self.path + rel)
|
|
97
|
+
|
|
98
|
+
def curl_file_url(self, rel: str) -> str:
|
|
99
|
+
return f"{self.curl_base()}/{self._curl_path(rel)}"
|
|
100
|
+
|
|
101
|
+
def curl_dir_url(self, rel_dir: str = "") -> str:
|
|
102
|
+
rel = rel_dir.strip("/")
|
|
103
|
+
if rel:
|
|
104
|
+
rel += "/"
|
|
105
|
+
return f"{self.curl_base()}/{self._curl_path(rel)}"
|
|
106
|
+
|
|
107
|
+
def sftp_path(self, rel: str = "") -> str:
|
|
108
|
+
"""Path for the SFTP subsystem: absolute, ``~/``-relative or login-dir relative."""
|
|
109
|
+
base = self.path
|
|
110
|
+
if base.startswith("~/"):
|
|
111
|
+
base = base[2:]
|
|
112
|
+
lead = "/" if self.absolute else ""
|
|
113
|
+
p = f"{lead}{base}{rel}"
|
|
114
|
+
return p if p else "."
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _split_host_port(host: str) -> tuple[str, int | None]:
|
|
118
|
+
if host.startswith("["): # [v6]:port
|
|
119
|
+
end = host.find("]")
|
|
120
|
+
if end != -1:
|
|
121
|
+
rest = host[end + 1 :]
|
|
122
|
+
if rest.startswith(":") and rest[1:].isdigit():
|
|
123
|
+
return host[1:end], int(rest[1:])
|
|
124
|
+
return host[1:end], None
|
|
125
|
+
if host.count(":") == 1:
|
|
126
|
+
name, _, port = host.partition(":")
|
|
127
|
+
if port.isdigit():
|
|
128
|
+
return name, int(port)
|
|
129
|
+
return host, None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_SAFE_PATH = frozenset(
|
|
133
|
+
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$&'()*+,;=:@/"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def escape_path(path: str) -> str:
|
|
138
|
+
"""Percent-encode a remote path for a URL; ``/`` is kept.
|
|
139
|
+
|
|
140
|
+
Encodes everything libcurl would otherwise misread: control bytes, space,
|
|
141
|
+
non-ASCII, and ``" # % < > ? [ ] ^ ` { | } \\``.
|
|
142
|
+
"""
|
|
143
|
+
raw = path.encode("utf-8", "surrogateescape")
|
|
144
|
+
out = []
|
|
145
|
+
for b in raw:
|
|
146
|
+
if b in _SAFE_PATH:
|
|
147
|
+
out.append(chr(b))
|
|
148
|
+
else:
|
|
149
|
+
out.append(f"%{b:02X}")
|
|
150
|
+
return "".join(out)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def escape_userinfo(s: str) -> str:
|
|
154
|
+
return quote(s, safe="")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def unescape(s: str) -> str:
|
|
158
|
+
return unquote(s, errors="surrogateescape")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def parse(raw: str) -> RemoteURL:
|
|
162
|
+
"""Parse ``[scheme://][user[:password]@]host[:port][/path]``."""
|
|
163
|
+
raw = raw.strip()
|
|
164
|
+
if not raw:
|
|
165
|
+
raise MissingArgumentError("Remote host not set.")
|
|
166
|
+
|
|
167
|
+
m = _SCHEME_RE.match(raw)
|
|
168
|
+
if m:
|
|
169
|
+
scheme_name, rest = m.group(1).lower(), m.group(2)
|
|
170
|
+
try:
|
|
171
|
+
scheme = Scheme(scheme_name)
|
|
172
|
+
except ValueError:
|
|
173
|
+
raise UnknownProtocolError(f"Protocol unknown '{scheme_name}://'.") from None
|
|
174
|
+
else:
|
|
175
|
+
rest = raw
|
|
176
|
+
head = rest.split("/", 1)[0]
|
|
177
|
+
# "ftp:host" (one slash missing) or "foo:bar" is not a host:port form.
|
|
178
|
+
if (
|
|
179
|
+
":" in head
|
|
180
|
+
and "@" not in head
|
|
181
|
+
and not _PORT_HOST_RE.match(head)
|
|
182
|
+
and re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", head)
|
|
183
|
+
and not head.split(":", 1)[1].isdigit()
|
|
184
|
+
):
|
|
185
|
+
name = head.split(":", 1)[0]
|
|
186
|
+
raise UnknownProtocolError(f"Protocol unknown '{name}:'.")
|
|
187
|
+
scheme = DEFAULT_SCHEME
|
|
188
|
+
|
|
189
|
+
# userinfo: split at the LAST '@' of the authority part (passwords may contain '@').
|
|
190
|
+
authority, sep, path = rest.partition("/")
|
|
191
|
+
user: str | None = None
|
|
192
|
+
password: str | None = None
|
|
193
|
+
if "@" in authority:
|
|
194
|
+
userinfo, _, hostport = authority.rpartition("@")
|
|
195
|
+
authority = hostport
|
|
196
|
+
u, has_pw, p = userinfo.partition(":")
|
|
197
|
+
user = unescape(u)
|
|
198
|
+
password = unescape(p) if has_pw else None
|
|
199
|
+
if not authority:
|
|
200
|
+
raise MissingArgumentError("Remote host not set.")
|
|
201
|
+
|
|
202
|
+
url = RemoteURL(scheme=scheme, host=authority, user=user, password=password)
|
|
203
|
+
url.set_path(path if sep else "")
|
|
204
|
+
return url
|
gitftp/version.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Version information."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["__version__", "runtime_info", "version_line"]
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from gitftp._version import __version__
|
|
9
|
+
except ImportError: # pragma: no cover - only in an unbuilt checkout
|
|
10
|
+
__version__ = "0.0.0+unknown"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def version_line() -> str:
|
|
14
|
+
"""The line printed by ``git-ftp --version``, in upstream's format."""
|
|
15
|
+
return f"git-ftp version {__version__}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def runtime_info() -> list[str]:
|
|
19
|
+
"""Which transports and libraries this installation has."""
|
|
20
|
+
lines: list[str] = []
|
|
21
|
+
try:
|
|
22
|
+
import pycurl
|
|
23
|
+
|
|
24
|
+
info = pycurl.version_info()
|
|
25
|
+
protocols = " ".join(p for p in info[8] if p in ("ftp", "ftps"))
|
|
26
|
+
lines.append(f"libcurl {info[1]} ({info[5] or 'no TLS'}), protocols: {protocols}")
|
|
27
|
+
except ImportError:
|
|
28
|
+
lines.append("libcurl: pycurl not available (ftp, ftps and ftpes disabled)")
|
|
29
|
+
try:
|
|
30
|
+
import paramiko
|
|
31
|
+
|
|
32
|
+
lines.append(f"paramiko {paramiko.__version__} (sftp)")
|
|
33
|
+
except ImportError:
|
|
34
|
+
lines.append("paramiko: not available (sftp disabled)")
|
|
35
|
+
return lines
|