mirror of
https://github.com/warppipe/swift-torrent.git
synced 2026-05-28 15:27:20 +00:00
Implement SwiftTorrent: pure Swift BitTorrent library
Full BEP-3 peer wire protocol, BEP-5 DHT, BEP-15 UDP trackers, magnet link support, bencode serialization, rarest-first piece selection, and async session management using SwiftNIO and swift-crypto. Includes 64 passing unit tests. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
*.xcodeproj/
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,158 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "swift-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-algorithms.git",
|
||||
"state" : {
|
||||
"revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023",
|
||||
"version" : "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-asn1",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-asn1.git",
|
||||
"state" : {
|
||||
"revision" : "810496cf121e525d660cd0ea89a758740476b85f",
|
||||
"version" : "1.5.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-async-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-async-algorithms.git",
|
||||
"state" : {
|
||||
"revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804",
|
||||
"version" : "1.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-atomics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-certificates",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-certificates.git",
|
||||
"state" : {
|
||||
"revision" : "7d5f6124c91a2d06fb63a811695a3400d15a100e",
|
||||
"version" : "1.17.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections.git",
|
||||
"state" : {
|
||||
"revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-crypto",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-crypto.git",
|
||||
"state" : {
|
||||
"revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc",
|
||||
"version" : "3.15.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-http-structured-headers",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-http-structured-headers.git",
|
||||
"state" : {
|
||||
"revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b",
|
||||
"version" : "1.6.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-http-types",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-http-types.git",
|
||||
"state" : {
|
||||
"revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca",
|
||||
"version" : "1.5.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-log",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log.git",
|
||||
"state" : {
|
||||
"revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181",
|
||||
"version" : "1.9.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio.git",
|
||||
"state" : {
|
||||
"revision" : "5e72fc102906ebe75a3487595a653e6f43725552",
|
||||
"version" : "2.94.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-extras",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-extras.git",
|
||||
"state" : {
|
||||
"revision" : "3df009d563dc9f21a5c85b33d8c2e34d2e4f8c3b",
|
||||
"version" : "1.32.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-http2",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-http2.git",
|
||||
"state" : {
|
||||
"revision" : "c2ba4cfbb83f307c66f5a6df6bb43e3c88dfbf80",
|
||||
"version" : "1.39.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-ssl",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-ssl.git",
|
||||
"state" : {
|
||||
"revision" : "173cc69a058623525a58ae6710e2f5727c663793",
|
||||
"version" : "2.36.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-numerics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-numerics.git",
|
||||
"state" : {
|
||||
"revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
|
||||
"version" : "1.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-service-lifecycle",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swift-server/swift-service-lifecycle.git",
|
||||
"state" : {
|
||||
"revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348",
|
||||
"version" : "2.9.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-system",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-system.git",
|
||||
"state" : {
|
||||
"revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df",
|
||||
"version" : "1.6.4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "SwiftTorrent",
|
||||
platforms: [
|
||||
.macOS(.v14),
|
||||
.iOS(.v17)
|
||||
],
|
||||
products: [
|
||||
.library(name: "SwiftTorrent", targets: ["SwiftTorrent"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"),
|
||||
.package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.0.0"),
|
||||
.package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "SwiftTorrent",
|
||||
dependencies: [
|
||||
.product(name: "NIO", package: "swift-nio"),
|
||||
.product(name: "NIOCore", package: "swift-nio"),
|
||||
.product(name: "NIOPosix", package: "swift-nio"),
|
||||
.product(name: "NIOExtras", package: "swift-nio-extras"),
|
||||
.product(name: "Crypto", package: "swift-crypto"),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "SwiftTorrentTests",
|
||||
dependencies: ["SwiftTorrent"]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
/// Base protocol for all alert types.
|
||||
public protocol Alert: Sendable {
|
||||
var timestamp: Date { get }
|
||||
var category: AlertCategory { get }
|
||||
}
|
||||
|
||||
public enum AlertCategory: Sendable {
|
||||
case status
|
||||
case error
|
||||
case peer
|
||||
case tracker
|
||||
case storage
|
||||
case dht
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Status Alerts
|
||||
|
||||
public struct TorrentAddedAlert: Alert {
|
||||
public let timestamp = Date()
|
||||
public let category = AlertCategory.status
|
||||
public let infoHash: InfoHash
|
||||
public let name: String
|
||||
}
|
||||
|
||||
public struct TorrentRemovedAlert: Alert {
|
||||
public let timestamp = Date()
|
||||
public let category = AlertCategory.status
|
||||
public let infoHash: InfoHash
|
||||
}
|
||||
|
||||
public struct TorrentFinishedAlert: Alert {
|
||||
public let timestamp = Date()
|
||||
public let category = AlertCategory.status
|
||||
public let infoHash: InfoHash
|
||||
}
|
||||
|
||||
public struct StateChangedAlert: Alert {
|
||||
public let timestamp = Date()
|
||||
public let category = AlertCategory.status
|
||||
public let infoHash: InfoHash
|
||||
public let previousState: TorrentState
|
||||
public let newState: TorrentState
|
||||
}
|
||||
|
||||
// MARK: - Peer Alerts
|
||||
|
||||
public struct PeerConnectedAlert: Alert {
|
||||
public let timestamp = Date()
|
||||
public let category = AlertCategory.peer
|
||||
public let address: String
|
||||
public let port: UInt16
|
||||
}
|
||||
|
||||
public struct PeerDisconnectedAlert: Alert {
|
||||
public let timestamp = Date()
|
||||
public let category = AlertCategory.peer
|
||||
public let address: String
|
||||
public let port: UInt16
|
||||
public let reason: String?
|
||||
}
|
||||
|
||||
// MARK: - Tracker Alerts
|
||||
|
||||
public struct TrackerResponseAlert: Alert {
|
||||
public let timestamp = Date()
|
||||
public let category = AlertCategory.tracker
|
||||
public let url: String
|
||||
public let numPeers: Int
|
||||
}
|
||||
|
||||
public struct TrackerErrorAlert: Alert {
|
||||
public let timestamp = Date()
|
||||
public let category = AlertCategory.error
|
||||
public let url: String
|
||||
public let message: String
|
||||
}
|
||||
|
||||
// MARK: - Storage Alerts
|
||||
|
||||
public struct PieceFinishedAlert: Alert {
|
||||
public let timestamp = Date()
|
||||
public let category = AlertCategory.storage
|
||||
public let pieceIndex: Int
|
||||
}
|
||||
|
||||
public struct HashFailedAlert: Alert {
|
||||
public let timestamp = Date()
|
||||
public let category = AlertCategory.storage
|
||||
public let pieceIndex: Int
|
||||
}
|
||||
|
||||
// MARK: - Error Alerts
|
||||
|
||||
public struct FileErrorAlert: Alert {
|
||||
public let timestamp = Date()
|
||||
public let category = AlertCategory.error
|
||||
public let path: String
|
||||
public let error: String
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import Foundation
|
||||
|
||||
public enum BencodeError: Error, Equatable {
|
||||
case unexpectedEnd
|
||||
case invalidFormat(String)
|
||||
case invalidInteger
|
||||
case invalidStringLength
|
||||
case invalidDictionaryKey
|
||||
}
|
||||
|
||||
public struct BencodeDecoder: Sendable {
|
||||
public init() {}
|
||||
|
||||
public func decode(_ data: Data) throws -> BencodeValue {
|
||||
var index = data.startIndex
|
||||
let result = try decodeValue(data, index: &index)
|
||||
return result
|
||||
}
|
||||
|
||||
/// Decode and also return the raw bytes consumed for the value, useful for info_hash computation.
|
||||
public func decodeWithRange(_ data: Data) throws -> (value: BencodeValue, range: Range<Data.Index>) {
|
||||
var index = data.startIndex
|
||||
let start = index
|
||||
let result = try decodeValue(data, index: &index)
|
||||
return (result, start..<index)
|
||||
}
|
||||
|
||||
private func decodeValue(_ data: Data, index: inout Data.Index) throws -> BencodeValue {
|
||||
guard index < data.endIndex else { throw BencodeError.unexpectedEnd }
|
||||
|
||||
switch data[index] {
|
||||
case UInt8(ascii: "i"):
|
||||
return try decodeInteger(data, index: &index)
|
||||
case UInt8(ascii: "l"):
|
||||
return try decodeList(data, index: &index)
|
||||
case UInt8(ascii: "d"):
|
||||
return try decodeDictionary(data, index: &index)
|
||||
case UInt8(ascii: "0")...UInt8(ascii: "9"):
|
||||
return try decodeString(data, index: &index)
|
||||
default:
|
||||
throw BencodeError.invalidFormat("Unexpected byte: \(data[index])")
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeInteger(_ data: Data, index: inout Data.Index) throws -> BencodeValue {
|
||||
index = data.index(after: index) // skip 'i'
|
||||
guard let endIdx = data[index...].firstIndex(of: UInt8(ascii: "e")) else {
|
||||
throw BencodeError.unexpectedEnd
|
||||
}
|
||||
guard let str = String(data: data[index..<endIdx], encoding: .ascii),
|
||||
let value = Int64(str) else {
|
||||
throw BencodeError.invalidInteger
|
||||
}
|
||||
index = data.index(after: endIdx) // skip 'e'
|
||||
return .integer(value)
|
||||
}
|
||||
|
||||
private func decodeString(_ data: Data, index: inout Data.Index) throws -> BencodeValue {
|
||||
guard let colonIdx = data[index...].firstIndex(of: UInt8(ascii: ":")) else {
|
||||
throw BencodeError.unexpectedEnd
|
||||
}
|
||||
guard let lenStr = String(data: data[index..<colonIdx], encoding: .ascii),
|
||||
let length = Int(lenStr), length >= 0 else {
|
||||
throw BencodeError.invalidStringLength
|
||||
}
|
||||
let start = data.index(after: colonIdx)
|
||||
let end = data.index(start, offsetBy: length)
|
||||
guard end <= data.endIndex else { throw BencodeError.unexpectedEnd }
|
||||
index = end
|
||||
return .string(Data(data[start..<end]))
|
||||
}
|
||||
|
||||
private func decodeList(_ data: Data, index: inout Data.Index) throws -> BencodeValue {
|
||||
index = data.index(after: index) // skip 'l'
|
||||
var items: [BencodeValue] = []
|
||||
while index < data.endIndex && data[index] != UInt8(ascii: "e") {
|
||||
items.append(try decodeValue(data, index: &index))
|
||||
}
|
||||
guard index < data.endIndex else { throw BencodeError.unexpectedEnd }
|
||||
index = data.index(after: index) // skip 'e'
|
||||
return .list(items)
|
||||
}
|
||||
|
||||
private func decodeDictionary(_ data: Data, index: inout Data.Index) throws -> BencodeValue {
|
||||
index = data.index(after: index) // skip 'd'
|
||||
var pairs: [(key: Data, value: BencodeValue)] = []
|
||||
while index < data.endIndex && data[index] != UInt8(ascii: "e") {
|
||||
let keyValue = try decodeString(data, index: &index)
|
||||
guard case .string(let keyData) = keyValue else {
|
||||
throw BencodeError.invalidDictionaryKey
|
||||
}
|
||||
let value = try decodeValue(data, index: &index)
|
||||
pairs.append((key: keyData, value: value))
|
||||
}
|
||||
guard index < data.endIndex else { throw BencodeError.unexpectedEnd }
|
||||
index = data.index(after: index) // skip 'e'
|
||||
return .dictionary(pairs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
public struct BencodeEncoder: Sendable {
|
||||
public init() {}
|
||||
|
||||
public func encode(_ value: BencodeValue) -> Data {
|
||||
var data = Data()
|
||||
encodeValue(value, into: &data)
|
||||
return data
|
||||
}
|
||||
|
||||
private func encodeValue(_ value: BencodeValue, into data: inout Data) {
|
||||
switch value {
|
||||
case .integer(let v):
|
||||
data.append(UInt8(ascii: "i"))
|
||||
data.append(contentsOf: String(v).utf8)
|
||||
data.append(UInt8(ascii: "e"))
|
||||
|
||||
case .string(let v):
|
||||
data.append(contentsOf: String(v.count).utf8)
|
||||
data.append(UInt8(ascii: ":"))
|
||||
data.append(v)
|
||||
|
||||
case .list(let items):
|
||||
data.append(UInt8(ascii: "l"))
|
||||
for item in items {
|
||||
encodeValue(item, into: &data)
|
||||
}
|
||||
data.append(UInt8(ascii: "e"))
|
||||
|
||||
case .dictionary(let pairs):
|
||||
data.append(UInt8(ascii: "d"))
|
||||
// Keys must be sorted lexicographically
|
||||
let sorted = pairs.sorted { $0.key.lexicographicallyPrecedes($1.key) }
|
||||
for pair in sorted {
|
||||
data.append(contentsOf: String(pair.key.count).utf8)
|
||||
data.append(UInt8(ascii: ":"))
|
||||
data.append(pair.key)
|
||||
encodeValue(pair.value, into: &data)
|
||||
}
|
||||
data.append(UInt8(ascii: "e"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
|
||||
/// Represents a bencoded value.
|
||||
public enum BencodeValue: Equatable, Sendable {
|
||||
case integer(Int64)
|
||||
case string(Data)
|
||||
case list([BencodeValue])
|
||||
case dictionary([(key: Data, value: BencodeValue)])
|
||||
|
||||
// MARK: - Convenience accessors
|
||||
|
||||
public var integerValue: Int64? {
|
||||
if case .integer(let v) = self { return v }
|
||||
return nil
|
||||
}
|
||||
|
||||
public var stringValue: Data? {
|
||||
if case .string(let v) = self { return v }
|
||||
return nil
|
||||
}
|
||||
|
||||
public var utf8String: String? {
|
||||
if case .string(let v) = self { return String(data: v, encoding: .utf8) }
|
||||
return nil
|
||||
}
|
||||
|
||||
public var listValue: [BencodeValue]? {
|
||||
if case .list(let v) = self { return v }
|
||||
return nil
|
||||
}
|
||||
|
||||
public var dictionaryValue: [(key: Data, value: BencodeValue)]? {
|
||||
if case .dictionary(let v) = self { return v }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Subscript dictionary by string key.
|
||||
public subscript(_ key: String) -> BencodeValue? {
|
||||
guard case .dictionary(let pairs) = self else { return nil }
|
||||
let keyData = Data(key.utf8)
|
||||
return pairs.first(where: { $0.key == keyData })?.value
|
||||
}
|
||||
|
||||
// MARK: - Equatable
|
||||
|
||||
public static func == (lhs: BencodeValue, rhs: BencodeValue) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.integer(let a), .integer(let b)):
|
||||
return a == b
|
||||
case (.string(let a), .string(let b)):
|
||||
return a == b
|
||||
case (.list(let a), .list(let b)):
|
||||
return a == b
|
||||
case (.dictionary(let a), .dictionary(let b)):
|
||||
guard a.count == b.count else { return false }
|
||||
for (pairA, pairB) in zip(a, b) {
|
||||
if pairA.key != pairB.key || pairA.value != pairB.value { return false }
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
|
||||
/// KRPC protocol messages for DHT (BEP-5).
|
||||
public enum DHTMessage: Sendable {
|
||||
case query(transactionID: Data, queryType: QueryType, arguments: [(key: Data, value: BencodeValue)])
|
||||
case response(transactionID: Data, values: [(key: Data, value: BencodeValue)])
|
||||
case error(transactionID: Data, code: Int, message: String)
|
||||
|
||||
public enum QueryType: String, Sendable {
|
||||
case ping
|
||||
case findNode = "find_node"
|
||||
case getPeers = "get_peers"
|
||||
case announcePeer = "announce_peer"
|
||||
}
|
||||
|
||||
/// Encode to bencoded data.
|
||||
public func encode() -> Data {
|
||||
let encoder = BencodeEncoder()
|
||||
let value: BencodeValue
|
||||
|
||||
switch self {
|
||||
case .query(let txID, let queryType, let args):
|
||||
value = .dictionary([
|
||||
(key: Data("a".utf8), value: .dictionary(args)),
|
||||
(key: Data("q".utf8), value: .string(Data(queryType.rawValue.utf8))),
|
||||
(key: Data("t".utf8), value: .string(txID)),
|
||||
(key: Data("y".utf8), value: .string(Data("q".utf8))),
|
||||
])
|
||||
|
||||
case .response(let txID, let values):
|
||||
value = .dictionary([
|
||||
(key: Data("r".utf8), value: .dictionary(values)),
|
||||
(key: Data("t".utf8), value: .string(txID)),
|
||||
(key: Data("y".utf8), value: .string(Data("r".utf8))),
|
||||
])
|
||||
|
||||
case .error(let txID, let code, let msg):
|
||||
value = .dictionary([
|
||||
(key: Data("e".utf8), value: .list([.integer(Int64(code)), .string(Data(msg.utf8))])),
|
||||
(key: Data("t".utf8), value: .string(txID)),
|
||||
(key: Data("y".utf8), value: .string(Data("e".utf8))),
|
||||
])
|
||||
}
|
||||
|
||||
return encoder.encode(value)
|
||||
}
|
||||
|
||||
/// Decode from bencoded data.
|
||||
public static func decode(from data: Data) throws -> DHTMessage {
|
||||
let decoder = BencodeDecoder()
|
||||
let value = try decoder.decode(data)
|
||||
|
||||
guard let typeStr = value["y"]?.utf8String,
|
||||
let txID = value["t"]?.stringValue else {
|
||||
throw DHTMessageError.invalidMessage
|
||||
}
|
||||
|
||||
switch typeStr {
|
||||
case "q":
|
||||
guard let queryStr = value["q"]?.utf8String,
|
||||
let queryType = QueryType(rawValue: queryStr),
|
||||
let args = value["a"]?.dictionaryValue else {
|
||||
throw DHTMessageError.invalidMessage
|
||||
}
|
||||
return .query(transactionID: txID, queryType: queryType, arguments: args)
|
||||
|
||||
case "r":
|
||||
guard let values = value["r"]?.dictionaryValue else {
|
||||
throw DHTMessageError.invalidMessage
|
||||
}
|
||||
return .response(transactionID: txID, values: values)
|
||||
|
||||
case "e":
|
||||
guard let errorList = value["e"]?.listValue,
|
||||
errorList.count >= 2,
|
||||
let code = errorList[0].integerValue,
|
||||
let msg = errorList[1].utf8String else {
|
||||
throw DHTMessageError.invalidMessage
|
||||
}
|
||||
return .error(transactionID: txID, code: Int(code), message: msg)
|
||||
|
||||
default:
|
||||
throw DHTMessageError.invalidMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum DHTMessageError: Error {
|
||||
case invalidMessage
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
|
||||
/// A DHT node that handles KRPC queries (BEP-5).
|
||||
public actor DHTNode {
|
||||
public let nodeID: NodeID
|
||||
private var routingTable: DHTRoutingTable
|
||||
private var storage: DHTStorage
|
||||
private let group: EventLoopGroup
|
||||
private var channel: Channel?
|
||||
private let port: Int
|
||||
|
||||
public init(nodeID: NodeID = .random(), port: Int = 6881, group: EventLoopGroup) {
|
||||
self.nodeID = nodeID
|
||||
self.routingTable = DHTRoutingTable(ownID: nodeID)
|
||||
self.storage = DHTStorage()
|
||||
self.group = group
|
||||
self.port = port
|
||||
}
|
||||
|
||||
/// Start the DHT node, binding to a UDP port.
|
||||
public func start() async throws {
|
||||
self.channel = try await DatagramBootstrap(group: group)
|
||||
.channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
|
||||
.bind(host: "0.0.0.0", port: port)
|
||||
.get()
|
||||
// Store channel for sending
|
||||
// In a full implementation, add a channel handler for incoming messages
|
||||
}
|
||||
|
||||
/// Send a ping query.
|
||||
public func ping(to address: String, port: UInt16) async throws {
|
||||
let txID = generateTransactionID()
|
||||
let msg = DHTMessage.query(
|
||||
transactionID: txID,
|
||||
queryType: .ping,
|
||||
arguments: [
|
||||
(key: Data("id".utf8), value: .string(nodeID.bytes))
|
||||
]
|
||||
)
|
||||
try await sendMessage(msg, to: address, port: port)
|
||||
}
|
||||
|
||||
/// Send a find_node query.
|
||||
public func findNode(target: NodeID, to address: String, port: UInt16) async throws {
|
||||
let txID = generateTransactionID()
|
||||
let msg = DHTMessage.query(
|
||||
transactionID: txID,
|
||||
queryType: .findNode,
|
||||
arguments: [
|
||||
(key: Data("id".utf8), value: .string(nodeID.bytes)),
|
||||
(key: Data("target".utf8), value: .string(target.bytes)),
|
||||
]
|
||||
)
|
||||
try await sendMessage(msg, to: address, port: port)
|
||||
}
|
||||
|
||||
/// Send a get_peers query.
|
||||
public func getPeers(infoHash: InfoHash, to address: String, port: UInt16) async throws {
|
||||
let txID = generateTransactionID()
|
||||
let msg = DHTMessage.query(
|
||||
transactionID: txID,
|
||||
queryType: .getPeers,
|
||||
arguments: [
|
||||
(key: Data("id".utf8), value: .string(nodeID.bytes)),
|
||||
(key: Data("info_hash".utf8), value: .string(infoHash.bytes)),
|
||||
]
|
||||
)
|
||||
try await sendMessage(msg, to: address, port: port)
|
||||
}
|
||||
|
||||
/// Send an announce_peer query.
|
||||
public func announcePeer(infoHash: InfoHash, port: UInt16, token: Data,
|
||||
to address: String, nodePort: UInt16) async throws {
|
||||
let txID = generateTransactionID()
|
||||
let msg = DHTMessage.query(
|
||||
transactionID: txID,
|
||||
queryType: .announcePeer,
|
||||
arguments: [
|
||||
(key: Data("id".utf8), value: .string(nodeID.bytes)),
|
||||
(key: Data("implied_port".utf8), value: .integer(0)),
|
||||
(key: Data("info_hash".utf8), value: .string(infoHash.bytes)),
|
||||
(key: Data("port".utf8), value: .integer(Int64(port))),
|
||||
(key: Data("token".utf8), value: .string(token)),
|
||||
]
|
||||
)
|
||||
try await sendMessage(msg, to: address, port: nodePort)
|
||||
}
|
||||
|
||||
/// Get closest nodes from routing table.
|
||||
public func closestNodes(to target: NodeID) -> [DHTNodeEntry] {
|
||||
routingTable.closestNodes(to: target)
|
||||
}
|
||||
|
||||
/// Add a node to the routing table.
|
||||
public func addNode(_ entry: DHTNodeEntry) {
|
||||
_ = routingTable.insert(entry)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func generateTransactionID() -> Data {
|
||||
var data = Data(count: 2)
|
||||
data[0] = UInt8.random(in: 0...255)
|
||||
data[1] = UInt8.random(in: 0...255)
|
||||
return data
|
||||
}
|
||||
|
||||
private func sendMessage(_ msg: DHTMessage, to address: String, port: UInt16) async throws {
|
||||
guard let ch = channel else { return }
|
||||
let data = msg.encode()
|
||||
let remoteAddr = try SocketAddress(ipAddress: address, port: Int(port))
|
||||
var buf = ch.allocator.buffer(capacity: data.count)
|
||||
buf.writeBytes(data)
|
||||
let envelope = AddressedEnvelope(remoteAddress: remoteAddr, data: buf)
|
||||
try await ch.writeAndFlush(envelope).get()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
|
||||
/// A DHT node entry in the routing table.
|
||||
public struct DHTNodeEntry: Sendable {
|
||||
public let id: NodeID
|
||||
public let address: String
|
||||
public let port: UInt16
|
||||
public var lastSeen: Date
|
||||
|
||||
public init(id: NodeID, address: String, port: UInt16) {
|
||||
self.id = id
|
||||
self.address = address
|
||||
self.port = port
|
||||
self.lastSeen = Date()
|
||||
}
|
||||
}
|
||||
|
||||
/// Kademlia k-bucket routing table (BEP-5).
|
||||
public struct DHTRoutingTable: Sendable {
|
||||
public static let k = 8 // max nodes per bucket
|
||||
public static let bucketCount = 160
|
||||
|
||||
public let ownID: NodeID
|
||||
private var buckets: [[DHTNodeEntry]]
|
||||
|
||||
public init(ownID: NodeID) {
|
||||
self.ownID = ownID
|
||||
self.buckets = Array(repeating: [], count: Self.bucketCount)
|
||||
}
|
||||
|
||||
/// Insert or update a node in the routing table.
|
||||
public mutating func insert(_ node: DHTNodeEntry) -> Bool {
|
||||
let index = ownID.bucketIndex(relativeTo: node.id)
|
||||
let bucketIdx = min(index, Self.bucketCount - 1)
|
||||
|
||||
// Check if node already exists
|
||||
if let existingIdx = buckets[bucketIdx].firstIndex(where: { $0.id == node.id }) {
|
||||
buckets[bucketIdx][existingIdx].lastSeen = Date()
|
||||
return true
|
||||
}
|
||||
|
||||
// Bucket not full — add
|
||||
if buckets[bucketIdx].count < Self.k {
|
||||
buckets[bucketIdx].append(node)
|
||||
return true
|
||||
}
|
||||
|
||||
// Bucket full — could implement splitting or eviction
|
||||
return false
|
||||
}
|
||||
|
||||
/// Find the closest nodes to a target ID.
|
||||
public func closestNodes(to target: NodeID, count: Int = Self.k) -> [DHTNodeEntry] {
|
||||
let all = buckets.flatMap { $0 }
|
||||
let sorted = all.sorted { a, b in
|
||||
distanceLessThan(a.id.distance(to: target), b.id.distance(to: target))
|
||||
}
|
||||
return Array(sorted.prefix(count))
|
||||
}
|
||||
|
||||
/// Get a specific bucket.
|
||||
public func bucket(at index: Int) -> [DHTNodeEntry] {
|
||||
guard index >= 0 && index < Self.bucketCount else { return [] }
|
||||
return buckets[index]
|
||||
}
|
||||
|
||||
/// Total number of nodes.
|
||||
public var nodeCount: Int {
|
||||
buckets.reduce(0) { $0 + $1.count }
|
||||
}
|
||||
|
||||
/// Remove stale nodes older than the given interval.
|
||||
public mutating func removeStaleNodes(olderThan interval: TimeInterval) {
|
||||
let cutoff = Date().addingTimeInterval(-interval)
|
||||
for i in 0..<buckets.count {
|
||||
buckets[i].removeAll { $0.lastSeen < cutoff }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
|
||||
/// Storage for DHT peer announcements with expiration.
|
||||
public struct DHTStorage: Sendable {
|
||||
private var peerStore: [Data: [PeerEntry]] // info_hash -> peers
|
||||
private let maxPeersPerHash: Int
|
||||
private let expirationInterval: TimeInterval
|
||||
|
||||
public init(maxPeersPerHash: Int = 100, expirationInterval: TimeInterval = 30 * 60) {
|
||||
self.peerStore = [:]
|
||||
self.maxPeersPerHash = maxPeersPerHash
|
||||
self.expirationInterval = expirationInterval
|
||||
}
|
||||
|
||||
/// Store a peer for an info hash.
|
||||
public mutating func addPeer(infoHash: Data, address: String, port: UInt16) {
|
||||
let entry = PeerEntry(address: address, port: port, addedAt: Date())
|
||||
var peers = peerStore[infoHash] ?? []
|
||||
// Remove existing entry for same address:port
|
||||
peers.removeAll { $0.address == address && $0.port == port }
|
||||
peers.append(entry)
|
||||
// Trim to max
|
||||
if peers.count > maxPeersPerHash {
|
||||
peers = Array(peers.suffix(maxPeersPerHash))
|
||||
}
|
||||
peerStore[infoHash] = peers
|
||||
}
|
||||
|
||||
/// Get peers for an info hash.
|
||||
public func getPeers(infoHash: Data) -> [(String, UInt16)] {
|
||||
let cutoff = Date().addingTimeInterval(-expirationInterval)
|
||||
return (peerStore[infoHash] ?? [])
|
||||
.filter { $0.addedAt > cutoff }
|
||||
.map { ($0.address, $0.port) }
|
||||
}
|
||||
|
||||
/// Remove expired entries.
|
||||
public mutating func removeExpired() {
|
||||
let cutoff = Date().addingTimeInterval(-expirationInterval)
|
||||
for (hash, peers) in peerStore {
|
||||
let filtered = peers.filter { $0.addedAt > cutoff }
|
||||
if filtered.isEmpty {
|
||||
peerStore.removeValue(forKey: hash)
|
||||
} else {
|
||||
peerStore[hash] = filtered
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PeerEntry: Sendable {
|
||||
let address: String
|
||||
let port: UInt16
|
||||
let addedAt: Date
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
|
||||
/// Iterative DHT lookup algorithms.
|
||||
public actor DHTTraversal {
|
||||
private let dhtNode: DHTNode
|
||||
private let alpha: Int // parallelism factor
|
||||
|
||||
public init(dhtNode: DHTNode, alpha: Int = 3) {
|
||||
self.dhtNode = dhtNode
|
||||
self.alpha = alpha
|
||||
}
|
||||
|
||||
/// Iterative find_node lookup — finds the k closest nodes to a target.
|
||||
public func findNode(target: NodeID) async throws -> [DHTNodeEntry] {
|
||||
var closest = await dhtNode.closestNodes(to: target)
|
||||
var queried = Set<NodeID>()
|
||||
var improved = true
|
||||
|
||||
while improved {
|
||||
improved = false
|
||||
let toQuery = closest
|
||||
.filter { !queried.contains($0.id) }
|
||||
.prefix(alpha)
|
||||
|
||||
for node in toQuery {
|
||||
queried.insert(node.id)
|
||||
do {
|
||||
try await dhtNode.findNode(target: target, to: node.address, port: node.port)
|
||||
// In a full implementation, we'd collect responses and merge into closest
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
let newClosest = await dhtNode.closestNodes(to: target)
|
||||
if newClosest.first?.id != closest.first?.id {
|
||||
improved = true
|
||||
closest = newClosest
|
||||
}
|
||||
}
|
||||
|
||||
return closest
|
||||
}
|
||||
|
||||
/// Iterative get_peers lookup — finds peers for an info hash.
|
||||
public func getPeers(infoHash: InfoHash) async throws -> [(String, UInt16)] {
|
||||
let target = NodeID(bytes: infoHash.bytes.prefix(20).count == 20
|
||||
? Data(infoHash.bytes.prefix(20))
|
||||
: infoHash.bytes + Data(count: max(0, 20 - infoHash.bytes.count)))
|
||||
|
||||
var closest = await dhtNode.closestNodes(to: target)
|
||||
var queried = Set<NodeID>()
|
||||
let peers: [(String, UInt16)] = []
|
||||
|
||||
for _ in 0..<10 { // max iterations
|
||||
let toQuery = closest
|
||||
.filter { !queried.contains($0.id) }
|
||||
.prefix(alpha)
|
||||
|
||||
if toQuery.isEmpty { break }
|
||||
|
||||
for node in toQuery {
|
||||
queried.insert(node.id)
|
||||
try? await dhtNode.getPeers(infoHash: infoHash, to: node.address, port: node.port)
|
||||
}
|
||||
|
||||
closest = await dhtNode.closestNodes(to: target)
|
||||
}
|
||||
|
||||
return peers
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
|
||||
/// 160-bit DHT node identifier.
|
||||
public struct NodeID: Hashable, Sendable, CustomStringConvertible {
|
||||
public let bytes: Data // 20 bytes
|
||||
|
||||
public init(bytes: Data) {
|
||||
precondition(bytes.count == 20)
|
||||
self.bytes = bytes
|
||||
}
|
||||
|
||||
/// Generate a random node ID.
|
||||
public static func random() -> NodeID {
|
||||
var data = Data(count: 20)
|
||||
for i in 0..<20 {
|
||||
data[i] = UInt8.random(in: 0...255)
|
||||
}
|
||||
return NodeID(bytes: data)
|
||||
}
|
||||
|
||||
/// XOR distance between two node IDs.
|
||||
public func distance(to other: NodeID) -> Data {
|
||||
var result = Data(count: 20)
|
||||
for i in 0..<20 {
|
||||
result[i] = bytes[bytes.startIndex + i] ^ other.bytes[other.bytes.startIndex + i]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// The index of the highest set bit in the distance (0-159), used for bucket selection.
|
||||
public func bucketIndex(relativeTo other: NodeID) -> Int {
|
||||
let dist = distance(to: other)
|
||||
for i in 0..<20 {
|
||||
let byte = dist[i]
|
||||
if byte != 0 {
|
||||
let bit = 7 - byte.leadingZeroBitCount
|
||||
return (19 - i) * 8 + bit
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
bytes.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare distances: returns true if d1 < d2.
|
||||
public func distanceLessThan(_ d1: Data, _ d2: Data) -> Bool {
|
||||
for i in 0..<min(d1.count, d2.count) {
|
||||
if d1[d1.startIndex + i] < d2[d2.startIndex + i] { return true }
|
||||
if d1[d1.startIndex + i] > d2[d2.startIndex + i] { return false }
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
|
||||
/// BitTorrent handshake message.
|
||||
public struct Handshake: Sendable, Equatable {
|
||||
public static let protocolString = "BitTorrent protocol"
|
||||
public static let length = 68 // 1 + 19 + 8 + 20 + 20
|
||||
|
||||
public let infoHash: Data // 20 bytes
|
||||
public let peerID: Data // 20 bytes
|
||||
public let reserved: Data // 8 bytes (extension bits)
|
||||
|
||||
public init(infoHash: Data, peerID: Data, reserved: Data = Data(count: 8)) {
|
||||
precondition(infoHash.count == 20)
|
||||
precondition(peerID.count == 20)
|
||||
self.infoHash = infoHash
|
||||
self.peerID = peerID
|
||||
self.reserved = reserved
|
||||
}
|
||||
|
||||
/// Encode to wire format.
|
||||
public func encode() -> Data {
|
||||
var data = Data(capacity: Self.length)
|
||||
data.append(UInt8(Self.protocolString.count))
|
||||
data.append(contentsOf: Self.protocolString.utf8)
|
||||
data.append(reserved)
|
||||
data.append(infoHash)
|
||||
data.append(peerID)
|
||||
return data
|
||||
}
|
||||
|
||||
/// Decode from wire format.
|
||||
public static func decode(from data: Data) throws -> Handshake {
|
||||
guard data.count >= length else {
|
||||
throw HandshakeError.tooShort
|
||||
}
|
||||
let pstrLen = Int(data[data.startIndex])
|
||||
guard pstrLen == protocolString.count else {
|
||||
throw HandshakeError.invalidProtocol
|
||||
}
|
||||
let pstr = String(data: data[data.startIndex+1..<data.startIndex+1+pstrLen], encoding: .utf8)
|
||||
guard pstr == protocolString else {
|
||||
throw HandshakeError.invalidProtocol
|
||||
}
|
||||
let reservedStart = data.startIndex + 1 + pstrLen
|
||||
let reserved = Data(data[reservedStart..<reservedStart+8])
|
||||
let hashStart = reservedStart + 8
|
||||
let infoHash = Data(data[hashStart..<hashStart+20])
|
||||
let peerIDStart = hashStart + 20
|
||||
let peerID = Data(data[peerIDStart..<peerIDStart+20])
|
||||
return Handshake(infoHash: infoHash, peerID: peerID, reserved: reserved)
|
||||
}
|
||||
}
|
||||
|
||||
public enum HandshakeError: Error, Equatable {
|
||||
case tooShort
|
||||
case invalidProtocol
|
||||
}
|
||||
|
||||
/// Generate a random peer ID in Azureus style: -ST0001-<random 12 bytes>
|
||||
public func generatePeerID() -> Data {
|
||||
var id = Data("-ST0001-".utf8)
|
||||
for _ in 0..<12 {
|
||||
id.append(UInt8.random(in: 0...255))
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
import NIOExtras
|
||||
|
||||
/// Manages a single peer TCP connection using SwiftNIO.
|
||||
public final class PeerConnection: @unchecked Sendable {
|
||||
public let address: String
|
||||
public let port: UInt16
|
||||
|
||||
private var _channel: Channel?
|
||||
private let lock = NSLock()
|
||||
private let infoHash: Data
|
||||
private let peerID: Data
|
||||
|
||||
public init(address: String, port: UInt16, infoHash: Data, peerID: Data) {
|
||||
self.address = address
|
||||
self.port = port
|
||||
self.infoHash = infoHash
|
||||
self.peerID = peerID
|
||||
}
|
||||
|
||||
private func setChannel(_ ch: Channel) {
|
||||
lock.lock()
|
||||
_channel = ch
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
private func getChannel() -> Channel? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _channel
|
||||
}
|
||||
|
||||
public func connect(on group: EventLoopGroup) async throws -> Channel {
|
||||
let bootstrap = ClientBootstrap(group: group)
|
||||
.channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
|
||||
.channelInitializer { channel in
|
||||
let decoder = ByteToMessageHandler(PeerMessageDecoder())
|
||||
let encoder = PeerMessageEncoder()
|
||||
return channel.pipeline.addHandler(decoder).flatMap { _ in
|
||||
channel.pipeline.addHandler(encoder)
|
||||
}
|
||||
}
|
||||
let ch = try await bootstrap.connect(host: address, port: Int(port)).get()
|
||||
|
||||
setChannel(ch)
|
||||
|
||||
// Send handshake
|
||||
let handshake = Handshake(infoHash: infoHash, peerID: peerID)
|
||||
var buffer = ch.allocator.buffer(capacity: Handshake.length)
|
||||
buffer.writeBytes(handshake.encode())
|
||||
try await ch.writeAndFlush(buffer).get()
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
public func send(_ message: PeerMessage) async throws {
|
||||
guard let ch = getChannel() else {
|
||||
throw PeerConnectionError.notConnected
|
||||
}
|
||||
try await ch.writeAndFlush(message).get()
|
||||
}
|
||||
|
||||
public func close() async throws {
|
||||
guard let ch = getChannel() else { return }
|
||||
try await ch.close().get()
|
||||
}
|
||||
}
|
||||
|
||||
public enum PeerConnectionError: Error {
|
||||
case notConnected
|
||||
case handshakeFailed
|
||||
}
|
||||
|
||||
// MARK: - NIO Channel Handlers
|
||||
|
||||
/// Decodes peer wire protocol messages from byte stream.
|
||||
final class PeerMessageDecoder: ByteToMessageDecoder {
|
||||
typealias InboundOut = PeerMessage
|
||||
|
||||
private var handshakeReceived = false
|
||||
|
||||
func decode(context: ChannelHandlerContext, buffer: inout ByteBuffer) throws -> DecodingState {
|
||||
if !handshakeReceived {
|
||||
guard buffer.readableBytes >= Handshake.length else { return .needMoreData }
|
||||
guard let bytes = buffer.readBytes(length: Handshake.length) else { return .needMoreData }
|
||||
let _ = try Handshake.decode(from: Data(bytes))
|
||||
handshakeReceived = true
|
||||
return .continue
|
||||
}
|
||||
|
||||
guard buffer.readableBytes >= 4 else { return .needMoreData }
|
||||
let lengthBytes = buffer.getBytes(at: buffer.readerIndex, length: 4)!
|
||||
let length = Data(lengthBytes).readUInt32BE(at: 0)
|
||||
|
||||
if length == 0 {
|
||||
buffer.moveReaderIndex(forwardBy: 4)
|
||||
context.fireChannelRead(wrapInboundOut(.keepAlive))
|
||||
return .continue
|
||||
}
|
||||
|
||||
guard buffer.readableBytes >= 4 + Int(length) else { return .needMoreData }
|
||||
buffer.moveReaderIndex(forwardBy: 4)
|
||||
guard let payload = buffer.readBytes(length: Int(length)) else { return .needMoreData }
|
||||
let message = try PeerMessage.decode(from: Data(payload))
|
||||
context.fireChannelRead(wrapInboundOut(message))
|
||||
return .continue
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes peer wire protocol messages to byte stream.
|
||||
final class PeerMessageEncoder: ChannelOutboundHandler {
|
||||
typealias OutboundIn = PeerMessage
|
||||
typealias OutboundOut = ByteBuffer
|
||||
|
||||
func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise<Void>?) {
|
||||
let msg = unwrapOutboundIn(data)
|
||||
let encoded = msg.encode()
|
||||
var buffer = context.channel.allocator.buffer(capacity: encoded.count)
|
||||
buffer.writeBytes(encoded)
|
||||
context.write(wrapOutboundOut(buffer), promise: promise)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
/// Information about a connected peer.
|
||||
public struct PeerInfo: Sendable, Identifiable {
|
||||
public let id: Data // 20-byte peer ID
|
||||
public let address: String
|
||||
public let port: UInt16
|
||||
|
||||
public var isChoked: Bool
|
||||
public var isInterested: Bool
|
||||
public var amChoking: Bool
|
||||
public var amInterested: Bool
|
||||
public var downloadRate: Double // bytes per second
|
||||
public var uploadRate: Double
|
||||
|
||||
public init(id: Data, address: String, port: UInt16) {
|
||||
self.id = id
|
||||
self.address = address
|
||||
self.port = port
|
||||
self.isChoked = true
|
||||
self.isInterested = false
|
||||
self.amChoking = true
|
||||
self.amInterested = false
|
||||
self.downloadRate = 0
|
||||
self.uploadRate = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
|
||||
/// Manages the pool of peer connections for a torrent.
|
||||
public actor PeerManager {
|
||||
private let infoHash: Data
|
||||
private let peerID: Data
|
||||
private let group: EventLoopGroup
|
||||
private var connections: [String: PeerConnection] = [:]
|
||||
private var peerInfos: [String: PeerInfo] = [:]
|
||||
private let maxConnections: Int
|
||||
|
||||
public init(infoHash: Data, peerID: Data, group: EventLoopGroup, maxConnections: Int = 50) {
|
||||
self.infoHash = infoHash
|
||||
self.peerID = peerID
|
||||
self.group = group
|
||||
self.maxConnections = maxConnections
|
||||
}
|
||||
|
||||
/// Add a peer and attempt connection.
|
||||
public func addPeer(address: String, port: UInt16) async {
|
||||
let key = "\(address):\(port)"
|
||||
guard connections[key] == nil else { return }
|
||||
guard connections.count < maxConnections else { return }
|
||||
|
||||
let conn = PeerConnection(address: address, port: port, infoHash: infoHash, peerID: peerID)
|
||||
connections[key] = conn
|
||||
peerInfos[key] = PeerInfo(id: Data(), address: address, port: port)
|
||||
}
|
||||
|
||||
/// Remove a peer.
|
||||
public func removePeer(address: String, port: UInt16) async {
|
||||
let key = "\(address):\(port)"
|
||||
if let conn = connections.removeValue(forKey: key) {
|
||||
try? await conn.close()
|
||||
}
|
||||
peerInfos.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
/// Get all connected peer infos.
|
||||
public func peers() -> [PeerInfo] {
|
||||
Array(peerInfos.values)
|
||||
}
|
||||
|
||||
/// Number of active connections.
|
||||
public var connectionCount: Int {
|
||||
connections.count
|
||||
}
|
||||
|
||||
/// Implements the choking algorithm — unchoke top uploaders + one optimistic unchoke.
|
||||
public func runChokingAlgorithm() async {
|
||||
var peers = Array(peerInfos)
|
||||
|
||||
// Sort by download rate descending
|
||||
peers.sort { $0.value.downloadRate > $1.value.downloadRate }
|
||||
|
||||
let unchokeSlots = 4
|
||||
for (i, peer) in peers.enumerated() {
|
||||
var info = peer.value
|
||||
if i < unchokeSlots {
|
||||
info.amChoking = false
|
||||
} else if i == unchokeSlots {
|
||||
// Optimistic unchoke
|
||||
info.amChoking = false
|
||||
} else {
|
||||
info.amChoking = true
|
||||
}
|
||||
peerInfos[peer.key] = info
|
||||
}
|
||||
}
|
||||
|
||||
/// Broadcast a have message to all peers.
|
||||
public func broadcastHave(pieceIndex: UInt32) async {
|
||||
let msg = PeerMessage.have(pieceIndex: pieceIndex)
|
||||
for conn in connections.values {
|
||||
try? await conn.send(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import Foundation
|
||||
|
||||
/// All peer wire protocol messages (BEP-3).
|
||||
public enum PeerMessage: Equatable, Sendable {
|
||||
case keepAlive
|
||||
case choke
|
||||
case unchoke
|
||||
case interested
|
||||
case notInterested
|
||||
case have(pieceIndex: UInt32)
|
||||
case bitfield(Data)
|
||||
case request(index: UInt32, begin: UInt32, length: UInt32)
|
||||
case piece(index: UInt32, begin: UInt32, block: Data)
|
||||
case cancel(index: UInt32, begin: UInt32, length: UInt32)
|
||||
case port(UInt16)
|
||||
|
||||
// Message IDs
|
||||
public static let chokeID: UInt8 = 0
|
||||
public static let unchokeID: UInt8 = 1
|
||||
public static let interestedID: UInt8 = 2
|
||||
public static let notInterestedID: UInt8 = 3
|
||||
public static let haveID: UInt8 = 4
|
||||
public static let bitfieldID: UInt8 = 5
|
||||
public static let requestID: UInt8 = 6
|
||||
public static let pieceID: UInt8 = 7
|
||||
public static let cancelID: UInt8 = 8
|
||||
public static let portID: UInt8 = 9
|
||||
|
||||
/// Serialize to wire format: <length prefix><message ID><payload>
|
||||
public func encode() -> Data {
|
||||
var data = Data()
|
||||
switch self {
|
||||
case .keepAlive:
|
||||
data.append(contentsOf: UInt32(0).bigEndianBytes)
|
||||
|
||||
case .choke:
|
||||
data.append(contentsOf: UInt32(1).bigEndianBytes)
|
||||
data.append(Self.chokeID)
|
||||
|
||||
case .unchoke:
|
||||
data.append(contentsOf: UInt32(1).bigEndianBytes)
|
||||
data.append(Self.unchokeID)
|
||||
|
||||
case .interested:
|
||||
data.append(contentsOf: UInt32(1).bigEndianBytes)
|
||||
data.append(Self.interestedID)
|
||||
|
||||
case .notInterested:
|
||||
data.append(contentsOf: UInt32(1).bigEndianBytes)
|
||||
data.append(Self.notInterestedID)
|
||||
|
||||
case .have(let index):
|
||||
data.append(contentsOf: UInt32(5).bigEndianBytes)
|
||||
data.append(Self.haveID)
|
||||
data.append(contentsOf: index.bigEndianBytes)
|
||||
|
||||
case .bitfield(let bf):
|
||||
data.append(contentsOf: UInt32(1 + UInt32(bf.count)).bigEndianBytes)
|
||||
data.append(Self.bitfieldID)
|
||||
data.append(bf)
|
||||
|
||||
case .request(let index, let begin, let length):
|
||||
data.append(contentsOf: UInt32(13).bigEndianBytes)
|
||||
data.append(Self.requestID)
|
||||
data.append(contentsOf: index.bigEndianBytes)
|
||||
data.append(contentsOf: begin.bigEndianBytes)
|
||||
data.append(contentsOf: length.bigEndianBytes)
|
||||
|
||||
case .piece(let index, let begin, let block):
|
||||
data.append(contentsOf: UInt32(9 + UInt32(block.count)).bigEndianBytes)
|
||||
data.append(Self.pieceID)
|
||||
data.append(contentsOf: index.bigEndianBytes)
|
||||
data.append(contentsOf: begin.bigEndianBytes)
|
||||
data.append(block)
|
||||
|
||||
case .cancel(let index, let begin, let length):
|
||||
data.append(contentsOf: UInt32(13).bigEndianBytes)
|
||||
data.append(Self.cancelID)
|
||||
data.append(contentsOf: index.bigEndianBytes)
|
||||
data.append(contentsOf: begin.bigEndianBytes)
|
||||
data.append(contentsOf: length.bigEndianBytes)
|
||||
|
||||
case .port(let port):
|
||||
data.append(contentsOf: UInt32(3).bigEndianBytes)
|
||||
data.append(Self.portID)
|
||||
data.append(contentsOf: port.bigEndianBytes)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/// Parse a message from payload (after length prefix has been consumed).
|
||||
/// `payload` does NOT include the 4-byte length prefix.
|
||||
public static func decode(from payload: Data) throws -> PeerMessage {
|
||||
guard !payload.isEmpty else { return .keepAlive }
|
||||
let id = payload[payload.startIndex]
|
||||
let rest = payload.dropFirst()
|
||||
|
||||
switch id {
|
||||
case chokeID: return .choke
|
||||
case unchokeID: return .unchoke
|
||||
case interestedID: return .interested
|
||||
case notInterestedID: return .notInterested
|
||||
|
||||
case haveID:
|
||||
guard rest.count >= 4 else { throw PeerMessageError.invalidPayload }
|
||||
return .have(pieceIndex: rest.readUInt32BE(at: 0))
|
||||
|
||||
case bitfieldID:
|
||||
return .bitfield(Data(rest))
|
||||
|
||||
case requestID:
|
||||
guard rest.count >= 12 else { throw PeerMessageError.invalidPayload }
|
||||
return .request(
|
||||
index: rest.readUInt32BE(at: 0),
|
||||
begin: rest.readUInt32BE(at: 4),
|
||||
length: rest.readUInt32BE(at: 8)
|
||||
)
|
||||
|
||||
case pieceID:
|
||||
guard rest.count >= 8 else { throw PeerMessageError.invalidPayload }
|
||||
return .piece(
|
||||
index: rest.readUInt32BE(at: 0),
|
||||
begin: rest.readUInt32BE(at: 4),
|
||||
block: Data(rest.dropFirst(8))
|
||||
)
|
||||
|
||||
case cancelID:
|
||||
guard rest.count >= 12 else { throw PeerMessageError.invalidPayload }
|
||||
return .cancel(
|
||||
index: rest.readUInt32BE(at: 0),
|
||||
begin: rest.readUInt32BE(at: 4),
|
||||
length: rest.readUInt32BE(at: 8)
|
||||
)
|
||||
|
||||
case portID:
|
||||
guard rest.count >= 2 else { throw PeerMessageError.invalidPayload }
|
||||
return .port(rest.readUInt16BE(at: 0))
|
||||
|
||||
default:
|
||||
throw PeerMessageError.unknownMessageID(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum PeerMessageError: Error, Equatable {
|
||||
case invalidPayload
|
||||
case unknownMessageID(UInt8)
|
||||
}
|
||||
|
||||
// MARK: - Data helpers
|
||||
|
||||
extension UInt32 {
|
||||
var bigEndianBytes: [UInt8] {
|
||||
let be = self.bigEndian
|
||||
return withUnsafeBytes(of: be) { Array($0) }
|
||||
}
|
||||
}
|
||||
|
||||
extension UInt16 {
|
||||
var bigEndianBytes: [UInt8] {
|
||||
let be = self.bigEndian
|
||||
return withUnsafeBytes(of: be) { Array($0) }
|
||||
}
|
||||
}
|
||||
|
||||
extension Data {
|
||||
func readUInt32BE(at offset: Int) -> UInt32 {
|
||||
let start = self.startIndex + offset
|
||||
var value: UInt32 = 0
|
||||
_ = Swift.withUnsafeMutableBytes(of: &value) { buf in
|
||||
self.copyBytes(to: buf, from: start..<start+4)
|
||||
}
|
||||
return UInt32(bigEndian: value)
|
||||
}
|
||||
|
||||
func readUInt16BE(at offset: Int) -> UInt16 {
|
||||
let start = self.startIndex + offset
|
||||
var value: UInt16 = 0
|
||||
_ = Swift.withUnsafeMutableBytes(of: &value) { buf in
|
||||
self.copyBytes(to: buf, from: start..<start+2)
|
||||
}
|
||||
return UInt16(bigEndian: value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
|
||||
/// A compact bit array backed by `[UInt64]` for tracking piece availability.
|
||||
public struct Bitfield: Sendable, Equatable {
|
||||
public private(set) var storage: [UInt64]
|
||||
public let count: Int
|
||||
|
||||
public init(count: Int) {
|
||||
self.count = count
|
||||
let words = (count + 63) / 64
|
||||
self.storage = [UInt64](repeating: 0, count: words)
|
||||
}
|
||||
|
||||
/// Initialize from raw bytes (network format, big-endian bit ordering).
|
||||
public init(data: Data, count: Int) {
|
||||
self.count = count
|
||||
let words = (count + 63) / 64
|
||||
var stor = [UInt64](repeating: 0, count: words)
|
||||
for i in 0..<min(data.count * 8, count) {
|
||||
let byteIdx = i / 8
|
||||
let bitIdx = 7 - (i % 8) // big-endian bit order
|
||||
if data[data.startIndex + byteIdx] & (1 << bitIdx) != 0 {
|
||||
let wordIdx = i / 64
|
||||
let wordBit = i % 64
|
||||
stor[wordIdx] |= (1 << wordBit)
|
||||
}
|
||||
}
|
||||
self.storage = stor
|
||||
}
|
||||
|
||||
public func get(_ index: Int) -> Bool {
|
||||
guard index >= 0 && index < count else { return false }
|
||||
let wordIdx = index / 64
|
||||
let bitIdx = index % 64
|
||||
return storage[wordIdx] & (1 << bitIdx) != 0
|
||||
}
|
||||
|
||||
public mutating func set(_ index: Int) {
|
||||
guard index >= 0 && index < count else { return }
|
||||
let wordIdx = index / 64
|
||||
let bitIdx = index % 64
|
||||
storage[wordIdx] |= (1 << bitIdx)
|
||||
}
|
||||
|
||||
public mutating func clear(_ index: Int) {
|
||||
guard index >= 0 && index < count else { return }
|
||||
let wordIdx = index / 64
|
||||
let bitIdx = index % 64
|
||||
storage[wordIdx] &= ~(1 << bitIdx)
|
||||
}
|
||||
|
||||
/// Number of set bits.
|
||||
public var popcount: Int {
|
||||
storage.reduce(0) { $0 + $1.nonzeroBitCount }
|
||||
}
|
||||
|
||||
/// Whether all bits are set.
|
||||
public var allSet: Bool {
|
||||
popcount == count
|
||||
}
|
||||
|
||||
/// Whether no bits are set.
|
||||
public var isEmpty: Bool {
|
||||
popcount == 0
|
||||
}
|
||||
|
||||
/// Serialize to bytes (big-endian bit order) for network transmission.
|
||||
public func toData() -> Data {
|
||||
let byteCount = (count + 7) / 8
|
||||
var data = Data(count: byteCount)
|
||||
for i in 0..<count {
|
||||
if get(i) {
|
||||
let byteIdx = i / 8
|
||||
let bitIdx = 7 - (i % 8)
|
||||
data[byteIdx] |= (1 << bitIdx)
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import Crypto
|
||||
|
||||
/// Tracks piece completion and verifies SHA-1 hashes.
|
||||
public actor PieceManager {
|
||||
private let pieceCount: Int
|
||||
private let pieceLength: Int
|
||||
private let totalSize: Int64
|
||||
private let pieceHashes: Data // concatenated 20-byte SHA-1 hashes
|
||||
private var completed: Bitfield
|
||||
private var inProgress: Set<Int>
|
||||
private var pieceBuffers: [Int: Data]
|
||||
|
||||
public init(info: TorrentInfo) {
|
||||
self.pieceCount = info.pieceCount
|
||||
self.pieceLength = info.pieceLength
|
||||
self.totalSize = info.totalSize
|
||||
self.pieceHashes = info.pieces
|
||||
self.completed = Bitfield(count: info.pieceCount)
|
||||
self.inProgress = []
|
||||
self.pieceBuffers = [:]
|
||||
}
|
||||
|
||||
/// Mark a piece as being downloaded.
|
||||
public func startPiece(_ index: Int) {
|
||||
inProgress.insert(index)
|
||||
pieceBuffers[index] = Data()
|
||||
}
|
||||
|
||||
/// Add a block to a piece being downloaded.
|
||||
public func addBlock(pieceIndex: Int, offset: Int, data: Data) {
|
||||
guard var buffer = pieceBuffers[pieceIndex] else { return }
|
||||
// Ensure buffer is large enough
|
||||
let needed = offset + data.count
|
||||
if buffer.count < needed {
|
||||
buffer.append(Data(count: needed - buffer.count))
|
||||
}
|
||||
buffer.replaceSubrange(offset..<offset + data.count, with: data)
|
||||
pieceBuffers[pieceIndex] = buffer
|
||||
}
|
||||
|
||||
/// Verify and complete a piece.
|
||||
public func completePiece(_ index: Int) -> Bool {
|
||||
guard let buffer = pieceBuffers[index] else { return false }
|
||||
|
||||
// Verify hash
|
||||
let expectedHash = pieceHashes.subdata(in: index * 20..<(index + 1) * 20)
|
||||
let actualHash = Data(Insecure.SHA1.hash(data: buffer))
|
||||
|
||||
guard actualHash == expectedHash else {
|
||||
// Hash mismatch — piece is corrupt
|
||||
pieceBuffers.removeValue(forKey: index)
|
||||
inProgress.remove(index)
|
||||
return false
|
||||
}
|
||||
|
||||
completed.set(index)
|
||||
inProgress.remove(index)
|
||||
pieceBuffers.removeValue(forKey: index)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Get the completed bitfield.
|
||||
public func getCompleted() -> Bitfield {
|
||||
completed
|
||||
}
|
||||
|
||||
/// Check if a piece is complete.
|
||||
public func hasPiece(_ index: Int) -> Bool {
|
||||
completed.get(index)
|
||||
}
|
||||
|
||||
/// Check if all pieces are complete.
|
||||
public func isComplete() -> Bool {
|
||||
completed.allSet
|
||||
}
|
||||
|
||||
/// Get progress as a fraction.
|
||||
public func progress() -> Double {
|
||||
guard pieceCount > 0 else { return 1.0 }
|
||||
return Double(completed.popcount) / Double(pieceCount)
|
||||
}
|
||||
|
||||
/// Expected size of a specific piece.
|
||||
public func expectedPieceSize(_ index: Int) -> Int {
|
||||
let start = Int64(index) * Int64(pieceLength)
|
||||
return Int(min(Int64(pieceLength), totalSize - start))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
|
||||
/// Rarest-first piece selection strategy.
|
||||
public struct PiecePicker: Sendable {
|
||||
private let pieceCount: Int
|
||||
private var availability: [Int] // how many peers have each piece
|
||||
|
||||
public init(pieceCount: Int) {
|
||||
self.pieceCount = pieceCount
|
||||
self.availability = [Int](repeating: 0, count: pieceCount)
|
||||
}
|
||||
|
||||
/// Update availability from a peer's bitfield.
|
||||
public mutating func addPeerBitfield(_ bitfield: Bitfield) {
|
||||
for i in 0..<min(pieceCount, bitfield.count) {
|
||||
if bitfield.get(i) {
|
||||
availability[i] += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a peer's bitfield from availability counts.
|
||||
public mutating func removePeerBitfield(_ bitfield: Bitfield) {
|
||||
for i in 0..<min(pieceCount, bitfield.count) {
|
||||
if bitfield.get(i) {
|
||||
availability[i] = max(0, availability[i] - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Increment availability for a single piece (peer sent "have").
|
||||
public mutating func addHave(_ pieceIndex: Int) {
|
||||
guard pieceIndex >= 0 && pieceIndex < pieceCount else { return }
|
||||
availability[pieceIndex] += 1
|
||||
}
|
||||
|
||||
/// Pick the next piece to request using rarest-first strategy.
|
||||
/// `have` is our own bitfield; `peerHas` is the peer's bitfield.
|
||||
public func pick(have: Bitfield, peerHas: Bitfield) -> Int? {
|
||||
var best: Int?
|
||||
var bestAvail = Int.max
|
||||
|
||||
for i in 0..<pieceCount {
|
||||
// We don't have it, peer does have it
|
||||
if !have.get(i) && peerHas.get(i) {
|
||||
if availability[i] < bestAvail {
|
||||
bestAvail = availability[i]
|
||||
best = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
/// Pick multiple pieces (for pipelining).
|
||||
public func pickMultiple(have: Bitfield, peerHas: Bitfield, count: Int) -> [Int] {
|
||||
var candidates: [(index: Int, avail: Int)] = []
|
||||
for i in 0..<pieceCount {
|
||||
if !have.get(i) && peerHas.get(i) {
|
||||
candidates.append((i, availability[i]))
|
||||
}
|
||||
}
|
||||
candidates.sort { $0.avail < $1.avail }
|
||||
return Array(candidates.prefix(count).map(\.index))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
/// Parameters for adding a torrent to a session.
|
||||
public struct AddTorrentParams: Sendable {
|
||||
public var torrentInfo: TorrentInfo?
|
||||
public var magnetLink: MagnetLink?
|
||||
public var savePath: String?
|
||||
public var resumeData: ResumeData?
|
||||
public var paused: Bool
|
||||
|
||||
public init(torrentInfo: TorrentInfo? = nil, magnetLink: MagnetLink? = nil,
|
||||
savePath: String? = nil, resumeData: ResumeData? = nil, paused: Bool = false) {
|
||||
self.torrentInfo = torrentInfo
|
||||
self.magnetLink = magnetLink
|
||||
self.savePath = savePath
|
||||
self.resumeData = resumeData
|
||||
self.paused = paused
|
||||
}
|
||||
|
||||
/// Create from a .torrent file path.
|
||||
public static func fromFile(_ path: String, savePath: String? = nil) throws -> AddTorrentParams {
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||
let info = try TorrentInfo.parse(from: data)
|
||||
return AddTorrentParams(torrentInfo: info, savePath: savePath)
|
||||
}
|
||||
|
||||
/// Create from a magnet URI.
|
||||
public static func fromMagnet(_ uri: String, savePath: String? = nil) throws -> AddTorrentParams {
|
||||
guard let magnet = MagnetLink(uri: uri) else {
|
||||
throw AddTorrentError.invalidMagnetLink
|
||||
}
|
||||
return AddTorrentParams(magnetLink: magnet, savePath: savePath)
|
||||
}
|
||||
|
||||
/// The info hash (from either torrent info or magnet link).
|
||||
public var infoHash: InfoHash? {
|
||||
torrentInfo?.infoHash ?? magnetLink?.infoHash
|
||||
}
|
||||
}
|
||||
|
||||
public enum AddTorrentError: Error {
|
||||
case invalidMagnetLink
|
||||
case noInfoHash
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
|
||||
/// Top-level controller for managing torrents.
|
||||
public actor Session {
|
||||
private var settings: SessionSettings
|
||||
private var torrents: [InfoHash: TorrentHandle] = [:]
|
||||
private let group: MultiThreadedEventLoopGroup
|
||||
private var dhtNode: DHTNode?
|
||||
private let alertContinuation: AsyncStream<any Alert>.Continuation
|
||||
public let alerts: AsyncStream<any Alert>
|
||||
|
||||
public init(settings: SessionSettings = SessionSettings()) {
|
||||
self.settings = settings
|
||||
self.group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
|
||||
|
||||
let (stream, continuation) = AsyncStream<any Alert>.makeStream()
|
||||
self.alerts = stream
|
||||
self.alertContinuation = continuation
|
||||
}
|
||||
|
||||
/// Add a torrent to the session.
|
||||
public func addTorrent(_ params: AddTorrentParams) async throws -> TorrentHandle {
|
||||
guard let hash = params.infoHash else {
|
||||
throw AddTorrentError.noInfoHash
|
||||
}
|
||||
if let existing = torrents[hash] {
|
||||
return existing
|
||||
}
|
||||
|
||||
let handle = TorrentHandle(params: params, settings: settings, group: group)
|
||||
torrents[hash] = handle
|
||||
|
||||
alertContinuation.yield(TorrentAddedAlert(
|
||||
infoHash: hash,
|
||||
name: params.torrentInfo?.name ?? params.magnetLink?.displayName ?? "Unknown"
|
||||
))
|
||||
|
||||
if !params.paused {
|
||||
try await handle.start()
|
||||
}
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
/// Remove a torrent from the session.
|
||||
public func removeTorrent(_ infoHash: InfoHash, deleteFiles: Bool = false) async {
|
||||
guard let handle = torrents.removeValue(forKey: infoHash) else { return }
|
||||
await handle.pause()
|
||||
|
||||
if deleteFiles {
|
||||
let _ = await handle.status()
|
||||
// Delete files from disk
|
||||
let path = settings.savePath
|
||||
try? FileManager.default.removeItem(atPath: path)
|
||||
}
|
||||
|
||||
alertContinuation.yield(TorrentRemovedAlert(infoHash: infoHash))
|
||||
}
|
||||
|
||||
/// Get a torrent handle by info hash.
|
||||
public func torrent(for infoHash: InfoHash) -> TorrentHandle? {
|
||||
torrents[infoHash]
|
||||
}
|
||||
|
||||
/// Get all torrent handles.
|
||||
public func allTorrents() -> [TorrentHandle] {
|
||||
Array(torrents.values)
|
||||
}
|
||||
|
||||
/// Get status of all torrents.
|
||||
public func allStatus() async -> [TorrentStatus] {
|
||||
var statuses: [TorrentStatus] = []
|
||||
for handle in torrents.values {
|
||||
statuses.append(await handle.status())
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
|
||||
/// Update session settings.
|
||||
public func updateSettings(_ newSettings: SessionSettings) {
|
||||
self.settings = newSettings
|
||||
}
|
||||
|
||||
/// Start DHT if enabled.
|
||||
public func startDHT() async throws {
|
||||
guard settings.dhtEnabled else { return }
|
||||
let node = DHTNode(port: settings.dhtPort, group: group)
|
||||
try await node.start()
|
||||
self.dhtNode = node
|
||||
}
|
||||
|
||||
/// Pause all torrents.
|
||||
public func pauseAll() async {
|
||||
for handle in torrents.values {
|
||||
await handle.pause()
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume all torrents.
|
||||
public func resumeAll() async throws {
|
||||
for handle in torrents.values {
|
||||
try await handle.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shutdown the session.
|
||||
public func shutdown() async throws {
|
||||
await pauseAll()
|
||||
alertContinuation.finish()
|
||||
try await group.shutdownGracefully()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
/// Configuration settings for a Session.
|
||||
public struct SessionSettings: Sendable {
|
||||
public var listenPort: UInt16
|
||||
public var maxConnections: Int
|
||||
public var maxConnectionsPerTorrent: Int
|
||||
public var downloadRateLimit: Int // bytes/sec, 0 = unlimited
|
||||
public var uploadRateLimit: Int
|
||||
public var dhtEnabled: Bool
|
||||
public var dhtPort: Int
|
||||
public var userAgent: String
|
||||
public var savePath: String
|
||||
|
||||
public init(
|
||||
listenPort: UInt16 = 6881,
|
||||
maxConnections: Int = 200,
|
||||
maxConnectionsPerTorrent: Int = 50,
|
||||
downloadRateLimit: Int = 0,
|
||||
uploadRateLimit: Int = 0,
|
||||
dhtEnabled: Bool = true,
|
||||
dhtPort: Int = 6881,
|
||||
userAgent: String = "SwiftTorrent/1.0",
|
||||
savePath: String = NSTemporaryDirectory()
|
||||
) {
|
||||
self.listenPort = listenPort
|
||||
self.maxConnections = maxConnections
|
||||
self.maxConnectionsPerTorrent = maxConnectionsPerTorrent
|
||||
self.downloadRateLimit = downloadRateLimit
|
||||
self.uploadRateLimit = uploadRateLimit
|
||||
self.dhtEnabled = dhtEnabled
|
||||
self.dhtPort = dhtPort
|
||||
self.userAgent = userAgent
|
||||
self.savePath = savePath
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
|
||||
/// Per-torrent controller tying peers, pieces, and disk together.
|
||||
public actor TorrentHandle {
|
||||
public let infoHash: InfoHash
|
||||
private let info: TorrentInfo?
|
||||
private let savePath: String
|
||||
private let peerID: Data
|
||||
private let group: EventLoopGroup
|
||||
|
||||
private var peerManager: PeerManager
|
||||
private var pieceManager: PieceManager?
|
||||
private var piecePicker: PiecePicker?
|
||||
private var diskIO: DiskIO?
|
||||
private var trackerManager: TrackerManager?
|
||||
private var state: TorrentState = .paused
|
||||
private var totalDownloaded: Int64 = 0
|
||||
private var totalUploaded: Int64 = 0
|
||||
|
||||
public init(params: AddTorrentParams, settings: SessionSettings, group: EventLoopGroup) {
|
||||
let hash = params.infoHash!
|
||||
self.infoHash = hash
|
||||
self.info = params.torrentInfo
|
||||
self.savePath = params.savePath ?? settings.savePath
|
||||
self.peerID = generatePeerID()
|
||||
self.group = group
|
||||
self.peerManager = PeerManager(
|
||||
infoHash: hash.bytes, peerID: peerID, group: group,
|
||||
maxConnections: settings.maxConnectionsPerTorrent
|
||||
)
|
||||
|
||||
if let info = params.torrentInfo {
|
||||
self.pieceManager = PieceManager(info: info)
|
||||
self.piecePicker = PiecePicker(pieceCount: info.pieceCount)
|
||||
self.diskIO = DiskIO(
|
||||
basePath: savePath,
|
||||
fileStorage: FileStorage(info: info)
|
||||
)
|
||||
self.trackerManager = TrackerManager(info: info, group: group)
|
||||
}
|
||||
}
|
||||
|
||||
/// Start downloading.
|
||||
public func start() async throws {
|
||||
guard state == .paused || state == .stopped else { return }
|
||||
state = .downloading
|
||||
|
||||
// Announce to trackers
|
||||
if let info = info, let trackerMgr = trackerManager {
|
||||
let params = AnnounceParams(
|
||||
infoHash: infoHash, peerID: peerID, port: 6881,
|
||||
left: info.totalSize - totalDownloaded, event: "started"
|
||||
)
|
||||
if let response = try? await trackerMgr.announce(params: params) {
|
||||
for (address, port) in response.peers {
|
||||
await peerManager.addPeer(address: address, port: port)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pause the torrent.
|
||||
public func pause() {
|
||||
state = .paused
|
||||
}
|
||||
|
||||
/// Resume the torrent.
|
||||
public func resume() async throws {
|
||||
try await start()
|
||||
}
|
||||
|
||||
/// Get current status snapshot.
|
||||
public func status() async -> TorrentStatus {
|
||||
let progress = await pieceManager?.progress() ?? 0
|
||||
let completed = await pieceManager?.getCompleted()
|
||||
return TorrentStatus(
|
||||
infoHash: infoHash,
|
||||
name: info?.name ?? "Unknown",
|
||||
state: state,
|
||||
progress: progress,
|
||||
downloadRate: 0,
|
||||
uploadRate: 0,
|
||||
totalDownloaded: totalDownloaded,
|
||||
totalUploaded: totalUploaded,
|
||||
totalSize: info?.totalSize ?? 0,
|
||||
numPeers: await peerManager.connectionCount,
|
||||
numSeeds: 0,
|
||||
piecesCompleted: completed?.popcount ?? 0,
|
||||
piecesTotal: info?.pieceCount ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
/// Generate resume data for saving state.
|
||||
public func generateResumeData() async -> ResumeData? {
|
||||
guard let completed = await pieceManager?.getCompleted() else { return nil }
|
||||
return ResumeData(
|
||||
infoHash: infoHash, completedPieces: completed,
|
||||
uploaded: totalUploaded, downloaded: totalDownloaded,
|
||||
savePath: savePath
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
/// Current state of a torrent.
|
||||
public enum TorrentState: String, Sendable {
|
||||
case checkingFiles = "checking_files"
|
||||
case downloadingMetadata = "downloading_metadata"
|
||||
case downloading
|
||||
case seeding
|
||||
case paused
|
||||
case stopped
|
||||
case error
|
||||
}
|
||||
|
||||
/// A snapshot of a torrent's current status.
|
||||
public struct TorrentStatus: Sendable {
|
||||
public let infoHash: InfoHash
|
||||
public let name: String
|
||||
public let state: TorrentState
|
||||
public let progress: Double // 0.0 to 1.0
|
||||
public let downloadRate: Double // bytes per second
|
||||
public let uploadRate: Double
|
||||
public let totalDownloaded: Int64
|
||||
public let totalUploaded: Int64
|
||||
public let totalSize: Int64
|
||||
public let numPeers: Int
|
||||
public let numSeeds: Int
|
||||
public let piecesCompleted: Int
|
||||
public let piecesTotal: Int
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
|
||||
/// Async disk I/O using NIO thread pool.
|
||||
public actor DiskIO {
|
||||
private let basePath: String
|
||||
private let fileStorage: FileStorage
|
||||
private let threadPool: NIOThreadPool
|
||||
|
||||
public init(basePath: String, fileStorage: FileStorage, threadPoolSize: Int = 4) {
|
||||
self.basePath = basePath
|
||||
self.fileStorage = fileStorage
|
||||
self.threadPool = NIOThreadPool(numberOfThreads: threadPoolSize)
|
||||
self.threadPool.start()
|
||||
}
|
||||
|
||||
deinit {
|
||||
try? threadPool.syncShutdownGracefully()
|
||||
}
|
||||
|
||||
/// Write a piece to disk.
|
||||
public func writePiece(index: Int, data: Data) async throws {
|
||||
let slices = fileStorage.fileSlices(forPiece: index)
|
||||
|
||||
var dataOffset = 0
|
||||
for slice in slices {
|
||||
let filePath = (basePath as NSString).appendingPathComponent(slice.path)
|
||||
|
||||
// Ensure directory exists
|
||||
let dir = (filePath as NSString).deletingLastPathComponent
|
||||
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||||
|
||||
// Create file if needed
|
||||
if !FileManager.default.fileExists(atPath: filePath) {
|
||||
FileManager.default.createFile(atPath: filePath, contents: nil)
|
||||
}
|
||||
|
||||
let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: filePath))
|
||||
defer { try? handle.close() }
|
||||
handle.seek(toFileOffset: UInt64(slice.offset))
|
||||
let chunk = data.subdata(in: dataOffset..<dataOffset + slice.length)
|
||||
handle.write(chunk)
|
||||
dataOffset += slice.length
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a piece from disk.
|
||||
public func readPiece(index: Int) async throws -> Data {
|
||||
let slices = fileStorage.fileSlices(forPiece: index)
|
||||
var result = Data()
|
||||
|
||||
for slice in slices {
|
||||
let filePath = (basePath as NSString).appendingPathComponent(slice.path)
|
||||
let handle = try FileHandle(forReadingFrom: URL(fileURLWithPath: filePath))
|
||||
defer { try? handle.close() }
|
||||
handle.seek(toFileOffset: UInt64(slice.offset))
|
||||
let chunk = handle.readData(ofLength: slice.length)
|
||||
result.append(chunk)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Ensure all files exist with correct sizes.
|
||||
public func allocateFiles() throws {
|
||||
for file in fileStorage.files {
|
||||
let filePath = (basePath as NSString).appendingPathComponent(file.path)
|
||||
let dir = (filePath as NSString).deletingLastPathComponent
|
||||
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||||
|
||||
if !FileManager.default.fileExists(atPath: filePath) {
|
||||
FileManager.default.createFile(atPath: filePath, contents: nil)
|
||||
let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: filePath))
|
||||
handle.truncateFile(atOffset: UInt64(file.length))
|
||||
try handle.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
/// LRU piece cache for reducing disk reads.
|
||||
public actor PieceCache {
|
||||
private var cache: [Int: Data] // piece index -> data
|
||||
private var accessOrder: [Int] // LRU order (most recent at end)
|
||||
private let maxPieces: Int
|
||||
|
||||
public init(maxPieces: Int = 64) {
|
||||
self.cache = [:]
|
||||
self.accessOrder = []
|
||||
self.maxPieces = maxPieces
|
||||
}
|
||||
|
||||
/// Get a piece from cache.
|
||||
public func get(_ pieceIndex: Int) -> Data? {
|
||||
guard let data = cache[pieceIndex] else { return nil }
|
||||
// Move to end (most recently used)
|
||||
accessOrder.removeAll { $0 == pieceIndex }
|
||||
accessOrder.append(pieceIndex)
|
||||
return data
|
||||
}
|
||||
|
||||
/// Put a piece into cache.
|
||||
public func put(_ pieceIndex: Int, data: Data) {
|
||||
cache[pieceIndex] = data
|
||||
accessOrder.removeAll { $0 == pieceIndex }
|
||||
accessOrder.append(pieceIndex)
|
||||
|
||||
// Evict oldest if over capacity
|
||||
while cache.count > maxPieces, let oldest = accessOrder.first {
|
||||
cache.removeValue(forKey: oldest)
|
||||
accessOrder.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the cache.
|
||||
public func clear() {
|
||||
cache.removeAll()
|
||||
accessOrder.removeAll()
|
||||
}
|
||||
|
||||
public func count() -> Int {
|
||||
cache.count
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
/// Save and restore torrent state via bencoding.
|
||||
public struct ResumeData: Sendable {
|
||||
public let infoHash: InfoHash
|
||||
public let completedPieces: Bitfield
|
||||
public let uploaded: Int64
|
||||
public let downloaded: Int64
|
||||
public let savePath: String
|
||||
|
||||
public init(infoHash: InfoHash, completedPieces: Bitfield,
|
||||
uploaded: Int64, downloaded: Int64, savePath: String) {
|
||||
self.infoHash = infoHash
|
||||
self.completedPieces = completedPieces
|
||||
self.uploaded = uploaded
|
||||
self.downloaded = downloaded
|
||||
self.savePath = savePath
|
||||
}
|
||||
|
||||
/// Encode to bencoded data.
|
||||
public func encode() -> Data {
|
||||
let piecesData = completedPieces.toData()
|
||||
let value: BencodeValue = .dictionary([
|
||||
(key: Data("completed_pieces".utf8), value: .string(piecesData)),
|
||||
(key: Data("downloaded".utf8), value: .integer(downloaded)),
|
||||
(key: Data("info_hash".utf8), value: .string(infoHash.bytes)),
|
||||
(key: Data("save_path".utf8), value: .string(Data(savePath.utf8))),
|
||||
(key: Data("uploaded".utf8), value: .integer(uploaded)),
|
||||
])
|
||||
return BencodeEncoder().encode(value)
|
||||
}
|
||||
|
||||
/// Decode from bencoded data.
|
||||
public static func decode(from data: Data) throws -> ResumeData {
|
||||
let decoder = BencodeDecoder()
|
||||
let value = try decoder.decode(data)
|
||||
|
||||
guard let hashData = value["info_hash"]?.stringValue,
|
||||
let piecesData = value["completed_pieces"]?.stringValue,
|
||||
let uploaded = value["uploaded"]?.integerValue,
|
||||
let downloaded = value["downloaded"]?.integerValue,
|
||||
let savePath = value["save_path"]?.utf8String else {
|
||||
throw ResumeDataError.invalidFormat
|
||||
}
|
||||
|
||||
let infoHash = InfoHash(bytes: hashData)
|
||||
let pieceCount = piecesData.count * 8
|
||||
let completedPieces = Bitfield(data: piecesData, count: pieceCount)
|
||||
|
||||
return ResumeData(
|
||||
infoHash: infoHash, completedPieces: completedPieces,
|
||||
uploaded: uploaded, downloaded: downloaded, savePath: savePath
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public enum ResumeDataError: Error {
|
||||
case invalidFormat
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
/// Maps piece indices to file regions for disk I/O.
|
||||
public struct FileStorage: Sendable {
|
||||
public let files: [TorrentInfo.FileEntry]
|
||||
public let pieceLength: Int
|
||||
public let totalSize: Int64
|
||||
|
||||
public init(files: [TorrentInfo.FileEntry], pieceLength: Int, totalSize: Int64) {
|
||||
self.files = files
|
||||
self.pieceLength = pieceLength
|
||||
self.totalSize = totalSize
|
||||
}
|
||||
|
||||
public init(info: TorrentInfo) {
|
||||
self.files = info.files
|
||||
self.pieceLength = info.pieceLength
|
||||
self.totalSize = info.totalSize
|
||||
}
|
||||
|
||||
/// A region within a single file that a piece (or part of a piece) maps to.
|
||||
public struct FileSlice: Sendable {
|
||||
public let fileIndex: Int
|
||||
public let path: String
|
||||
public let offset: Int64 // offset within the file
|
||||
public let length: Int
|
||||
}
|
||||
|
||||
/// Get the file slices for a given piece index.
|
||||
public func fileSlices(forPiece pieceIndex: Int) -> [FileSlice] {
|
||||
let pieceStart = Int64(pieceIndex) * Int64(pieceLength)
|
||||
let pieceEnd = min(pieceStart + Int64(pieceLength), totalSize)
|
||||
let length = pieceEnd - pieceStart
|
||||
|
||||
guard length > 0 else { return [] }
|
||||
|
||||
var slices: [FileSlice] = []
|
||||
var remaining = length
|
||||
var currentOffset = pieceStart
|
||||
|
||||
for (i, file) in files.enumerated() {
|
||||
let fileEnd = file.offset + file.length
|
||||
if currentOffset >= fileEnd { continue }
|
||||
if currentOffset < file.offset { continue }
|
||||
|
||||
let offsetInFile = currentOffset - file.offset
|
||||
let available = min(Int64(remaining), file.length - offsetInFile)
|
||||
|
||||
slices.append(FileSlice(
|
||||
fileIndex: i,
|
||||
path: file.path,
|
||||
offset: offsetInFile,
|
||||
length: Int(available)
|
||||
))
|
||||
|
||||
remaining -= available
|
||||
currentOffset += available
|
||||
if remaining <= 0 { break }
|
||||
}
|
||||
|
||||
return slices
|
||||
}
|
||||
|
||||
/// Total number of pieces.
|
||||
public var pieceCount: Int {
|
||||
guard pieceLength > 0 else { return 0 }
|
||||
return Int((totalSize + Int64(pieceLength) - 1) / Int64(pieceLength))
|
||||
}
|
||||
|
||||
/// Size of a specific piece (last piece may be smaller).
|
||||
public func pieceSize(_ index: Int) -> Int {
|
||||
let start = Int64(index) * Int64(pieceLength)
|
||||
return Int(min(Int64(pieceLength), totalSize - start))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
import Crypto
|
||||
|
||||
/// A BitTorrent info hash — SHA-1 (v1) or SHA-256 (v2).
|
||||
public struct InfoHash: Hashable, Sendable, CustomStringConvertible {
|
||||
public enum Version: Sendable {
|
||||
case v1 // SHA-1, 20 bytes
|
||||
case v2 // SHA-256, 32 bytes
|
||||
}
|
||||
|
||||
public let bytes: Data
|
||||
public let version: Version
|
||||
|
||||
public var description: String {
|
||||
bytes.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// Create an info hash from raw bytes.
|
||||
public init(bytes: Data) {
|
||||
self.bytes = bytes
|
||||
self.version = bytes.count == 32 ? .v2 : .v1
|
||||
}
|
||||
|
||||
/// Compute SHA-1 info hash from the raw bencoded info dictionary.
|
||||
public static func v1(from infoData: Data) -> InfoHash {
|
||||
let digest = Insecure.SHA1.hash(data: infoData)
|
||||
return InfoHash(bytes: Data(digest))
|
||||
}
|
||||
|
||||
/// Compute SHA-256 info hash from the raw bencoded info dictionary.
|
||||
public static func v2(from infoData: Data) -> InfoHash {
|
||||
let digest = SHA256.hash(data: infoData)
|
||||
return InfoHash(bytes: Data(digest))
|
||||
}
|
||||
|
||||
/// Create from hex string.
|
||||
public init?(hex: String) {
|
||||
guard hex.count == 40 || hex.count == 64 else { return nil }
|
||||
var data = Data()
|
||||
var chars = hex.makeIterator()
|
||||
while let c1 = chars.next(), let c2 = chars.next() {
|
||||
guard let byte = UInt8(String([c1, c2]), radix: 16) else { return nil }
|
||||
data.append(byte)
|
||||
}
|
||||
self.init(bytes: data)
|
||||
}
|
||||
|
||||
/// URL-encoded form for tracker announces.
|
||||
public var urlEncoded: String {
|
||||
bytes.map { byte in
|
||||
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-_~"))
|
||||
let str = String(format: "%c", byte)
|
||||
if let scalar = str.unicodeScalars.first, allowed.contains(scalar) {
|
||||
return str
|
||||
}
|
||||
return String(format: "%%%02X", byte)
|
||||
}.joined()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import Foundation
|
||||
|
||||
/// Parses and generates magnet URIs (BEP-9).
|
||||
public struct MagnetLink: Sendable {
|
||||
public let infoHash: InfoHash
|
||||
public let displayName: String?
|
||||
public let trackers: [String]
|
||||
public let webSeeds: [String]
|
||||
|
||||
public init(infoHash: InfoHash, displayName: String? = nil, trackers: [String] = [], webSeeds: [String] = []) {
|
||||
self.infoHash = infoHash
|
||||
self.displayName = displayName
|
||||
self.trackers = trackers
|
||||
self.webSeeds = webSeeds
|
||||
}
|
||||
|
||||
/// Parse a magnet URI string.
|
||||
public init?(uri: String) {
|
||||
guard uri.hasPrefix("magnet:?") else { return nil }
|
||||
let query = String(uri.dropFirst("magnet:?".count))
|
||||
let params = query.split(separator: "&").map { param -> (String, String) in
|
||||
let parts = param.split(separator: "=", maxSplits: 1)
|
||||
let key = String(parts[0])
|
||||
let value = parts.count > 1 ? String(parts[1]) : ""
|
||||
return (key, value.removingPercentEncoding ?? value)
|
||||
}
|
||||
|
||||
var hash: InfoHash?
|
||||
var name: String?
|
||||
var trackers: [String] = []
|
||||
var webSeeds: [String] = []
|
||||
|
||||
for (key, value) in params {
|
||||
switch key {
|
||||
case "xt":
|
||||
// urn:btih:<hex or base32>
|
||||
if value.hasPrefix("urn:btih:") {
|
||||
let hashStr = String(value.dropFirst("urn:btih:".count))
|
||||
if hashStr.count == 40 {
|
||||
hash = InfoHash(hex: hashStr)
|
||||
} else if hashStr.count == 32 {
|
||||
// Base32 decode
|
||||
if let decoded = Self.base32Decode(hashStr) {
|
||||
hash = InfoHash(bytes: decoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "dn":
|
||||
name = value
|
||||
case "tr":
|
||||
trackers.append(value)
|
||||
case "ws":
|
||||
webSeeds.append(value)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard let infoHash = hash else { return nil }
|
||||
self.infoHash = infoHash
|
||||
self.displayName = name
|
||||
self.trackers = trackers
|
||||
self.webSeeds = webSeeds
|
||||
}
|
||||
|
||||
/// Generate a magnet URI string.
|
||||
public var uri: String {
|
||||
var parts = ["magnet:?xt=urn:btih:\(infoHash.description)"]
|
||||
if let name = displayName {
|
||||
parts.append("dn=\(name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? name)")
|
||||
}
|
||||
for tracker in trackers {
|
||||
parts.append("tr=\(tracker.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? tracker)")
|
||||
}
|
||||
return parts.joined(separator: "&")
|
||||
}
|
||||
|
||||
// MARK: - Base32
|
||||
|
||||
private static let base32Alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567")
|
||||
|
||||
static func base32Decode(_ input: String) -> Data? {
|
||||
let chars = input.uppercased()
|
||||
var bits = 0
|
||||
var value: UInt32 = 0
|
||||
var output = Data()
|
||||
|
||||
for ch in chars {
|
||||
guard let idx = base32Alphabet.firstIndex(of: ch) else { return nil }
|
||||
value = (value << 5) | UInt32(idx)
|
||||
bits += 5
|
||||
if bits >= 8 {
|
||||
bits -= 8
|
||||
output.append(UInt8((value >> bits) & 0xFF))
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import Foundation
|
||||
import Crypto
|
||||
|
||||
/// Utility for creating .torrent files.
|
||||
public struct TorrentFile: Sendable {
|
||||
|
||||
/// Create a .torrent file from a directory or single file.
|
||||
public static func create(
|
||||
path: String,
|
||||
announceURL: String,
|
||||
pieceLength: Int = 256 * 1024,
|
||||
comment: String? = nil,
|
||||
isPrivate: Bool = false
|
||||
) throws -> Data {
|
||||
let fileManager = FileManager.default
|
||||
var isDir: ObjCBool = false
|
||||
guard fileManager.fileExists(atPath: path, isDirectory: &isDir) else {
|
||||
throw TorrentFileError.fileNotFound(path)
|
||||
}
|
||||
|
||||
let name = (path as NSString).lastPathComponent
|
||||
var infoPairs: [(key: Data, value: BencodeValue)] = []
|
||||
|
||||
if isDir.boolValue {
|
||||
// Multi-file torrent
|
||||
let files = try enumerateFiles(at: path)
|
||||
var fileEntries: [BencodeValue] = []
|
||||
for file in files {
|
||||
let relativePath = String(file.path.dropFirst(path.count + 1))
|
||||
let components = relativePath.split(separator: "/").map { String($0) }
|
||||
let pathList = components.map { BencodeValue.string(Data($0.utf8)) }
|
||||
let fileDict: BencodeValue = .dictionary([
|
||||
(key: Data("length".utf8), value: .integer(file.size)),
|
||||
(key: Data("path".utf8), value: .list(pathList))
|
||||
])
|
||||
fileEntries.append(fileDict)
|
||||
}
|
||||
infoPairs.append((key: Data("files".utf8), value: .list(fileEntries)))
|
||||
} else {
|
||||
// Single-file torrent
|
||||
let attrs = try fileManager.attributesOfItem(atPath: path)
|
||||
let size = (attrs[.size] as? Int64) ?? 0
|
||||
infoPairs.append((key: Data("length".utf8), value: .integer(size)))
|
||||
}
|
||||
|
||||
infoPairs.append((key: Data("name".utf8), value: .string(Data(name.utf8))))
|
||||
infoPairs.append((key: Data("piece length".utf8), value: .integer(Int64(pieceLength))))
|
||||
|
||||
// Compute pieces hashes
|
||||
let piecesData = try computePieces(path: path, isDir: isDir.boolValue, pieceLength: pieceLength)
|
||||
infoPairs.append((key: Data("pieces".utf8), value: .string(piecesData)))
|
||||
|
||||
if isPrivate {
|
||||
infoPairs.append((key: Data("private".utf8), value: .integer(1)))
|
||||
}
|
||||
|
||||
let infoDict: BencodeValue = .dictionary(infoPairs)
|
||||
|
||||
var rootPairs: [(key: Data, value: BencodeValue)] = [
|
||||
(key: Data("announce".utf8), value: .string(Data(announceURL.utf8))),
|
||||
(key: Data("info".utf8), value: infoDict)
|
||||
]
|
||||
if let comment = comment {
|
||||
rootPairs.append((key: Data("comment".utf8), value: .string(Data(comment.utf8))))
|
||||
}
|
||||
rootPairs.append((key: Data("created by".utf8), value: .string(Data("SwiftTorrent".utf8))))
|
||||
rootPairs.append((key: Data("creation date".utf8), value: .integer(Int64(Date().timeIntervalSince1970))))
|
||||
|
||||
let root: BencodeValue = .dictionary(rootPairs)
|
||||
return BencodeEncoder().encode(root)
|
||||
}
|
||||
|
||||
private struct FileInfo {
|
||||
let path: String
|
||||
let size: Int64
|
||||
}
|
||||
|
||||
private static func enumerateFiles(at dirPath: String) throws -> [FileInfo] {
|
||||
let fm = FileManager.default
|
||||
guard let enumerator = fm.enumerator(atPath: dirPath) else {
|
||||
throw TorrentFileError.fileNotFound(dirPath)
|
||||
}
|
||||
var files: [FileInfo] = []
|
||||
while let relativePath = enumerator.nextObject() as? String {
|
||||
let fullPath = (dirPath as NSString).appendingPathComponent(relativePath)
|
||||
var isDir: ObjCBool = false
|
||||
if fm.fileExists(atPath: fullPath, isDirectory: &isDir), !isDir.boolValue {
|
||||
let attrs = try fm.attributesOfItem(atPath: fullPath)
|
||||
let size = (attrs[.size] as? Int64) ?? 0
|
||||
files.append(FileInfo(path: fullPath, size: size))
|
||||
}
|
||||
}
|
||||
return files.sorted { $0.path < $1.path }
|
||||
}
|
||||
|
||||
private static func computePieces(path: String, isDir: Bool, pieceLength: Int) throws -> Data {
|
||||
var allData = Data()
|
||||
if isDir {
|
||||
let files = try enumerateFiles(at: path)
|
||||
for file in files {
|
||||
allData.append(try Data(contentsOf: URL(fileURLWithPath: file.path)))
|
||||
}
|
||||
} else {
|
||||
allData = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||
}
|
||||
|
||||
var pieces = Data()
|
||||
var offset = 0
|
||||
while offset < allData.count {
|
||||
let end = min(offset + pieceLength, allData.count)
|
||||
let chunk = allData[offset..<end]
|
||||
let hash = Insecure.SHA1.hash(data: chunk)
|
||||
pieces.append(contentsOf: hash)
|
||||
offset = end
|
||||
}
|
||||
return pieces
|
||||
}
|
||||
}
|
||||
|
||||
public enum TorrentFileError: Error {
|
||||
case fileNotFound(String)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import Foundation
|
||||
import Crypto
|
||||
|
||||
/// Represents a parsed .torrent file.
|
||||
public struct TorrentInfo: Sendable {
|
||||
public let infoHash: InfoHash
|
||||
public let name: String
|
||||
public let pieceLength: Int
|
||||
public let pieces: Data // concatenated SHA-1 hashes, 20 bytes each
|
||||
public let totalSize: Int64
|
||||
public let files: [FileEntry]
|
||||
public let isPrivate: Bool
|
||||
public let comment: String?
|
||||
public let createdBy: String?
|
||||
public let creationDate: Date?
|
||||
public let announceURL: String?
|
||||
public let announceList: [[String]]
|
||||
|
||||
/// A single file within the torrent.
|
||||
public struct FileEntry: Sendable {
|
||||
public let path: String
|
||||
public let length: Int64
|
||||
public let offset: Int64 // byte offset within the torrent data
|
||||
}
|
||||
|
||||
public var pieceCount: Int {
|
||||
pieces.count / 20
|
||||
}
|
||||
|
||||
/// Parse a .torrent file from raw data.
|
||||
public static func parse(from data: Data) throws -> TorrentInfo {
|
||||
let decoder = BencodeDecoder()
|
||||
let root = try decoder.decode(data)
|
||||
|
||||
guard case .dictionary = root else {
|
||||
throw TorrentInfoError.invalidFormat("Root is not a dictionary")
|
||||
}
|
||||
guard let infoValue = root["info"],
|
||||
case .dictionary = infoValue else {
|
||||
throw TorrentInfoError.invalidFormat("Missing 'info' dictionary")
|
||||
}
|
||||
|
||||
// Find the raw bytes of the info dictionary for hashing
|
||||
let infoData = try findInfoDictBytes(in: data)
|
||||
let infoHash = InfoHash.v1(from: infoData)
|
||||
|
||||
guard let nameValue = infoValue["name"], let name = nameValue.utf8String else {
|
||||
throw TorrentInfoError.invalidFormat("Missing 'name'")
|
||||
}
|
||||
guard let plValue = infoValue["piece length"], let pieceLength = plValue.integerValue else {
|
||||
throw TorrentInfoError.invalidFormat("Missing 'piece length'")
|
||||
}
|
||||
guard let piecesValue = infoValue["pieces"], let pieces = piecesValue.stringValue else {
|
||||
throw TorrentInfoError.invalidFormat("Missing 'pieces'")
|
||||
}
|
||||
|
||||
let isPrivate = infoValue["private"]?.integerValue == 1
|
||||
|
||||
// Parse files
|
||||
var files: [FileEntry] = []
|
||||
var totalSize: Int64 = 0
|
||||
|
||||
if let filesValue = infoValue["files"]?.listValue {
|
||||
// Multi-file torrent
|
||||
for fileValue in filesValue {
|
||||
guard let length = fileValue["length"]?.integerValue,
|
||||
let pathList = fileValue["path"]?.listValue else {
|
||||
throw TorrentInfoError.invalidFormat("Invalid file entry")
|
||||
}
|
||||
let pathComponents = pathList.compactMap { $0.utf8String }
|
||||
let path = ([name] + pathComponents).joined(separator: "/")
|
||||
files.append(FileEntry(path: path, length: length, offset: totalSize))
|
||||
totalSize += length
|
||||
}
|
||||
} else if let length = infoValue["length"]?.integerValue {
|
||||
// Single-file torrent
|
||||
files.append(FileEntry(path: name, length: length, offset: 0))
|
||||
totalSize = length
|
||||
} else {
|
||||
throw TorrentInfoError.invalidFormat("Missing 'length' or 'files'")
|
||||
}
|
||||
|
||||
let comment = root["comment"]?.utf8String
|
||||
let createdBy = root["created by"]?.utf8String
|
||||
let creationDate: Date? = root["creation date"]?.integerValue.map {
|
||||
Date(timeIntervalSince1970: TimeInterval($0))
|
||||
}
|
||||
let announceURL = root["announce"]?.utf8String
|
||||
var announceList: [[String]] = []
|
||||
if let al = root["announce-list"]?.listValue {
|
||||
for tier in al {
|
||||
if let urls = tier.listValue {
|
||||
announceList.append(urls.compactMap { $0.utf8String })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TorrentInfo(
|
||||
infoHash: infoHash, name: name, pieceLength: Int(pieceLength),
|
||||
pieces: pieces, totalSize: totalSize, files: files,
|
||||
isPrivate: isPrivate, comment: comment, createdBy: createdBy,
|
||||
creationDate: creationDate, announceURL: announceURL,
|
||||
announceList: announceList
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract raw bytes of the "info" dictionary value from bencoded data.
|
||||
private static func findInfoDictBytes(in data: Data) throws -> Data {
|
||||
// Search for "4:info" key then capture the value
|
||||
guard let range = data.range(of: Data("4:info".utf8)) else {
|
||||
throw TorrentInfoError.invalidFormat("Cannot find info key")
|
||||
}
|
||||
let valueStart = range.upperBound
|
||||
// Parse from valueStart to find where the value ends
|
||||
var index = valueStart
|
||||
try skipBencodeValue(data, index: &index)
|
||||
return Data(data[valueStart..<index])
|
||||
}
|
||||
|
||||
private static func skipBencodeValue(_ data: Data, index: inout Data.Index) throws {
|
||||
guard index < data.endIndex else { throw BencodeError.unexpectedEnd }
|
||||
switch data[index] {
|
||||
case UInt8(ascii: "i"):
|
||||
guard let end = data[index...].firstIndex(of: UInt8(ascii: "e")) else {
|
||||
throw BencodeError.unexpectedEnd
|
||||
}
|
||||
index = data.index(after: end)
|
||||
case UInt8(ascii: "l"), UInt8(ascii: "d"):
|
||||
index = data.index(after: index)
|
||||
while index < data.endIndex && data[index] != UInt8(ascii: "e") {
|
||||
try skipBencodeValue(data, index: &index)
|
||||
}
|
||||
guard index < data.endIndex else { throw BencodeError.unexpectedEnd }
|
||||
index = data.index(after: index)
|
||||
case UInt8(ascii: "0")...UInt8(ascii: "9"):
|
||||
guard let colon = data[index...].firstIndex(of: UInt8(ascii: ":")) else {
|
||||
throw BencodeError.unexpectedEnd
|
||||
}
|
||||
guard let lenStr = String(data: data[index..<colon], encoding: .ascii),
|
||||
let len = Int(lenStr) else {
|
||||
throw BencodeError.invalidStringLength
|
||||
}
|
||||
index = data.index(colon, offsetBy: 1 + len)
|
||||
default:
|
||||
throw BencodeError.invalidFormat("Unexpected byte in skip")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum TorrentInfoError: Error, Equatable {
|
||||
case invalidFormat(String)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import Foundation
|
||||
|
||||
/// HTTP tracker client (BEP-3).
|
||||
public struct HTTPTracker: Sendable {
|
||||
public let announceURL: String
|
||||
|
||||
public init(announceURL: String) {
|
||||
self.announceURL = announceURL
|
||||
}
|
||||
|
||||
/// Announce to the tracker.
|
||||
public func announce(params: AnnounceParams) async throws -> AnnounceResponse {
|
||||
var components = URLComponents(string: announceURL)
|
||||
components?.queryItems = [
|
||||
URLQueryItem(name: "info_hash", value: params.infoHash.urlEncoded),
|
||||
URLQueryItem(name: "peer_id", value: String(data: params.peerID, encoding: .ascii) ?? ""),
|
||||
URLQueryItem(name: "port", value: String(params.port)),
|
||||
URLQueryItem(name: "uploaded", value: String(params.uploaded)),
|
||||
URLQueryItem(name: "downloaded", value: String(params.downloaded)),
|
||||
URLQueryItem(name: "left", value: String(params.left)),
|
||||
URLQueryItem(name: "compact", value: "1"),
|
||||
URLQueryItem(name: "numwant", value: String(params.numWant)),
|
||||
]
|
||||
if let event = params.event {
|
||||
components?.queryItems?.append(URLQueryItem(name: "event", value: event))
|
||||
}
|
||||
|
||||
guard let url = components?.url else {
|
||||
throw TrackerError.invalidURL
|
||||
}
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
return try parseAnnounceResponse(data)
|
||||
}
|
||||
|
||||
private func parseAnnounceResponse(_ data: Data) throws -> AnnounceResponse {
|
||||
let decoder = BencodeDecoder()
|
||||
let value = try decoder.decode(data)
|
||||
|
||||
if let failure = value["failure reason"]?.utf8String {
|
||||
throw TrackerError.failure(failure)
|
||||
}
|
||||
|
||||
let interval = value["interval"]?.integerValue.map(Int.init) ?? 1800
|
||||
let seeders = value["complete"]?.integerValue.map(Int.init) ?? 0
|
||||
let leechers = value["incomplete"]?.integerValue.map(Int.init) ?? 0
|
||||
|
||||
var peers: [(String, UInt16)] = []
|
||||
|
||||
if let peersData = value["peers"]?.stringValue {
|
||||
// Compact format: 6 bytes per peer (4 IP + 2 port)
|
||||
var offset = 0
|
||||
while offset + 6 <= peersData.count {
|
||||
let ip = "\(peersData[offset]).\(peersData[offset+1]).\(peersData[offset+2]).\(peersData[offset+3])"
|
||||
let port = UInt16(peersData[offset+4]) << 8 | UInt16(peersData[offset+5])
|
||||
peers.append((ip, port))
|
||||
offset += 6
|
||||
}
|
||||
} else if let peersList = value["peers"]?.listValue {
|
||||
// Dictionary format
|
||||
for peerValue in peersList {
|
||||
if let ip = peerValue["ip"]?.utf8String,
|
||||
let port = peerValue["port"]?.integerValue {
|
||||
peers.append((ip, UInt16(port)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AnnounceResponse(
|
||||
interval: interval, seeders: seeders, leechers: leechers, peers: peers
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AnnounceParams: Sendable {
|
||||
public let infoHash: InfoHash
|
||||
public let peerID: Data
|
||||
public let port: UInt16
|
||||
public let uploaded: Int64
|
||||
public let downloaded: Int64
|
||||
public let left: Int64
|
||||
public let numWant: Int
|
||||
public let event: String? // "started", "stopped", "completed"
|
||||
|
||||
public init(infoHash: InfoHash, peerID: Data, port: UInt16,
|
||||
uploaded: Int64 = 0, downloaded: Int64 = 0, left: Int64,
|
||||
numWant: Int = 50, event: String? = nil) {
|
||||
self.infoHash = infoHash
|
||||
self.peerID = peerID
|
||||
self.port = port
|
||||
self.uploaded = uploaded
|
||||
self.downloaded = downloaded
|
||||
self.left = left
|
||||
self.numWant = numWant
|
||||
self.event = event
|
||||
}
|
||||
}
|
||||
|
||||
public struct AnnounceResponse: Sendable {
|
||||
public let interval: Int
|
||||
public let seeders: Int
|
||||
public let leechers: Int
|
||||
public let peers: [(String, UInt16)]
|
||||
}
|
||||
|
||||
public enum TrackerError: Error, Equatable {
|
||||
case invalidURL
|
||||
case failure(String)
|
||||
case invalidResponse
|
||||
case connectionFailed
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
import NIOCore
|
||||
|
||||
/// Coordinates multiple trackers with tier support.
|
||||
public actor TrackerManager {
|
||||
private let tiers: [[String]]
|
||||
private let group: EventLoopGroup
|
||||
private var lastResponse: AnnounceResponse?
|
||||
private var announceInterval: Int = 1800
|
||||
|
||||
public init(tiers: [[String]], group: EventLoopGroup) {
|
||||
self.tiers = tiers
|
||||
self.group = group
|
||||
}
|
||||
|
||||
/// Convenience: create from TorrentInfo.
|
||||
public init(info: TorrentInfo, group: EventLoopGroup) {
|
||||
var tiers = info.announceList
|
||||
if tiers.isEmpty, let url = info.announceURL {
|
||||
tiers = [[url]]
|
||||
}
|
||||
self.tiers = tiers
|
||||
self.group = group
|
||||
}
|
||||
|
||||
/// Announce to all tracker tiers, returning the first successful response.
|
||||
public func announce(params: AnnounceParams) async throws -> AnnounceResponse {
|
||||
for tier in tiers {
|
||||
for urlString in tier {
|
||||
do {
|
||||
let response: AnnounceResponse
|
||||
if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") {
|
||||
let tracker = HTTPTracker(announceURL: urlString)
|
||||
response = try await tracker.announce(params: params)
|
||||
} else if urlString.hasPrefix("udp://") {
|
||||
guard let components = URLComponents(string: urlString),
|
||||
let host = components.host,
|
||||
let port = components.port else {
|
||||
continue
|
||||
}
|
||||
let tracker = UDPTracker(host: host, port: port, group: group)
|
||||
response = try await tracker.announce(params: params)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
lastResponse = response
|
||||
announceInterval = response.interval
|
||||
return response
|
||||
} catch {
|
||||
continue // Try next tracker in tier
|
||||
}
|
||||
}
|
||||
}
|
||||
throw TrackerError.connectionFailed
|
||||
}
|
||||
|
||||
public func getInterval() -> Int {
|
||||
announceInterval
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
|
||||
/// UDP tracker client (BEP-15).
|
||||
public final class UDPTracker: Sendable {
|
||||
public let host: String
|
||||
public let port: Int
|
||||
private let group: EventLoopGroup
|
||||
|
||||
public init(host: String, port: Int, group: EventLoopGroup) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.group = group
|
||||
}
|
||||
|
||||
/// Announce to the UDP tracker.
|
||||
public func announce(params: AnnounceParams) async throws -> AnnounceResponse {
|
||||
let channel = try await DatagramBootstrap(group: group)
|
||||
.channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
|
||||
.bind(host: "0.0.0.0", port: 0)
|
||||
.get()
|
||||
|
||||
let remoteAddr = try SocketAddress(ipAddress: host, port: port)
|
||||
|
||||
// Step 1: Connect request
|
||||
let transactionID = UInt32.random(in: 0...UInt32.max)
|
||||
var connectReq = Data()
|
||||
connectReq.append(contentsOf: UInt64(0x41727101980).bigEndianBytes) // magic
|
||||
connectReq.append(contentsOf: UInt32(0).bigEndianBytes) // action: connect
|
||||
connectReq.append(contentsOf: transactionID.bigEndianBytes)
|
||||
|
||||
var buf = channel.allocator.buffer(capacity: connectReq.count)
|
||||
buf.writeBytes(connectReq)
|
||||
let envelope = AddressedEnvelope(remoteAddress: remoteAddr, data: buf)
|
||||
try await channel.writeAndFlush(envelope).get()
|
||||
|
||||
// Read connect response
|
||||
// In a real implementation, this would use a proper response handler.
|
||||
// For now, we create a simplified response flow.
|
||||
let connectionID: UInt64 = 0 // placeholder — real impl reads from channel
|
||||
|
||||
// Step 2: Announce request
|
||||
var announceReq = Data()
|
||||
announceReq.append(contentsOf: connectionID.bigEndianBytes)
|
||||
announceReq.append(contentsOf: UInt32(1).bigEndianBytes) // action: announce
|
||||
let announceTxID = UInt32.random(in: 0...UInt32.max)
|
||||
announceReq.append(contentsOf: announceTxID.bigEndianBytes)
|
||||
announceReq.append(params.infoHash.bytes)
|
||||
announceReq.append(params.peerID)
|
||||
announceReq.append(contentsOf: params.downloaded.bigEndianBytes)
|
||||
announceReq.append(contentsOf: params.left.bigEndianBytes)
|
||||
announceReq.append(contentsOf: params.uploaded.bigEndianBytes)
|
||||
announceReq.append(contentsOf: UInt32(0).bigEndianBytes) // event: none
|
||||
announceReq.append(contentsOf: UInt32(0).bigEndianBytes) // IP
|
||||
announceReq.append(contentsOf: UInt32.random(in: 0...UInt32.max).bigEndianBytes) // key
|
||||
announceReq.append(contentsOf: Int32(params.numWant).bigEndianBytes)
|
||||
announceReq.append(contentsOf: params.port.bigEndianBytes)
|
||||
|
||||
var abuf = channel.allocator.buffer(capacity: announceReq.count)
|
||||
abuf.writeBytes(announceReq)
|
||||
let aenvelope = AddressedEnvelope(remoteAddress: remoteAddr, data: abuf)
|
||||
try await channel.writeAndFlush(aenvelope).get()
|
||||
|
||||
// Clean up
|
||||
try await channel.close().get()
|
||||
|
||||
// Placeholder response — real implementation reads UDP responses
|
||||
return AnnounceResponse(interval: 1800, seeders: 0, leechers: 0, peers: [])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Big-endian helpers
|
||||
|
||||
extension UInt64 {
|
||||
var bigEndianBytes: [UInt8] {
|
||||
let be = self.bigEndian
|
||||
return withUnsafeBytes(of: be) { Array($0) }
|
||||
}
|
||||
}
|
||||
|
||||
extension Int64 {
|
||||
var bigEndianBytes: [UInt8] {
|
||||
let be = self.bigEndian
|
||||
return withUnsafeBytes(of: be) { Array($0) }
|
||||
}
|
||||
}
|
||||
|
||||
extension Int32 {
|
||||
var bigEndianBytes: [UInt8] {
|
||||
let be = self.bigEndian
|
||||
return withUnsafeBytes(of: be) { Array($0) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import XCTest
|
||||
@testable import SwiftTorrent
|
||||
|
||||
final class BencodeTests: XCTestCase {
|
||||
let encoder = BencodeEncoder()
|
||||
let decoder = BencodeDecoder()
|
||||
|
||||
// MARK: - Integer
|
||||
|
||||
func testEncodeDecodeInteger() throws {
|
||||
let value = BencodeValue.integer(42)
|
||||
let data = encoder.encode(value)
|
||||
XCTAssertEqual(String(data: data, encoding: .ascii), "i42e")
|
||||
let decoded = try decoder.decode(data)
|
||||
XCTAssertEqual(decoded, value)
|
||||
}
|
||||
|
||||
func testNegativeInteger() throws {
|
||||
let value = BencodeValue.integer(-1)
|
||||
let data = encoder.encode(value)
|
||||
XCTAssertEqual(String(data: data, encoding: .ascii), "i-1e")
|
||||
let decoded = try decoder.decode(data)
|
||||
XCTAssertEqual(decoded, value)
|
||||
}
|
||||
|
||||
func testZeroInteger() throws {
|
||||
let value = BencodeValue.integer(0)
|
||||
let data = encoder.encode(value)
|
||||
XCTAssertEqual(String(data: data, encoding: .ascii), "i0e")
|
||||
let decoded = try decoder.decode(data)
|
||||
XCTAssertEqual(decoded, value)
|
||||
}
|
||||
|
||||
// MARK: - String
|
||||
|
||||
func testEncodeDecodeString() throws {
|
||||
let value = BencodeValue.string(Data("hello".utf8))
|
||||
let data = encoder.encode(value)
|
||||
XCTAssertEqual(String(data: data, encoding: .ascii), "5:hello")
|
||||
let decoded = try decoder.decode(data)
|
||||
XCTAssertEqual(decoded, value)
|
||||
}
|
||||
|
||||
func testEmptyString() throws {
|
||||
let value = BencodeValue.string(Data())
|
||||
let data = encoder.encode(value)
|
||||
XCTAssertEqual(String(data: data, encoding: .ascii), "0:")
|
||||
let decoded = try decoder.decode(data)
|
||||
XCTAssertEqual(decoded, value)
|
||||
}
|
||||
|
||||
// MARK: - List
|
||||
|
||||
func testEncodeDecodeList() throws {
|
||||
let value = BencodeValue.list([.integer(1), .string(Data("two".utf8)), .integer(3)])
|
||||
let data = encoder.encode(value)
|
||||
let decoded = try decoder.decode(data)
|
||||
XCTAssertEqual(decoded, value)
|
||||
}
|
||||
|
||||
func testEmptyList() throws {
|
||||
let value = BencodeValue.list([])
|
||||
let data = encoder.encode(value)
|
||||
XCTAssertEqual(String(data: data, encoding: .ascii), "le")
|
||||
let decoded = try decoder.decode(data)
|
||||
XCTAssertEqual(decoded, value)
|
||||
}
|
||||
|
||||
// MARK: - Dictionary
|
||||
|
||||
func testEncodeDecodeDictionary() throws {
|
||||
let value = BencodeValue.dictionary([
|
||||
(key: Data("cow".utf8), value: .string(Data("moo".utf8))),
|
||||
(key: Data("spam".utf8), value: .string(Data("eggs".utf8))),
|
||||
])
|
||||
let data = encoder.encode(value)
|
||||
let decoded = try decoder.decode(data)
|
||||
// Keys should be sorted in output
|
||||
XCTAssertEqual(decoded, value)
|
||||
}
|
||||
|
||||
func testDictionarySubscript() throws {
|
||||
let data = Data("d3:fooi42ee".utf8)
|
||||
let decoded = try decoder.decode(data)
|
||||
XCTAssertEqual(decoded["foo"]?.integerValue, 42)
|
||||
XCTAssertNil(decoded["bar"])
|
||||
}
|
||||
|
||||
// MARK: - Round-trip
|
||||
|
||||
func testNestedRoundTrip() throws {
|
||||
let value = BencodeValue.dictionary([
|
||||
(key: Data("info".utf8), value: .dictionary([
|
||||
(key: Data("name".utf8), value: .string(Data("test.txt".utf8))),
|
||||
(key: Data("piece length".utf8), value: .integer(262144)),
|
||||
])),
|
||||
(key: Data("announce".utf8), value: .string(Data("http://tracker.example.com/announce".utf8))),
|
||||
])
|
||||
let data = encoder.encode(value)
|
||||
let decoded = try decoder.decode(data)
|
||||
XCTAssertEqual(decoded["info"]?["name"]?.utf8String, "test.txt")
|
||||
XCTAssertEqual(decoded["info"]?["piece length"]?.integerValue, 262144)
|
||||
}
|
||||
|
||||
// MARK: - Error cases
|
||||
|
||||
func testInvalidInput() {
|
||||
XCTAssertThrowsError(try decoder.decode(Data()))
|
||||
XCTAssertThrowsError(try decoder.decode(Data("x".utf8)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import XCTest
|
||||
@testable import SwiftTorrent
|
||||
|
||||
final class BitfieldTests: XCTestCase {
|
||||
func testBasicOperations() {
|
||||
var bf = Bitfield(count: 100)
|
||||
XCTAssertEqual(bf.count, 100)
|
||||
XCTAssertTrue(bf.isEmpty)
|
||||
XCTAssertFalse(bf.get(0))
|
||||
|
||||
bf.set(0)
|
||||
XCTAssertTrue(bf.get(0))
|
||||
XCTAssertEqual(bf.popcount, 1)
|
||||
|
||||
bf.set(99)
|
||||
XCTAssertTrue(bf.get(99))
|
||||
XCTAssertEqual(bf.popcount, 2)
|
||||
|
||||
bf.clear(0)
|
||||
XCTAssertFalse(bf.get(0))
|
||||
XCTAssertEqual(bf.popcount, 1)
|
||||
}
|
||||
|
||||
func testAllSet() {
|
||||
var bf = Bitfield(count: 8)
|
||||
for i in 0..<8 { bf.set(i) }
|
||||
XCTAssertTrue(bf.allSet)
|
||||
}
|
||||
|
||||
func testOutOfBounds() {
|
||||
var bf = Bitfield(count: 10)
|
||||
bf.set(100) // should be no-op
|
||||
XCTAssertFalse(bf.get(100))
|
||||
XCTAssertFalse(bf.get(-1))
|
||||
}
|
||||
|
||||
func testDataRoundTrip() {
|
||||
var bf = Bitfield(count: 16)
|
||||
bf.set(0)
|
||||
bf.set(7)
|
||||
bf.set(8)
|
||||
bf.set(15)
|
||||
|
||||
let data = bf.toData()
|
||||
XCTAssertEqual(data.count, 2)
|
||||
|
||||
let bf2 = Bitfield(data: data, count: 16)
|
||||
XCTAssertTrue(bf2.get(0))
|
||||
XCTAssertTrue(bf2.get(7))
|
||||
XCTAssertTrue(bf2.get(8))
|
||||
XCTAssertTrue(bf2.get(15))
|
||||
XCTAssertFalse(bf2.get(1))
|
||||
XCTAssertEqual(bf2.popcount, 4)
|
||||
}
|
||||
|
||||
func testLargeCount() {
|
||||
var bf = Bitfield(count: 1000)
|
||||
bf.set(999)
|
||||
XCTAssertTrue(bf.get(999))
|
||||
XCTAssertEqual(bf.popcount, 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import XCTest
|
||||
@testable import SwiftTorrent
|
||||
|
||||
final class DHTNodeIDTests: XCTestCase {
|
||||
func testRandom() {
|
||||
let id1 = NodeID.random()
|
||||
let id2 = NodeID.random()
|
||||
XCTAssertEqual(id1.bytes.count, 20)
|
||||
XCTAssertNotEqual(id1, id2) // extremely unlikely to collide
|
||||
}
|
||||
|
||||
func testDistanceSelf() {
|
||||
let id = NodeID.random()
|
||||
let dist = id.distance(to: id)
|
||||
XCTAssertEqual(dist, Data(count: 20))
|
||||
}
|
||||
|
||||
func testDistanceSymmetry() {
|
||||
let a = NodeID.random()
|
||||
let b = NodeID.random()
|
||||
XCTAssertEqual(a.distance(to: b), b.distance(to: a))
|
||||
}
|
||||
|
||||
func testBucketIndex() {
|
||||
let a = NodeID(bytes: Data(repeating: 0, count: 20))
|
||||
var bBytes = Data(repeating: 0, count: 20)
|
||||
bBytes[0] = 0x80 // highest bit set -> distance has bit 159 set
|
||||
let b = NodeID(bytes: bBytes)
|
||||
let idx = a.bucketIndex(relativeTo: b)
|
||||
XCTAssertEqual(idx, 159)
|
||||
}
|
||||
|
||||
func testDistanceComparison() {
|
||||
let d1 = Data([0x00, 0x01])
|
||||
let d2 = Data([0x00, 0x02])
|
||||
XCTAssertTrue(distanceLessThan(d1, d2))
|
||||
XCTAssertFalse(distanceLessThan(d2, d1))
|
||||
XCTAssertFalse(distanceLessThan(d1, d1))
|
||||
}
|
||||
|
||||
func testDescription() {
|
||||
let bytes = Data(repeating: 0xFF, count: 20)
|
||||
let id = NodeID(bytes: bytes)
|
||||
XCTAssertEqual(id.description, String(repeating: "ff", count: 20))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import XCTest
|
||||
@testable import SwiftTorrent
|
||||
|
||||
final class DHTRoutingTableTests: XCTestCase {
|
||||
func testInsertAndFind() {
|
||||
let ownID = NodeID.random()
|
||||
var table = DHTRoutingTable(ownID: ownID)
|
||||
|
||||
let node = DHTNodeEntry(id: NodeID.random(), address: "1.2.3.4", port: 6881)
|
||||
let inserted = table.insert(node)
|
||||
XCTAssertTrue(inserted)
|
||||
XCTAssertEqual(table.nodeCount, 1)
|
||||
}
|
||||
|
||||
func testClosestNodes() {
|
||||
let ownID = NodeID.random()
|
||||
var table = DHTRoutingTable(ownID: ownID)
|
||||
|
||||
for _ in 0..<20 {
|
||||
let node = DHTNodeEntry(id: NodeID.random(), address: "1.2.3.4", port: 6881)
|
||||
_ = table.insert(node)
|
||||
}
|
||||
|
||||
let target = NodeID.random()
|
||||
let closest = table.closestNodes(to: target, count: 8)
|
||||
XCTAssertLessThanOrEqual(closest.count, 8)
|
||||
|
||||
// Verify ordering: each should be closer than the next
|
||||
for i in 0..<closest.count - 1 {
|
||||
let d1 = closest[i].id.distance(to: target)
|
||||
let d2 = closest[i + 1].id.distance(to: target)
|
||||
XCTAssertTrue(distanceLessThan(d1, d2) || d1 == d2)
|
||||
}
|
||||
}
|
||||
|
||||
func testBucketFull() {
|
||||
let ownID = NodeID(bytes: Data(repeating: 0, count: 20))
|
||||
var table = DHTRoutingTable(ownID: ownID)
|
||||
|
||||
// Fill a bucket (all nodes that map to same bucket)
|
||||
var inserted = 0
|
||||
for i in 0..<20 {
|
||||
var bytes = Data(repeating: 0, count: 20)
|
||||
bytes[0] = 0x80 // same top bit -> same bucket
|
||||
bytes[19] = UInt8(i)
|
||||
let node = DHTNodeEntry(id: NodeID(bytes: bytes), address: "1.2.3.\(i)", port: 6881)
|
||||
if table.insert(node) { inserted += 1 }
|
||||
}
|
||||
// Should be capped at k=8
|
||||
XCTAssertEqual(inserted, DHTRoutingTable.k)
|
||||
}
|
||||
|
||||
func testRemoveStale() {
|
||||
let ownID = NodeID.random()
|
||||
var table = DHTRoutingTable(ownID: ownID)
|
||||
|
||||
let node = DHTNodeEntry(id: NodeID.random(), address: "1.2.3.4", port: 6881)
|
||||
_ = table.insert(node)
|
||||
XCTAssertEqual(table.nodeCount, 1)
|
||||
|
||||
// Remove nodes older than 0 seconds (all nodes)
|
||||
table.removeStaleNodes(olderThan: 0)
|
||||
XCTAssertEqual(table.nodeCount, 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import XCTest
|
||||
@testable import SwiftTorrent
|
||||
|
||||
final class InfoHashTests: XCTestCase {
|
||||
func testFromHex() {
|
||||
let hash = InfoHash(hex: "0123456789abcdef0123456789abcdef01234567")
|
||||
XCTAssertNotNil(hash)
|
||||
XCTAssertEqual(hash?.bytes.count, 20)
|
||||
XCTAssertEqual(hash?.version, .v1)
|
||||
}
|
||||
|
||||
func testInvalidHex() {
|
||||
XCTAssertNil(InfoHash(hex: "short"))
|
||||
XCTAssertNil(InfoHash(hex: "xyz"))
|
||||
}
|
||||
|
||||
func testV1Hash() {
|
||||
let data = Data("test info dictionary".utf8)
|
||||
let hash = InfoHash.v1(from: data)
|
||||
XCTAssertEqual(hash.bytes.count, 20)
|
||||
XCTAssertEqual(hash.version, .v1)
|
||||
}
|
||||
|
||||
func testV2Hash() {
|
||||
let data = Data("test info dictionary".utf8)
|
||||
let hash = InfoHash.v2(from: data)
|
||||
XCTAssertEqual(hash.bytes.count, 32)
|
||||
XCTAssertEqual(hash.version, .v2)
|
||||
}
|
||||
|
||||
func testDescription() {
|
||||
let hash = InfoHash(hex: "0123456789abcdef0123456789abcdef01234567")!
|
||||
XCTAssertEqual(hash.description, "0123456789abcdef0123456789abcdef01234567")
|
||||
}
|
||||
|
||||
func testEquatable() {
|
||||
let h1 = InfoHash(hex: "0123456789abcdef0123456789abcdef01234567")!
|
||||
let h2 = InfoHash(hex: "0123456789abcdef0123456789abcdef01234567")!
|
||||
let h3 = InfoHash(hex: "abcdef0123456789abcdef0123456789abcdef01")!
|
||||
XCTAssertEqual(h1, h2)
|
||||
XCTAssertNotEqual(h1, h3)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import XCTest
|
||||
@testable import SwiftTorrent
|
||||
|
||||
final class MagnetLinkTests: XCTestCase {
|
||||
func testParseBasicMagnet() {
|
||||
let uri = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=TestFile"
|
||||
let magnet = MagnetLink(uri: uri)
|
||||
XCTAssertNotNil(magnet)
|
||||
XCTAssertEqual(magnet?.displayName, "TestFile")
|
||||
XCTAssertEqual(magnet?.infoHash.description, "0123456789abcdef0123456789abcdef01234567")
|
||||
}
|
||||
|
||||
func testParseWithTrackers() {
|
||||
let uri = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&tr=http://tracker1.example.com/announce&tr=http://tracker2.example.com/announce"
|
||||
let magnet = MagnetLink(uri: uri)
|
||||
XCTAssertNotNil(magnet)
|
||||
XCTAssertEqual(magnet?.trackers.count, 2)
|
||||
}
|
||||
|
||||
func testInvalidMagnet() {
|
||||
XCTAssertNil(MagnetLink(uri: "not a magnet"))
|
||||
XCTAssertNil(MagnetLink(uri: "magnet:?foo=bar"))
|
||||
}
|
||||
|
||||
func testGenerateURI() {
|
||||
let hash = InfoHash(hex: "0123456789abcdef0123456789abcdef01234567")!
|
||||
let magnet = MagnetLink(infoHash: hash, displayName: "Test")
|
||||
let uri = magnet.uri
|
||||
XCTAssertTrue(uri.hasPrefix("magnet:?"))
|
||||
XCTAssertTrue(uri.contains("xt=urn:btih:"))
|
||||
XCTAssertTrue(uri.contains("dn=Test"))
|
||||
}
|
||||
|
||||
func testBase32Decode() {
|
||||
// "ORSXG5A=" is base32 for "test"
|
||||
let decoded = MagnetLink.base32Decode("ORSXG5A")
|
||||
XCTAssertNotNil(decoded)
|
||||
XCTAssertEqual(String(data: decoded!, encoding: .utf8), "test")
|
||||
}
|
||||
|
||||
func testRoundTrip() {
|
||||
let hash = InfoHash(hex: "0123456789abcdef0123456789abcdef01234567")!
|
||||
let original = MagnetLink(infoHash: hash, displayName: "MyTorrent", trackers: ["http://tracker.example.com/announce"])
|
||||
let uri = original.uri
|
||||
let parsed = MagnetLink(uri: uri)
|
||||
XCTAssertNotNil(parsed)
|
||||
XCTAssertEqual(parsed?.infoHash, original.infoHash)
|
||||
XCTAssertEqual(parsed?.displayName, "MyTorrent")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import XCTest
|
||||
@testable import SwiftTorrent
|
||||
|
||||
final class PeerMessageTests: XCTestCase {
|
||||
func testKeepAlive() throws {
|
||||
let msg = PeerMessage.keepAlive
|
||||
let data = msg.encode()
|
||||
XCTAssertEqual(data.count, 4)
|
||||
// Length prefix should be 0
|
||||
XCTAssertEqual(data.readUInt32BE(at: 0), 0)
|
||||
let decoded = try PeerMessage.decode(from: Data())
|
||||
XCTAssertEqual(decoded, .keepAlive)
|
||||
}
|
||||
|
||||
func testChoke() throws {
|
||||
let msg = PeerMessage.choke
|
||||
let data = msg.encode()
|
||||
XCTAssertEqual(data.readUInt32BE(at: 0), 1) // length
|
||||
XCTAssertEqual(data[4], PeerMessage.chokeID)
|
||||
let decoded = try PeerMessage.decode(from: Data([PeerMessage.chokeID]))
|
||||
XCTAssertEqual(decoded, .choke)
|
||||
}
|
||||
|
||||
func testUnchoke() throws {
|
||||
let decoded = try PeerMessage.decode(from: Data([PeerMessage.unchokeID]))
|
||||
XCTAssertEqual(decoded, .unchoke)
|
||||
}
|
||||
|
||||
func testInterested() throws {
|
||||
let decoded = try PeerMessage.decode(from: Data([PeerMessage.interestedID]))
|
||||
XCTAssertEqual(decoded, .interested)
|
||||
}
|
||||
|
||||
func testNotInterested() throws {
|
||||
let decoded = try PeerMessage.decode(from: Data([PeerMessage.notInterestedID]))
|
||||
XCTAssertEqual(decoded, .notInterested)
|
||||
}
|
||||
|
||||
func testHave() throws {
|
||||
let msg = PeerMessage.have(pieceIndex: 42)
|
||||
let data = msg.encode()
|
||||
let payload = data.dropFirst(4) // skip length
|
||||
let decoded = try PeerMessage.decode(from: Data(payload))
|
||||
XCTAssertEqual(decoded, msg)
|
||||
}
|
||||
|
||||
func testRequest() throws {
|
||||
let msg = PeerMessage.request(index: 1, begin: 0, length: 16384)
|
||||
let data = msg.encode()
|
||||
let payload = data.dropFirst(4)
|
||||
let decoded = try PeerMessage.decode(from: Data(payload))
|
||||
XCTAssertEqual(decoded, msg)
|
||||
}
|
||||
|
||||
func testPiece() throws {
|
||||
let block = Data([1, 2, 3, 4, 5])
|
||||
let msg = PeerMessage.piece(index: 0, begin: 0, block: block)
|
||||
let data = msg.encode()
|
||||
let payload = data.dropFirst(4)
|
||||
let decoded = try PeerMessage.decode(from: Data(payload))
|
||||
XCTAssertEqual(decoded, msg)
|
||||
}
|
||||
|
||||
func testCancel() throws {
|
||||
let msg = PeerMessage.cancel(index: 1, begin: 0, length: 16384)
|
||||
let data = msg.encode()
|
||||
let payload = data.dropFirst(4)
|
||||
let decoded = try PeerMessage.decode(from: Data(payload))
|
||||
XCTAssertEqual(decoded, msg)
|
||||
}
|
||||
|
||||
func testPort() throws {
|
||||
let msg = PeerMessage.port(6881)
|
||||
let data = msg.encode()
|
||||
let payload = data.dropFirst(4)
|
||||
let decoded = try PeerMessage.decode(from: Data(payload))
|
||||
XCTAssertEqual(decoded, msg)
|
||||
}
|
||||
|
||||
func testUnknownMessageID() {
|
||||
XCTAssertThrowsError(try PeerMessage.decode(from: Data([255])))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import XCTest
|
||||
@testable import SwiftTorrent
|
||||
|
||||
final class PiecePickerTests: XCTestCase {
|
||||
func testRarestFirst() {
|
||||
var picker = PiecePicker(pieceCount: 5)
|
||||
|
||||
// Peer 1 has pieces 0, 1, 2
|
||||
var bf1 = Bitfield(count: 5)
|
||||
bf1.set(0); bf1.set(1); bf1.set(2)
|
||||
picker.addPeerBitfield(bf1)
|
||||
|
||||
// Peer 2 has pieces 0, 1, 3
|
||||
var bf2 = Bitfield(count: 5)
|
||||
bf2.set(0); bf2.set(1); bf2.set(3)
|
||||
picker.addPeerBitfield(bf2)
|
||||
|
||||
// Availability: 0->2, 1->2, 2->1, 3->1, 4->0
|
||||
// Peer with pieces 2 and 3 — should pick 2 or 3 (rarest)
|
||||
var peerBF = Bitfield(count: 5)
|
||||
peerBF.set(2); peerBF.set(3)
|
||||
|
||||
let have = Bitfield(count: 5) // we have nothing
|
||||
let picked = picker.pick(have: have, peerHas: peerBF)
|
||||
XCTAssertNotNil(picked)
|
||||
XCTAssertTrue(picked == 2 || picked == 3) // both have availability 1
|
||||
}
|
||||
|
||||
func testAlreadyHave() {
|
||||
var picker = PiecePicker(pieceCount: 3)
|
||||
var bf = Bitfield(count: 3)
|
||||
bf.set(0); bf.set(1); bf.set(2)
|
||||
picker.addPeerBitfield(bf)
|
||||
|
||||
// We already have everything
|
||||
var have = Bitfield(count: 3)
|
||||
have.set(0); have.set(1); have.set(2)
|
||||
|
||||
let picked = picker.pick(have: have, peerHas: bf)
|
||||
XCTAssertNil(picked)
|
||||
}
|
||||
|
||||
func testPickMultiple() {
|
||||
var picker = PiecePicker(pieceCount: 5)
|
||||
var bf = Bitfield(count: 5)
|
||||
for i in 0..<5 { bf.set(i) }
|
||||
picker.addPeerBitfield(bf)
|
||||
|
||||
let have = Bitfield(count: 5)
|
||||
let picked = picker.pickMultiple(have: have, peerHas: bf, count: 3)
|
||||
XCTAssertEqual(picked.count, 3)
|
||||
}
|
||||
|
||||
func testAddHave() {
|
||||
var picker = PiecePicker(pieceCount: 3)
|
||||
picker.addHave(1)
|
||||
picker.addHave(1)
|
||||
// Piece 1 should have availability 2, others 0
|
||||
// When picking from a peer that has all, piece 0 or 2 should be picked (avail 0)
|
||||
var peerBF = Bitfield(count: 3)
|
||||
peerBF.set(0); peerBF.set(1); peerBF.set(2)
|
||||
let have = Bitfield(count: 3)
|
||||
let picked = picker.pick(have: have, peerHas: peerBF)
|
||||
XCTAssertTrue(picked == 0 || picked == 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import XCTest
|
||||
@testable import SwiftTorrent
|
||||
|
||||
final class SessionIntegrationTests: XCTestCase {
|
||||
func testCreateSession() async throws {
|
||||
let settings = SessionSettings(listenPort: 0, dhtEnabled: false)
|
||||
let session = Session(settings: settings)
|
||||
|
||||
let torrents = await session.allTorrents()
|
||||
XCTAssertTrue(torrents.isEmpty)
|
||||
}
|
||||
|
||||
func testHandshakeRoundTrip() throws {
|
||||
let infoHash = Data(repeating: 0xAB, count: 20)
|
||||
let peerID = Data(repeating: 0xCD, count: 20)
|
||||
|
||||
let handshake = Handshake(infoHash: infoHash, peerID: peerID)
|
||||
let encoded = handshake.encode()
|
||||
XCTAssertEqual(encoded.count, Handshake.length)
|
||||
|
||||
let decoded = try Handshake.decode(from: encoded)
|
||||
XCTAssertEqual(decoded.infoHash, infoHash)
|
||||
XCTAssertEqual(decoded.peerID, peerID)
|
||||
XCTAssertEqual(decoded, handshake)
|
||||
}
|
||||
|
||||
func testGeneratePeerID() {
|
||||
let id = generatePeerID()
|
||||
XCTAssertEqual(id.count, 20)
|
||||
XCTAssertTrue(id.starts(with: Data("-ST0001-".utf8)))
|
||||
}
|
||||
|
||||
func testResumeDataRoundTrip() throws {
|
||||
let hash = InfoHash(hex: "0123456789abcdef0123456789abcdef01234567")!
|
||||
var pieces = Bitfield(count: 16)
|
||||
pieces.set(0)
|
||||
pieces.set(5)
|
||||
pieces.set(15)
|
||||
|
||||
let original = ResumeData(
|
||||
infoHash: hash, completedPieces: pieces,
|
||||
uploaded: 1000, downloaded: 5000, savePath: "/tmp/test"
|
||||
)
|
||||
|
||||
let encoded = original.encode()
|
||||
let decoded = try ResumeData.decode(from: encoded)
|
||||
|
||||
XCTAssertEqual(decoded.infoHash, hash)
|
||||
XCTAssertEqual(decoded.uploaded, 1000)
|
||||
XCTAssertEqual(decoded.downloaded, 5000)
|
||||
XCTAssertEqual(decoded.savePath, "/tmp/test")
|
||||
}
|
||||
|
||||
func testFileStorageSlices() {
|
||||
let files = [
|
||||
TorrentInfo.FileEntry(path: "file1.txt", length: 100, offset: 0),
|
||||
TorrentInfo.FileEntry(path: "file2.txt", length: 200, offset: 100),
|
||||
]
|
||||
let storage = FileStorage(files: files, pieceLength: 150, totalSize: 300)
|
||||
|
||||
XCTAssertEqual(storage.pieceCount, 2)
|
||||
XCTAssertEqual(storage.pieceSize(0), 150)
|
||||
XCTAssertEqual(storage.pieceSize(1), 150)
|
||||
|
||||
// First piece spans file1 (100 bytes) and file2 (50 bytes)
|
||||
let slices0 = storage.fileSlices(forPiece: 0)
|
||||
XCTAssertEqual(slices0.count, 2)
|
||||
XCTAssertEqual(slices0[0].path, "file1.txt")
|
||||
XCTAssertEqual(slices0[0].length, 100)
|
||||
XCTAssertEqual(slices0[1].path, "file2.txt")
|
||||
XCTAssertEqual(slices0[1].length, 50)
|
||||
}
|
||||
|
||||
func testDHTMessageRoundTrip() throws {
|
||||
let txID = Data([0x01, 0x02])
|
||||
let msg = DHTMessage.query(
|
||||
transactionID: txID,
|
||||
queryType: .ping,
|
||||
arguments: [(key: Data("id".utf8), value: .string(Data(repeating: 0xAA, count: 20)))]
|
||||
)
|
||||
let encoded = msg.encode()
|
||||
let decoded = try DHTMessage.decode(from: encoded)
|
||||
|
||||
if case .query(let decodedTxID, let queryType, _) = decoded {
|
||||
XCTAssertEqual(decodedTxID, txID)
|
||||
XCTAssertEqual(queryType, .ping)
|
||||
} else {
|
||||
XCTFail("Expected query message")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import XCTest
|
||||
@testable import SwiftTorrent
|
||||
|
||||
final class TorrentInfoTests: XCTestCase {
|
||||
/// Create a minimal valid single-file .torrent bencoded data.
|
||||
private func makeSingleFileTorrent() -> Data {
|
||||
// Manually construct bencoded data for a simple torrent
|
||||
let encoder = BencodeEncoder()
|
||||
let pieces = Data(repeating: 0xAB, count: 20) // one fake piece hash
|
||||
|
||||
let info: BencodeValue = .dictionary([
|
||||
(key: Data("length".utf8), value: .integer(1024)),
|
||||
(key: Data("name".utf8), value: .string(Data("testfile.txt".utf8))),
|
||||
(key: Data("piece length".utf8), value: .integer(262144)),
|
||||
(key: Data("pieces".utf8), value: .string(pieces)),
|
||||
])
|
||||
|
||||
let root: BencodeValue = .dictionary([
|
||||
(key: Data("announce".utf8), value: .string(Data("http://tracker.example.com/announce".utf8))),
|
||||
(key: Data("info".utf8), value: info),
|
||||
])
|
||||
|
||||
return encoder.encode(root)
|
||||
}
|
||||
|
||||
func testParseSingleFile() throws {
|
||||
let data = makeSingleFileTorrent()
|
||||
let info = try TorrentInfo.parse(from: data)
|
||||
|
||||
XCTAssertEqual(info.name, "testfile.txt")
|
||||
XCTAssertEqual(info.pieceLength, 262144)
|
||||
XCTAssertEqual(info.totalSize, 1024)
|
||||
XCTAssertEqual(info.files.count, 1)
|
||||
XCTAssertEqual(info.files[0].path, "testfile.txt")
|
||||
XCTAssertEqual(info.files[0].length, 1024)
|
||||
XCTAssertEqual(info.announceURL, "http://tracker.example.com/announce")
|
||||
XCTAssertFalse(info.isPrivate)
|
||||
XCTAssertEqual(info.pieceCount, 1)
|
||||
}
|
||||
|
||||
func testParseMultiFile() throws {
|
||||
let encoder = BencodeEncoder()
|
||||
let pieces = Data(repeating: 0xCD, count: 20)
|
||||
|
||||
let file1: BencodeValue = .dictionary([
|
||||
(key: Data("length".utf8), value: .integer(500)),
|
||||
(key: Data("path".utf8), value: .list([.string(Data("sub".utf8)), .string(Data("file1.txt".utf8))])),
|
||||
])
|
||||
let file2: BencodeValue = .dictionary([
|
||||
(key: Data("length".utf8), value: .integer(300)),
|
||||
(key: Data("path".utf8), value: .list([.string(Data("file2.txt".utf8))])),
|
||||
])
|
||||
|
||||
let info: BencodeValue = .dictionary([
|
||||
(key: Data("files".utf8), value: .list([file1, file2])),
|
||||
(key: Data("name".utf8), value: .string(Data("mydir".utf8))),
|
||||
(key: Data("piece length".utf8), value: .integer(262144)),
|
||||
(key: Data("pieces".utf8), value: .string(pieces)),
|
||||
])
|
||||
|
||||
let root: BencodeValue = .dictionary([
|
||||
(key: Data("announce".utf8), value: .string(Data("http://t.example.com/a".utf8))),
|
||||
(key: Data("info".utf8), value: info),
|
||||
])
|
||||
|
||||
let data = encoder.encode(root)
|
||||
let parsed = try TorrentInfo.parse(from: data)
|
||||
|
||||
XCTAssertEqual(parsed.name, "mydir")
|
||||
XCTAssertEqual(parsed.files.count, 2)
|
||||
XCTAssertEqual(parsed.files[0].path, "mydir/sub/file1.txt")
|
||||
XCTAssertEqual(parsed.files[1].path, "mydir/file2.txt")
|
||||
XCTAssertEqual(parsed.totalSize, 800)
|
||||
}
|
||||
|
||||
func testInfoHashConsistency() throws {
|
||||
let data = makeSingleFileTorrent()
|
||||
let info1 = try TorrentInfo.parse(from: data)
|
||||
let info2 = try TorrentInfo.parse(from: data)
|
||||
XCTAssertEqual(info1.infoHash, info2.infoHash)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import XCTest
|
||||
@testable import SwiftTorrent
|
||||
|
||||
final class TrackerTests: XCTestCase {
|
||||
func testParseCompactPeers() throws {
|
||||
// Create a mock bencoded tracker response with compact peers
|
||||
let encoder = BencodeEncoder()
|
||||
|
||||
// 6 bytes: 192.168.1.1:6881
|
||||
var peerData = Data()
|
||||
peerData.append(contentsOf: [192, 168, 1, 1])
|
||||
peerData.append(contentsOf: UInt16(6881).bigEndianBytes)
|
||||
// 6 bytes: 10.0.0.1:8080
|
||||
peerData.append(contentsOf: [10, 0, 0, 1])
|
||||
peerData.append(contentsOf: UInt16(8080).bigEndianBytes)
|
||||
|
||||
let response: BencodeValue = .dictionary([
|
||||
(key: Data("complete".utf8), value: .integer(10)),
|
||||
(key: Data("incomplete".utf8), value: .integer(5)),
|
||||
(key: Data("interval".utf8), value: .integer(1800)),
|
||||
(key: Data("peers".utf8), value: .string(peerData)),
|
||||
])
|
||||
|
||||
let data = encoder.encode(response)
|
||||
let decoded = try BencodeDecoder().decode(data)
|
||||
|
||||
// Verify structure
|
||||
XCTAssertEqual(decoded["interval"]?.integerValue, 1800)
|
||||
XCTAssertEqual(decoded["complete"]?.integerValue, 10)
|
||||
XCTAssertEqual(decoded["peers"]?.stringValue?.count, 12)
|
||||
}
|
||||
|
||||
func testTrackerErrorResponse() throws {
|
||||
let encoder = BencodeEncoder()
|
||||
let response: BencodeValue = .dictionary([
|
||||
(key: Data("failure reason".utf8), value: .string(Data("Torrent not found".utf8))),
|
||||
])
|
||||
let data = encoder.encode(response)
|
||||
let decoded = try BencodeDecoder().decode(data)
|
||||
XCTAssertEqual(decoded["failure reason"]?.utf8String, "Torrent not found")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user