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
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""FTP, FTPS (implicit TLS) and FTPES (explicit TLS) through libcurl (pycurl).
|
|
2
|
+
|
|
3
|
+
One easy handle per instance is reused for every operation; libcurl keeps the
|
|
4
|
+
control connection in its cache, so each worker logs in once. Credentials are
|
|
5
|
+
passed through CURLOPT_USERNAME/PASSWORD, never inside the URL.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
import posixpath
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from gitftp.auth import Credentials
|
|
17
|
+
from gitftp.errors import DownloadError, UploadError
|
|
18
|
+
from gitftp.output import Output
|
|
19
|
+
from gitftp.transport import listing
|
|
20
|
+
from gitftp.transport.base import Entry, ProgressFn, RemoteNotFound, Transport
|
|
21
|
+
from gitftp.url import RemoteURL, Scheme
|
|
22
|
+
|
|
23
|
+
# libcurl error codes we interpret
|
|
24
|
+
E_UNSUPPORTED_PROTOCOL = 1
|
|
25
|
+
E_COULDNT_RESOLVE_PROXY = 5
|
|
26
|
+
E_COULDNT_RESOLVE_HOST = 6
|
|
27
|
+
E_COULDNT_CONNECT = 7
|
|
28
|
+
E_REMOTE_ACCESS_DENIED = 9
|
|
29
|
+
E_FTP_COULDNT_RETR_FILE = 19
|
|
30
|
+
E_QUOTE_ERROR = 21
|
|
31
|
+
E_UPLOAD_FAILED = 25
|
|
32
|
+
E_OPERATION_TIMEDOUT = 28
|
|
33
|
+
E_SSL_CONNECT_ERROR = 35
|
|
34
|
+
E_ABORTED_BY_CALLBACK = 42
|
|
35
|
+
E_PEER_FAILED_VERIFICATION = 60
|
|
36
|
+
E_USE_SSL_FAILED = 64
|
|
37
|
+
E_LOGIN_DENIED = 67
|
|
38
|
+
E_REMOTE_FILE_NOT_FOUND = 78
|
|
39
|
+
E_SSL_CACERT_BADFILE = 77
|
|
40
|
+
E_SSL_PEER_CERTIFICATE = 51
|
|
41
|
+
|
|
42
|
+
NOT_FOUND_CODES = frozenset(
|
|
43
|
+
{E_REMOTE_ACCESS_DENIED, E_FTP_COULDNT_RETR_FILE, E_REMOTE_FILE_NOT_FOUND}
|
|
44
|
+
)
|
|
45
|
+
TLS_CODES = frozenset(
|
|
46
|
+
{E_SSL_CONNECT_ERROR, E_PEER_FAILED_VERIFICATION, E_SSL_CACERT_BADFILE, E_SSL_PEER_CERTIFICATE}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CurlOptions:
|
|
51
|
+
"""Transport settings coming from the command line and config."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
insecure: bool = False,
|
|
57
|
+
cacert: str | None = None,
|
|
58
|
+
active: bool = False,
|
|
59
|
+
disable_epsv: bool = False,
|
|
60
|
+
proxy: str | None = None,
|
|
61
|
+
trace: Callable[[str], None] | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
self.insecure = insecure
|
|
64
|
+
self.cacert = cacert
|
|
65
|
+
self.active = active
|
|
66
|
+
self.disable_epsv = disable_epsv
|
|
67
|
+
self.proxy = proxy
|
|
68
|
+
self.trace = trace
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check_available(scheme: Scheme) -> None:
|
|
72
|
+
try:
|
|
73
|
+
import pycurl
|
|
74
|
+
except ImportError as e:
|
|
75
|
+
raise DownloadError(
|
|
76
|
+
"pycurl is not available; install git-ftp with its wheels or the libcurl "
|
|
77
|
+
"development headers."
|
|
78
|
+
) from e
|
|
79
|
+
info = pycurl.version_info()
|
|
80
|
+
protocols = set(info[8])
|
|
81
|
+
if "ftp" not in protocols:
|
|
82
|
+
raise DownloadError("Protocol 'ftp' is not supported by the installed libcurl.")
|
|
83
|
+
if scheme.secure and ("ftps" not in protocols or not info[5]):
|
|
84
|
+
raise DownloadError(
|
|
85
|
+
f"Protocol '{scheme.value}' is not supported by the installed libcurl (no TLS)."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def describe_error(code: int, message: str, url: RemoteURL) -> str:
|
|
90
|
+
"""Upstream's ``check_curl_exit_status`` texts, with a few TLS hints added."""
|
|
91
|
+
display = url.display()
|
|
92
|
+
if code == E_REMOTE_ACCESS_DENIED:
|
|
93
|
+
return (
|
|
94
|
+
"Access to resource denied. This usually means that the file or directory "
|
|
95
|
+
"does not exist. Wrong path?"
|
|
96
|
+
)
|
|
97
|
+
if code == E_LOGIN_DENIED:
|
|
98
|
+
return f"Can't access remote '{display}'. Failed to log in. Correct user and password?"
|
|
99
|
+
if code == E_REMOTE_FILE_NOT_FOUND:
|
|
100
|
+
return "The resource does not exist."
|
|
101
|
+
if code in TLS_CODES:
|
|
102
|
+
return (
|
|
103
|
+
f"Can't access remote '{display}'. TLS verification failed ({message}). "
|
|
104
|
+
"Use --cacert to trust the server certificate or --insecure to skip verification."
|
|
105
|
+
)
|
|
106
|
+
if code == E_USE_SSL_FAILED:
|
|
107
|
+
return f"Can't access remote '{display}'. The server refused TLS ({message})."
|
|
108
|
+
return f"Can't access remote '{display}'. Network down? Wrong URL? ({message})"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _discard(_data: bytes) -> None:
|
|
112
|
+
"""Header sink: without it libcurl prints FTP pseudo-headers to stdout."""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _discard_body(data: bytes) -> int:
|
|
116
|
+
"""Body sink for NOBODY requests (SIZE/MDTM replies are delivered as body too)."""
|
|
117
|
+
return len(data)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class CurlFtpTransport(Transport):
|
|
121
|
+
def __init__(
|
|
122
|
+
self, url: RemoteURL, creds: Credentials, options: CurlOptions, out: Output
|
|
123
|
+
) -> None:
|
|
124
|
+
super().__init__()
|
|
125
|
+
self.url = url
|
|
126
|
+
self.creds = creds
|
|
127
|
+
self.options = options
|
|
128
|
+
self.out = out
|
|
129
|
+
self._curl: Any = None
|
|
130
|
+
self._mlsd_supported = True
|
|
131
|
+
self._progress: ProgressFn | None = None
|
|
132
|
+
|
|
133
|
+
# -- lifecycle ---------------------------------------------------------
|
|
134
|
+
def open(self) -> None:
|
|
135
|
+
import pycurl
|
|
136
|
+
|
|
137
|
+
self._pycurl = pycurl
|
|
138
|
+
self._curl = pycurl.Curl()
|
|
139
|
+
|
|
140
|
+
def close(self) -> None:
|
|
141
|
+
if self._curl is not None:
|
|
142
|
+
self._curl.close()
|
|
143
|
+
self._curl = None
|
|
144
|
+
|
|
145
|
+
# -- callbacks ---------------------------------------------------------
|
|
146
|
+
def _xferinfo(self, dl_total: int, dl_now: int, ul_total: int, ul_now: int) -> int:
|
|
147
|
+
if self.cancel.is_set():
|
|
148
|
+
return 1
|
|
149
|
+
if self._progress is not None:
|
|
150
|
+
if ul_total or ul_now:
|
|
151
|
+
self._progress(ul_now, ul_total)
|
|
152
|
+
else:
|
|
153
|
+
self._progress(dl_now, dl_total)
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
def _debug(self, kind: int, data: bytes) -> None:
|
|
157
|
+
if self.options.trace is None or kind > 2:
|
|
158
|
+
return
|
|
159
|
+
prefix = {0: "* ", 1: "< ", 2: "> "}[kind]
|
|
160
|
+
text = data.decode("utf-8", "replace").rstrip("\r\n")
|
|
161
|
+
for line in text.split("\n"):
|
|
162
|
+
self.options.trace(prefix + line.rstrip("\r"))
|
|
163
|
+
|
|
164
|
+
# -- request setup -----------------------------------------------------
|
|
165
|
+
def _prepare(self, target: str) -> Any:
|
|
166
|
+
pc = self._pycurl
|
|
167
|
+
c = self._curl
|
|
168
|
+
c.reset()
|
|
169
|
+
c.setopt(pc.URL, target)
|
|
170
|
+
if hasattr(pc, "PROTOCOLS_STR"):
|
|
171
|
+
c.setopt(pc.PROTOCOLS_STR, "ftp,ftps")
|
|
172
|
+
else: # pragma: no cover - older libcurl
|
|
173
|
+
c.setopt(pc.PROTOCOLS, pc.PROTO_FTP | pc.PROTO_FTPS)
|
|
174
|
+
if self.creds.user:
|
|
175
|
+
c.setopt(pc.USERNAME, self.creds.user.encode("utf-8", "surrogateescape"))
|
|
176
|
+
if self.creds.password is not None:
|
|
177
|
+
c.setopt(pc.PASSWORD, self.creds.password.encode("utf-8", "surrogateescape"))
|
|
178
|
+
c.setopt(pc.NETRC, pc.NETRC_IGNORED)
|
|
179
|
+
else:
|
|
180
|
+
c.setopt(pc.NETRC, pc.NETRC_OPTIONAL)
|
|
181
|
+
c.setopt(pc.NOSIGNAL, 1)
|
|
182
|
+
c.setopt(pc.TCP_KEEPALIVE, 1)
|
|
183
|
+
c.setopt(pc.CONNECTTIMEOUT_MS, 30000)
|
|
184
|
+
c.setopt(pc.LOW_SPEED_LIMIT, 1)
|
|
185
|
+
c.setopt(pc.LOW_SPEED_TIME, 60)
|
|
186
|
+
c.setopt(pc.FTP_RESPONSE_TIMEOUT, 60)
|
|
187
|
+
c.setopt(pc.FTP_SKIP_PASV_IP, 1)
|
|
188
|
+
if self.options.active:
|
|
189
|
+
c.setopt(pc.FTPPORT, "-")
|
|
190
|
+
elif self.options.disable_epsv:
|
|
191
|
+
c.setopt(pc.FTP_USE_EPSV, 0)
|
|
192
|
+
if self.options.proxy:
|
|
193
|
+
c.setopt(pc.PROXY, self.options.proxy)
|
|
194
|
+
if self.url.scheme.secure:
|
|
195
|
+
c.setopt(pc.USE_SSL, pc.USESSL_ALL)
|
|
196
|
+
c.setopt(pc.SSLVERSION, pc.SSLVERSION_TLSv1_2)
|
|
197
|
+
if self.options.insecure:
|
|
198
|
+
c.setopt(pc.SSL_VERIFYPEER, 0)
|
|
199
|
+
c.setopt(pc.SSL_VERIFYHOST, 0)
|
|
200
|
+
if self.options.cacert:
|
|
201
|
+
c.setopt(pc.CAINFO, self.options.cacert)
|
|
202
|
+
c.setopt(pc.HEADERFUNCTION, _discard)
|
|
203
|
+
c.setopt(pc.WRITEFUNCTION, _discard_body)
|
|
204
|
+
c.setopt(pc.NOPROGRESS, 0)
|
|
205
|
+
c.setopt(pc.XFERINFOFUNCTION, self._xferinfo)
|
|
206
|
+
if self.options.trace is not None:
|
|
207
|
+
c.setopt(pc.VERBOSE, 1)
|
|
208
|
+
c.setopt(pc.DEBUGFUNCTION, self._debug)
|
|
209
|
+
return c
|
|
210
|
+
|
|
211
|
+
def _perform(self, c: Any) -> None:
|
|
212
|
+
"""Perform, re-raising libcurl errors as :class:`CurlError`."""
|
|
213
|
+
try:
|
|
214
|
+
c.perform()
|
|
215
|
+
except self._pycurl.error as e:
|
|
216
|
+
args: tuple[Any, ...] = tuple(e.args)
|
|
217
|
+
code = int(args[0]) if args else 0
|
|
218
|
+
message = str(args[1]) if len(args) > 1 else ""
|
|
219
|
+
raise CurlError(code, message) from None
|
|
220
|
+
|
|
221
|
+
def _file_url(self, path: str) -> str:
|
|
222
|
+
return self.url.curl_file_url(path)
|
|
223
|
+
|
|
224
|
+
# -- operations --------------------------------------------------------
|
|
225
|
+
def get(self, path: str) -> bytes:
|
|
226
|
+
buf = io.BytesIO()
|
|
227
|
+
c = self._prepare(self._file_url(path))
|
|
228
|
+
c.setopt(self._pycurl.WRITEDATA, buf)
|
|
229
|
+
try:
|
|
230
|
+
self._perform(c)
|
|
231
|
+
except CurlError as e:
|
|
232
|
+
if e.code in NOT_FOUND_CODES:
|
|
233
|
+
raise RemoteNotFound(path) from e
|
|
234
|
+
raise DownloadError(describe_error(e.code, e.message, self.url)) from e
|
|
235
|
+
return buf.getvalue()
|
|
236
|
+
|
|
237
|
+
def get_file(self, path: str, local: Path, *, progress: ProgressFn | None = None) -> None:
|
|
238
|
+
self._progress = progress
|
|
239
|
+
try:
|
|
240
|
+
with open(local, "wb") as fh:
|
|
241
|
+
c = self._prepare(self._file_url(path))
|
|
242
|
+
c.setopt(self._pycurl.WRITEDATA, fh)
|
|
243
|
+
try:
|
|
244
|
+
self._perform(c)
|
|
245
|
+
except CurlError as e:
|
|
246
|
+
if e.code in NOT_FOUND_CODES:
|
|
247
|
+
raise RemoteNotFound(path) from e
|
|
248
|
+
raise DownloadError(describe_error(e.code, e.message, self.url)) from e
|
|
249
|
+
finally:
|
|
250
|
+
self._progress = None
|
|
251
|
+
|
|
252
|
+
def _upload(self, reader: Any, size: int, remote: str) -> None:
|
|
253
|
+
pc = self._pycurl
|
|
254
|
+
c = self._prepare(self._file_url(remote))
|
|
255
|
+
c.setopt(pc.UPLOAD, 1)
|
|
256
|
+
c.setopt(pc.READDATA, reader)
|
|
257
|
+
c.setopt(pc.INFILESIZE_LARGE, size)
|
|
258
|
+
c.setopt(pc.FTP_CREATE_MISSING_DIRS, 2)
|
|
259
|
+
try:
|
|
260
|
+
self._perform(c)
|
|
261
|
+
except CurlError as e:
|
|
262
|
+
raise UploadError(describe_error(e.code, e.message, self.url)) from e
|
|
263
|
+
|
|
264
|
+
def put(
|
|
265
|
+
self, local: Path, remote: str, size: int, *, progress: ProgressFn | None = None
|
|
266
|
+
) -> None:
|
|
267
|
+
self._progress = progress
|
|
268
|
+
try:
|
|
269
|
+
with open(local, "rb") as fh:
|
|
270
|
+
self._upload(fh, size, remote)
|
|
271
|
+
finally:
|
|
272
|
+
self._progress = None
|
|
273
|
+
|
|
274
|
+
def put_bytes(self, data: bytes, remote: str) -> None:
|
|
275
|
+
self._upload(io.BytesIO(data), len(data), remote)
|
|
276
|
+
|
|
277
|
+
def delete(self, path: str) -> None:
|
|
278
|
+
pc = self._pycurl
|
|
279
|
+
directory, name = posixpath.split(path)
|
|
280
|
+
c = self._prepare(self.url.curl_dir_url(directory))
|
|
281
|
+
c.setopt(pc.NOBODY, 1)
|
|
282
|
+
c.setopt(pc.POSTQUOTE, [b"DELE " + name.encode("utf-8", "surrogateescape")])
|
|
283
|
+
try:
|
|
284
|
+
self._perform(c)
|
|
285
|
+
except CurlError as e:
|
|
286
|
+
if e.code == E_REMOTE_ACCESS_DENIED:
|
|
287
|
+
return # the directory is gone, so is the file
|
|
288
|
+
if e.code == E_QUOTE_ERROR and not self.exists(path):
|
|
289
|
+
return
|
|
290
|
+
raise UploadError(f"Could not delete '{path}': {e.message}") from e
|
|
291
|
+
|
|
292
|
+
def mkdir_p(self, directory: str) -> None:
|
|
293
|
+
pc = self._pycurl
|
|
294
|
+
c = self._prepare(self.url.curl_dir_url(directory))
|
|
295
|
+
c.setopt(pc.NOBODY, 1)
|
|
296
|
+
c.setopt(pc.FTP_CREATE_MISSING_DIRS, 2)
|
|
297
|
+
try:
|
|
298
|
+
self._perform(c)
|
|
299
|
+
except CurlError as e:
|
|
300
|
+
raise UploadError(describe_error(e.code, e.message, self.url)) from e
|
|
301
|
+
|
|
302
|
+
def stat(self, path: str) -> Entry | None:
|
|
303
|
+
pc = self._pycurl
|
|
304
|
+
c = self._prepare(self._file_url(path))
|
|
305
|
+
c.setopt(pc.NOBODY, 1)
|
|
306
|
+
c.setopt(pc.OPT_FILETIME, 1)
|
|
307
|
+
try:
|
|
308
|
+
self._perform(c)
|
|
309
|
+
except CurlError as e:
|
|
310
|
+
if e.code in NOT_FOUND_CODES or e.code == E_QUOTE_ERROR:
|
|
311
|
+
return None
|
|
312
|
+
raise DownloadError(describe_error(e.code, e.message, self.url)) from e
|
|
313
|
+
mtime = c.getinfo(pc.INFO_FILETIME)
|
|
314
|
+
size_attr = getattr(pc, "CONTENT_LENGTH_DOWNLOAD_T", pc.CONTENT_LENGTH_DOWNLOAD)
|
|
315
|
+
size = c.getinfo(size_attr)
|
|
316
|
+
return Entry(
|
|
317
|
+
name=posixpath.basename(path),
|
|
318
|
+
is_dir=False,
|
|
319
|
+
size=int(size) if size is not None and size >= 0 else None,
|
|
320
|
+
mtime=int(mtime) if mtime is not None and mtime >= 0 else None,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def exists(self, path: str) -> bool:
|
|
324
|
+
return self.stat(path) is not None
|
|
325
|
+
|
|
326
|
+
def _list_raw(self, directory: str, custom: str | None, names_only: bool = False) -> bytes:
|
|
327
|
+
pc = self._pycurl
|
|
328
|
+
buf = io.BytesIO()
|
|
329
|
+
c = self._prepare(self.url.curl_dir_url(directory))
|
|
330
|
+
c.setopt(pc.WRITEDATA, buf)
|
|
331
|
+
if custom:
|
|
332
|
+
c.setopt(pc.CUSTOMREQUEST, custom)
|
|
333
|
+
if names_only:
|
|
334
|
+
c.setopt(pc.DIRLISTONLY, 1)
|
|
335
|
+
self._perform(c)
|
|
336
|
+
return buf.getvalue()
|
|
337
|
+
|
|
338
|
+
def list_dir(self, path: str) -> list[Entry]:
|
|
339
|
+
directory = path.strip("/")
|
|
340
|
+
if self._mlsd_supported:
|
|
341
|
+
try:
|
|
342
|
+
data = self._list_raw(directory, "MLSD")
|
|
343
|
+
except CurlError as e:
|
|
344
|
+
if e.code == E_REMOTE_ACCESS_DENIED:
|
|
345
|
+
raise RemoteNotFound(path) from e
|
|
346
|
+
if e.code in (E_FTP_COULDNT_RETR_FILE, E_QUOTE_ERROR, E_REMOTE_FILE_NOT_FOUND):
|
|
347
|
+
self._mlsd_supported = False
|
|
348
|
+
else:
|
|
349
|
+
raise DownloadError(describe_error(e.code, e.message, self.url)) from e
|
|
350
|
+
else:
|
|
351
|
+
entries = listing.parse_mlsd(data)
|
|
352
|
+
if entries or not data.strip():
|
|
353
|
+
return entries
|
|
354
|
+
self._mlsd_supported = False
|
|
355
|
+
try:
|
|
356
|
+
data = self._list_raw(directory, None)
|
|
357
|
+
except CurlError as e:
|
|
358
|
+
if e.code in (E_REMOTE_ACCESS_DENIED, E_REMOTE_FILE_NOT_FOUND):
|
|
359
|
+
raise RemoteNotFound(path) from e
|
|
360
|
+
raise DownloadError(describe_error(e.code, e.message, self.url)) from e
|
|
361
|
+
entries = listing.parse_list(data)
|
|
362
|
+
if entries or not data.strip():
|
|
363
|
+
return entries
|
|
364
|
+
# Unparsable LIST format: fall back to names and probe each one.
|
|
365
|
+
names = listing.parse_nlst(self._list_raw(directory, None, names_only=True))
|
|
366
|
+
result = []
|
|
367
|
+
for name in names:
|
|
368
|
+
child = f"{directory}/{name}" if directory else name
|
|
369
|
+
try:
|
|
370
|
+
self._list_raw(child, None, names_only=True)
|
|
371
|
+
result.append(Entry(name=name, is_dir=True))
|
|
372
|
+
except CurlError:
|
|
373
|
+
st = self.stat(child)
|
|
374
|
+
result.append(
|
|
375
|
+
Entry(
|
|
376
|
+
name=name,
|
|
377
|
+
is_dir=False,
|
|
378
|
+
size=st.size if st else None,
|
|
379
|
+
mtime=st.mtime if st else None,
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
return result
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class CurlError(Exception):
|
|
386
|
+
def __init__(self, code: int, message: str) -> None:
|
|
387
|
+
super().__init__(code, message)
|
|
388
|
+
self.code = code
|
|
389
|
+
self.message = message
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Parsers for FTP directory listings (MLSD, Unix/DOS LIST, NLST)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import calendar
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
|
|
9
|
+
from gitftp.transport.base import Entry
|
|
10
|
+
|
|
11
|
+
_MONTHS = {m.lower(): i for i, m in enumerate(calendar.month_abbr) if m}
|
|
12
|
+
|
|
13
|
+
_UNIX_RE = re.compile(
|
|
14
|
+
r"^(?P<type>[-dlcbps])(?P<perm>[rwxsStTlL\-+@.]{9,})\s+"
|
|
15
|
+
r"(?:\d+\s+)?" # link count (may be absent on some servers)
|
|
16
|
+
r"(?P<owner>\S+)\s+(?:(?P<group>\S+)\s+)?"
|
|
17
|
+
r"(?P<size>\d+)\s+"
|
|
18
|
+
r"(?P<month>[A-Za-z]{3})\s+(?P<day>\d{1,2})\s+"
|
|
19
|
+
r"(?:(?P<year>\d{4})|(?P<hour>\d{1,2}):(?P<minute>\d{2}))\s+"
|
|
20
|
+
r"(?P<name>.+)$"
|
|
21
|
+
)
|
|
22
|
+
_DOS_RE = re.compile(
|
|
23
|
+
r"^(?P<month>\d{2})-(?P<day>\d{2})-(?P<year>\d{2,4})\s+"
|
|
24
|
+
r"(?P<hour>\d{2}):(?P<minute>\d{2})(?P<ampm>[AP]M)\s+"
|
|
25
|
+
r"(?P<size><DIR>|\d+)\s+(?P<name>.+)$"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _decode(data: bytes) -> list[str]:
|
|
30
|
+
text = data.decode("utf-8", "surrogateescape")
|
|
31
|
+
return [line.rstrip("\r") for line in text.split("\n") if line.strip("\r")]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_mlsd_time(value: str) -> int | None:
|
|
35
|
+
m = re.match(r"^(\d{14})(?:\.\d+)?$", value)
|
|
36
|
+
if not m:
|
|
37
|
+
return None
|
|
38
|
+
dt = datetime.strptime(m.group(1), "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc)
|
|
39
|
+
return int(dt.timestamp())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_mlsd(data: bytes) -> list[Entry]:
|
|
43
|
+
entries: list[Entry] = []
|
|
44
|
+
for line in _decode(data):
|
|
45
|
+
facts_part, sep, name = line.partition(" ")
|
|
46
|
+
if not sep or not name:
|
|
47
|
+
continue
|
|
48
|
+
facts: dict[str, str] = {}
|
|
49
|
+
for fact in facts_part.split(";"):
|
|
50
|
+
if "=" in fact:
|
|
51
|
+
k, v = fact.split("=", 1)
|
|
52
|
+
facts[k.lower()] = v
|
|
53
|
+
ftype = facts.get("type", "file").lower()
|
|
54
|
+
if ftype in ("cdir", "pdir") or name in (".", ".."):
|
|
55
|
+
continue
|
|
56
|
+
is_link = ftype.startswith("os.unix=slink") or ftype == "os.unix=symlink"
|
|
57
|
+
is_dir = ftype == "dir"
|
|
58
|
+
size = int(facts["size"]) if facts.get("size", "").isdigit() else None
|
|
59
|
+
mtime = _parse_mlsd_time(facts["modify"]) if "modify" in facts else None
|
|
60
|
+
entries.append(
|
|
61
|
+
Entry(
|
|
62
|
+
name=name, is_dir=is_dir, size=size, mtime=mtime, mtime_exact=True, is_link=is_link
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
return entries
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _guess_year_ts(month: int, day: int, hour: int, minute: int, now: datetime) -> int:
|
|
69
|
+
year = now.year
|
|
70
|
+
try:
|
|
71
|
+
dt = datetime(year, month, day, hour, minute, tzinfo=timezone.utc)
|
|
72
|
+
except ValueError:
|
|
73
|
+
return 0
|
|
74
|
+
if dt.timestamp() > now.timestamp() + 86400:
|
|
75
|
+
dt = dt.replace(year=year - 1)
|
|
76
|
+
return int(dt.timestamp())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_list(data: bytes, now: datetime | None = None) -> list[Entry]:
|
|
80
|
+
"""Parse a ``LIST`` reply in Unix ``ls -l`` or DOS/IIS format."""
|
|
81
|
+
now = now or datetime.now(timezone.utc)
|
|
82
|
+
entries: list[Entry] = []
|
|
83
|
+
for line in _decode(data):
|
|
84
|
+
if line.lower().startswith("total "):
|
|
85
|
+
continue
|
|
86
|
+
m = _UNIX_RE.match(line)
|
|
87
|
+
if m:
|
|
88
|
+
name = m.group("name")
|
|
89
|
+
is_link = m.group("type") == "l"
|
|
90
|
+
if is_link and " -> " in name:
|
|
91
|
+
name = name.split(" -> ", 1)[0]
|
|
92
|
+
if name in (".", ".."):
|
|
93
|
+
continue
|
|
94
|
+
month = _MONTHS.get(m.group("month").lower())
|
|
95
|
+
if month is None:
|
|
96
|
+
continue
|
|
97
|
+
day = int(m.group("day"))
|
|
98
|
+
if m.group("year"):
|
|
99
|
+
dt = datetime(int(m.group("year")), month, day, tzinfo=timezone.utc)
|
|
100
|
+
mtime = int(dt.timestamp())
|
|
101
|
+
else:
|
|
102
|
+
mtime = _guess_year_ts(
|
|
103
|
+
month, day, int(m.group("hour")), int(m.group("minute")), now
|
|
104
|
+
)
|
|
105
|
+
entries.append(
|
|
106
|
+
Entry(
|
|
107
|
+
name=name,
|
|
108
|
+
is_dir=m.group("type") == "d",
|
|
109
|
+
size=int(m.group("size")),
|
|
110
|
+
mtime=mtime,
|
|
111
|
+
mtime_exact=False,
|
|
112
|
+
is_link=is_link,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
continue
|
|
116
|
+
m = _DOS_RE.match(line)
|
|
117
|
+
if m:
|
|
118
|
+
name = m.group("name")
|
|
119
|
+
if name in (".", ".."):
|
|
120
|
+
continue
|
|
121
|
+
year = int(m.group("year"))
|
|
122
|
+
if year < 100:
|
|
123
|
+
year += 2000 if year < 70 else 1900
|
|
124
|
+
hour = int(m.group("hour")) % 12
|
|
125
|
+
if m.group("ampm") == "PM":
|
|
126
|
+
hour += 12
|
|
127
|
+
dt = datetime(
|
|
128
|
+
year,
|
|
129
|
+
int(m.group("month")),
|
|
130
|
+
int(m.group("day")),
|
|
131
|
+
hour,
|
|
132
|
+
int(m.group("minute")),
|
|
133
|
+
tzinfo=timezone.utc,
|
|
134
|
+
)
|
|
135
|
+
is_dir = m.group("size") == "<DIR>"
|
|
136
|
+
entries.append(
|
|
137
|
+
Entry(
|
|
138
|
+
name=name,
|
|
139
|
+
is_dir=is_dir,
|
|
140
|
+
size=None if is_dir else int(m.group("size")),
|
|
141
|
+
mtime=int(dt.timestamp()),
|
|
142
|
+
mtime_exact=False,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
return entries
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_nlst(data: bytes) -> list[str]:
|
|
149
|
+
names = []
|
|
150
|
+
for line in _decode(data):
|
|
151
|
+
name = line.rsplit("/", 1)[-1] if line.endswith("/") is False else line.rstrip("/")
|
|
152
|
+
name = name.rsplit("/", 1)[-1]
|
|
153
|
+
if name and name not in (".", ".."):
|
|
154
|
+
names.append(name)
|
|
155
|
+
return names
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Scheme to transport factory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from gitftp.auth import Credentials
|
|
9
|
+
from gitftp.errors import UnknownProtocolError
|
|
10
|
+
from gitftp.output import Output
|
|
11
|
+
from gitftp.transport.base import Connector, Transport
|
|
12
|
+
from gitftp.url import RemoteURL, Scheme
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class TransportOptions:
|
|
17
|
+
insecure: bool = False
|
|
18
|
+
cacert: str | None = None
|
|
19
|
+
active: bool = False
|
|
20
|
+
disable_epsv: bool = False
|
|
21
|
+
proxy: str | None = None
|
|
22
|
+
trace: Callable[[str], None] | None = None
|
|
23
|
+
known_hosts_files: list[str] | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check_available(scheme: Scheme) -> None:
|
|
27
|
+
if scheme is Scheme.SFTP:
|
|
28
|
+
from gitftp.transport import sftp
|
|
29
|
+
|
|
30
|
+
sftp.check_available()
|
|
31
|
+
else:
|
|
32
|
+
from gitftp.transport import curlftp
|
|
33
|
+
|
|
34
|
+
curlftp.check_available(scheme)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def connector(
|
|
38
|
+
url: RemoteURL, creds: Credentials, topts: TransportOptions, out: Output
|
|
39
|
+
) -> Connector:
|
|
40
|
+
"""Return a factory that opens a fresh connection each time it is called."""
|
|
41
|
+
if url.scheme is Scheme.SFTP:
|
|
42
|
+
from gitftp.transport.sftp import DirCache, SftpOptions, SftpTransport
|
|
43
|
+
|
|
44
|
+
cache = DirCache()
|
|
45
|
+
sopts = SftpOptions(
|
|
46
|
+
insecure=topts.insecure, trace=topts.trace, known_hosts_files=topts.known_hosts_files
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def open_sftp() -> Transport:
|
|
50
|
+
t = SftpTransport(url, creds, sopts, out, dircache=cache)
|
|
51
|
+
t.open()
|
|
52
|
+
return t
|
|
53
|
+
|
|
54
|
+
return open_sftp
|
|
55
|
+
if url.scheme in (Scheme.FTP, Scheme.FTPS, Scheme.FTPES):
|
|
56
|
+
from gitftp.transport.curlftp import CurlFtpTransport, CurlOptions
|
|
57
|
+
|
|
58
|
+
copts = CurlOptions(
|
|
59
|
+
insecure=topts.insecure,
|
|
60
|
+
cacert=topts.cacert,
|
|
61
|
+
active=topts.active,
|
|
62
|
+
disable_epsv=topts.disable_epsv,
|
|
63
|
+
proxy=topts.proxy,
|
|
64
|
+
trace=topts.trace,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def open_ftp() -> Transport:
|
|
68
|
+
t = CurlFtpTransport(url, creds, copts, out)
|
|
69
|
+
t.open()
|
|
70
|
+
return t
|
|
71
|
+
|
|
72
|
+
return open_ftp
|
|
73
|
+
raise UnknownProtocolError(f"Protocol unknown '{url.scheme.value}://'.")
|