ccssh 1.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.
- ccssh/__init__.py +21 -0
- ccssh/__main__.py +5 -0
- ccssh/algs.py +290 -0
- ccssh/async_client.py +419 -0
- ccssh/auth.py +170 -0
- ccssh/certs.py +283 -0
- ccssh/chacha.py +117 -0
- ccssh/channel.py +124 -0
- ccssh/chansock.py +227 -0
- ccssh/cli.py +889 -0
- ccssh/client.py +1274 -0
- ccssh/crypto.py +316 -0
- ccssh/dh_groups.py +92 -0
- ccssh/forward.py +872 -0
- ccssh/gcm.py +87 -0
- ccssh/hostcheck.py +217 -0
- ccssh/hostkeys.py +250 -0
- ccssh/kex.py +222 -0
- ccssh/kex_ext.py +429 -0
- ccssh/mldsa.py +210 -0
- ccssh/mlkem.py +367 -0
- ccssh/mux.py +301 -0
- ccssh/sftp.py +270 -0
- ccssh/shell.py +257 -0
- ccssh/sk.py +224 -0
- ccssh/sntrup.py +325 -0
- ccssh/stub.py +710 -0
- ccssh/transport.py +737 -0
- ccssh/umac.py +262 -0
- ccssh/wire.py +199 -0
- ccssh-1.0.0.dist-info/METADATA +442 -0
- ccssh-1.0.0.dist-info/RECORD +36 -0
- ccssh-1.0.0.dist-info/WHEEL +5 -0
- ccssh-1.0.0.dist-info/entry_points.txt +2 -0
- ccssh-1.0.0.dist-info/licenses/LICENSE +21 -0
- ccssh-1.0.0.dist-info/top_level.txt +1 -0
ccssh/mux.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Channel multiplexer: share one SSH transport between exec/sftp/shell
|
|
3
|
+
channels and port forwarders.
|
|
4
|
+
|
|
5
|
+
SSH has a single transport stream, so exactly one thread may read it.
|
|
6
|
+
Without a multiplexer, two concurrent readers (e.g. an exec loop and a
|
|
7
|
+
forwarding pump) steal each other's packets and both stall. The mux owns
|
|
8
|
+
the only reader thread; everyone else consumes per-channel queues:
|
|
9
|
+
|
|
10
|
+
* channel messages carrying our channel id go to that channel's queue;
|
|
11
|
+
* ``CHANNEL_SUCCESS``/``CHANNEL_FAILURE`` (99/100, which carry NO channel
|
|
12
|
+
id on the wire) go to the channel with the oldest outstanding channel
|
|
13
|
+
request (see :meth:`expect_reply` - register *before* sending);
|
|
14
|
+
* server-initiated ``CHANNEL_OPEN`` and stray 81/82 go to the catch-all
|
|
15
|
+
queue (port forwarders);
|
|
16
|
+
* ``DISCONNECT``/``KEXINIT`` are broadcast to every queue (in-flight
|
|
17
|
+
operations keep their existing "peer rekey/closed" error paths);
|
|
18
|
+
* ``IGNORE``/``DEBUG`` are skipped, ``GLOBAL_REQUEST`` answered centrally.
|
|
19
|
+
|
|
20
|
+
Rekey still needs idle channels (same rule as before): :meth:`busy`
|
|
21
|
+
reports whether any channel is registered. The reader also emits idle
|
|
22
|
+
SSH ``IGNORE`` keepalives so quiet forward-only connections survive.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
import queue
|
|
26
|
+
import struct
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
|
|
30
|
+
from ccssh.transport import CCSSHError, CCSSHTimeoutError, enc_ignore
|
|
31
|
+
|
|
32
|
+
_DEAD = object() # sentinel: connection died, wake every blocked getter
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ChannelMux:
|
|
36
|
+
"""Demultiplex one transport into per-channel queues (sync only)."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, transport, *, idle_keepalive: float = 30.0):
|
|
39
|
+
self._t = transport
|
|
40
|
+
self._idle_keepalive = idle_keepalive
|
|
41
|
+
self._lock = threading.Lock()
|
|
42
|
+
self._queues: dict[int, queue.Queue] = {}
|
|
43
|
+
self._catch: queue.Queue = queue.Queue()
|
|
44
|
+
self._expect: list[int] = [] # channels awaiting 99/100, oldest first
|
|
45
|
+
self._stop = threading.Event()
|
|
46
|
+
self._dead = threading.Event()
|
|
47
|
+
self._dead_err: Exception | None = None
|
|
48
|
+
self._paused = threading.Event()
|
|
49
|
+
self._parked = threading.Event()
|
|
50
|
+
# Locks: _lock guards maps/queues; request_lock serialises
|
|
51
|
+
# expect_reply()+send pairs so 99/100 replies cannot be misrouted
|
|
52
|
+
# when two threads set up channels concurrently.
|
|
53
|
+
self.request_lock = threading.Lock()
|
|
54
|
+
self._chan_next = 1
|
|
55
|
+
self._last_io = time.monotonic()
|
|
56
|
+
self._th = threading.Thread(target=self._reader, daemon=True)
|
|
57
|
+
|
|
58
|
+
# lifecycle
|
|
59
|
+
def start(self) -> "ChannelMux":
|
|
60
|
+
self._th.start()
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def stop(self) -> None:
|
|
64
|
+
self._stop.set()
|
|
65
|
+
self._paused.clear() # let a parked reader observe the stop
|
|
66
|
+
self._die(CCSSHError("multiplexer stopped"))
|
|
67
|
+
th = self._th
|
|
68
|
+
if th.is_alive():
|
|
69
|
+
th.join(timeout=2.0)
|
|
70
|
+
|
|
71
|
+
def pause(self) -> None:
|
|
72
|
+
"""Park the reader outside any transport read (for rekey).
|
|
73
|
+
|
|
74
|
+
Rekey swaps the transport's crypto state mid-stream; a concurrent
|
|
75
|
+
reader would decrypt one half of a packet with the old keys and
|
|
76
|
+
the other half with the new ones. Blocks (bounded) until parked.
|
|
77
|
+
No-op when the reader isn't running yet.
|
|
78
|
+
"""
|
|
79
|
+
self._paused.set()
|
|
80
|
+
if not self._th.is_alive():
|
|
81
|
+
# Reader not running (e.g. pause-before-start): just latch the
|
|
82
|
+
# flag; the first loop iteration will park on it.
|
|
83
|
+
return
|
|
84
|
+
try:
|
|
85
|
+
margin = self.timeout
|
|
86
|
+
except Exception:
|
|
87
|
+
margin = 10.0
|
|
88
|
+
deadline = time.monotonic() + max(2.0, margin + 1.0)
|
|
89
|
+
while not self._parked.is_set():
|
|
90
|
+
if self._dead.is_set() or self._stop.is_set():
|
|
91
|
+
break
|
|
92
|
+
if time.monotonic() > deadline:
|
|
93
|
+
raise CCSSHError("timed out parking the mux reader")
|
|
94
|
+
time.sleep(0.01)
|
|
95
|
+
|
|
96
|
+
def resume(self) -> None:
|
|
97
|
+
self._paused.clear()
|
|
98
|
+
# The reader may be inside its park-wait: give it a beat to
|
|
99
|
+
# observe the cleared flag and leave the parked state, otherwise
|
|
100
|
+
# a stale parked bit would confuse the next pause().
|
|
101
|
+
deadline = time.monotonic() + 2.0
|
|
102
|
+
while self._parked.is_set() and time.monotonic() < deadline:
|
|
103
|
+
time.sleep(0.01)
|
|
104
|
+
self._parked.clear()
|
|
105
|
+
|
|
106
|
+
def busy(self) -> bool:
|
|
107
|
+
"""
|
|
108
|
+
Any registered channel queues (open or pending channels).
|
|
109
|
+
|
|
110
|
+
A forwarder with zero open channels does not count: its pump only
|
|
111
|
+
consumes mux queues and idle-sends nothing that a rekey could
|
|
112
|
+
corrupt (rekey pauses the reader; sends are atomic per packet).
|
|
113
|
+
"""
|
|
114
|
+
with self._lock:
|
|
115
|
+
return bool(self._queues)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def dead(self) -> bool:
|
|
119
|
+
return self._dead.is_set()
|
|
120
|
+
|
|
121
|
+
# channels
|
|
122
|
+
def register(self, chan_id: int) -> queue.Queue:
|
|
123
|
+
"""Create (or reuse) the inbound queue for our channel id."""
|
|
124
|
+
with self._lock:
|
|
125
|
+
q = self._queues.get(chan_id)
|
|
126
|
+
if q is None:
|
|
127
|
+
q = queue.Queue()
|
|
128
|
+
self._queues[chan_id] = q
|
|
129
|
+
return q
|
|
130
|
+
|
|
131
|
+
def alloc_chan(self) -> int:
|
|
132
|
+
"""
|
|
133
|
+
Allocate a fresh channel id from the single shared namespace.
|
|
134
|
+
|
|
135
|
+
All mux users (exec/shell/sftp/tunnels/forwards) must allocate
|
|
136
|
+
here: disjoint local counters would eventually collide on the
|
|
137
|
+
shared queues and misroute packets (channel-confusion).
|
|
138
|
+
"""
|
|
139
|
+
with self._lock:
|
|
140
|
+
n = self._chan_next
|
|
141
|
+
self._chan_next = n + 1
|
|
142
|
+
return n
|
|
143
|
+
|
|
144
|
+
def _queue_for(self, chan_id: int) -> queue.Queue | None:
|
|
145
|
+
"""Queue lookup without creating (for drain loops)."""
|
|
146
|
+
with self._lock:
|
|
147
|
+
return self._queues.get(chan_id)
|
|
148
|
+
|
|
149
|
+
def unregister(self, chan_id: int) -> None:
|
|
150
|
+
with self._lock:
|
|
151
|
+
self._queues.pop(chan_id, None)
|
|
152
|
+
self._expect = [c for c in self._expect if c != chan_id]
|
|
153
|
+
|
|
154
|
+
def expect_reply(self, chan_id: int) -> None:
|
|
155
|
+
"""
|
|
156
|
+
Record an outstanding channel request (call BEFORE sending it,
|
|
157
|
+
so a fast reply can never overtake the registration).
|
|
158
|
+
"""
|
|
159
|
+
with self._lock:
|
|
160
|
+
self._expect.append(chan_id)
|
|
161
|
+
|
|
162
|
+
def catch_all(self) -> queue.Queue:
|
|
163
|
+
return self._catch
|
|
164
|
+
|
|
165
|
+
def send(self, payload: bytes) -> None:
|
|
166
|
+
self._t.send_packet(payload)
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def timeout(self) -> float:
|
|
170
|
+
try:
|
|
171
|
+
return float(getattr(self._t, "timeout", 10.0))
|
|
172
|
+
except Exception:
|
|
173
|
+
return 10.0
|
|
174
|
+
|
|
175
|
+
# consumption
|
|
176
|
+
def _raise_if_dead(self) -> None:
|
|
177
|
+
if self._dead.is_set():
|
|
178
|
+
raise CCSSHError(str(self._dead_err or "connection closed by peer"))
|
|
179
|
+
|
|
180
|
+
def recv_chan(self, chan_id: int, timeout: float | None = None) -> bytes:
|
|
181
|
+
"""Next packet for a channel (None timeout blocks until it arrives
|
|
182
|
+
or the connection dies; a numeric timeout raises
|
|
183
|
+
CCSSHTimeoutError like a direct transport read)."""
|
|
184
|
+
with self._lock:
|
|
185
|
+
q = self._queues.get(chan_id)
|
|
186
|
+
if q is None:
|
|
187
|
+
raise CCSSHError(f"channel {chan_id} is not registered with the mux")
|
|
188
|
+
while True:
|
|
189
|
+
try:
|
|
190
|
+
p = q.get(timeout=timeout)
|
|
191
|
+
except queue.Empty:
|
|
192
|
+
self._raise_if_dead()
|
|
193
|
+
raise CCSSHTimeoutError("channel read timeout")
|
|
194
|
+
if p is _DEAD:
|
|
195
|
+
# Re-queue for any other getter on this channel, then fail.
|
|
196
|
+
try:
|
|
197
|
+
q.put(_DEAD)
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
self._raise_if_dead()
|
|
201
|
+
raise CCSSHError("connection closed by peer")
|
|
202
|
+
return p
|
|
203
|
+
|
|
204
|
+
# the only transport reader
|
|
205
|
+
def _die(self, err: Exception) -> None:
|
|
206
|
+
with self._lock:
|
|
207
|
+
if self._dead.is_set():
|
|
208
|
+
return
|
|
209
|
+
self._dead_err = err
|
|
210
|
+
self._dead.set()
|
|
211
|
+
targets = list(self._queues.values()) + [self._catch]
|
|
212
|
+
for q in targets:
|
|
213
|
+
try:
|
|
214
|
+
q.put(_DEAD)
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
def _broadcast(self, p: bytes) -> None:
|
|
219
|
+
with self._lock:
|
|
220
|
+
targets = list(self._queues.values()) + [self._catch]
|
|
221
|
+
for q in targets:
|
|
222
|
+
try:
|
|
223
|
+
q.put(p)
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
def _route(self, p: bytes) -> None:
|
|
228
|
+
# NB: IGNORE/DEBUG/GLOBAL_REQUEST never reach here: the reader uses
|
|
229
|
+
# transport.recv_packet(), which skips and answers them centrally.
|
|
230
|
+
if not p:
|
|
231
|
+
return
|
|
232
|
+
m = p[0]
|
|
233
|
+
if m in (1, 20): # DISCONNECT, KEXINIT: everyone must see these
|
|
234
|
+
self._broadcast(p)
|
|
235
|
+
if m == 1:
|
|
236
|
+
self._die(CCSSHError("connection closed by peer"))
|
|
237
|
+
return
|
|
238
|
+
if m in (99, 100): # SUCCESS/FAILURE carry no channel id
|
|
239
|
+
with self._lock:
|
|
240
|
+
chan = self._expect.pop(0) if self._expect else None
|
|
241
|
+
q = self._queues.get(chan) if chan is not None else None
|
|
242
|
+
if q is not None:
|
|
243
|
+
try:
|
|
244
|
+
q.put(p)
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
return
|
|
248
|
+
if m == 90:
|
|
249
|
+
# Server-initiated open (forwarded-tcpip): p[1:5] is the type
|
|
250
|
+
# string length, NOT a channel id - always to catch-all.
|
|
251
|
+
try:
|
|
252
|
+
self._catch.put(p)
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
return
|
|
256
|
+
if len(p) >= 5 and m in (91, 92, 93, 94, 95, 96, 97, 98):
|
|
257
|
+
try:
|
|
258
|
+
(rcpt,) = struct.unpack(">I", p[1:5])
|
|
259
|
+
except Exception:
|
|
260
|
+
return
|
|
261
|
+
with self._lock:
|
|
262
|
+
q = self._queues.get(rcpt)
|
|
263
|
+
if q is not None:
|
|
264
|
+
try:
|
|
265
|
+
q.put(p)
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
# anything else for an unknown/stale channel is dropped
|
|
269
|
+
return
|
|
270
|
+
try:
|
|
271
|
+
self._catch.put(p)
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
def _reader(self) -> None:
|
|
276
|
+
while not self._stop.is_set():
|
|
277
|
+
if self._paused.is_set():
|
|
278
|
+
self._parked.set()
|
|
279
|
+
self._paused.wait(0.5)
|
|
280
|
+
continue
|
|
281
|
+
self._parked.clear()
|
|
282
|
+
try:
|
|
283
|
+
p = self._t.recv_packet(keepalive=False)
|
|
284
|
+
except CCSSHTimeoutError:
|
|
285
|
+
if self._idle_keepalive > 0 and not self._stop.is_set() \
|
|
286
|
+
and time.monotonic() - self._last_io >= self._idle_keepalive:
|
|
287
|
+
try:
|
|
288
|
+
self.send(enc_ignore())
|
|
289
|
+
except Exception:
|
|
290
|
+
self._die(CCSSHError("connection closed by peer"))
|
|
291
|
+
return
|
|
292
|
+
self._last_io = time.monotonic()
|
|
293
|
+
continue
|
|
294
|
+
except Exception as e:
|
|
295
|
+
self._die(e)
|
|
296
|
+
return
|
|
297
|
+
self._last_io = time.monotonic()
|
|
298
|
+
try:
|
|
299
|
+
self._route(p)
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
ccssh/sftp.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SFTP version 3 client (draft-ietf-secsh-filexfer-02): stat, list, get, put,
|
|
3
|
+
remove, mkdir over a subsystem channel.
|
|
4
|
+
|
|
5
|
+
SFTP packets may straddle SSH channel messages, so responses accumulate in
|
|
6
|
+
a length-prefixed buffer. Each request goes out as a single CHANNEL_DATA
|
|
7
|
+
message within the server's maxpacket (servers drop oversized ones).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ccssh import wire as W
|
|
12
|
+
|
|
13
|
+
# Message types.
|
|
14
|
+
F_INIT, F_VERSION = 1, 2
|
|
15
|
+
F_OPEN, F_CLOSE, F_READ, F_WRITE = 3, 4, 5, 6
|
|
16
|
+
F_OPENDIR, F_READDIR = 11, 12
|
|
17
|
+
F_REMOVE, F_MKDIR = 13, 14
|
|
18
|
+
F_REALPATH, F_STAT = 16, 17
|
|
19
|
+
F_HANDLE, F_DATA, F_NAME, F_ATTRS, F_STATUS = 102, 103, 104, 105, 101
|
|
20
|
+
|
|
21
|
+
# Status codes.
|
|
22
|
+
S_OK, S_EOF = 0, 1
|
|
23
|
+
S_NO_SUCH_FILE, S_FAILURE = 2, 4
|
|
24
|
+
S_OP_UNSUPPORTED = 8
|
|
25
|
+
|
|
26
|
+
# OPEN pflags.
|
|
27
|
+
P_READ, P_WRITE, P_APPEND, P_CREAT, P_TRUNC, P_EXCL = 0x01, 0x02, 0x04, 0x08, 0x10, 0x20
|
|
28
|
+
|
|
29
|
+
# Attribute flags.
|
|
30
|
+
A_SIZE, A_PERM, A_ACMOD = 0x01, 0x04, 0x08
|
|
31
|
+
A_EXTENDED = 0x80000000
|
|
32
|
+
|
|
33
|
+
CHUNK = 32768
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SFTPError(ValueError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _req(t: int, rid: int, body: bytes) -> bytes:
|
|
41
|
+
return W.enc_u32(1 + 4 + len(body)) + bytes([t]) + W.enc_u32(rid) + body
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _dec_attrs_reader(r: W.Reader) -> dict:
|
|
45
|
+
fl = r.u32()
|
|
46
|
+
out: dict = {}
|
|
47
|
+
if fl & A_SIZE:
|
|
48
|
+
out["size"] = r.u64()
|
|
49
|
+
if fl & 0x02:
|
|
50
|
+
out["uid"], out["gid"] = r.u32(), r.u32()
|
|
51
|
+
if fl & A_PERM:
|
|
52
|
+
out["permissions"] = r.u32()
|
|
53
|
+
if fl & A_ACMOD:
|
|
54
|
+
out["atime"], out["mtime"] = r.u32(), r.u32()
|
|
55
|
+
if fl & A_EXTENDED:
|
|
56
|
+
n = r.u32()
|
|
57
|
+
if n > 64:
|
|
58
|
+
raise SFTPError("too many extended attrs")
|
|
59
|
+
for _ in range(n):
|
|
60
|
+
r.string()
|
|
61
|
+
r.string()
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def dec_attrs(buf: bytes) -> dict:
|
|
66
|
+
return _dec_attrs_reader(W.Reader(buf))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SFTPClient:
|
|
70
|
+
"""An open SFTP channel. Use CCSSHClient.open_sftp()."""
|
|
71
|
+
|
|
72
|
+
"""
|
|
73
|
+
Upper bound on one SFTP reply (length prefix). Our own requests are
|
|
74
|
+
small (reads are <= maxpacket); anything bigger is a malicious or
|
|
75
|
+
broken server - fail instead of buffering it into memory.
|
|
76
|
+
"""
|
|
77
|
+
MAX_REPLY = 1024 * 1024
|
|
78
|
+
|
|
79
|
+
def __init__(self, send_chan_data, recv_packet_fn, server_chan: int, on_close=None, maxpacket: int = 32768):
|
|
80
|
+
self._send = send_chan_data # fn(server_chan, bytes)
|
|
81
|
+
self._recv = recv_packet_fn # fn() -> channel-data bytes (one message)
|
|
82
|
+
self._srv = server_chan
|
|
83
|
+
self._on_close = on_close
|
|
84
|
+
"""
|
|
85
|
+
Servers drop CHANNEL_DATA beyond their maxpacket ("rcvd big packet"),
|
|
86
|
+
so every request goes out as one message within maxpacket.
|
|
87
|
+
"""
|
|
88
|
+
self._maxpacket = max(1024, maxpacket)
|
|
89
|
+
self._rid = 1
|
|
90
|
+
self._buf = b""
|
|
91
|
+
self._closed = False
|
|
92
|
+
|
|
93
|
+
def close(self) -> None:
|
|
94
|
+
if self._closed:
|
|
95
|
+
return
|
|
96
|
+
self._closed = True
|
|
97
|
+
if self._on_close:
|
|
98
|
+
self._on_close()
|
|
99
|
+
|
|
100
|
+
def _call(self, ftype: int, body: bytes) -> tuple[int, bytes]:
|
|
101
|
+
rid = self._rid & 0xFFFFFFFF
|
|
102
|
+
if rid == 0: # 0 is never used on the wire: skip it explicitly
|
|
103
|
+
rid = 1
|
|
104
|
+
self._rid = 2
|
|
105
|
+
else:
|
|
106
|
+
self._rid += 1
|
|
107
|
+
req = _req(ftype, rid, body)
|
|
108
|
+
if len(req) > self._maxpacket:
|
|
109
|
+
raise SFTPError("sftp request exceeds server maxpacket")
|
|
110
|
+
self._send(self._srv, req)
|
|
111
|
+
while True:
|
|
112
|
+
data = self._recv()
|
|
113
|
+
self._buf += data
|
|
114
|
+
if len(self._buf) > 2 * self.MAX_REPLY + 4:
|
|
115
|
+
# Pipelined/garbage bytes beyond any single reply: fail
|
|
116
|
+
# closed instead of buffering a runaway server into OOM.
|
|
117
|
+
try:
|
|
118
|
+
self.close()
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
raise SFTPError("sftp: reply buffer exceeded")
|
|
122
|
+
if len(self._buf) < 4:
|
|
123
|
+
continue
|
|
124
|
+
ln = int.from_bytes(self._buf[:4], "big")
|
|
125
|
+
if ln > self.MAX_REPLY:
|
|
126
|
+
raise SFTPError(f"sftp: reply too big ({ln} bytes)")
|
|
127
|
+
if len(self._buf) < 4 + ln:
|
|
128
|
+
continue
|
|
129
|
+
pkt, self._buf = self._buf[4:4 + ln], self._buf[4 + ln:]
|
|
130
|
+
r = W.Reader(pkt)
|
|
131
|
+
rt, rr = r.byte(), r.u32()
|
|
132
|
+
if rr != rid:
|
|
133
|
+
# Desynchronised stream: fail closed, do not continue on a
|
|
134
|
+
# half-consumed buffer.
|
|
135
|
+
try:
|
|
136
|
+
self.close()
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
raise SFTPError(f"sftp: id mismatch {rr}!={rid}")
|
|
140
|
+
return rt, pkt[5:]
|
|
141
|
+
|
|
142
|
+
def _check(self, rtype: int, rest: bytes, want: int) -> bytes:
|
|
143
|
+
if rtype == F_STATUS:
|
|
144
|
+
r = W.Reader(rest)
|
|
145
|
+
code = r.u32()
|
|
146
|
+
msg = r.string().decode(errors="replace")
|
|
147
|
+
if code != S_OK:
|
|
148
|
+
raise SFTPError(f"sftp status {code}: {msg}")
|
|
149
|
+
return b""
|
|
150
|
+
if rtype != want:
|
|
151
|
+
raise SFTPError(f"sftp: expected {want}, got {rtype}")
|
|
152
|
+
return rest
|
|
153
|
+
|
|
154
|
+
def realpath(self, path: str) -> str:
|
|
155
|
+
t, rest = self._call(F_REALPATH, W.enc_string(path.encode()))
|
|
156
|
+
r = W.Reader(self._check(t, rest, F_NAME))
|
|
157
|
+
r.u32() # count, always 1 here
|
|
158
|
+
return r.string().decode()
|
|
159
|
+
|
|
160
|
+
def stat(self, path: str) -> dict:
|
|
161
|
+
t, rest = self._call(F_STAT, W.enc_string(path.encode()))
|
|
162
|
+
return dec_attrs(self._check(t, rest, F_ATTRS))
|
|
163
|
+
|
|
164
|
+
def listdir(self, path: str, *, max_entries: int = 50000) -> list[str]:
|
|
165
|
+
t, rest = self._call(F_OPENDIR, W.enc_string(path.encode()))
|
|
166
|
+
handle = W.Reader(self._check(t, rest, F_HANDLE)).string()
|
|
167
|
+
out: list[str] = []
|
|
168
|
+
out_bytes = 0
|
|
169
|
+
try:
|
|
170
|
+
while True:
|
|
171
|
+
t2, r2 = self._call(F_READDIR, W.enc_string(handle))
|
|
172
|
+
if t2 == F_STATUS:
|
|
173
|
+
r = W.Reader(r2)
|
|
174
|
+
if r.u32() == S_EOF:
|
|
175
|
+
break
|
|
176
|
+
raise SFTPError("readdir failed")
|
|
177
|
+
r = W.Reader(self._check(t2, r2, F_NAME))
|
|
178
|
+
n = r.u32()
|
|
179
|
+
if n > 1024:
|
|
180
|
+
raise SFTPError("readdir count too large")
|
|
181
|
+
for _ in range(n):
|
|
182
|
+
name = r.string().decode()
|
|
183
|
+
r.string() # longname
|
|
184
|
+
_dec_attrs_reader(r)
|
|
185
|
+
out_bytes += len(name.encode())
|
|
186
|
+
if len(out) >= max_entries or out_bytes > 4 * 1024 * 1024:
|
|
187
|
+
raise SFTPError("directory listing too large")
|
|
188
|
+
out.append(name)
|
|
189
|
+
finally:
|
|
190
|
+
try:
|
|
191
|
+
self._call(F_CLOSE, W.enc_string(handle))
|
|
192
|
+
except Exception:
|
|
193
|
+
pass # never mask the original error with a close failure
|
|
194
|
+
return out
|
|
195
|
+
|
|
196
|
+
def _open(self, path: str, pflags: int) -> bytes:
|
|
197
|
+
t, rest = self._call(F_OPEN, W.enc_string(path.encode()) + W.enc_u32(pflags) + W.enc_u32(0))
|
|
198
|
+
return W.Reader(self._check(t, rest, F_HANDLE)).string()
|
|
199
|
+
|
|
200
|
+
def _close(self, handle: bytes) -> None:
|
|
201
|
+
t, rest = self._call(F_CLOSE, W.enc_string(handle))
|
|
202
|
+
if t == F_STATUS:
|
|
203
|
+
r = W.Reader(rest)
|
|
204
|
+
if r.u32() != S_OK:
|
|
205
|
+
raise SFTPError("close failed")
|
|
206
|
+
|
|
207
|
+
def get(self, path: str, *, max_bytes: int | None = 1 << 30) -> bytes:
|
|
208
|
+
"""
|
|
209
|
+
Download a whole file. A server error mid-transfer raises
|
|
210
|
+
(never silent truncation); max_bytes caps a runaway server
|
|
211
|
+
(None disables the cap).
|
|
212
|
+
"""
|
|
213
|
+
h = self._open(path, P_READ)
|
|
214
|
+
chunks: list[bytes] = []
|
|
215
|
+
off = 0
|
|
216
|
+
try:
|
|
217
|
+
while True:
|
|
218
|
+
t, rest = self._call(F_READ, W.enc_string(h) + W.enc_u64(off) + W.enc_u32(CHUNK))
|
|
219
|
+
if t == F_STATUS:
|
|
220
|
+
r = W.Reader(rest)
|
|
221
|
+
if r.u32() == S_EOF:
|
|
222
|
+
break
|
|
223
|
+
raise SFTPError(f"read failed: {r.string().decode(errors='replace')}")
|
|
224
|
+
data = W.Reader(self._check(t, rest, F_DATA)).string()
|
|
225
|
+
if not data:
|
|
226
|
+
break
|
|
227
|
+
chunks.append(data)
|
|
228
|
+
off += len(data)
|
|
229
|
+
if max_bytes is not None and off > max_bytes:
|
|
230
|
+
raise SFTPError(f"file exceeds max_bytes={max_bytes}")
|
|
231
|
+
finally:
|
|
232
|
+
try:
|
|
233
|
+
self._close(h)
|
|
234
|
+
except Exception:
|
|
235
|
+
pass # never mask the transfer error with a close failure
|
|
236
|
+
return b"".join(chunks)
|
|
237
|
+
|
|
238
|
+
def put(self, path: str, data: bytes, *, mode: int = 0o644) -> None:
|
|
239
|
+
attrs = W.enc_u32(A_PERM) + W.enc_u32(mode)
|
|
240
|
+
t, rest = self._call(F_OPEN, W.enc_string(path.encode()) + W.enc_u32(P_WRITE | P_CREAT | P_TRUNC) + attrs)
|
|
241
|
+
h = W.Reader(self._check(t, rest, F_HANDLE)).string()
|
|
242
|
+
if not data:
|
|
243
|
+
# Empty file: OPEN|TRUNC already truncated it; no zero-WRITE.
|
|
244
|
+
self._close(h)
|
|
245
|
+
return
|
|
246
|
+
wchunk = max(1024, self._maxpacket - 128)
|
|
247
|
+
try:
|
|
248
|
+
off = 0
|
|
249
|
+
while off < len(data):
|
|
250
|
+
chunk = data[off:off + wchunk]
|
|
251
|
+
t2, r2 = self._call(F_WRITE, W.enc_string(h) + W.enc_u64(off) + W.enc_string(chunk))
|
|
252
|
+
if t2 == F_STATUS:
|
|
253
|
+
r = W.Reader(r2)
|
|
254
|
+
if r.u32() != S_OK:
|
|
255
|
+
raise SFTPError("write failed")
|
|
256
|
+
off += len(chunk)
|
|
257
|
+
finally:
|
|
258
|
+
try:
|
|
259
|
+
self._close(h)
|
|
260
|
+
except Exception:
|
|
261
|
+
pass # never mask the transfer error with a close failure
|
|
262
|
+
|
|
263
|
+
def remove(self, path: str) -> None:
|
|
264
|
+
t, rest = self._call(F_REMOVE, W.enc_string(path.encode()))
|
|
265
|
+
self._check(t, rest, F_STATUS)
|
|
266
|
+
|
|
267
|
+
def mkdir(self, path: str, *, mode: int = 0o755) -> None:
|
|
268
|
+
attrs = W.enc_u32(A_PERM) + W.enc_u32(mode)
|
|
269
|
+
t, rest = self._call(F_MKDIR, W.enc_string(path.encode()) + attrs)
|
|
270
|
+
self._check(t, rest, F_STATUS)
|