ed2k-client 0.1.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,64 @@
1
+ """Python client for the eDonkey2000 (eD2k) and Kad networks: connect, search, download and share."""
2
+
3
+ from ed2k_client.client import Client, ClientConfig, ClientStatus, ServerStatus
4
+ from ed2k_client.download import AichPolicy, ContentPolicy, DownloadProgress, DownloadResult, DownloadSettings
5
+ from ed2k_client.errors import (
6
+ ConnectionFailedError,
7
+ DownloadError,
8
+ Ed2kError,
9
+ HashMismatchError,
10
+ LinkMismatchError,
11
+ NotConnectedError,
12
+ ProtocolError,
13
+ SecurityError,
14
+ StateInUseError,
15
+ )
16
+ from ed2k_client.hashing import Ed2kHash, FileHashes, hash_file
17
+ from ed2k_client.links import FileLink, ServerLink
18
+ from ed2k_client.search import FileType, SearchQuery, SearchResult
19
+ from ed2k_client.security import DocumentKind, Inspection, inspect_document
20
+ from ed2k_client.servermet import ServerEntry, ServerList, ServerMemory
21
+ from ed2k_client.settings import IoSettings, KadSettings, Limits, PeerSettings, ShareSettings, Timeouts
22
+ from ed2k_client.sources import Source
23
+
24
+ __all__ = [
25
+ 'AichPolicy',
26
+ 'Client',
27
+ 'ClientConfig',
28
+ 'ClientStatus',
29
+ 'ConnectionFailedError',
30
+ 'ContentPolicy',
31
+ 'DocumentKind',
32
+ 'DownloadError',
33
+ 'DownloadProgress',
34
+ 'DownloadResult',
35
+ 'DownloadSettings',
36
+ 'Ed2kError',
37
+ 'Ed2kHash',
38
+ 'FileHashes',
39
+ 'FileLink',
40
+ 'FileType',
41
+ 'HashMismatchError',
42
+ 'Inspection',
43
+ 'IoSettings',
44
+ 'KadSettings',
45
+ 'Limits',
46
+ 'LinkMismatchError',
47
+ 'NotConnectedError',
48
+ 'PeerSettings',
49
+ 'ProtocolError',
50
+ 'SearchQuery',
51
+ 'SearchResult',
52
+ 'SecurityError',
53
+ 'ServerEntry',
54
+ 'ServerLink',
55
+ 'ServerList',
56
+ 'ServerMemory',
57
+ 'ServerStatus',
58
+ 'ShareSettings',
59
+ 'Source',
60
+ 'StateInUseError',
61
+ 'Timeouts',
62
+ 'hash_file',
63
+ 'inspect_document',
64
+ ]
@@ -0,0 +1,7 @@
1
+ """Run the command line with ``python -m ed2k_client``."""
2
+
3
+ import sys
4
+
5
+ from ed2k_client.cli import main
6
+
7
+ sys.exit(main())
ed2k_client/aich.py ADDED
@@ -0,0 +1,351 @@
1
+ """AICH, the SHA-1 hash tree eMule keeps over a file, and the proof of block hashes against its root.
2
+
3
+ The tree follows ``CAICHHashTree`` of aMule:
4
+ https://github.com/amule-project/amule/blob/6a1105634d81242a581a0f36a7e00379b12a83f3/src/SHAHashSet.cpp#L102
5
+ Its leaves are blocks of 180 KiB that start at a part boundary, the request blocks of :mod:`ed2k_client.partfile`,
6
+ and its upper levels split the file into whole parts first. A node holding an odd number of units gives the larger
7
+ half to the side it hangs on, and the root counts as a left node. A node is named by its path from the root: the root
8
+ is 1, and every step down appends one bit, 1 for the left child and 0 for the right one.
9
+
10
+ The root comes from the ``h=`` field of a link, which makes it trusted. A peer with the complete file answers a
11
+ request for one part with the hashes of the part's blocks and of the sibling of every node on the way up. The client
12
+ folds them to the root and keeps only hashes that took part in reproducing it, so a forged answer needs a second
13
+ preimage of SHA-1. The blocks of a downloaded part are then compared with their proven hashes one by one.
14
+ """
15
+
16
+ from dataclasses import dataclass
17
+ from typing import TYPE_CHECKING, Self
18
+
19
+ from Crypto.Hash import SHA1
20
+
21
+ from ed2k_client.constants import AICH_HASH_LENGTH, BLOCK_SIZE, PART_SIZE
22
+ from ed2k_client.errors import ProtocolError
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Iterator, Mapping
26
+
27
+ from ed2k_client.wire import Reader
28
+
29
+ ROOT_IDENT: int = 1
30
+
31
+ # aMule refuses a 32-bit identifier above 2^22, which no file under the size limit needs:
32
+ # https://github.com/amule-project/amule/blob/6a1105634d81242a581a0f36a7e00379b12a83f3/src/SHAHashSet.cpp#L592
33
+ MAX_IDENT: int = 0x400000
34
+
35
+
36
+ def combine(left: bytes, right: bytes) -> bytes:
37
+ """Hash two child hashes into the hash of their parent.
38
+
39
+ :param left: the left child's hash.
40
+ :type left: bytes
41
+ :param right: the right child's hash.
42
+ :type right: bytes
43
+ :returns: the parent's hash.
44
+ :rtype: bytes
45
+ """
46
+ return SHA1.new(left + right).digest()
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class AichNode:
51
+ """One node of the tree: the bytes it covers and its name.
52
+
53
+ :ivar ident: the path from the root as bits, 1 for the root.
54
+ :ivar start: the first byte offset it covers.
55
+ :ivar size: the number of bytes it covers.
56
+ :ivar left: whether it is a left child, which the root counts as.
57
+ """
58
+
59
+ ident: int
60
+ start: int
61
+ size: int
62
+ left: bool
63
+
64
+ @classmethod
65
+ def root(cls, size: int) -> Self:
66
+ """Build the root of a file's tree.
67
+
68
+ :param size: the file size.
69
+ :type size: int
70
+ :returns: the root.
71
+ :rtype: Self
72
+ """
73
+ return cls(ident=ROOT_IDENT, start=0, size=size, left=True)
74
+
75
+ @property
76
+ def leaf(self) -> bool:
77
+ """Whether the node is one block and has no children."""
78
+ return self.size <= BLOCK_SIZE
79
+
80
+ def children(self) -> tuple[Self, Self]:
81
+ """Split the node the way aMule splits it.
82
+
83
+ :returns: the left and the right child.
84
+ :rtype: tuple[Self, Self]
85
+ """
86
+ # A node larger than a part is split by whole parts, a part or less by blocks:
87
+ # https://github.com/amule-project/amule/blob/6a1105634d81242a581a0f36a7e00379b12a83f3/src/SHAHashSet.cpp#L128
88
+ unit = BLOCK_SIZE if self.size <= PART_SIZE else PART_SIZE
89
+ units = -(-self.size // unit)
90
+ left_size = (units + self.left) // 2 * unit
91
+ cls = type(self)
92
+ return (
93
+ cls(ident=self.ident << 1 | 1, start=self.start, size=left_size, left=True),
94
+ cls(ident=self.ident << 1, start=self.start + left_size, size=self.size - left_size, left=False),
95
+ )
96
+
97
+ def path_to(self, *, start: int, size: int) -> list[Self]:
98
+ """Walk down to the node that covers exactly a range.
99
+
100
+ :param start: the first byte offset of the range.
101
+ :type start: int
102
+ :param size: the length of the range.
103
+ :type size: int
104
+ :returns: the nodes from this one down to the one covering the range.
105
+ :rtype: list[Self]
106
+ :raises ValueError: when no node covers exactly that range.
107
+ """
108
+ node, path = self, [self]
109
+ while (node.start, node.size) != (start, size):
110
+ if node.leaf:
111
+ err_msg = f'no node covers {size} bytes at {start}'
112
+ raise ValueError(err_msg)
113
+ left, right = node.children()
114
+ node = left if start < right.start else right
115
+ path.append(node)
116
+ return path
117
+
118
+ def leaves(self) -> Iterator[Self]:
119
+ """Yield the blocks under this node from left to right.
120
+
121
+ :returns: the leaves.
122
+ :rtype: Iterator[Self]
123
+ """
124
+ if self.leaf:
125
+ yield self
126
+ return
127
+ for child in self.children():
128
+ yield from child.leaves()
129
+
130
+ def descendant(self, ident: int) -> Self:
131
+ """Find the node an identifier names.
132
+
133
+ :param ident: the identifier.
134
+ :type ident: int
135
+ :returns: the node.
136
+ :rtype: Self
137
+ :raises ProtocolError: when the identifier names no node of this tree.
138
+ """
139
+ node = self
140
+ for shift in range(ident.bit_length() - 2, -1, -1):
141
+ if node.leaf:
142
+ err_msg = f'AICH identifier {ident} reaches below a block'
143
+ raise ProtocolError(err_msg)
144
+ left, right = node.children()
145
+ node = left if ident >> shift & 1 else right
146
+ return node
147
+
148
+
149
+ def read_recovery(reader: Reader, *, count: int) -> dict[int, bytes]:
150
+ """Read the hashes of a recovery answer by node identifier.
151
+
152
+ aMule writes 16-bit identifiers and a closing zero count for a file under 4 GB, and a zero count followed by
153
+ 32-bit identifiers for a larger one: https://github.com/amule-project/amule/blob/6a1105634d81242a581a0f36a7e00379b12a83f3/src/SHAHashSet.cpp#L534
154
+
155
+ :param reader: the reader, placed after the part number and the root.
156
+ :type reader: Reader
157
+ :param count: how many hashes the requested part needs.
158
+ :type count: int
159
+ :returns: the hashes, not yet proven.
160
+ :rtype: dict[int, bytes]
161
+ :raises ProtocolError: on another count, a repeated identifier or one that would replace the root.
162
+ """
163
+ wide = False
164
+ found = reader.u16()
165
+ if found == 0:
166
+ wide, found = True, reader.u16()
167
+ if found != count:
168
+ err_msg = f'AICH answer with {found} hashes where {count} were due'
169
+ raise ProtocolError(err_msg)
170
+
171
+ hashes: dict[int, bytes] = {}
172
+ for _ in range(found):
173
+ ident = reader.u32() if wide else reader.u16()
174
+ if not ROOT_IDENT < ident <= MAX_IDENT or ident in hashes:
175
+ err_msg = f'AICH answer with the invalid or repeated identifier {ident}'
176
+ raise ProtocolError(err_msg)
177
+ hashes[ident] = reader.take(AICH_HASH_LENGTH)
178
+ return hashes
179
+
180
+
181
+ class AichTree:
182
+ """The hashes of one file's tree that are proven against its trusted root.
183
+
184
+ :ivar size: the file size.
185
+ :ivar root: the trusted root hash.
186
+ """
187
+
188
+ def __init__(self, *, size: int, root: bytes) -> None:
189
+ """Start from the trusted root alone.
190
+
191
+ :param size: the file size.
192
+ :type size: int
193
+ :param root: the root hash from the link.
194
+ :type root: bytes
195
+ """
196
+ self.size = size
197
+ self.root = root
198
+ self._top = AichNode.root(size)
199
+ self._proven: dict[int, bytes] = {ROOT_IDENT: root}
200
+
201
+ def _part_path(self, part: int) -> list[AichNode]:
202
+ """Walk from the root to the node of one part.
203
+
204
+ :param part: the part index.
205
+ :type part: int
206
+ :returns: the nodes from the root down to the part.
207
+ :rtype: list[AichNode]
208
+ """
209
+ start = part * PART_SIZE
210
+ return self._top.path_to(start=start, size=min(PART_SIZE, self.size - start))
211
+
212
+ def block_hash(self, *, start: int, size: int) -> bytes | None:
213
+ """Look up the proven hash of one block.
214
+
215
+ :param start: the first byte offset of the block.
216
+ :type start: int
217
+ :param size: the block length.
218
+ :type size: int
219
+ :returns: the hash, or ``None`` while it is not proven.
220
+ :rtype: bytes | None
221
+ """
222
+ return self._proven.get(self._top.path_to(start=start, size=size)[-1].ident)
223
+
224
+ def ready(self, part: int) -> bool:
225
+ """Check whether every block of a part has a proven hash.
226
+
227
+ :param part: the part index.
228
+ :type part: int
229
+ :returns: whether the part can be checked block by block.
230
+ :rtype: bool
231
+ """
232
+ return all(leaf.ident in self._proven for leaf in self._part_path(part)[-1].leaves())
233
+
234
+ def source_part(self, part: int) -> int:
235
+ """Name the part whose recovery answer proves the blocks of a part.
236
+
237
+ A part of one block is a leaf, and aMule serves no answer for it. Being the last part, it is the right
238
+ sibling of a node on the way to the part before it, so that part's answer carries its hash:
239
+ https://github.com/amule-project/amule/blob/6a1105634d81242a581a0f36a7e00379b12a83f3/src/DownloadClient.cpp#L1681
240
+
241
+ :param part: the part index.
242
+ :type part: int
243
+ :returns: the part to ask for.
244
+ :rtype: int
245
+ """
246
+ return part - 1 if part > 0 and self._part_path(part)[-1].leaf else part
247
+
248
+ def hash_count(self, part: int) -> int:
249
+ """Count the hashes a recovery answer for a part carries.
250
+
251
+ :param part: the part index.
252
+ :type part: int
253
+ :returns: one sibling per level above the part plus one hash per block.
254
+ :rtype: int
255
+ """
256
+ path = self._part_path(part)
257
+ return len(path) - 1 + sum(1 for _ in path[-1].leaves())
258
+
259
+ def accept(self, *, part: int, hashes: Mapping[int, bytes]) -> None:
260
+ """Prove the hashes of a recovery answer against the root and keep the proven ones.
261
+
262
+ :param part: the part the answer was asked for.
263
+ :type part: int
264
+ :param hashes: the hashes by node identifier.
265
+ :type hashes: Mapping[int, bytes]
266
+ :raises ProtocolError: when the hashes do not reproduce the root or leave a block of the part out.
267
+ """
268
+ folded: dict[int, bytes] = {}
269
+ if self._fold(self._top, hashes=hashes, ancestors=self._ancestors(hashes), folded=folded) != self.root:
270
+ err_msg = f'AICH answer for part {part} does not lead to the root of the link'
271
+ raise ProtocolError(err_msg)
272
+
273
+ proven = self._proven_by(folded=folded, hashes=hashes)
274
+ if any(leaf.ident not in proven for leaf in self._part_path(part)[-1].leaves()):
275
+ err_msg = f'the recovery hashes leave blocks of part {part} out'
276
+ raise ProtocolError(err_msg)
277
+ self._proven.update(proven)
278
+
279
+ def _ancestors(self, hashes: Mapping[int, bytes]) -> set[int]:
280
+ """Collect the nodes above the given ones, the only nodes a fold has to visit.
281
+
282
+ Folding along them alone makes a 4 GB file cost as much as a small one.
283
+
284
+ :param hashes: the hashes by node identifier.
285
+ :type hashes: Mapping[int, bytes]
286
+ :returns: the identifiers of every strict ancestor of a given node.
287
+ :rtype: set[int]
288
+ :raises ProtocolError: when an identifier names no node of this file's tree.
289
+ """
290
+ ancestors: set[int] = set()
291
+ for ident in hashes:
292
+ self._top.descendant(ident)
293
+ parent = ident
294
+ while parent > ROOT_IDENT:
295
+ parent >>= 1
296
+ ancestors.add(parent)
297
+ return ancestors
298
+
299
+ def _fold(
300
+ self, node: AichNode, *, hashes: Mapping[int, bytes], ancestors: set[int], folded: dict[int, bytes]
301
+ ) -> bytes | None:
302
+ """Compute a node's hash from below where possible and check it against a given one.
303
+
304
+ :param node: the node.
305
+ :type node: AichNode
306
+ :param hashes: the hashes of the answer.
307
+ :type hashes: Mapping[int, bytes]
308
+ :param ancestors: the nodes above the given ones.
309
+ :type ancestors: set[int]
310
+ :param folded: receives every hash computed from children.
311
+ :type folded: dict[int, bytes]
312
+ :returns: the hash, or ``None`` when neither the answer nor its children give one.
313
+ :rtype: bytes | None
314
+ :raises ProtocolError: when a given hash contradicts the hash of its children.
315
+ """
316
+ given = hashes.get(node.ident)
317
+ if node.ident not in ancestors:
318
+ return given
319
+ left, right = (
320
+ self._fold(child, hashes=hashes, ancestors=ancestors, folded=folded) for child in node.children()
321
+ )
322
+ if left is None or right is None:
323
+ return given
324
+ digest = combine(left, right)
325
+ if given is not None and given != digest:
326
+ err_msg = f'AICH hash of node {node.ident} contradicts its children'
327
+ raise ProtocolError(err_msg)
328
+ folded[node.ident] = digest
329
+ return digest
330
+
331
+ def _proven_by(self, *, folded: Mapping[int, bytes], hashes: Mapping[int, bytes]) -> dict[int, bytes]:
332
+ """Collect the hashes the root was computed from, so a stray hash beside them proves nothing.
333
+
334
+ :param folded: the hashes computed from children.
335
+ :type folded: Mapping[int, bytes]
336
+ :param hashes: the hashes of the answer.
337
+ :type hashes: Mapping[int, bytes]
338
+ :returns: the proven hashes by node identifier.
339
+ :rtype: dict[int, bytes]
340
+ """
341
+ # Every child of a folded node gave its parent a hash, either folded from below or taken from the answer
342
+ proven: dict[int, bytes] = {}
343
+ pending = [self._top]
344
+ while pending:
345
+ node = pending.pop()
346
+ if node.ident in folded:
347
+ proven[node.ident] = folded[node.ident]
348
+ pending.extend(node.children())
349
+ else:
350
+ proven[node.ident] = hashes[node.ident]
351
+ return proven