codecraftai-protocol-ssh 1.3.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.
@@ -0,0 +1,297 @@
1
+ """
2
+ SFTP Protocol Implementation (Version 3)
3
+ """
4
+ import struct
5
+ import logging
6
+ import threading
7
+ from ccai.protocols.ssh import sshconstants
8
+ from ccai.protocols.ssh.sshpacket import SshPacket
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ class SshSFTPAttributes:
13
+ def __init__(self, size=None, uid=None, gid=None, mode=None, atime=None, mtime=None):
14
+ self.st_size = size
15
+ self.st_uid = uid
16
+ self.st_gid = gid
17
+ self.st_mode = mode
18
+ self.st_atime = atime
19
+ self.st_mtime = mtime
20
+
21
+ @classmethod
22
+ def from_attrs(cls, attrs):
23
+ # Helper to create from parsed attributes dict (TODO)
24
+ return cls()
25
+
26
+ class SshSFTPError(Exception):
27
+ pass
28
+
29
+ class SshSFTPClient:
30
+ """
31
+ SFTP Client implementation.
32
+ """
33
+ def __init__(self, channel):
34
+ self.channel = channel
35
+ self.request_id = 0
36
+ self.lock = threading.Lock()
37
+ self.channel.request_subsystem("sftp")
38
+ self._init_sftp()
39
+
40
+ def _init_sftp(self):
41
+ """
42
+ Send INIT and wait for VERSION.
43
+ """
44
+ pkt = SshPacket()
45
+ pkt.add_uint32(3) # Version 3
46
+ self._send_packet(sshconstants.SSH_FXP_INIT, pkt.payload)
47
+
48
+ msg_id, payload = self._recv_packet()
49
+ if msg_id != sshconstants.SSH_FXP_VERSION:
50
+ raise SshSFTPError(f"Expected SSH_FXP_VERSION, got {msg_id}")
51
+
52
+ # Parse version
53
+ version = struct.unpack('>I', payload[:4])[0]
54
+ logger.info(f"SFTP Version: {version}")
55
+
56
+ def open(self, filename: str, pflags: int = 0, attrs: dict = None) -> bytes:
57
+ """
58
+ Open a file. Returns handle.
59
+ pflags: SSH_FXF_READ, SSH_FXF_WRITE, etc. (Need mapped constants, assuming valid int passed)
60
+ """
61
+ req_id = self._next_id()
62
+ pkt = SshPacket()
63
+ pkt.add_uint32(req_id)
64
+ pkt.add_string(filename)
65
+ pkt.add_uint32(0) # Pflags placeholder (TODO: Add mapping) - wait using raw int
66
+ # Actually pflags logic.
67
+ # READ=1, WRITE=2, APPEND=4, CREAT=8, TRUNC=16, EXCL=32
68
+ if pflags == 0:
69
+ pflags = 1 # READ default
70
+
71
+ pkt = SshPacket()
72
+ pkt.add_uint32(req_id)
73
+ pkt.add_string(filename)
74
+ pkt.add_uint32(pflags)
75
+ pkt.add_uint32(0) # Attrs (0)
76
+
77
+ self._send_packet(sshconstants.SSH_FXP_OPEN, pkt.payload)
78
+ msg_id, payload = self._recv_packet()
79
+ return self._parse_handle_response(msg_id, payload, req_id)
80
+
81
+ def close(self, handle: bytes):
82
+ req_id = self._next_id()
83
+ pkt = SshPacket()
84
+ pkt.add_uint32(req_id)
85
+ pkt.add_string(handle)
86
+ self._send_packet(sshconstants.SSH_FXP_CLOSE, pkt.payload)
87
+ msg_id, payload = self._recv_packet()
88
+ self._parse_status_response(msg_id, payload, req_id)
89
+
90
+ def read(self, handle: bytes, offset: int, length: int) -> bytes:
91
+ req_id = self._next_id()
92
+ pkt = SshPacket()
93
+ pkt.add_uint32(req_id)
94
+ pkt.add_string(handle)
95
+ pkt.add_uint64(offset)
96
+ pkt.add_uint32(length)
97
+
98
+ self._send_packet(sshconstants.SSH_FXP_READ, pkt.payload)
99
+ msg_id, payload = self._recv_packet()
100
+
101
+ if msg_id == sshconstants.SSH_FXP_DATA:
102
+ pkt = SshPacket(payload=payload)
103
+ pkt.get_uint32() # Req ID
104
+ return pkt.get_string()
105
+ elif msg_id == sshconstants.SSH_FXP_STATUS:
106
+ self._check_status(payload, req_id)
107
+ return b"" # Should raise EOF if status is EOF
108
+ else:
109
+ raise SshSFTPError(f"Unexpected message {msg_id}")
110
+
111
+ def write(self, handle: bytes, offset: int, data: bytes):
112
+ """
113
+ Write to a file.
114
+ """
115
+ req_id = self._next_id()
116
+ pkt = SshPacket()
117
+ pkt.add_uint32(req_id)
118
+ pkt.add_string(handle)
119
+ pkt.add_uint64(offset)
120
+ pkt.add_string(data)
121
+
122
+ self._send_packet(sshconstants.SSH_FXP_WRITE, pkt.payload)
123
+ msg_id, payload = self._recv_packet()
124
+ self._parse_status_response(msg_id, payload, req_id)
125
+
126
+ def listdir(self, path: str) -> list:
127
+ """
128
+ List directory contents.
129
+ """
130
+ # Open directory
131
+ req_id = self._next_id()
132
+ pkt = SshPacket()
133
+ pkt.add_uint32(req_id)
134
+ pkt.add_string(path)
135
+ self._send_packet(sshconstants.SSH_FXP_OPENDIR, pkt.payload)
136
+ msg_id, payload = self._recv_packet()
137
+ handle = self._parse_handle_response(msg_id, payload, req_id)
138
+
139
+ items = []
140
+ try:
141
+ while True:
142
+ req_id = self._next_id()
143
+ pkt = SshPacket()
144
+ pkt.add_uint32(req_id)
145
+ pkt.add_string(handle)
146
+ self._send_packet(sshconstants.SSH_FXP_READDIR, pkt.payload)
147
+
148
+ msg_id, payload = self._recv_packet()
149
+ if msg_id == sshconstants.SSH_FXP_NAME:
150
+ pkt = SshPacket(payload=payload)
151
+ rid = pkt.get_uint32()
152
+ if rid != req_id:
153
+ raise SshSFTPError("Request ID mismatch")
154
+
155
+ count = pkt.get_uint32()
156
+ for _ in range(count):
157
+ filename = pkt.get_string().decode('utf-8')
158
+ pkt.get_string() # consume the 'longname' field; only the filename is listed
159
+ self._parse_attrs(pkt) # consume attrs
160
+ items.append(filename)
161
+ elif msg_id == sshconstants.SSH_FXP_STATUS:
162
+ self._check_status(payload, req_id) # Will raise EOF or error
163
+ break
164
+ else:
165
+ raise SshSFTPError(f"Unexpected message {msg_id}")
166
+ except EOFError:
167
+ # SSH_FX_EOF from READDIR marks the end of the directory listing; it is the normal loop exit.
168
+ pass
169
+ finally:
170
+ self.close(handle)
171
+
172
+ return items
173
+
174
+ def stat(self, path: str) -> SshSFTPAttributes:
175
+ """
176
+ Get attributes for a path (follows symlinks).
177
+ """
178
+ return self._request_stat(path, sshconstants.SSH_FXP_STAT)
179
+
180
+ def lstat(self, path: str) -> SshSFTPAttributes:
181
+ """
182
+ Get attributes for a path (does not follow symlinks).
183
+ """
184
+ return self._request_stat(path, sshconstants.SSH_FXP_LSTAT)
185
+
186
+ def _request_stat(self, path: str, msg_code: int) -> SshSFTPAttributes:
187
+ req_id = self._next_id()
188
+ pkt = SshPacket()
189
+ pkt.add_uint32(req_id)
190
+ pkt.add_string(path)
191
+ self._send_packet(msg_code, pkt.payload)
192
+
193
+ msg_id, payload = self._recv_packet()
194
+ if msg_id == sshconstants.SSH_FXP_ATTRS:
195
+ pkt = SshPacket(payload=payload)
196
+ rid = pkt.get_uint32()
197
+ if rid != req_id:
198
+ raise SshSFTPError("Request ID mismatch")
199
+ return self._parse_attrs(pkt)
200
+ elif msg_id == sshconstants.SSH_FXP_STATUS:
201
+ self._check_status(payload, req_id)
202
+ # Should raise error
203
+ return None
204
+ else:
205
+ raise SshSFTPError(f"Unexpected message {msg_id}")
206
+
207
+ def _parse_attrs(self, pkt: SshPacket) -> SshSFTPAttributes:
208
+ """
209
+ Parse SFTP Attributes from packet.
210
+ """
211
+ flags = pkt.get_uint32()
212
+ size = None
213
+ uid = None
214
+ gid = None
215
+ mode = None
216
+ atime = None
217
+ mtime = None
218
+
219
+ if flags & sshconstants.SSH_FILEXFER_ATTR_SIZE:
220
+ size = pkt.get_uint64()
221
+ if flags & sshconstants.SSH_FILEXFER_ATTR_UIDGID:
222
+ uid = pkt.get_uint32()
223
+ gid = pkt.get_uint32()
224
+ if flags & sshconstants.SSH_FILEXFER_ATTR_PERMISSIONS:
225
+ mode = pkt.get_uint32()
226
+ if flags & sshconstants.SSH_FILEXFER_ATTR_ACMODTIME:
227
+ atime = pkt.get_uint32()
228
+ mtime = pkt.get_uint32()
229
+ if flags & sshconstants.SSH_FILEXFER_ATTR_EXTENDED:
230
+ count = pkt.get_uint32()
231
+ for _ in range(count):
232
+ pkt.get_string()
233
+ pkt.get_string()
234
+
235
+ return SshSFTPAttributes(size, uid, gid, mode, atime, mtime)
236
+
237
+ def _next_id(self):
238
+ with self.lock:
239
+ self.request_id += 1
240
+ return self.request_id
241
+
242
+ def _send_packet(self, msg_type: int, payload: bytes):
243
+ # Frame: Length (4) + Type (1) + Payload
244
+ length = 1 + len(payload)
245
+ out = struct.pack('>I', length) + bytes([msg_type]) + payload
246
+ self.channel.send(out)
247
+
248
+ def _recv_packet(self):
249
+ # Read length
250
+ data = self._read_exactly(4)
251
+ length = struct.unpack('>I', data)[0]
252
+
253
+ # Read type + payload
254
+ data = self._read_exactly(length)
255
+ msg_type = data[0]
256
+ payload = data[1:]
257
+ return msg_type, payload
258
+
259
+ def _read_exactly(self, n):
260
+ data = b""
261
+ while len(data) < n:
262
+ chunk = self.channel.recv(n - len(data))
263
+ if not chunk:
264
+ raise SshSFTPError("Connection closed")
265
+ data += chunk
266
+ return data
267
+
268
+ def _parse_handle_response(self, msg_id, payload, req_id):
269
+ if msg_id == sshconstants.SSH_FXP_HANDLE:
270
+ pkt = SshPacket(payload=payload)
271
+ rid = pkt.get_uint32()
272
+ if rid != req_id:
273
+ raise SshSFTPError("Request ID mismatch")
274
+ return pkt.get_string()
275
+ elif msg_id == sshconstants.SSH_FXP_STATUS:
276
+ self._check_status(payload, req_id)
277
+ # A non-OK status raises above; an OK status carries no handle.
278
+ return None
279
+ else:
280
+ raise SshSFTPError(f"Unexpected message {msg_id}")
281
+
282
+ def _parse_status_response(self, msg_id, payload, req_id):
283
+ if msg_id == sshconstants.SSH_FXP_STATUS:
284
+ self._check_status(payload, req_id)
285
+ else:
286
+ raise SshSFTPError(f"Unexpected message {msg_id}")
287
+
288
+ def _check_status(self, payload, req_id):
289
+ pkt = SshPacket(payload=payload)
290
+ rid = pkt.get_uint32()
291
+ status_code = pkt.get_uint32()
292
+ if rid != req_id:
293
+ raise SshSFTPError("Request ID mismatch in status")
294
+ if status_code != sshconstants.SSH_FX_OK:
295
+ if status_code == sshconstants.SSH_FX_EOF:
296
+ raise EOFError("SFTP EOF")
297
+ raise SshSFTPError(f"SFTP Error Code: {status_code}")