├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── bin ├── download.cr ├── readbencode.cr └── readtorrent.cr ├── shard.yml ├── spec ├── fixtures │ └── debian.torrent ├── helper │ ├── test_node.cr │ └── test_peer.cr ├── spec_helper.cr └── torrent │ ├── bencode │ ├── any_spec.cr │ ├── extensions_spec.cr │ ├── lexer_spec.cr │ ├── mapping_spec.cr │ └── pull_parser_spec.cr │ ├── client │ └── peer_spec.cr │ ├── dht │ ├── bucket_spec.cr │ ├── default_rpc_methods_spec.cr │ ├── dispatcher_spec.cr │ ├── node_list_spec.cr │ ├── node_spec.cr │ ├── peer_list_spec.cr │ └── structure_spec.cr │ ├── transfer_spec.cr │ └── util │ ├── bitfield_spec.cr │ ├── endian_spec.cr │ ├── gmp_spec.cr │ ├── kademlia_list_spec.cr │ ├── network_order_struct_spec.cr │ └── request_list_spec.cr └── src ├── torrent.cr └── torrent ├── bencode.cr ├── bencode ├── any.cr ├── extensions.cr ├── lexer.cr ├── mapping.cr └── pull_parser.cr ├── client.cr ├── client ├── http_tracker.cr ├── peer.cr ├── protocol.cr ├── tcp_peer.cr ├── tracker.cr ├── udp_tracker.cr └── wire.cr ├── dht ├── bucket.cr ├── default_rpc_methods.cr ├── dispatcher.cr ├── error.cr ├── error_code.cr ├── finder.cr ├── manager.cr ├── node.cr ├── node_address.cr ├── node_finder.cr ├── node_list.cr ├── peer_list.cr ├── peers_finder.cr ├── structure.cr └── udp_node.cr ├── extension ├── handler.cr ├── manager.cr └── peer_exchange.cr ├── file.cr ├── file_manager ├── base.cr └── file_system.cr ├── leech_strategy ├── base.cr ├── default.cr └── null.cr ├── manager ├── base.cr └── transfer.cr ├── peer_list.cr ├── piece_picker ├── base.cr ├── rarest_first.cr └── sequential.cr ├── structure.cr ├── transfer.cr ├── util ├── async_io_channel.cr ├── bitfield.cr ├── endian.cr ├── gmp.cr ├── kademlia_list.cr ├── logger.cr ├── network_order_struct.cr ├── random.cr ├── request_list.cr ├── spawn.cr └── udp_socket.cr └── version.cr /.gitignore: -------------------------------------------------------------------------------- 1 | /doc/ 2 | /libs/ 3 | /lib/ 4 | /.crystal/ 5 | /.shards/ 6 | 7 | /downloads/ 8 | *.log* 9 | /*.torrent 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # torrent.cr [![Build Status](https://travis-ci.org/Papierkorb/torrent.svg?branch=master)](https://travis-ci.org/Papierkorb/torrent) 2 | 3 | A BitTorrent client library written in pure Crystal. 4 | 5 | Do note that this shard is currently in **BETA**. 6 | 7 | ## Installation 8 | 9 | Add this to your application's `shard.yml`: 10 | 11 | ```yaml 12 | dependencies: 13 | torrent: 14 | github: Papierkorb/torrent 15 | ``` 16 | 17 | ## Usage 18 | 19 | Please see `bin/` for example applications. 20 | 21 | ## What's missing? 22 | 23 | * Improve architecture 24 | * Improve performance (Seeding is fine, leeching is expensive) 25 | * Smarter leech and seed strategies 26 | * Tons of other things 27 | * The planned BEPs 28 | * More tests (Figure out how to best test networking code) 29 | 30 | ## Implemented BEPs 31 | 32 | The index of all BEPs can be found at http://www.bittorrent.org/beps/bep_0000.html 33 | 34 | * BEP-0003: The BitTorrent Protocol Specification 35 | * BEP-0005: DHT Protocol (*Experimental*) 36 | * BEP-0006: Fast Extension 37 | * BEP-0010: Extension Protocol 38 | * BEP-0011: Peer Exchange (PEX) 39 | * BEP-0015: UDP Tracker Protocol for BitTorrent (*Except for scraping*) 40 | * BEP-0020: Peer ID Conventions 41 | * BEP-0023: Tracker Returns Compact Peer Lists 42 | * BEP-0027: Private Torrents 43 | * BEP-0041: UDP Tracker Protocol Extensions 44 | * BEP-0048: Tracker Protocol Extension: Scrape 45 | 46 | ### Planned 47 | 48 | * BEP-0009: Extension for Peers to Send Metadata Files 49 | * BEP-0040: Canonical Peer Priority 50 | 51 | *All entries are in ascending order of their BEP number* 52 | 53 | ## Contributing 54 | 55 | 1. Fork it ( https://github.com/Papierkorb/torrent.cr/fork ) 56 | 2. Create your feature branch (git checkout -b my-new-feature) 57 | 3. Write tests 58 | 4. Commit your changes (git commit -am 'Add some feature') 59 | 5. Push to the branch (git push origin my-new-feature) 60 | 6. Create a new Pull Request 61 | 62 | ## Contributors 63 | 64 | - [Papierkorb](https://github.com/Papierkorb) Stefan Merettig - creator, maintainer 65 | 66 | ## Disclaimer 67 | 68 | The authors of this library are in no way responsible for any copyright 69 | infiringements caused by using this library or software using this library. 70 | There are many legitimate use-cases for torrents outside of piracy. This library 71 | was written with the intention to be used for such legal purposes. 72 | 73 | ## License 74 | 75 | This library is licensed under the Mozilla Public License 2.0 ("MPL-2"). 76 | 77 | For a copy of the full license text see the included `LICENSE` file. 78 | 79 | For a legally non-binding explanation visit: 80 | [tl;drLegal](https://tldrlegal.com/license/mozilla-public-license-2.0-%28mpl-2%29) 81 | -------------------------------------------------------------------------------- /bin/download.cr: -------------------------------------------------------------------------------- 1 | require "../src/torrent" 2 | require "io/hexdump" 3 | require "colorize" 4 | 5 | if ARGV.size < 1 6 | puts "Usage: download file.torrent" 7 | exit 8 | end 9 | 10 | file = Torrent::File.read(ARGV[0]) 11 | 12 | Dir.mkdir_p("downloads") # Create transfer manager(s) 13 | files = Torrent::FileManager::FileSystem.new("downloads") 14 | manager = Torrent::Manager::Transfer.new(files) 15 | transfer = manager.add_transfer file 16 | 17 | picker = Torrent::PiecePicker::RarestFirst.new(transfer) 18 | transfer.piece_picker = picker 19 | 20 | logger = Logger.new(File.open("download.log", "w")) # Logger 21 | logger.level = Logger::DEBUG 22 | Torrent.logger = logger 23 | manager.start! # Begin downloading the torrent! 24 | start_time = Time.now 25 | 26 | transfer.download_completed.on do # Be notified when we're done 27 | elapsed = Time.now - start_time 28 | puts "-> Finished download after #{elapsed}" 29 | # exit 0 30 | end 31 | 32 | manager.extensions.unknown_message.on do |peer, id, payload| 33 | logger.error "Peer #{peer.address} #{peer.port} invoked unknown extension #{id}: #{payload.hexstring}" 34 | end 35 | 36 | spawn do # Update the "TUI" screen each second 37 | peers = Hash(Torrent::Client::Peer, UInt64).new 38 | STDOUT.print "\e[2J" 39 | 40 | loop do 41 | render_screen(manager, peers, STDOUT, Time.now - start_time) 42 | sleep 1 43 | end 44 | end 45 | 46 | sleep # Run fibers. 47 | 48 | def readable_size(bytes) 49 | if bytes < 1024 50 | "#{bytes} B" 51 | elsif bytes < 1024 ** 2 52 | "#{bytes / 1024} KiB" 53 | elsif bytes < 1024 ** 3 54 | "%.2f MiB" % { bytes / (1024.0 ** 2) } 55 | else 56 | "%.2f GiB" % { bytes / (1024.0 ** 3) } 57 | end 58 | end 59 | 60 | def timespan_s(timespan) 61 | "#{timespan.hours.to_s.rjust(2, '0')}:#{timespan.minutes.to_s.rjust(2, '0')}:#{timespan.seconds.to_s.rjust(2, '0')}" 62 | end 63 | 64 | def estimate(total_size, current_size, runtime) 65 | return "???" if current_size == 0 || total_size == 0 66 | 67 | msec = runtime.total_milliseconds 68 | speed = current_size.to_f64 / msec.to_f64 69 | remaining = (total_size - current_size).to_f64 / speed 70 | timespan_s Time::Span.new(0, 0, 0, 0, remaining.to_i) 71 | end 72 | 73 | def render_screen(manager, peers, io, runtime) 74 | io.print "\e[;H\e[0J" # Clear screen 75 | 76 | transfer = manager.transfers.first 77 | file = transfer.file 78 | done = transfer.pieces_done 79 | total = transfer.piece_count 80 | total_sec = 0 81 | percent = (done.to_f64 / total.to_f64 * 100.0).to_i.to_s 82 | io.print "Torrent #{file.info_hash[0, 10].hexstring} - PEERS " \ 83 | "#{manager.peers.size} / #{peers.size} " \ 84 | "+#{manager.peer_list.candidates.size} - #{percent.rjust 3}% " \ 85 | "[#{done} / #{total} ETA #{estimate total, done, runtime}] " \ 86 | "#{timespan_s runtime}\n" 87 | 88 | io.print "ADDRESS:PORT UP DOWN DOWN/s FLAGS PIECES\n" 89 | manager.peers.each do |peer| 90 | name = begin 91 | "#{peer.address}:#{peer.port}" 92 | rescue 93 | "" 94 | end 95 | 96 | up = readable_size peer.bytes_sent 97 | down = readable_size peer.bytes_received 98 | down_sec = peer.bytes_received - (peers[peer]? || 0) 99 | total_sec += down_sec 100 | 101 | peers[peer] = peer.bytes_received 102 | pieces = transfer.requests.pieces_of_peer(peer).map(&.index).join(", ") 103 | flags = [ 104 | (peer.status.choked_by_peer? ? 'C' : 'c'), 105 | (peer.status.interested_in_peer? ? 'I' : 'i'), 106 | '/', 107 | (peer.status.choking_peer? ? 'C' : 'c'), 108 | (peer.status.peer_is_interested? ? 'I' : 'i'), 109 | ].join 110 | 111 | leech = peer.transfer.leech_strategy.as(Torrent::LeechStrategy::Default) 112 | 113 | begin 114 | name = name.colorize.bold.to_s if leech.fast_peer?(peer) 115 | rescue 116 | # Race condition: If we're rendering after another peer was added but 117 | # before the strategy got note of it. 118 | end 119 | 120 | io.print "#{name.ljust 22} #{up.ljust 12} #{down.ljust 12} #{(readable_size(down_sec) + "/s").ljust(14)} #{flags.ljust 12} #{pieces}\n" 121 | end 122 | 123 | up = readable_size manager.peers.map(&.bytes_sent).sum 124 | down = readable_size manager.peers.map(&.bytes_received).sum 125 | 126 | io.print "\n" 127 | io.print "TOTAL #{up.ljust 12} #{down.ljust 12} #{readable_size(total_sec)}/s\n" 128 | end 129 | -------------------------------------------------------------------------------- /bin/readbencode.cr: -------------------------------------------------------------------------------- 1 | require "../src/torrent" 2 | require "io/hexdump" 3 | require "colorize" 4 | 5 | if ARGV.size < 1 6 | puts "Usage: readbencode [bencode file]" 7 | puts " If no file is given, reads the file from stdin" 8 | exit 9 | end 10 | 11 | buf = Bytes.new File.size(ARGV[0]) 12 | File.open(ARGV[0]){|h| h.read_fully buf} 13 | 14 | dump Torrent::Bencode.load(buf) 15 | def dump(any, prefix = "") 16 | if any.integer? 17 | puts "#{prefix}(i) #{any.to_s}" 18 | elsif any.string? 19 | begin 20 | puts "#{prefix}(s) #{any.to_s}" 21 | rescue ArgumentError 22 | puts "#{prefix}Binary data START (#{any.size} Bytes)" 23 | puts any.to_slice.hexdump 24 | puts "#{prefix}Binary data END" 25 | end 26 | 27 | elsif any.list? 28 | puts "#{prefix}[" 29 | pre = " " * (prefix.size) + "- " 30 | any.to_a.each_with_index do |any, idx| 31 | dump any, pre 32 | end 33 | puts "#{" " * prefix.size}]" 34 | elsif any.dictionary? 35 | puts "#{prefix}{" 36 | pre = " " * (prefix.size + 2) 37 | any.to_h.each do |key, any| 38 | puts "#{pre[0...-2]}#{key} =" 39 | dump any, pre 40 | end 41 | puts "#{" " * prefix.size}}" 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /bin/readtorrent.cr: -------------------------------------------------------------------------------- 1 | require "../src/torrent" 2 | require "io/hexdump" 3 | require "colorize" 4 | 5 | if ARGV.size < 1 6 | puts "Usage: readtorrent file.torrent" 7 | exit 8 | end 9 | 10 | dump_file Torrent::File.read(ARGV[0]) 11 | 12 | def readable_size(bytes) 13 | if bytes < 1024 14 | "#{bytes} B" 15 | elsif bytes < 1024 * 1024 16 | "#{bytes / 1024} KiB" 17 | elsif bytes < 1024 * 1024 * 1024 18 | "%.2f MiB" % { bytes / (1024.0 ** 2) } 19 | else 20 | "%.2f GiB" % { bytes / (1024.0 ** 3) } 21 | end 22 | end 23 | 24 | def dump_file(file) 25 | puts "Name: ".colorize.bold.to_s + file.name 26 | puts "Size: ".colorize.bold.to_s + readable_size(file.total_size) + " (#{file.total_size} Bytes)" 27 | puts "Pieces: ".colorize.bold.to_s + "#{file.piece_count} each with " + readable_size(file.piece_size) 28 | puts "Info hash: ".colorize.bold.to_s + file.info_hash.hexstring 29 | puts "Creator: ".colorize.bold.to_s + (file.created_by || "Unknown") 30 | puts "Created at: ".colorize.bold.to_s + (file.created_at || "Unknown").to_s 31 | puts "Trackers".colorize.bold.to_s 32 | file.announce_list.each do |url| 33 | puts " #{url}" 34 | end 35 | 36 | puts "Files".colorize.bold.to_s 37 | 38 | offset = 0 39 | file.files.each do |descr| 40 | first = offset / file.piece_size 41 | offset += descr.length 42 | last = offset / file.piece_size 43 | last -= 1 if offset.divisible_by? file.piece_size 44 | puts " #{readable_size descr.length} #{descr.path} [#{first}..#{last}]" 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: torrent 2 | version: 0.2.0 3 | 4 | dependencies: 5 | cute: 6 | github: Papierkorb/cute 7 | 8 | development_dependencies: 9 | spec2: 10 | github: waterlink/spec2.cr 11 | version: ~> 0.11.0 12 | 13 | authors: 14 | - Stefan Merettig 15 | 16 | license: MPL2 17 | -------------------------------------------------------------------------------- /spec/fixtures/debian.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Papierkorb/torrent/68b466801fa2f772a7ee28e72fb32ee69556ab59/spec/fixtures/debian.torrent -------------------------------------------------------------------------------- /spec/helper/test_node.cr: -------------------------------------------------------------------------------- 1 | def node(id) 2 | TestNode.new(100 + id.to_big_i) 3 | end 4 | 5 | def query(method, args, transaction = "F00".to_slice) 6 | Torrent::Dht::Structure::Query.new(transaction, method, args) 7 | end 8 | 9 | def response(data, transaction = "F00".to_slice) 10 | Torrent::Dht::Structure::Response.new(transaction, data) 11 | end 12 | 13 | def error(code, message, transaction = "F00".to_slice) 14 | Torrent::Dht::Structure::Error.new(transaction, code, message) 15 | end 16 | 17 | # Mock DHT node 18 | class TestNode < Torrent::Dht::Node 19 | getter sent : Array(Torrent::Dht::Structure::Message) 20 | setter peers_token 21 | setter last_seen 22 | setter health 23 | 24 | def initialize(id) 25 | super 26 | 27 | @sent = Array(Torrent::Dht::Structure::Message).new 28 | end 29 | 30 | def remote_address 31 | Socket::IPAddress.new("1.2.3.4", 12345) 32 | end 33 | 34 | def close 35 | # Nothing. 36 | end 37 | 38 | def send(message : Torrent::Dht::Structure::Message) 39 | sent << message 40 | end 41 | 42 | def receive(packet : String) 43 | handle_incoming packet.to_slice 44 | end 45 | 46 | def receive(packet : Bytes) 47 | handle_incoming packet 48 | end 49 | 50 | def transactions 51 | @calls.keys 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/helper/test_peer.cr: -------------------------------------------------------------------------------- 1 | # Mock peer implementation 2 | class TestPeer < Torrent::Client::Peer 3 | struct Packet 4 | getter type : UInt8 5 | getter payload : Bytes 6 | getter? ping : Bool 7 | 8 | def id 9 | Torrent::Client::Wire::MessageType.from_value @type 10 | end 11 | 12 | def initialize(@type, payload, @ping = false) 13 | @payload = Bytes.new(payload.size) 14 | @payload.copy_from(payload) 15 | end 16 | end 17 | 18 | # Captured, outgoing packets 19 | getter packets : Array(Packet) 20 | 21 | # Make some private fields writable for testing 22 | setter bitfield 23 | setter extension_protocol 24 | setter fast_extension 25 | setter dht_protocol 26 | 27 | def initialize(transfer) 28 | super(transfer.manager, transfer) 29 | @packets = Array(Packet).new 30 | end 31 | 32 | def send_packet(type : UInt8, payload : Bytes? = nil) 33 | payload ||= Bytes.new(0) 34 | @packets << Packet.new(type, payload) 35 | end 36 | 37 | def send_data(data : Bytes) 38 | if data.size == 0 # Detect PING 39 | @packets << Packet.new(0u8, data, true) 40 | else 41 | @packets << Packet.new(data[0], data + 1) 42 | end 43 | end 44 | 45 | def send_data(&block : -> Bytes?) 46 | result = block.call 47 | send_data(result) if result.is_a?(Bytes) 48 | end 49 | 50 | def address : String 51 | "123.123.123.123" 52 | end 53 | 54 | def port : UInt16 55 | 12345u16 56 | end 57 | 58 | def run_once 59 | # Nothing to do. 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec2" 2 | # require "spec2-mocks" 3 | require "cute/spec" 4 | require "../src/torrent" 5 | require "./helper/*" 6 | 7 | Spec2.random_order 8 | -------------------------------------------------------------------------------- /spec/torrent/bencode/any_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | private def any(code) 4 | Torrent::Bencode.load(code.to_slice) 5 | end 6 | 7 | Spec2.describe Torrent::Bencode::Any do 8 | describe "#object" do 9 | it "returns the integer on an integer" do 10 | expect(any("i5e").object).to eq 5i64 11 | end 12 | 13 | it "returns the slice on a byte string" do 14 | expect(any("4:okay").object).to eq "okay".to_slice 15 | end 16 | 17 | it "returns the array on an array" do 18 | expect(any("li3ee").object).to eq([ any("i3e") ]) 19 | end 20 | 21 | it "returns the hash on a hash" do 22 | expect(any("d3:fooi8ee").object).to eq({ "foo" => any("i8e") }) 23 | end 24 | end 25 | 26 | describe "#to_i" do 27 | it "returns the integer on an integer" do 28 | expect(any("i5e").to_i).to eq 5i64 29 | end 30 | 31 | it "fails on a byte string" do 32 | expect{ any("4:okay").to_i }.to raise_error(Torrent::Bencode::Any::Error) 33 | end 34 | 35 | it "fails on an array" do 36 | expect{ any("li3ee").to_i }.to raise_error(Torrent::Bencode::Any::Error) 37 | end 38 | 39 | it "fails on a hash" do 40 | expect{ any("d3:fooi8ee").to_i }.to raise_error(Torrent::Bencode::Any::Error) 41 | end 42 | end 43 | 44 | describe "#to_s" do 45 | it "returns the integer on an integer" do 46 | expect(any("i5e").to_s).to eq 5i64.to_s 47 | end 48 | 49 | it "returns the string on a byte string" do 50 | expect(any("4:okay").to_s).to eq "okay".to_s 51 | end 52 | 53 | it "returns the array on an array" do 54 | expect(any("li3ee").to_s).to eq([ any("i3e") ].to_s) 55 | end 56 | 57 | it "returns the hash on a hash" do 58 | expect(any("d3:fooi8ee").to_s).to eq({ "foo" => any("i8e") }.to_s) 59 | end 60 | end 61 | 62 | describe "#to_a" do 63 | it "fails on an integer" do 64 | expect{ any("i5e").to_a }.to raise_error(Torrent::Bencode::Any::Error) 65 | end 66 | 67 | it "fails on a byte string" do 68 | expect{ any("4:okay").to_a }.to raise_error(Torrent::Bencode::Any::Error) 69 | end 70 | 71 | it "returns the array on an array" do 72 | expect(any("li3ee").to_a).to eq [ any("i3e") ] 73 | end 74 | 75 | it "fails on a hash" do 76 | expect{ any("d3:fooi8ee").to_a }.to raise_error(Torrent::Bencode::Any::Error) 77 | end 78 | end 79 | 80 | describe "#to_h" do 81 | it "fails on an integer" do 82 | expect{ any("i5e").to_h }.to raise_error(Torrent::Bencode::Any::Error) 83 | end 84 | 85 | it "fails on a byte string" do 86 | expect{ any("4:okay").to_h }.to raise_error(Torrent::Bencode::Any::Error) 87 | end 88 | 89 | it "fails on an array" do 90 | expect{ any("li3ee").to_h }.to raise_error(Torrent::Bencode::Any::Error) 91 | end 92 | 93 | it "returns the hash on a hash" do 94 | expect(any("d3:fooi8ee").to_h).to eq({ "foo" => any("i8e") }) 95 | end 96 | end 97 | 98 | describe "#to_b" do 99 | it "returns true for a non-zero integer" do 100 | expect(any("i1e").to_b).to be_true 101 | end 102 | 103 | it "returns false for a non-zero integer" do 104 | expect(any("i0e").to_b).to be_false 105 | end 106 | 107 | it "fails for anything but an integer" do 108 | expect{ any("4:okay").to_b }.to raise_error(Torrent::Bencode::Any::Error) 109 | expect{ any("li3ee").to_b }.to raise_error(Torrent::Bencode::Any::Error) 110 | expect{ any("d3:fooi8ee").to_b }.to raise_error(Torrent::Bencode::Any::Error) 111 | end 112 | end 113 | 114 | describe "#size" do 115 | it "fails on an integer" do 116 | expect{ any("i5e").size }.to raise_error(Torrent::Bencode::Any::Error) 117 | end 118 | 119 | it "returns the size on a byte string" do 120 | expect(any("4:okay").size).to eq 4 121 | end 122 | 123 | it "returns the size on an array" do 124 | expect(any("li3ee").size).to eq 1 125 | end 126 | 127 | it "returns the size on a hash" do 128 | expect(any("d3:fooi8ee").size).to eq 1 129 | end 130 | end 131 | 132 | describe "#[]" do 133 | it "fails on an integer" do 134 | expect{ any("i5e")[0] }.to raise_error(Torrent::Bencode::Any::Error) 135 | end 136 | 137 | it "fails on a byte string" do 138 | expect{ any("3:foo")[0] }.to raise_error(Torrent::Bencode::Any::Error) 139 | end 140 | 141 | it "works on an array in-bounds" do 142 | expect(any("li3ee")[0]).to eq any("i3e") 143 | end 144 | 145 | it "works on a hash if the key is found" do 146 | expect(any("d3:fooi8ee")["foo"]).to eq any("i8e") 147 | end 148 | 149 | it "fails on an array out-of-bounds" do 150 | expect{ any("li3ee")[1] }.to raise_error(IndexError) 151 | end 152 | 153 | it "fails on a hash if the key is NOT found" do 154 | expect{ any("d3:fooi8ee")["bar"] }.to raise_error(KeyError) 155 | end 156 | end 157 | 158 | # See https://github.com/waterlink/spec2.cr/issues/46 159 | describe "#[] question" do 160 | it "fails on an integer" do 161 | expect{ any("i5e")[0]? }.to raise_error(Torrent::Bencode::Any::Error) 162 | end 163 | 164 | it "fails on a byte string" do 165 | expect{ any("3:foo")[0]? }.to raise_error(Torrent::Bencode::Any::Error) 166 | end 167 | 168 | it "works on an array in-bounds" do 169 | expect(any("li3ee")[0]?).to eq any("i3e") 170 | end 171 | 172 | it "works on a hash if the key is found" do 173 | expect(any("d3:fooi8ee")["foo"]?).to eq any("i8e") 174 | end 175 | 176 | it "returns nil on an array out-of-bounds" do 177 | expect(any("li3ee")[1]?).to be_nil 178 | end 179 | 180 | it "returns nil on a hash if the key is NOT found" do 181 | expect(any("d3:fooi8ee")["bar"]?).to be_nil 182 | end 183 | end 184 | 185 | describe "#to_bencode" do 186 | it "returns the bencode data for an integer" do 187 | expect(any("i5e").to_bencode).to eq "i5e".to_slice 188 | end 189 | 190 | it "returns the bencode data for a byte string" do 191 | expect(any("4:okay").to_bencode).to eq "4:okay".to_slice 192 | end 193 | 194 | it "returns the bencode data for an array" do 195 | expect(any("li3ee").to_bencode).to eq "li3ee".to_slice 196 | end 197 | 198 | it "returns the bencode data for a hash" do 199 | expect(any("d3:fooi8ee").to_bencode).to eq "d3:fooi8ee".to_slice 200 | end 201 | end 202 | end 203 | -------------------------------------------------------------------------------- /spec/torrent/bencode/extensions_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | Spec2.describe "Type.new extensions" do 4 | {% for type in %i[ UInt8 UInt16 UInt32 UInt64 Int8 Int16 Int32 Int64 ] %} 5 | it "works with {{ type }}" do 6 | expect({{ type.id }}.from_bencode("i5e".to_slice)).to eq(5) 7 | end 8 | {% end %} 9 | 10 | it "works with String" do 11 | expect(String.from_bencode("5:hello".to_slice)).to eq "hello" 12 | end 13 | 14 | it "works with simple Array" do 15 | expect(Array(Int32).from_bencode("li3ei4ei5ee".to_slice)).to eq [ 3, 4, 5 ] 16 | end 17 | 18 | it "works with simple Hash" do 19 | expect(Hash(String, Int32).from_bencode("d1:ai7e1:bi8ee".to_slice)).to eq({ "a" => 7, "b" => 8 }) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/torrent/bencode/lexer_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | Spec2.describe Torrent::Bencode::Lexer do 4 | it "lexes" do 5 | [ 6 | { "i123e", [ Torrent::Bencode::Token.new(0, Torrent::Bencode::TokenType::Integer, 123i64) ]}, 7 | { "i-456e", [ Torrent::Bencode::Token.new(0, Torrent::Bencode::TokenType::Integer, -456i64) ]}, 8 | { "4:spam", [ Torrent::Bencode::Token.new(0, Torrent::Bencode::TokenType::ByteString, 0i64, "spam".to_slice) ]}, 9 | { "3:foo3:bar", [ 10 | Torrent::Bencode::Token.new(0, Torrent::Bencode::TokenType::ByteString, 0i64, "foo".to_slice), 11 | Torrent::Bencode::Token.new(5, Torrent::Bencode::TokenType::ByteString, 0i64, "bar".to_slice) ]}, 12 | { "5:yaddai0e", [ 13 | Torrent::Bencode::Token.new(0, Torrent::Bencode::TokenType::ByteString, 0i64, "yadda".to_slice), 14 | Torrent::Bencode::Token.new(7, Torrent::Bencode::TokenType::Integer, 0i64) ]}, 15 | { "de", [ 16 | Torrent::Bencode::Token.new(0, Torrent::Bencode::TokenType::Dictionary, 0i64), 17 | Torrent::Bencode::Token.new(1, Torrent::Bencode::TokenType::EndMarker, 0i64) 18 | ]}, 19 | { "le", [ 20 | Torrent::Bencode::Token.new(0, Torrent::Bencode::TokenType::List, 0i64), 21 | Torrent::Bencode::Token.new(1, Torrent::Bencode::TokenType::EndMarker, 0i64) 22 | ]}, 23 | { "li123ee", [ 24 | Torrent::Bencode::Token.new(0, Torrent::Bencode::TokenType::List, 0i64), 25 | Torrent::Bencode::Token.new(1, Torrent::Bencode::TokenType::Integer, 123i64), 26 | Torrent::Bencode::Token.new(6, Torrent::Bencode::TokenType::EndMarker, 0i64) 27 | ]}, 28 | { "l2:eee", [ 29 | Torrent::Bencode::Token.new(0, Torrent::Bencode::TokenType::List, 0i64), 30 | Torrent::Bencode::Token.new(1, Torrent::Bencode::TokenType::ByteString, 0i64, "ee".to_slice), 31 | Torrent::Bencode::Token.new(5, Torrent::Bencode::TokenType::EndMarker, 0i64) 32 | ]}, 33 | { "l0:e", [ 34 | Torrent::Bencode::Token.new(0, Torrent::Bencode::TokenType::List, 0i64), 35 | Torrent::Bencode::Token.new(1, Torrent::Bencode::TokenType::ByteString, 0i64, "".to_slice), 36 | Torrent::Bencode::Token.new(3, Torrent::Bencode::TokenType::EndMarker, 0i64) 37 | ]}, 38 | { "llee", [ 39 | Torrent::Bencode::Token.new(0, Torrent::Bencode::TokenType::List, 0i64), 40 | Torrent::Bencode::Token.new(1, Torrent::Bencode::TokenType::List, 0i64), 41 | Torrent::Bencode::Token.new(2, Torrent::Bencode::TokenType::EndMarker, 0i64), 42 | Torrent::Bencode::Token.new(3, Torrent::Bencode::TokenType::EndMarker, 0i64) 43 | ]} 44 | ].each do |string, expected| 45 | lexer = Torrent::Bencode::Lexer.new(IO::Memory.new(string.to_slice)) 46 | 47 | expected.each do |tok| 48 | expect(lexer.next_token).to eq tok 49 | end 50 | 51 | expect(lexer.next_token).to eq Torrent::Bencode::Token.new( 52 | position: string.size, 53 | type: Torrent::Bencode::TokenType::Eof 54 | ) 55 | 56 | expect(lexer.eof?).to be_true 57 | end 58 | end 59 | 60 | it "fails for 4:abc" do 61 | lexer = Torrent::Bencode::Lexer.new(IO::Memory.new("4:abc".to_slice)) 62 | expect{ lexer.next_token }.to raise_error(Torrent::Bencode::Lexer::Error, match /premature end of string/i) 63 | end 64 | 65 | it "fails for -4:abc" do 66 | lexer = Torrent::Bencode::Lexer.new(IO::Memory.new("-4:abc".to_slice)) 67 | expect{ lexer.next_token }.to raise_error(Torrent::Bencode::Lexer::Error, match /unknown token/i) 68 | end 69 | 70 | it "fails for i123" do 71 | lexer = Torrent::Bencode::Lexer.new(IO::Memory.new("i123".to_slice)) 72 | expect{ lexer.next_token }.to raise_error(Torrent::Bencode::Lexer::Error, match /premature end/i) 73 | end 74 | 75 | it "fails for i12a" do 76 | lexer = Torrent::Bencode::Lexer.new(IO::Memory.new("i12a".to_slice)) 77 | expect{ lexer.next_token }.to raise_error(Torrent::Bencode::Lexer::Error, match /unexpected byte/i) 78 | end 79 | 80 | it "fails for z" do 81 | lexer = Torrent::Bencode::Lexer.new(IO::Memory.new("z".to_slice)) 82 | expect{ lexer.next_token }.to raise_error(Torrent::Bencode::Lexer::Error, match /unknown token/i) 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/torrent/bencode/mapping_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | private class Test 4 | Torrent::Bencode.mapping( 5 | foo: Int32, 6 | opt: { nilable: true, type: String, key: :bar }, 7 | num: { nilable: true, type: Int32, default: 4 }, 8 | many: Array(Test), 9 | ) 10 | 11 | def initialize(@foo, @opt, @num, @many) 12 | end 13 | end 14 | 15 | private class StrictTest 16 | Torrent::Bencode.mapping({ foo: Int32 }, strict: true) 17 | end 18 | 19 | private class DoubleConverter 20 | def self.from_bencode(pull) 21 | pull.read_integer.to_i32 * 2 22 | end 23 | 24 | def self.to_bencode(value, io) 25 | (value * 2).to_bencode io 26 | end 27 | end 28 | 29 | private class ConverterTest 30 | Torrent::Bencode.mapping( 31 | foo: { type: Int32, converter: DoubleConverter }, 32 | bar: { type: Int32, converter: DoubleConverter, default: 5 }, 33 | ) 34 | 35 | def initialize(@foo, @bar) 36 | end 37 | end 38 | 39 | Spec2.describe "Torrent::Bencode.mapping" do 40 | describe ".from_bencode" do 41 | it "works in the general case" do 42 | test = Test.from_bencode("d3:fooi7e4:manyle3:numi-1ee".to_slice) 43 | expect(test.foo).to eq 7 44 | expect(test.opt).to be_nil 45 | expect(test.num).to eq -1 46 | expect(test.many.empty?).to be_true 47 | end 48 | 49 | it "uses default values" do 50 | test = Test.from_bencode("d3:fooi7e4:manylee".to_slice) 51 | expect(test.foo).to eq 7 52 | expect(test.opt).to be_nil 53 | expect(test.num).to eq 4 54 | expect(test.many.empty?).to be_true 55 | end 56 | 57 | it "works with custom key" do 58 | test = Test.from_bencode("d3:fooi7e4:manyle3:bar3:^_^e".to_slice) 59 | expect(test.foo).to eq 7 60 | expect(test.opt).to eq "^_^" 61 | expect(test.num).to eq 4 62 | expect(test.many.empty?).to be_true 63 | end 64 | 65 | it "can build recursive structures" do 66 | test = Test.from_bencode("d3:fooi7e4:manyld3:fooi7e4:manyle3:numi-1eeee".to_slice) 67 | expect(test.foo).to eq 7 68 | expect(test.opt).to be_nil 69 | expect(test.num).to eq 4 70 | expect(test.many.size).to eq 1 71 | 72 | inner = test.many.first 73 | expect(inner.foo).to eq 7 74 | expect(inner.opt).to be_nil 75 | expect(inner.num).to eq -1 76 | expect(inner.many.empty?).to be_true 77 | end 78 | 79 | it "fails if an attribute is missing" do 80 | expect{ Test.from_bencode("d4:manylee".to_slice) }.to raise_error(Torrent::Bencode::Error, match /missing/i) 81 | end 82 | 83 | it "ignores unknown attributes" do 84 | test = Test.from_bencode("d7:unknownd3:fooi9ee3:fooi7e4:manylee".to_slice) 85 | expect(test.foo).to eq 7 86 | expect(test.opt).to be_nil 87 | expect(test.num).to eq 4 88 | expect(test.many.empty?).to be_true 89 | end 90 | 91 | it "supports a converter" do 92 | test = ConverterTest.from_bencode("d3:fooi7e3:bari4ee".to_slice) 93 | expect(test.foo).to eq 14 94 | expect(test.bar).to eq 8 95 | end 96 | 97 | it "supports a converter with default value" do 98 | test = ConverterTest.from_bencode("d3:fooi7ee".to_slice) 99 | expect(test.foo).to eq 14 100 | expect(test.bar).to eq 5 101 | end 102 | end 103 | 104 | describe ".from_bencode strict mode" do 105 | it "works with no unknown attributes" do 106 | test = StrictTest.from_bencode("d3:fooi7ee".to_slice) 107 | expect(test.foo).to eq 7 108 | end 109 | 110 | it "fails if an attribute is unknown" do 111 | expect{ StrictTest.from_bencode("d3:fooi7e3:bar3:asde".to_slice) }.to raise_error(Torrent::Bencode::Error, match /unknown attribute/i) 112 | end 113 | end 114 | 115 | describe "#to_bencode" do 116 | it "sorts the dictionary keys" do 117 | test = Test.from_bencode("d4:manyld3:fooi7e4:manyle3:numi-1eee3:fooi7ee".to_slice) 118 | expect(test.to_bencode).to eq "d3:fooi7e4:manyld3:fooi7e4:manyle3:numi-1eee3:numi4ee".to_slice 119 | end 120 | 121 | it "supports a converter" do 122 | test = ConverterTest.new(foo: 2, bar: 3) 123 | expect(test.to_bencode).to eq "d3:bari6e3:fooi4ee".to_slice 124 | end 125 | 126 | it "uses the custom key" do 127 | test = Test.new(foo: 5, opt: "Ok", num: 4, many: [ ] of Test) 128 | expect(test.to_bencode).to eq "d3:bar2:Ok3:fooi5e4:manyle3:numi4ee".to_slice 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /spec/torrent/bencode/pull_parser_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | private def pull(string) 4 | lexer = Torrent::Bencode::Lexer.new(IO::Memory.new(string.to_slice)) 5 | Torrent::Bencode::PullParser.new(lexer) 6 | end 7 | 8 | Spec2.describe Torrent::Bencode::PullParser do 9 | # TODO: Split into #describe/#it blocks. 10 | it "#read_integer" do 11 | expect(pull("i5e").read_integer).to eq 5 12 | end 13 | 14 | it "#read_byte_slice" do 15 | expect(pull("4:abcd").read_byte_slice).to eq "abcd".to_slice 16 | end 17 | 18 | it "#read_dictionary" do 19 | parser = pull("d1:ai42e1:bi1337ee") 20 | 21 | parser.read_dictionary do 22 | expect(parser.read_byte_slice).to eq "a".to_slice 23 | expect(parser.read_integer).to eq 42i64 24 | expect(parser.read_byte_slice).to eq "b".to_slice 25 | expect(parser.read_integer).to eq 1337i64 26 | end 27 | end 28 | 29 | it "#read_list" do 30 | parser = pull("l1:ai42e1:bi1337ee") 31 | 32 | parser.read_list do 33 | expect(parser.read_byte_slice).to eq "a".to_slice 34 | expect(parser.read_integer).to eq 42i64 35 | expect(parser.read_byte_slice).to eq "b".to_slice 36 | expect(parser.read_integer).to eq 1337i64 37 | end 38 | end 39 | 40 | it "#read_dictionary error case" do 41 | expect{ pull("d").read_dictionary{ } }.to raise_error(Torrent::Bencode::PullParser::Error, match /eof/i) 42 | end 43 | 44 | it "#read_list error case" do 45 | expect{ pull("l").read_list{ } }.to raise_error(Torrent::Bencode::PullParser::Error, match /eof/i) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/torrent/dht/bucket_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | Spec2.describe Torrent::Dht::Bucket do 4 | let(:range){ 100.to_big_i...200.to_big_i } 5 | let(:nodes){ Array(Torrent::Dht::Node).new(Torrent::Dht::Bucket::MAX_NODES) } 6 | 7 | let(:node_a){ node 10 } 8 | let(:node_b){ node 20 } 9 | let(:node_outside){ node 10_000 } 10 | 11 | subject{ described_class.new range, nodes, Time.now } 12 | 13 | describe "#full?" do 14 | context "if less than 8 nodes are in it" do 15 | it "returns false" do 16 | 7.times{|i| nodes << node i} 17 | expect(subject.full?).to be_false 18 | end 19 | end 20 | 21 | context "if 8 nodes are in it" do 22 | it "returns true" do 23 | 8.times{|i| nodes << node i} 24 | expect(subject.full?).to be_true 25 | end 26 | end 27 | end 28 | 29 | describe "#includes?(Node)" do 30 | before{ nodes << node_a << node_b } 31 | 32 | context "if it knows the Node" do 33 | it "returns true" do 34 | expect(subject.includes? node_a).to be_true 35 | end 36 | end 37 | 38 | context "if it does NOT know the Node" do 39 | it "returns false" do 40 | expect(subject.includes? node_outside).to be_false 41 | end 42 | end 43 | end 44 | 45 | describe "#includes?(BigInt)" do 46 | before{ nodes << node_a << node_b } 47 | 48 | context "if it knows the node id" do 49 | it "returns true" do 50 | expect(subject.includes? node_a.id).to be_true 51 | end 52 | end 53 | 54 | context "if it does NOT know the node id" do 55 | it "returns false" do 56 | expect(subject.includes? node_outside.id).to be_false 57 | end 58 | end 59 | end 60 | 61 | describe "#find_node" do 62 | before{ nodes << node_a << node_b } 63 | 64 | context "if it knows the node id" do 65 | it "returns the node" do 66 | expect(subject.find_node node_a.id).to be node_a 67 | expect(subject.find_node node_b.id).to be node_b 68 | end 69 | end 70 | 71 | context "else" do 72 | it "returns nil" do 73 | expect(subject.find_node node_outside.id).to be_nil 74 | end 75 | end 76 | end 77 | 78 | describe "#splittable?" do 79 | context "if the buckets range is large enough" do 80 | it "returns true" do 81 | expect(subject.splittable?).to be_true 82 | end 83 | end 84 | 85 | context "if the bucket is pretty small already" do 86 | let(:range){ 1.to_big_i..8.to_big_i } 87 | it "returns false" do 88 | expect(subject.splittable?).to be_false 89 | end 90 | end 91 | end 92 | 93 | describe "#split" do 94 | it "returns two buckets containing the left and right portions" do 95 | 8.times{|i| nodes << node 47 + i} 96 | 97 | left, right = subject.split 98 | expect(left.range).to eq 100.to_big_i...150.to_big_i 99 | expect(right.range).to eq 150.to_big_i...200.to_big_i 100 | 101 | expect(left.includes? nodes[0].not_nil!).to be_true 102 | expect(left.includes? nodes[1].not_nil!).to be_true 103 | expect(left.includes? nodes[2].not_nil!).to be_true 104 | expect(left.includes? nodes[3].not_nil!).to be_true 105 | expect(left.includes? nodes[4].not_nil!).to be_false 106 | expect(left.includes? nodes[5].not_nil!).to be_false 107 | expect(left.includes? nodes[6].not_nil!).to be_false 108 | expect(left.includes? nodes[7].not_nil!).to be_false 109 | 110 | expect(right.includes? nodes[0].not_nil!).to be_false 111 | expect(right.includes? nodes[1].not_nil!).to be_false 112 | expect(right.includes? nodes[2].not_nil!).to be_false 113 | expect(right.includes? nodes[3].not_nil!).to be_false 114 | expect(right.includes? nodes[4].not_nil!).to be_true 115 | expect(right.includes? nodes[5].not_nil!).to be_true 116 | expect(right.includes? nodes[6].not_nil!).to be_true 117 | expect(right.includes? nodes[7].not_nil!).to be_true 118 | 119 | expect(left.full?).to be_false 120 | expect(right.full?).to be_false 121 | expect(left.last_refresh).to eq subject.last_refresh 122 | expect(right.last_refresh).to eq subject.last_refresh 123 | expect(left.node_count).to eq 4 124 | expect(right.node_count).to eq 4 125 | end 126 | end 127 | 128 | describe "#add" do 129 | context "if the bucket is full" do 130 | it "raises an ArgumentError" do 131 | 8.times{|i| nodes << node i } 132 | 133 | expect{ subject.add node_a }.to raise_error(ArgumentError, "Bucket is full") 134 | end 135 | end 136 | 137 | context "if the node id is out of range" do 138 | it "raises an IndexError" do 139 | expect{ subject.add node(1000) }.to raise_error(IndexError, match /range/i) 140 | end 141 | end 142 | 143 | context "if the node is already in the list" do 144 | it "does nothing" do 145 | nodes << node_a << node_b 146 | subject.add node_a 147 | expect(nodes).to eq [ node_a, node_b ] 148 | end 149 | end 150 | 151 | it "adds a node" do 152 | subject.add node_a 153 | expect(nodes).to eq [ node_a ] 154 | 155 | subject.add node_b 156 | expect(nodes).to eq [ node_a, node_b ] 157 | end 158 | end 159 | 160 | describe "#delete" do 161 | before{ nodes << node_a << node_b } 162 | 163 | it "removes a node" do 164 | subject.delete node_a 165 | expect(nodes).to eq [ node_b ] 166 | end 167 | end 168 | 169 | describe "#should_split?" do 170 | context "if the left side is full" do 171 | before do 172 | (0..7).each{|i| nodes << node(i) } 173 | end 174 | 175 | context "and the id is in the left" do 176 | it "returns false" do 177 | expect(subject.should_split? 110.to_big_i).to be_false 178 | end 179 | end 180 | 181 | context "and the id is in the right" do 182 | it "returns true" do 183 | expect(subject.should_split? 160.to_big_i).to be_true 184 | end 185 | end 186 | end 187 | 188 | context "if the right side is full" do 189 | before do 190 | (51..58).each{|i| nodes << node(i) } 191 | end 192 | 193 | context "and the id is in the left" do 194 | it "returns true" do 195 | expect(subject.should_split? 110.to_big_i).to be_true 196 | end 197 | end 198 | 199 | context "and the id is in the right" do 200 | it "returns false" do 201 | expect(subject.should_split? 160.to_big_i).to be_false 202 | end 203 | end 204 | end 205 | end 206 | end 207 | -------------------------------------------------------------------------------- /spec/torrent/dht/default_rpc_methods_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | Spec2.describe Torrent::Dht::DefaultRpcMethods do 4 | let(:my_id){ BigInt.new "85968058272638546411811" } 5 | let(:remote_id){ BigInt.new "555966236078694990583973964953671459193246344036" } 6 | let(:my_id_bytes){ Torrent::Util::Gmp.export_sha1 my_id } 7 | let(:remote_id_bytes){ Torrent::Util::Gmp.export_sha1 remote_id } 8 | 9 | let(:log){ Torrent::Util::Logger.new("Foo") } 10 | let(:manager){ Torrent::Dht::Manager.new my_id } 11 | let(:dispatcher){ manager.dispatcher } 12 | 13 | let(:node){ node 6 } 14 | let(:node_compact_addr) do 15 | Torrent::Dht::NodeAddress.to_compact({ node.to_address }) 16 | end 17 | 18 | let(:args){ { "id" => Torrent::Bencode::Any.new(Torrent::Util::Gmp.export_sha1 node.id) } } 19 | let(:sent){ node.sent } 20 | 21 | describe "ping" do 22 | it "sends a ping response" do 23 | expect(dispatcher.invoke_method node, query("ping", args)).to be_true 24 | expect(sent.size).to eq 1 25 | expect(sent.first).to eq response({ "id" => Torrent::Bencode::Any.new(my_id_bytes) }) 26 | end 27 | end 28 | 29 | describe "find_node" do 30 | before{ manager.nodes.try_add node } 31 | it "sends a find_node response" do 32 | args["target"] = Torrent::Bencode::Any.new(remote_id_bytes) 33 | expect(dispatcher.invoke_method node, query("find_node", args)).to be_true 34 | expect(sent.size).to eq 1 35 | expect(sent.first).to eq response({ 36 | "id" => Torrent::Bencode::Any.new(my_id_bytes), 37 | "nodes" => Torrent::Bencode::Any.new(node_compact_addr), 38 | }) 39 | end 40 | end 41 | 42 | describe "get_peers" do 43 | before{ manager.nodes.try_add node } 44 | 45 | context "if there are peers" do 46 | let(:peers) do 47 | [ 48 | Torrent::Bencode::Any.new(Bytes.new(6, &.to_u8)), 49 | Torrent::Bencode::Any.new(Bytes.new(6, &.to_u8.+(6))), 50 | ] 51 | end 52 | 53 | before do 54 | manager.add_torrent_peer node, remote_id_bytes, "0.1.2.3", 1029u16 55 | manager.add_torrent_peer node, remote_id_bytes, "6.7.8.9", 2571u16 56 | end 57 | 58 | it "replies with peers" do 59 | args["info_hash"] = Torrent::Bencode::Any.new remote_id_bytes 60 | expect(dispatcher.invoke_method node, query("get_peers", args)).to be_true 61 | expect(sent.size).to eq 1 62 | r = sent.first.as(Torrent::Dht::Structure::Response).data 63 | 64 | expect(r.keys.sort).to eq [ "id", "token", "values" ] 65 | expect(r["id"]).to eq Torrent::Bencode::Any.new(my_id_bytes) 66 | expect(r["token"].to_slice.size).to_be > 1 67 | expect(r["values"].to_a.size).to eq 2 68 | r["values"].to_a.each{|p| peers.delete p} 69 | expect(peers.empty?).to be_true 70 | end 71 | end 72 | 73 | context "if there are NO peers" do 74 | it "replies with nodes" do 75 | args["info_hash"] = Torrent::Bencode::Any.new remote_id_bytes 76 | expect(dispatcher.invoke_method node, query("get_peers", args)).to be_true 77 | expect(sent.size).to eq 1 78 | r = sent.first.as(Torrent::Dht::Structure::Response).data 79 | 80 | expect(r.keys.sort).to eq [ "id", "nodes", "token" ] 81 | expect(r["id"]).to eq Torrent::Bencode::Any.new(my_id_bytes) 82 | expect(r["token"].to_slice.size).to_be > 1 83 | expect(r["nodes"].to_slice).to eq node_compact_addr 84 | end 85 | end 86 | end 87 | 88 | describe "announce_peer" do 89 | let(:token){ "AbCd".to_slice } 90 | let(:implied_port){ false } 91 | let(:peer_list){ manager.peers.first } 92 | 93 | before do 94 | args["info_hash"] = Torrent::Bencode::Any.new remote_id_bytes 95 | args["implied_port"] = Torrent::Bencode::Any.new implied_port 96 | args["port"] = Torrent::Bencode::Any.new 7777 97 | 98 | # Don't use let(:token) here. 99 | args["token"] = Torrent::Bencode::Any.new "AbCd".to_slice 100 | 101 | node.remote_token = token 102 | end 103 | 104 | context "if the token is correct" do 105 | context "if implied_port is 1" do 106 | let(:implied_port){ true } 107 | 108 | it "stores the peer using the nodes UDP port" do 109 | expect(dispatcher.invoke_method node, query("announce_peer", args)).to be_true 110 | expect(sent.size).to eq 1 111 | expect(sent.first).to eq response({ "id" => Torrent::Bencode::Any.new(my_id_bytes) }) 112 | 113 | expect(peer_list.peers.size).to eq 1 114 | expect(peer_list.peers.first[1]).to eq Slice[ 1u8, 2u8, 3u8, 4u8, 0x30u8, 0x39u8 ] 115 | end 116 | end 117 | 118 | context "if implied_port is 0" do 119 | it "stores the peer" do 120 | expect(dispatcher.invoke_method node, query("announce_peer", args)).to be_true 121 | expect(sent.size).to eq 1 122 | expect(sent.first).to eq response({ "id" => Torrent::Bencode::Any.new(my_id_bytes) }) 123 | expect(peer_list.peers.size).to eq 1 124 | expect(peer_list.peers.first[1]).to eq Slice[ 1u8, 2u8, 3u8, 4u8, 0x1Eu8, 0x61u8 ] 125 | end 126 | end 127 | end 128 | 129 | context "if the token is NOT correct" do 130 | let(:token){ "WRONG".to_slice } 131 | 132 | it "sends an error" do 133 | expect(dispatcher.invoke_method node, query("announce_peer", args)).to be_true 134 | expect(sent.size).to eq 1 135 | expect(sent.first).to eq error(201, "Wrong Token") 136 | end 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /spec/torrent/dht/dispatcher_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | Spec2.describe Torrent::Dht::Dispatcher do 4 | subject{ described_class.new } 5 | 6 | describe "#add" do 7 | it "adds the method" do 8 | func = ->(a : Torrent::Dht::Node, b : Torrent::Dht::Structure::Query){ } 9 | subject.add "foo", func 10 | expect(subject.methods["foo"]).to eq func 11 | end 12 | end 13 | 14 | describe "#add(&block)" do 15 | it "adds the method" do 16 | subject.add("foo"){ } 17 | expect(subject.methods["foo"]?).not_to be_nil 18 | end 19 | end 20 | 21 | describe "#invoke_method" do 22 | let(:args){ { "one" => Torrent::Bencode::Any.new(1) } } 23 | let(:query){ Torrent::Dht::Structure::Query.new("ab".to_slice, "foo", args) } 24 | let(:node){ node 6 } 25 | context "if the method exists" do 26 | it "calls the lambda and returns true" do 27 | passed_node = nil 28 | passed_query = nil 29 | 30 | subject.add("foo") do |n, q| 31 | passed_node = n 32 | passed_query = q 33 | end 34 | 35 | expect(subject.invoke_method node, query).to be_true 36 | expect(passed_node).to be node 37 | expect(passed_query).to eq query 38 | end 39 | end 40 | 41 | context "if the method does NOT exist" do 42 | it "returns false" do 43 | passed_node = nil 44 | passed_query = nil 45 | 46 | expect(subject.invoke_method node, query).to be_false 47 | expect(passed_node).to be_nil 48 | expect(passed_query).to be_nil 49 | end 50 | end 51 | end 52 | 53 | describe "#remote_invocation" do 54 | let(:args){ { "one" => Torrent::Bencode::Any.new(1) } } 55 | let(:query){ Torrent::Dht::Structure::Query.new("ab".to_slice, "foo", args) } 56 | let(:node){ node 6 } 57 | 58 | context "if the method exists" do 59 | it "calls the lambda" do 60 | passed_node = nil 61 | passed_query = nil 62 | 63 | subject.add("foo") do |n, q| 64 | passed_node = n 65 | passed_query = q 66 | end 67 | 68 | expect(subject.remote_invocation node, query).to be_true 69 | expect(passed_node).to be node 70 | expect(passed_query).to eq query 71 | end 72 | 73 | context "and the handler raises an error" do 74 | it "sends a generic server error" do 75 | subject.add("foo"){|_n, _q| raise "Oh noes"} 76 | expect(subject.remote_invocation node, query).to be_false 77 | 78 | expect(node.sent.size).to eq 1 79 | e = node.sent.first.as(Torrent::Dht::Structure::Error) 80 | expect(e.code).to eq 202 81 | expect(e.message).to eq "A Server Error Occured" 82 | end 83 | end 84 | 85 | context "and the handler raises a QueryHandlerError" do 86 | it "sends a generic server error" do 87 | subject.add("foo") do |_n, _q| 88 | raise Torrent::Dht::QueryHandlerError.new("Oh noes", Torrent::Dht::ErrorCode::Protocol, "U Done Goofd") 89 | end 90 | 91 | expect(subject.remote_invocation node, query).to be_false 92 | 93 | expect(node.sent.size).to eq 1 94 | e = node.sent.first.as(Torrent::Dht::Structure::Error) 95 | expect(e.code).to eq 203 96 | expect(e.message).to eq "U Done Goofd" 97 | end 98 | end 99 | end 100 | 101 | context "if the method does NOT exist" do 102 | it "sends an unknown method error" do 103 | passed_node = nil 104 | passed_args = nil 105 | 106 | expect(subject.remote_invocation node, query).to be_false 107 | expect(node.sent.first).to be_a Torrent::Dht::Structure::Error 108 | e = node.sent.first.as(Torrent::Dht::Structure::Error) 109 | 110 | expect(e.transaction).to eq "ab".to_slice 111 | expect(e.code).to eq 204 112 | 113 | expect(passed_node).to be_nil 114 | expect(passed_args).to be_nil 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /spec/torrent/dht/node_list_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | Spec2.describe Torrent::Dht::NodeList do 4 | let(:node_id){ 10_000.to_big_i } 5 | subject{ described_class.new node_id } 6 | 7 | describe "#initialize" do 8 | context "if the node_id is less than 0" do 9 | let(:node_id){ -1 } 10 | it "raises" do 11 | expect{ subject }.to raise_error(ArgumentError, match /out of range/i) 12 | end 13 | end 14 | 15 | context "if the node_id equals 2**160" do 16 | let(:node_id){ BigInt.new(2)**160 } 17 | it "raises" do 18 | expect{ subject }.to raise_error(ArgumentError, match /out of range/i) 19 | end 20 | end 21 | 22 | context "if the node_id is greater than 2**160" do 23 | let(:node_id){ BigInt.new(2)**160 + 1 } 24 | it "raises" do 25 | expect{ subject }.to raise_error(ArgumentError, match /out of range/i) 26 | end 27 | end 28 | 29 | context "if the node_id is in range" do 30 | it "does not raise" do 31 | expect{ subject }.not_to raise_error 32 | end 33 | end 34 | end 35 | 36 | describe "#find_bucket" do 37 | context "if the id is less than 0" do 38 | it "raises an IndexError" do 39 | expect{ subject.find_bucket(-1.to_big_i) }.to raise_error IndexError 40 | end 41 | end 42 | 43 | context "if the id is 2**160 or greater" do 44 | it "raises an IndexError" do 45 | expect{ subject.find_bucket(2.to_big_i ** 160) }.to raise_error IndexError 46 | expect{ subject.find_bucket(2.to_big_i ** 160 + 1) }.to raise_error IndexError 47 | end 48 | end 49 | 50 | it "returns the bucket responsible for the id" do 51 | expect(subject.find_bucket(1234.to_big_i)).to be_a(Torrent::Dht::Bucket) 52 | end 53 | end 54 | 55 | describe "#would_accept?" do 56 | it "rejects ourself" do 57 | expect(subject.would_accept? node_id).to be_false 58 | end 59 | 60 | it "rejects known nodes" do 61 | expect(subject.try_add node(100)).to be_true 62 | expect(subject.would_accept? node(100).id).to be_false 63 | end 64 | 65 | context "if bucket is full" do 66 | before do 67 | 8.times{|i| subject.try_add node(node_id + i)} 68 | end 69 | 70 | it "rejects if bucket is not splittable" do 71 | expect(subject.would_accept?(node_id - 1)).to be_false 72 | end 73 | 74 | it "accepts if bucket is splittable" do 75 | expect(subject.would_accept?(2.to_big_i ** 160 - 200)).to be_true 76 | end 77 | end 78 | 79 | it "accepts otherwise" do 80 | expect(subject.would_accept? node(100).id).to be_true 81 | end 82 | end 83 | 84 | describe "#try_add" do 85 | it "rejects ourself" do 86 | expect(subject.try_add node(node_id - 100)).to be_false 87 | end 88 | 89 | it "rejects known nodes" do 90 | expect(subject.try_add node(100)).to be_true 91 | expect(subject.try_add node(100)).to be_false 92 | end 93 | 94 | context "if bucket is full" do 95 | before do 96 | 8.times{|i| subject.try_add node(node_id + i)} 97 | end 98 | 99 | it "rejects if bucket is not splittable" do 100 | expect(subject.try_add node(node_id - 1)).to be_false 101 | end 102 | 103 | it "accepts if bucket is splittable" do 104 | expect(subject.try_add node(2.to_big_i ** 160 - 200)).to be_true 105 | end 106 | end 107 | 108 | it "accepts otherwise" do 109 | expect(subject.try_add node(100)).to be_true 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /spec/torrent/dht/peer_list_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | Spec2.describe Torrent::Dht::PeerList do 4 | subject{ described_class.new 1.to_big_i } 5 | 6 | before do 7 | subject.add "1.1.1.1", 1u16 8 | subject.add "2.2.2.2", 2u16 9 | subject.add "3.3.3.3", 3u16 10 | subject.add "4.4.4.4", 4u16 11 | end 12 | 13 | describe "#checkout_timeouts" do 14 | let(:old){ { Time.new(2016, 11, 17, 16, 0, 0), Bytes.new(1) } } 15 | let(:current){ { Time.now, Bytes.new(2) } } 16 | before do 17 | subject.peers.clear 18 | subject.peers << old << current 19 | end 20 | 21 | it "removes older peers" do 22 | subject.check_timeouts 23 | expect(subject.peers.size).to eq 1 24 | expect(subject.peers).to eq [ current ] 25 | end 26 | end 27 | 28 | describe "#add" do 29 | context "if the peer is already known" do 30 | it "updates the time" do 31 | before = subject.peers.first[0] 32 | subject.add "1.1.1.1", 1u16 33 | after = subject.peers.first[0] 34 | 35 | expect(subject.peers.size).to eq 4 36 | expect(after).not_to eq before 37 | end 38 | end 39 | 40 | context "if the peer list is full" do 41 | let(:fake){ { Time.now, Bytes.new(1) } } 42 | 43 | before do 44 | subject.peers.clear 45 | 1000.times{ subject.peers << fake } 46 | end 47 | 48 | it "rejects the peer" do 49 | subject.add "1.2.3.4", 1234u16 50 | 51 | expect(subject.peers.size).to eq 1000 52 | expect(subject.peers.all?(&.==(fake))).to be_true 53 | end 54 | end 55 | 56 | it "adds the peer" do 57 | subject.add "5.5.5.5", 5u16 58 | 59 | expect(subject.peers.size).to eq 5 60 | time, native = subject.peers[4] 61 | expect(native).to eq Slice[ 5u8, 5u8, 5u8, 5u8, 0u8, 5u8 ] 62 | expect(time).to_be > Time.now - 1.second 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/torrent/dht/structure_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | Spec2.describe Torrent::Dht::Structure::Message do 4 | describe ".from" do 5 | subject{ described_class.from Torrent::Bencode.load packet.to_slice } 6 | 7 | context "if y == 'q'" do 8 | let(:packet){ "d1:t2:xy1:y1:q1:q3:abc1:ad3:foo3:baree" } 9 | 10 | it "builds a Query" do 11 | expect(subject).to be_a(Torrent::Dht::Structure::Query) 12 | 13 | query = subject.as(Torrent::Dht::Structure::Query) 14 | expect(query.transaction).to eq "xy".to_slice 15 | expect(query.method).to eq "abc" 16 | expect(query.args).to eq({ "foo" => Torrent::Bencode::Any.new("bar") }) 17 | end 18 | end 19 | 20 | context "if y == 'r'" do 21 | let(:packet){ "d1:t2:xy1:y1:r1:rd3:foo3:baree" } 22 | 23 | it "builds a Response" do 24 | expect(subject).to be_a(Torrent::Dht::Structure::Response) 25 | 26 | response = subject.as(Torrent::Dht::Structure::Response) 27 | expect(response.transaction).to eq "xy".to_slice 28 | expect(response.data).to eq({ "foo" => Torrent::Bencode::Any.new("bar") }) 29 | end 30 | end 31 | 32 | context "if y == 'e'" do 33 | let(:packet){ "d1:t2:xy1:y1:e1:eli201e5:helloee" } 34 | 35 | it "builds an Error" do 36 | expect(subject).to be_a(Torrent::Dht::Structure::Error) 37 | 38 | error = subject.as(Torrent::Dht::Structure::Error) 39 | expect(error.transaction).to eq "xy".to_slice 40 | expect(error.code).to eq 201 41 | expect(error.message).to eq "hello" 42 | end 43 | end 44 | 45 | context "else" do 46 | let(:packet){ "d1:t2:xy1:y1:d1:rd3:foo3:baree" } 47 | it "raises an ArgumentError" do 48 | expect{ subject }.to raise_error(ArgumentError, match /unknown type/i) 49 | end 50 | end 51 | end 52 | end 53 | 54 | Spec2.describe Torrent::Dht::Structure::Query do 55 | subject{ described_class.new("xyz".to_slice, "foo", { "bar" => Torrent::Bencode::Any.new("baz") }) } 56 | 57 | describe "#response" do 58 | it "returns a Response" do 59 | r = subject.response({ "one" => Torrent::Bencode::Any.new(2) }) 60 | expect(r).to be_a Torrent::Dht::Structure::Response 61 | expect(r.transaction).to eq "xyz".to_slice 62 | expect(r.data).to eq({ "one" => Torrent::Bencode::Any.new(2) }) 63 | end 64 | end 65 | 66 | describe "#error(ErrorCode)" do 67 | context "if a message is given" do 68 | it "uses the given message" do 69 | e = subject.error(Torrent::Dht::ErrorCode::Server, "oh noes") 70 | expect(e).to be_a Torrent::Dht::Structure::Error 71 | expect(e.transaction).to eq "xyz".to_slice 72 | expect(e.code).to eq Torrent::Dht::ErrorCode::Server.value 73 | expect(e.message).to eq "oh noes" 74 | end 75 | end 76 | 77 | context "if NO message is given" do 78 | it "uses the default message" do 79 | e = subject.error(Torrent::Dht::ErrorCode::Server) 80 | expect(e).to be_a Torrent::Dht::Structure::Error 81 | expect(e.transaction).to eq "xyz".to_slice 82 | expect(e.code).to eq Torrent::Dht::ErrorCode::Server.value 83 | expect(e.message).to eq "A Server Error Occured" 84 | end 85 | end 86 | end 87 | 88 | describe "#error(Int32)" do 89 | it "returns an Error" do 90 | e = subject.error(123, "something happened") 91 | expect(e).to be_a Torrent::Dht::Structure::Error 92 | expect(e.transaction).to eq "xyz".to_slice 93 | expect(e.code).to eq 123 94 | expect(e.message).to eq "something happened" 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /spec/torrent/transfer_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | Spec2.describe Torrent::Transfer do 4 | let(:file){ Torrent::File.read("#{__DIR__}/../fixtures/debian.torrent") } 5 | let(:file_manager){ Torrent::FileManager::FileSystem.new("/tmp") } 6 | let(:manager){ Torrent::Manager::Transfer.new(file_manager) } 7 | 8 | let(:downloaded){ 1000u64 } 9 | let(:uploaded){ 100u64 } 10 | let(:peer_id){ nil } 11 | let(:status){ Torrent::Transfer::Status::Running } 12 | let(:picker){ Torrent::PiecePicker::Sequential.new } 13 | 14 | subject do 15 | described_class.new( 16 | file: file, 17 | manager: manager, 18 | uploaded: uploaded, 19 | downloaded: downloaded, 20 | peer_id: peer_id, 21 | status: status, 22 | piece_picker: picker, 23 | ) 24 | end 25 | 26 | describe "#transfer_ratio" do 27 | context "if nothing has been uploaded yet" do 28 | let(:uploaded){ 0u64 } 29 | it "returns 0" do 30 | expect(subject.transfer_ratio).to eq 0f64 31 | end 32 | end 33 | 34 | context "if nothing has been downloaded yet" do 35 | let(:downloaded){ 0u64 } 36 | it "returns 0" do 37 | expect(subject.transfer_ratio).to eq 0f64 38 | end 39 | end 40 | 41 | it "returns the uploaded divided by the downloaded amount" do 42 | expect(subject.transfer_ratio).to eq 0.10 43 | end 44 | end 45 | 46 | describe "#left" do 47 | it "returns the total_size minus the downloaded amount" do 48 | expect(subject.left).to eq file.total_size - downloaded 49 | end 50 | end 51 | 52 | # pending "#read_piece" 53 | # pending "#read_piece_for_upload" 54 | # pending "#write_piece" 55 | 56 | describe "#start" do 57 | it "calls the leech strategies #start method" do 58 | # TODO: Compiler bug if the following line is not commented. 59 | # subject.start 60 | end 61 | end 62 | 63 | describe "#change_status" do 64 | let!(:status_changed){ Cute.spy subject, status_changed(status : Torrent::Transfer::Status) } 65 | 66 | # TODO: No idea why the following line fails to compile. 67 | # Maybe it's a bug in spec2 or the compiler? 68 | # let!(:download_completed){ Cute.spy(subject, download_completed) } 69 | 70 | context "if the new status is the current status" do 71 | it "does nothing" do 72 | subject.change_status status 73 | expect(status_changed.empty?).to be_true 74 | # expect(download_completed.empty?).to be_true 75 | end 76 | end 77 | 78 | context "if the status is 'Completed'" do 79 | let(:new_status){ Torrent::Transfer::Status::Completed } 80 | it "also emits download_completed" do 81 | subject.change_status new_status 82 | expect(subject.status).to eq new_status 83 | expect(status_changed).to eq [ new_status ] 84 | # expect(download_completed).to eq [ nil ] 85 | end 86 | end 87 | 88 | context "if the status is something else" do 89 | let(:new_status){ Torrent::Transfer::Status::Stopped } 90 | it "emits status_changed" do 91 | subject.change_status new_status 92 | expect(subject.status).to eq new_status 93 | expect(status_changed).to eq [ new_status ] 94 | # expect(download_completed.empty?).to be_true 95 | end 96 | end 97 | end 98 | 99 | describe "#save" do 100 | it "returns a hash containing all persistable data" do 101 | expect(subject.save).to eq({ 102 | "uploaded" => uploaded, 103 | "downloaded" => downloaded, 104 | "peer_id" => subject.peer_id, 105 | "status" => status.to_s, 106 | "info_hash" => file.info_hash.hexstring, 107 | "bitfield_size" => file.piece_count, 108 | "bitfield_data" => ("00" * (file.piece_count / 8 + 1)), 109 | }) 110 | end 111 | end 112 | 113 | describe "#initialize(resume)" do 114 | let(:resume){ subject.save } 115 | let(:resume_peer_id){ true } 116 | 117 | let(:resumed_subject) do 118 | described_class.new( 119 | resume: resume, 120 | file: file, 121 | manager: manager, 122 | piece_picker: picker, 123 | peer_id: resume_peer_id, 124 | ) 125 | end 126 | 127 | context "if the info_hash does not match" do 128 | it "raises" do 129 | resume["info_hash"] = "not_correct" 130 | expect{ resumed_subject }.to raise_error(ArgumentError, match /wrong file/i) 131 | end 132 | end 133 | 134 | context "if the bitfield_size does not match" do 135 | it "raises" do 136 | resume["bitfield_size"] = file.piece_count - 1 137 | expect{ resumed_subject }.to raise_error(ArgumentError, match /wrong bitfield_size/i) 138 | end 139 | end 140 | 141 | context "if the peer_id is a string" do 142 | let(:resume_peer_id){ "my-peer-id" } 143 | it "uses it instead of the resume peer_id" do 144 | expect(resumed_subject.peer_id).to eq resume_peer_id 145 | end 146 | end 147 | 148 | context "if the peer_id is true" do 149 | let(:resume_peer_id){ true } 150 | it "uses the resume peer_id" do 151 | expect(resumed_subject.peer_id).to eq resume["peer_id"].as(String) 152 | end 153 | end 154 | 155 | context "if the peer_id is false" do 156 | let(:resume_peer_id){ false } 157 | it "generates a new peer-id" do 158 | expect(resumed_subject.peer_id).not_to eq resume_peer_id 159 | expect(resumed_subject.peer_id).to match /^-CR/i 160 | end 161 | end 162 | 163 | it "restores the transfer" do 164 | expect(resumed_subject.downloaded).to eq downloaded 165 | expect(resumed_subject.uploaded).to eq uploaded 166 | expect(resumed_subject.status).to eq status 167 | expect(resumed_subject.requests.public_bitfield).to eq subject.requests.public_bitfield 168 | end 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /spec/torrent/util/bitfield_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | private def bitfield(*bytes) 4 | data = Bytes.new bytes.size do |idx| 5 | bytes[idx].to_u8 6 | end 7 | 8 | Torrent::Util::Bitfield.new(data) 9 | end 10 | 11 | Spec2.describe Torrent::Util::Bitfield do 12 | describe ".bytesize" do 13 | it "returns the bytes needed" do 14 | expect(Torrent::Util::Bitfield.bytesize(32)).to eq 4 15 | expect(Torrent::Util::Bitfield.bytesize(33)).to eq 5 16 | end 17 | end 18 | 19 | describe "#clone" do 20 | it "deep-copies the data" do 21 | original = bitfield(0x11, 0x22) 22 | copy = original.clone 23 | expect(original.to_bytes.pointer(0)).not_to eq(copy.to_bytes.pointer(0)) 24 | end 25 | end 26 | 27 | describe "#size" do 28 | it "returns the count of bits" do 29 | expect(bitfield(1, 2, 3, 4, 5).size).to eq 40 30 | end 31 | end 32 | 33 | describe "#all_ones?" do 34 | it "returns true" do 35 | expect(bitfield(0xFF).all_ones?).to be_true 36 | expect(bitfield(0xFF, 0xFF, 0xFF, 0xFF).all_ones?).to be_true 37 | expect(bitfield(0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF).all_ones?).to be_true 38 | expect(bitfield(0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF).all_ones?).to be_true 39 | expect(bitfield(0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF).all_ones?).to be_true 40 | end 41 | 42 | it "returns false" do 43 | expect(bitfield(0xEF).all_ones?).to be_false 44 | expect(bitfield(0xFF, 0xEF).all_ones?).to be_false 45 | expect(bitfield(0xFF, 0xEF, 0xFF).all_ones?).to be_false 46 | expect(bitfield(0xFF, 0xEF, 0xFF, 0xFF).all_ones?).to be_false 47 | expect(bitfield(0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xEF, 0xFF).all_ones?).to be_false 48 | expect(bitfield(0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xEF).all_ones?).to be_false 49 | end 50 | end 51 | 52 | describe "#all_zero?" do 53 | it "returns true" do 54 | expect(bitfield(0).all_zero?).to be_true 55 | expect(bitfield(0, 0, 0, 0).all_zero?).to be_true 56 | expect(bitfield(0, 0, 0, 0, 0, 0, 0, 0, 0).all_zero?).to be_true 57 | expect(bitfield(0, 0, 0, 0, 0, 0, 0, 0, 0, 0).all_zero?).to be_true 58 | expect(bitfield(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0).all_zero?).to be_true 59 | end 60 | 61 | it "returns false" do 62 | expect(bitfield(1).all_zero?).to be_false 63 | expect(bitfield(0, 1).all_zero?).to be_false 64 | expect(bitfield(0, 1, 0).all_zero?).to be_false 65 | expect(bitfield(0, 1, 0, 0).all_zero?).to be_false 66 | expect(bitfield(0, 0, 0, 0, 1, 0, 0, 0, 0).all_zero?).to be_false 67 | expect(bitfield(0, 0, 0, 0, 0, 1, 0, 0, 0).all_zero?).to be_false 68 | expect(bitfield(0, 0, 0, 0, 0, 0, 1, 0, 0).all_zero?).to be_false 69 | expect(bitfield(0, 0, 0, 0, 0, 0, 0, 1, 0).all_zero?).to be_false 70 | expect(bitfield(0, 0, 0, 0, 0, 0, 0, 0, 1).all_zero?).to be_false 71 | end 72 | end 73 | 74 | describe "#all?" do 75 | it "handles non-full-byte sizes" do 76 | bits = Torrent::Util::Bitfield.new(64 + 8 + 2, 0xFFu8) 77 | expect(bits.all?(true)).to be_true 78 | 79 | bits[64 + 8 + 1] = false 80 | expect(bits.all?(true)).to be_false 81 | end 82 | end 83 | 84 | describe "#[]" do 85 | it "returns true" do 86 | expect(bitfield(0x02)[6]).to be_true 87 | expect(bitfield(0x00, 0x02)[14]).to be_true 88 | end 89 | 90 | it "returns false" do 91 | expect(bitfield(0xEF)[3]).to be_false 92 | expect(bitfield(0xFF, 0xFE)[15]).to be_false 93 | end 94 | end 95 | 96 | describe "#[] assignment" do 97 | it "sets a bit" do 98 | bits = bitfield(0x00) 99 | bits[1] = true 100 | expect(bits.to_bytes).to eq Slice[ 0x40u8 ] 101 | 102 | bits = bitfield(0x00, 0x00) 103 | bits[9] = true 104 | expect(bits.to_bytes).to eq Slice[ 0x00u8, 0x40u8 ] 105 | end 106 | 107 | it "clears a bit" do 108 | bits = bitfield(0xFF) 109 | bits[1] = false 110 | expect(bits.to_bytes).to eq Slice[ 0xBFu8 ].as(Bytes) 111 | 112 | bits = bitfield(0xFF, 0xFF) 113 | bits[9] = false 114 | expect(bits.to_bytes).to eq Slice[ 0xFFu8, 0xBFu8 ].as(Bytes) 115 | end 116 | end 117 | 118 | describe "#find_next_unset" do 119 | it "finds the next unset bit" do 120 | data = Bytes.new 23, 0xFFu8 121 | bits = Torrent::Util::Bitfield.new(data) 122 | 123 | data.each_with_index do |_el, idx| 124 | 8.times do |bit| 125 | data[idx] &= ~(1 << bit) 126 | expect(bits.find_next_unset).to eq (idx * 8 + bit) 127 | data[idx] |= 1 << bit 128 | end 129 | end 130 | end 131 | end 132 | 133 | describe "#count" do 134 | it "counts set bits" do 135 | expect(bitfield(0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xEF, 0xFC).count(true)).to eq(69) 136 | end 137 | 138 | it "counts clear bits" do 139 | expect(bitfield(0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xEF, 0xFC).count(false)).to eq(3) 140 | end 141 | end 142 | 143 | describe "#each(Bool)" do 144 | it "yields each true bit index" do 145 | expected = [ 0, 5, 15 ] 146 | actual = [ ] of Int32 147 | 148 | bitfield(0x84, 0x01).each(true){|idx| actual << idx} 149 | expect(actual).to eq expected 150 | end 151 | 152 | it "yields each false bit index" do 153 | expected = [ 1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12, 13, 14 ] 154 | actual = [ ] of Int32 155 | 156 | bitfield(0x84, 0x01).each(false){|idx| actual << idx} 157 | expect(actual).to eq expected 158 | end 159 | end 160 | 161 | describe ".restore" do 162 | context "if the byte size does not match" do 163 | it "raises" do 164 | expect{ described_class.restore 1, "1122" }.to raise_error(ArgumentError) 165 | expect{ described_class.restore 9, "11" }.to raise_error(ArgumentError) 166 | end 167 | end 168 | 169 | it "builds a bitfield" do 170 | bits = described_class.restore 15, "1234" 171 | expect(bits.size).to eq 15 172 | expect(bits.data.hexstring).to eq "1234" 173 | 174 | expected = [ 3, 6, 10, 11, 13 ] 175 | actual = [ ] of Int32 176 | bits.each(true){|idx| actual << idx} 177 | expect(actual).to eq expected 178 | end 179 | end 180 | end 181 | -------------------------------------------------------------------------------- /spec/torrent/util/endian_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | Spec2.describe Torrent::Util::Endian do 4 | it "works with UInt8" do 5 | expect(Torrent::Util::Endian.swap(0x11u8)).to eq 0x11u8 6 | end 7 | 8 | it "works with UInt16" do 9 | expect(Torrent::Util::Endian.swap(0x1122u16)).to eq 0x2211u16 10 | end 11 | 12 | it "works with UInt32" do 13 | expect(Torrent::Util::Endian.swap(0x11223344u32)).to eq 0x44332211u32 14 | end 15 | 16 | it "works with UInt64" do 17 | expect(Torrent::Util::Endian.swap(0x1122334455667788u64)).to eq 0x8877665544332211u64 18 | end 19 | 20 | it "works with Int8" do 21 | expect(Torrent::Util::Endian.swap(0x11i8)).to eq 0x11i8 22 | end 23 | 24 | it "works with Int16" do 25 | expect(Torrent::Util::Endian.swap(0x1122i16)).to eq 0x2211i16 26 | end 27 | 28 | it "works with Int32" do 29 | expect(Torrent::Util::Endian.swap(0x11223344i32)).to eq 0x44332211i32 30 | end 31 | 32 | it "works with Int64" do 33 | expect(Torrent::Util::Endian.swap(0x1122334455667788i64)).to eq (0x8877665544332211u64).to_i64 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/torrent/util/gmp_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | Spec2.describe Torrent::Util::Gmp do 4 | let(:bytes) do 5 | Slice[ 6 | 0x11u8, 0x22u8, 0x33u8, 0x44u8, 0x55u8, 7 | 0x66u8, 0x77u8, 0x88u8, 0x99u8, 0x00u8, 8 | 0x11u8, 0x22u8, 0x33u8, 0x44u8, 0x55u8, 9 | 0x66u8, 0x77u8, 0x88u8, 0x99u8, 0x00u8 10 | ] 11 | end 12 | 13 | let(:bigint) do 14 | BigInt.new "97815534420055201845582779189627195583443278080" 15 | end 16 | 17 | describe ".export_sha1" do 18 | it "exports the BigInt" do 19 | expect(Torrent::Util::Gmp.export_sha1 bigint).to eq bytes 20 | end 21 | 22 | context "if the number is too big" do 23 | it "raises IndexError" do 24 | expect{ Torrent::Util::Gmp.export_sha1 2.to_big_i ** 160 }.to raise_error(IndexError) 25 | end 26 | end 27 | 28 | context "if the number is too small" do 29 | it "pads to 20 bytes" do 30 | expect(Torrent::Util::Gmp.export_sha1 0x11.to_big_i).to eq Slice [ 31 | 0x00u8, 0x00u8, 0x00u8, 0x00u8, 0x00u8, 32 | 0x00u8, 0x00u8, 0x00u8, 0x00u8, 0x00u8, 33 | 0x00u8, 0x00u8, 0x00u8, 0x00u8, 0x00u8, 34 | 0x00u8, 0x00u8, 0x00u8, 0x00u8, 0x11u8 35 | ] 36 | end 37 | end 38 | end 39 | 40 | describe ".import_sha1" do 41 | it "imports the BigInt" do 42 | expect(Torrent::Util::Gmp.import_sha1 bytes).to eq bigint 43 | end 44 | 45 | context "if the number is too big" do 46 | it "raises IndexError" do 47 | huge = Bytes.new(21, 0u8) 48 | huge[0] = 0x01u8 49 | 50 | expect{ Torrent::Util::Gmp.import_sha1 huge }.to raise_error(IndexError) 51 | end 52 | end 53 | end 54 | 55 | describe "sanity check" do 56 | it "can import the exported data" do 57 | data = Torrent::Util::Gmp.export_sha1 bigint 58 | expect(Torrent::Util::Gmp.import_sha1 data).to eq bigint 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/torrent/util/kademlia_list_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | private class KademliaObject 4 | @hash : BigInt 5 | 6 | def initialize(hash : Int) 7 | @hash = hash.to_big_i 8 | end 9 | 10 | def kademlia_distance(other : Int) : BigInt 11 | @hash ^ other.to_big_i 12 | end 13 | end 14 | 15 | Spec2.describe Torrent::Util::KademliaList do 16 | let(:a){ KademliaObject.new 1 } 17 | let(:b){ KademliaObject.new 2 } 18 | let(:c){ KademliaObject.new 3 } 19 | let(:d){ KademliaObject.new 4 } 20 | let(:e){ KademliaObject.new 5 } 21 | let(:f){ KademliaObject.new 6 } 22 | let(:g){ KademliaObject.new 7 } 23 | let(:h){ KademliaObject.new 8 } 24 | let(:i){ KademliaObject.new 12 } 25 | 26 | let(:compare){ 3.to_big_i } 27 | let(:max_size){ 4 } 28 | 29 | subject do 30 | Torrent::Util::KademliaList(KademliaObject).new(compare, max_size).tap do |list| 31 | list << a << b << c << d << e << f << g << h 32 | end 33 | end 34 | 35 | describe "#to_a" do 36 | it "returns the element array" do 37 | expect(subject.to_a).to eq [ c, b, a, g ] 38 | end 39 | end 40 | 41 | describe "#try_add" do 42 | context "if the element is already in the list" do 43 | it "returns false" do 44 | expect(subject.try_add b).to be_false 45 | expect(subject.to_a).to eq [ c, b, a, g ] 46 | end 47 | end 48 | end 49 | 50 | describe "#<<" do 51 | before do 52 | subject.clear 53 | subject << b << f << d 54 | end 55 | 56 | context "if the added object is farther than the farthest" do 57 | context "and the list is full" do 58 | it "does nothing" do 59 | subject << h << i 60 | expect(subject.to_a).to eq [ b, f, d, h ] 61 | end 62 | end 63 | 64 | it "adds the element at the end" do 65 | subject << i 66 | expect(subject.to_a).to eq [ b, f, d, i ] 67 | end 68 | end 69 | 70 | context "if the added object is nearer than the nearest" do 71 | it "adds the element at the start" do 72 | subject << c 73 | expect(subject.to_a).to eq [ c, b, f, d ] 74 | end 75 | end 76 | 77 | it "adds the element in the middle" do 78 | subject << e 79 | expect(subject.to_a).to eq [ b, f, e, d ] 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/torrent/util/network_order_struct_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | private lib Native 4 | struct Network 5 | integer : Int32 6 | array : Int8[4] 7 | end 8 | end 9 | 10 | private struct Test < Torrent::Util::NetworkOrderStruct(Native::Network) 11 | fields( 12 | integer : Int32, 13 | array : Int8[4], 14 | ) 15 | end 16 | 17 | Spec2.describe "Torrent::Util::NetworkOrderStruct" do 18 | describe "#initialize" do 19 | it "creates empty instance with no arguments" do 20 | test = Test.new 21 | expect(test.integer).to eq 0 22 | expect(test.array).to eq StaticArray[ 0i8, 0i8, 0i8, 0i8 ] 23 | end 24 | 25 | it "creates populated instance with a T argument" do 26 | inner = Native::Network.new 27 | inner.integer = 0x44332211 28 | inner.array = StaticArray[ 1i8, 2i8, 3i8, 4i8 ] 29 | 30 | test = Test.new(inner) 31 | expect(test.integer).to eq 0x11223344 32 | expect(test.array).to eq StaticArray[ 1i8, 2i8, 3i8, 4i8 ] 33 | end 34 | 35 | it "creates populated instance with value arguments" do 36 | test = Test.new(integer: 4, array: StaticArray[ 1i8, 2i8, 3i8, 4i8 ]) 37 | expect(test.integer).to eq 4 38 | expect(test.array).to eq StaticArray[ 1i8, 2i8, 3i8, 4i8 ] 39 | end 40 | end 41 | 42 | describe "generated getter" do 43 | it "passes-through on a non-integer" do 44 | test = Test.new(integer: 4, array: StaticArray[ 1i8, 2i8, 3i8, 4i8 ]) 45 | expect(test.array).to eq StaticArray[ 1i8, 2i8, 3i8, 4i8 ] 46 | end 47 | 48 | it "converts integers to host endianess" do 49 | test = Test.new(integer: 4, array: StaticArray[ 1i8, 2i8, 3i8, 4i8 ]) 50 | test.inner.integer = 0x44332211 51 | expect(test.integer).to eq 0x11223344 52 | end 53 | end 54 | 55 | describe "generated setter" do 56 | it "passes-through on a non-integer" do 57 | test = Test.new 58 | test.array = StaticArray[ 1i8, 2i8, 3i8, 4i8 ] 59 | expect(test.inner.array).to eq StaticArray[ 1i8, 2i8, 3i8, 4i8 ] 60 | end 61 | 62 | it "converts integers to network endianess" do 63 | test = Test.new 64 | test.integer = 0x11223344 65 | expect(test.inner.integer).to eq 0x44332211 66 | end 67 | end 68 | 69 | describe "#from" do 70 | context "on Bytes" do 71 | it "returns a populated instance" do 72 | data = StaticArray[ 0x11u8, 0x22u8, 0x33u8, 0x44u8, 0x1u8, 0x2u8, 0x3u8, 0x4u8 ] 73 | 74 | test = Test.from(data.to_slice) 75 | expect(test.integer).to eq 0x11223344 76 | expect(test.array).to eq StaticArray[ 1i8, 2i8, 3i8, 4i8 ] 77 | end 78 | end 79 | 80 | context "on an IO" do 81 | it "returns a populated instance" do 82 | data = StaticArray[ 0x11u8, 0x22u8, 0x33u8, 0x44u8, 0x1u8, 0x2u8, 0x3u8, 0x4u8 ] 83 | 84 | io = IO::Memory.new(data.to_slice) 85 | test = Test.from(io) 86 | expect(test.integer).to eq 0x11223344 87 | expect(test.array).to eq StaticArray[ 1i8, 2i8, 3i8, 4i8 ] 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/torrent/util/request_list_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | Spec2.describe Torrent::Util::RequestList::Piece do 4 | let(:index){ 1234u32 } 5 | let(:size){ 160u32 } 6 | let(:block_size){ 16u32 } 7 | 8 | subject{ described_class.new(index, size, block_size) } 9 | 10 | describe "#count" do 11 | context "if the piece size is multiple of block size" do 12 | it "equals size / block_size" do 13 | expect(subject.count).to eq 10 14 | end 15 | end 16 | 17 | context "if the piece size is not multiple of block size" do 18 | let(:size){ 165u32 } 19 | 20 | it "equals size / block_size + 1" do 21 | expect(subject.count).to eq 11 22 | end 23 | end 24 | end 25 | 26 | describe "#complete?" do 27 | context "if @progress is not all true" do 28 | it "returns false" do 29 | subject.progress.map!{ true } 30 | subject.progress[0] = false 31 | expect(subject.complete?).to be_false 32 | end 33 | end 34 | 35 | context "if @progress is all true" do 36 | it "returns true" do 37 | subject.progress.map!{ true } 38 | expect(subject.complete?).to be_true 39 | end 40 | end 41 | end 42 | 43 | describe "#complete?(block_idx)" do 44 | context "if the block is complete" do 45 | it "returns true" do 46 | subject.progress[1] = true 47 | expect(subject.complete? 1).to be_true 48 | end 49 | end 50 | 51 | context "if the block is not complete" do 52 | it "returns true" do 53 | subject.progress[1] = false 54 | expect(subject.complete? 1).to be_false 55 | end 56 | end 57 | end 58 | 59 | describe "#offset_to_block" do 60 | context "if the offset is not divisible by the block size" do 61 | it "raises an ArgumentError" do 62 | expect{ subject.offset_to_block 5 }.to raise_error(ArgumentError, match /not divisible by block size/i) 63 | end 64 | end 65 | 66 | it "returns the block index" do 67 | expect(subject.offset_to_block 0).to eq 0 68 | expect(subject.offset_to_block 16).to eq 1 69 | expect(subject.offset_to_block 32).to eq 2 70 | end 71 | end 72 | 73 | describe "#mark_complete" do 74 | context "if now all blocks are complete" do 75 | it "returns true" do 76 | subject.progress.map!{ true } 77 | subject.progress[1] = false 78 | 79 | expect(subject.mark_complete 1).to be_true 80 | expect(subject.complete?).to be_true 81 | end 82 | end 83 | 84 | context "if not all blocks are complete" do 85 | it "returns false" do 86 | subject.progress.map!{ true } 87 | subject.progress[1] = false 88 | subject.progress[2] = false 89 | 90 | expect(subject.mark_complete 1).to be_false 91 | expect(subject.complete?).to be_false 92 | end 93 | end 94 | end 95 | 96 | describe "#to_a" do 97 | let(:size){ 38u32 } 98 | 99 | it "returns an array of all tuples" do 100 | expect(subject.to_a).to eq([ 101 | { 1234u32, 0u32, 16u32 }, 102 | { 1234u32, 16u32, 16u32 }, 103 | { 1234u32, 32u32, 6u32 }, 104 | ]) 105 | end 106 | end 107 | 108 | describe "#tuple" do 109 | context "if the last block is divisible by the block size" do 110 | it "returns a tuple" do 111 | expect(subject.tuple 9).to eq({ 1234u32, 9 * 16u32, 16u32 }) 112 | end 113 | end 114 | 115 | context "if the last block is not divisible by the block size" do 116 | let(:size){ 155u32 } 117 | 118 | it "returns a tuple" do 119 | expect(subject.tuple 9).to eq({ 1234u32, 9 * 16u32, 11u32 }) 120 | end 121 | end 122 | 123 | context "if the index is not the last block" do 124 | it "returns a tuple" do 125 | expect(subject.tuple 1).to eq({ 1234u32, 16u32, 16u32 }) 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /src/torrent.cr: -------------------------------------------------------------------------------- 1 | require "digest/sha1" 2 | require "uri" 3 | require "http/client" 4 | require "logger" 5 | 6 | # DHT specific requires 7 | require "big" 8 | 9 | # External shards 10 | require "cute" # Shard, github.com/Papierkorb/cute 11 | 12 | # A BitTorrent client library in pure Crystal. 13 | module Torrent 14 | @@logger : Logger? 15 | 16 | # Base class for all custom errors in `Torrent` 17 | class Error < Exception; end 18 | 19 | # 20 | def self.logger 21 | @@logger 22 | end 23 | 24 | def self.logger=(log) 25 | @@logger = log 26 | end 27 | end 28 | 29 | require "./torrent/bencode" 30 | require "./torrent/*" 31 | require "./torrent/util/*" 32 | require "./torrent/piece_picker/*" 33 | require "./torrent/manager/*" 34 | require "./torrent/file_manager/*" 35 | require "./torrent/client/*" 36 | require "./torrent/extension/*" 37 | require "./torrent/leech_strategy/*" 38 | require "./torrent/dht/*" 39 | -------------------------------------------------------------------------------- /src/torrent/bencode.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | # A parser for BEncode data 3 | module Bencode 4 | 5 | # Generic error class 6 | class Error < Torrent::Error; end 7 | 8 | # Parses the Bencode data in *bytes* and returns the structure. 9 | def self.load(bytes : Bytes) : Any 10 | load(IO::Memory.new bytes) 11 | end 12 | 13 | # Parses the Bencode data in *io* and returns the structure. 14 | def self.load(io : IO) : Any 15 | lexer = Lexer.new(io) 16 | pull = PullParser.new(lexer) 17 | Any.new(pull) 18 | end 19 | end 20 | end 21 | 22 | require "./bencode/*" 23 | -------------------------------------------------------------------------------- /src/torrent/bencode/any.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Bencode 3 | # Container for Bencode values, similar to `JSON::Any`. 4 | class Any 5 | class Error < Bencode::Error; end 6 | 7 | alias AnyArray = Array(self) 8 | alias AnyHash = Hash(String, self) 9 | 10 | # Variable type 11 | getter type : TokenType 12 | 13 | @integer : Int64 = 0i64 14 | @byte_string : Bytes? 15 | @hash : AnyHash? 16 | @array : AnyArray? 17 | 18 | # Builds an integer instance 19 | def initialize(integer : Int) 20 | @integer = integer.to_i64 21 | @type = TokenType::Integer 22 | end 23 | 24 | # Builds an integer instance out of a `Bool` 25 | def initialize(bool : Bool) 26 | @integer = bool ? 1i64 : 0i64 27 | @type = TokenType::Integer 28 | end 29 | 30 | # Builds a byte-string instance 31 | def initialize(bytes : Bytes) 32 | @byte_string = bytes 33 | @type = TokenType::ByteString 34 | end 35 | 36 | # ditto 37 | def initialize(string : String) 38 | @byte_string = string.to_slice 39 | @type = TokenType::ByteString 40 | end 41 | 42 | # Builds an array instance 43 | def initialize(array : AnyArray) 44 | @array = array 45 | @type = TokenType::List 46 | end 47 | 48 | # Builds a hash instance 49 | def initialize(hash : AnyHash) 50 | @hash = hash 51 | @type = TokenType::Dictionary 52 | end 53 | 54 | # Builds an instance out of a pull-parser state 55 | def initialize(pull : PullParser) 56 | token = pull.peek_token 57 | @type = token.type 58 | @integer = 0i64 59 | 60 | case @type 61 | when TokenType::Integer 62 | @integer = pull.read_integer 63 | when TokenType::ByteString 64 | @byte_string = pull.read_byte_slice 65 | when TokenType::List 66 | @array = Array(self).new(pull) 67 | when TokenType::Dictionary 68 | @hash = Hash(String, self).new(pull) 69 | else 70 | raise Lexer::Error.new("Unexpected token of type #{@type}", token.position) 71 | end 72 | end 73 | 74 | delegate integer?, byte_string?, dictionary?, list?, to: @type 75 | 76 | # Is this a hash? 77 | def hash? 78 | @type.dictionary? 79 | end 80 | 81 | # Is this an array? 82 | def array? 83 | @type.list? 84 | end 85 | 86 | # Is this a string? 87 | def string? 88 | @type.byte_string? 89 | end 90 | 91 | # Returns the inner object 92 | def object : Int64 | Bytes | AnyArray | AnyHash 93 | case @type 94 | when TokenType::Integer then @integer 95 | when TokenType::ByteString then @byte_string.not_nil! 96 | when TokenType::List then @array.not_nil! 97 | when TokenType::Dictionary then @hash.not_nil! 98 | else 99 | raise Error.new("Broken any instance, type is unknown: #{@type.inspect}") 100 | end 101 | end 102 | 103 | # Returns the integer value, or raises if it's not an integer 104 | def to_i : Int64 105 | raise Error.new("Not an integer, but a #{@type}") unless @type.integer? 106 | @integer 107 | end 108 | 109 | # Returns the byte string, or raises if it's not a byte string 110 | def to_slice 111 | raise Error.new("Not a byte slice, but a #{@type}") unless @type.byte_string? 112 | @byte_string.not_nil! 113 | end 114 | 115 | # Returns the array, or raises if it's not an array 116 | def to_a 117 | raise Error.new("Not an array, but a #{@type}") unless @type.list? 118 | @array.not_nil! 119 | end 120 | 121 | # Returns the hash, or raises if it's not a hash 122 | def to_h 123 | raise Error.new("Not a hash, but a #{@type}") unless @type.dictionary? 124 | @hash.not_nil! 125 | end 126 | 127 | # Returns a boolean if it's an integer 128 | def to_b : Bool 129 | to_i != 0 130 | end 131 | 132 | # Serializes the inner object to Bencode. 133 | def to_bencode(io) 134 | object.to_bencode(io) 135 | end 136 | 137 | # Returns the inner objects hash 138 | def hash 139 | object.hash 140 | end 141 | 142 | def inspect(io) 143 | io << "" 146 | io 147 | end 148 | 149 | # Calls `#to_s` on the inner object. 150 | # If this is a `string?`, will return the string instead. 151 | def to_s 152 | if @type.byte_string? 153 | String.new @byte_string.not_nil!, "UTF-8" 154 | else 155 | object.to_s 156 | end 157 | end 158 | 159 | # Calls `#to_s` on the inner object. 160 | def to_s(io) 161 | object.to_s(io) 162 | end 163 | 164 | # Returns the size of the array, hash or byte string. 165 | # Raises if this is an integer. 166 | def size : Int 167 | case @type 168 | when TokenType::List 169 | @array.not_nil!.size 170 | when TokenType::Dictionary 171 | @hash.not_nil!.size 172 | when TokenType::ByteString 173 | @byte_string.not_nil!.bytesize 174 | else 175 | raise Error.new("Expected hash or array, but is a #{@type}") 176 | end 177 | end 178 | 179 | # Returns `true` if the *other* any is of the same type and contains an 180 | # object equal to this one. 181 | def ==(other : self) 182 | @type == other.type && object == other.object 183 | end 184 | 185 | {% for suffix in [ "", "?" ] %} 186 | # Accesses the value for *key*. 187 | # Raises if this is not a hash. 188 | def []{{ suffix.id }}(key : String) 189 | to_h[key]{{ suffix.id }} 190 | end 191 | 192 | # Accesses the value at *index*. 193 | # Raises if this is not an array. 194 | def []{{ suffix.id }}(index : Int) 195 | to_a[index]{{ suffix.id }} 196 | end 197 | {% end %} 198 | end 199 | end 200 | end 201 | -------------------------------------------------------------------------------- /src/torrent/bencode/extensions.cr: -------------------------------------------------------------------------------- 1 | private def pull_parser(input : Bytes) 2 | pull_parser(IO::Memory.new input) 3 | end 4 | 5 | private def pull_parser(input : IO) 6 | lexer = Torrent::Bencode::Lexer.new(input) 7 | Torrent::Bencode::PullParser.new(lexer) 8 | end 9 | 10 | {% for type, func in { UInt8: :u8, UInt16: :u16, UInt32: :u32, UInt64: :u64, Int8: :i8, Int16: :i16, Int32: :i32, Int64: :i64 } %} 11 | struct {{ type.id }} 12 | def self.new(pull : Torrent::Bencode::PullParser) : self 13 | pull.read_integer.to_{{ func.id }} 14 | end 15 | 16 | def self.from_bencode(bytes_or_io : Bytes | IO) : self 17 | new pull_parser(bytes_or_io) 18 | end 19 | 20 | def to_bencode(io : IO) : IO 21 | io.print "i#{self}e" 22 | io 23 | end 24 | end 25 | {% end %} 26 | 27 | class String 28 | def self.new(pull : Torrent::Bencode::PullParser) : self 29 | String.new pull.read_byte_slice 30 | end 31 | 32 | def self.from_bencode(bytes_or_io : Bytes | IO) : self 33 | String.new pull_parser(bytes_or_io) 34 | end 35 | 36 | def to_bencode(io : IO) : IO 37 | to_slice.to_bencode(io) 38 | end 39 | end 40 | 41 | struct Slice(T) 42 | def self.new(pull : Torrent::Bencode::PullParser) : self 43 | pull.read_byte_slice 44 | end 45 | 46 | def self.from_bencode(bytes_or_io : Bytes | IO) : self 47 | new pull_parser(bytes_or_io) 48 | end 49 | 50 | def to_bencode(io : IO) : IO 51 | io.print "#{bytesize}:" 52 | io.write self 53 | io 54 | end 55 | end 56 | 57 | class Hash(K, V) 58 | def self.new(pull : Torrent::Bencode::PullParser) : self 59 | hsh = Hash(K, V).new 60 | 61 | pull.read_dictionary do 62 | hsh[String.new(pull)] = V.new(pull) 63 | end 64 | 65 | hsh 66 | end 67 | 68 | def self.from_bencode(bytes_or_io : Bytes | IO) : self 69 | new pull_parser(bytes_or_io) 70 | end 71 | 72 | def to_bencode(io : IO) : IO 73 | io.print "d" 74 | 75 | keys.sort.each do |key| 76 | key.to_bencode(io) 77 | self[key].to_bencode(io) 78 | end 79 | 80 | io.print "e" 81 | io 82 | end 83 | end 84 | 85 | class Array(T) 86 | def self.new(pull : Torrent::Bencode::PullParser) : self 87 | ary = Array(T).new 88 | 89 | pull.read_list do 90 | ary << T.new(pull) 91 | end 92 | 93 | ary 94 | end 95 | 96 | def self.from_bencode(bytes_or_io : Bytes | IO) : self 97 | new pull_parser(bytes_or_io) 98 | end 99 | 100 | def to_bencode(io : IO) : IO 101 | io.print "l" 102 | each(&.to_bencode(io)) 103 | io.print "e" 104 | io 105 | end 106 | end 107 | 108 | struct Enum 109 | def self.new(pull : Torrent::Bencode::PullParser) : self 110 | {% begin %} 111 | klass = {{@type}}::{{ @type.constants.first }}.value.class 112 | from_value klass.new(pull.read_integer) 113 | {% end %} 114 | end 115 | 116 | def to_bencode(io : IO) : IO 117 | value.to_bencode(io) 118 | end 119 | end 120 | 121 | struct Bool 122 | def self.new(pull : Torrent::Bencode::PullParser) : self 123 | pull.read_integer != 0 124 | end 125 | 126 | def Bool.from_bencode(bytes_or_io : Bytes | IO) : self 127 | Bool.new pull_parser(bytes_or_io) 128 | end 129 | 130 | def to_bencode(io : IO) : IO 131 | io.print self ? "i1e" : "i0e" 132 | io 133 | end 134 | end 135 | 136 | class Object 137 | def to_bencode : Bytes 138 | io = IO::Memory.new 139 | to_bencode(io) 140 | io.to_slice 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /src/torrent/bencode/lexer.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Bencode 3 | enum TokenType : UInt8 4 | Eof 5 | Integer = 0x69u8 # i 6 | ByteString = 0x73u8 # s 7 | List = 0x6Cu8 # l 8 | Dictionary = 0x64u8 # d 9 | EndMarker = 0x65u8 # e 10 | end 11 | 12 | record Token, 13 | position : Int32, 14 | type : TokenType, 15 | int_value : Int64 = 0i64, 16 | byte_value : Bytes? = nil 17 | 18 | # Lexer for Bencode data. This is an internal class. 19 | # See `Object.from_bencode` or `Bencode.load` instead. 20 | class Lexer 21 | getter? eof : Bool = false 22 | getter position : Int32 = 0 23 | 24 | @peek_buf : UInt8? 25 | 26 | DIGIT_LOW = 0x30u8 # '0' 27 | DIGIT_HIGH = 0x39u8 # '9' 28 | MINUS_SIGN = 0x2Du8 # '-' 29 | LENGTH_SIGN = 0x3Au8 # ':' 30 | 31 | class Error < Bencode::Error 32 | getter position : Int32 33 | 34 | def initialize(message, @position) 35 | super("#{message} at byte #{@position}") 36 | end 37 | end 38 | 39 | def initialize(@io : IO) 40 | end 41 | 42 | def each_token 43 | until @eof 44 | yield next_token 45 | end 46 | end 47 | 48 | def next_token : Token 49 | pos = @position 50 | byte = next_byte? 51 | 52 | case byte 53 | when nil 54 | @eof = true 55 | Token.new(pos, TokenType::Eof) 56 | when TokenType::Integer.value 57 | integer = consume_integer(TokenType::EndMarker.value) 58 | Token.new(pos, TokenType::Integer, int_value: integer) 59 | when DIGIT_LOW..DIGIT_HIGH 60 | unget_byte(byte) 61 | data = consume_byte_string 62 | Token.new(pos, TokenType::ByteString, byte_value: data) 63 | when TokenType::List.value 64 | Token.new(pos, TokenType::List) 65 | when TokenType::Dictionary.value 66 | Token.new(pos, TokenType::Dictionary) 67 | when TokenType::EndMarker.value 68 | Token.new(pos, TokenType::EndMarker) 69 | else 70 | raise "Unknown token #{byte.unsafe_chr.inspect}" 71 | end 72 | end 73 | 74 | private def unget_byte(byte) 75 | @peek_buf = byte 76 | @position -= 1 77 | end 78 | 79 | private def next_byte? : UInt8 | Nil 80 | if @peek_buf 81 | byte = @peek_buf 82 | @peek_buf = nil 83 | else 84 | byte = @io.read_byte 85 | end 86 | 87 | @position += 1 88 | byte 89 | 90 | rescue IO::EOFError 91 | nil 92 | end 93 | 94 | private def peek_byte? : UInt8 | Nil 95 | @peek_buf = @io.read_byte 96 | rescue IO::EOFError 97 | nil 98 | end 99 | 100 | private def next_byte : UInt8 101 | byte = next_byte? 102 | raise "Premature end of data" if byte.nil? 103 | byte 104 | end 105 | 106 | private def peek_byte : UInt8 107 | byte = peek_byte? 108 | raise "Premature end of data" if byte.nil? 109 | byte 110 | end 111 | 112 | private def consume_integer(end_sign : UInt8) : Int64 113 | negate = (peek_byte == MINUS_SIGN) 114 | next_byte if negate 115 | 116 | value = read_integer(end_sign) 117 | negate ? -value : value 118 | end 119 | 120 | private def read_integer(end_sign : UInt8) : Int64 121 | value = 0i64 122 | 123 | while byte = next_byte 124 | break unless digit = read_integer_byte(byte, end_sign) 125 | value = value * 10 + digit 126 | end 127 | 128 | value 129 | end 130 | 131 | private def read_integer_byte(byte : UInt8, end_sign) : Int64 | Nil 132 | return nil if byte == end_sign 133 | 134 | if byte < DIGIT_LOW || byte > DIGIT_HIGH 135 | raise "Unexpected byte #{byte.unsafe_chr.inspect} while reading integer" 136 | end 137 | 138 | (byte - DIGIT_LOW).to_i64 139 | end 140 | 141 | private def consume_byte_string : Bytes 142 | length = read_integer(LENGTH_SIGN) 143 | 144 | data = Bytes.new(length) 145 | @io.read_fully(data) 146 | 147 | @position += length 148 | data 149 | 150 | rescue IO::EOFError 151 | raise "Premature end of string" 152 | end 153 | 154 | private def raise(message) 155 | ::raise Error.new(message, @position) 156 | end 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /src/torrent/bencode/mapping.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Bencode 3 | macro mapping(properties, strict = false) 4 | {% for key, value in properties %} 5 | {% if value.is_a?(NamedTupleLiteral) %} 6 | {% properties[key][:var] = key %} 7 | {% properties[key][:key] = key unless properties[key].keys.map(&.stringify).includes?("key") %} 8 | {% else %} 9 | {% properties[key] = { type: value, var: key, key: key } %} 10 | {% end %} 11 | 12 | {% end %} 13 | 14 | {% for key, value in properties %} 15 | property {{ key.id }} : {{ value[:type] }}{{ (value[:nilable] ? "?" : "").id }} 16 | {% end %} 17 | 18 | def initialize(%pull : Torrent::Bencode::PullParser) 19 | {% for key, value in properties %} 20 | %found_{key.id} = false 21 | %var_{key.id} = nil 22 | {% end %} 23 | 24 | %pull.read_dictionary do 25 | key = String.new(%pull) 26 | case key 27 | {% for key, value in properties %} 28 | when {{ value[:key].id.stringify }} 29 | %found_{key.id} = true 30 | %var_{key.id} = {% if value[:converter] %}{{ value[:converter] }}.from_bencode(%pull){% else %}{{ value[:type] }}.new(%pull){% end %} 31 | {% end %} 32 | else 33 | {% if strict %} 34 | ::raise Torrent::Bencode::Error.new("Unknown attribute '#{key}'") 35 | {% else %} 36 | %pull.read_and_discard 37 | {% end %} 38 | end 39 | end 40 | 41 | {% for key, value in properties %} 42 | if %found_{key.id} 43 | @{{ key.id }} = %var_{key.id}.not_nil! 44 | else 45 | {% if value[:default] != nil %} 46 | @{{ key.id }} = {{ value[:default] }} 47 | {% elsif !value[:nilable] %} 48 | ::raise Torrent::Bencode::Error.new("Missing attribute '{{ key }}'") 49 | {% end %} 50 | end 51 | {% end %} 52 | end 53 | 54 | # Creates an instance from the *input* 55 | def self.from_bencode(input : Bytes) 56 | from_bencode(IO::Memory.new(input)) 57 | end 58 | 59 | # ditto 60 | def self.from_bencode(input : IO) 61 | lexer = Torrent::Bencode::Lexer.new(input) 62 | parser = Torrent::Bencode::PullParser.new(lexer) 63 | new(parser) 64 | end 65 | 66 | # Writes the Bencoded data to *io* 67 | def to_bencode(io : IO) : IO 68 | io.print "d" 69 | 70 | {% mapping = { } of String => NamedTupleLiteral %} 71 | {% for key in properties.keys.sort %} 72 | {% mapping[properties[key][:key].id.stringify] = properties[key] %} 73 | {% end %} 74 | 75 | {% for key in mapping.keys.sort %} 76 | unless @{{ mapping[key][:var].id }}.nil? 77 | io.print "{{ key.id.size }}:{{ key.id }}" 78 | 79 | {% if mapping[key][:converter] %} 80 | {{ mapping[key][:converter] }}.to_bencode(@{{ mapping[key][:var].id }}.not_nil!, io) 81 | {% else %} 82 | @{{ mapping[key][:var].id }}.not_nil!.to_bencode(io) 83 | {% end %} 84 | end 85 | {% end %} 86 | 87 | io.print "e" 88 | io 89 | end 90 | end 91 | 92 | macro mapping(**properties) 93 | ::Torrent::Bencode.mapping({{ properties }}) 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /src/torrent/bencode/pull_parser.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Bencode 3 | class PullParser 4 | class Error < Bencode::Error; end 5 | 6 | @next_token : Token? 7 | 8 | def initialize(@lexer : Lexer) 9 | end 10 | 11 | def read_byte_slice : Bytes 12 | read_token(TokenType::ByteString).byte_value.not_nil! 13 | end 14 | 15 | def read_list 16 | read_token(TokenType::List) 17 | 18 | while tok = next_token_no_eof 19 | break if tok.type.end_marker? 20 | 21 | @next_token = tok 22 | yield 23 | end 24 | end 25 | 26 | def read_dictionary 27 | read_token(TokenType::Dictionary) 28 | 29 | while tok = next_token_no_eof 30 | break if tok.type.end_marker? 31 | 32 | @next_token = tok 33 | yield 34 | end 35 | end 36 | 37 | def read_integer : Int64 38 | read_token(TokenType::Integer).int_value 39 | end 40 | 41 | # Reads the following token, discarding it. 42 | # If it's a list or dictionary, the call is recursive. 43 | def read_and_discard 44 | tok = next_token 45 | if tok.type.dictionary? || tok.type.list? 46 | read_and_discard_till_end 47 | end 48 | end 49 | 50 | private def read_and_discard_till_end 51 | depth = 1 52 | 53 | while tok = next_token 54 | if tok.type.end_marker? 55 | depth -= 1 56 | break if depth == 0 57 | end 58 | 59 | depth += 1 if tok.type.dictionary? || tok.type.list? 60 | end 61 | end 62 | 63 | def next_token?(type) 64 | peek_token.type == type 65 | end 66 | 67 | def peek_token 68 | next_token.tap do |tok| 69 | @next_token = tok 70 | end 71 | end 72 | 73 | private def read_token(type) 74 | tok = next_token 75 | raise "Expected a #{type} token, but got a #{tok.type} instead near #{tok.position}" if tok.type != type 76 | tok 77 | end 78 | 79 | def next_token : Token 80 | buffered = @next_token 81 | if buffered 82 | @next_token = nil 83 | buffered 84 | else 85 | @lexer.next_token || raise "Premature end of token stream" 86 | end 87 | end 88 | 89 | private def next_token_no_eof : Token 90 | next_token.tap do |tok| 91 | raise "Unexpected EOF token" if tok.type.eof? 92 | end 93 | end 94 | 95 | private def raise(message) 96 | ::raise Error.new(message) 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /src/torrent/client.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Client 3 | 4 | # Base error class for connection errors 5 | class Error < Torrent::Error 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /src/torrent/client/http_tracker.cr: -------------------------------------------------------------------------------- 1 | require "./tracker" # Require order ... 2 | 3 | module Torrent 4 | module Client 5 | # Client for a Torrent Tracker to find peers for a specific torrent. 6 | class HttpTracker < Tracker 7 | # Builds a client for contacting the tracker at *url* 8 | def initialize(@url : URI) 9 | @log = Util::Logger.new("Tracker/#{@url.host}") 10 | end 11 | 12 | # Asks the tracker for peers of *transfer*. Raises on error. 13 | def get_peers(transfer : Torrent::Transfer) : Array(Structure::PeerInfo) 14 | @last_request = Time.now 15 | response = do_get build_announce_url(transfer) 16 | list = Structure::PeerList.from_bencode(response) 17 | 18 | @retry_interval = list.interval.to_i32 19 | @log.debug "Received #{list.peers.size} IPv4 and #{list.peers6.size} IPv6 peers" 20 | @log.debug "Retry interval is #{@retry_interval} seconds" 21 | 22 | list.peers + list.peers6 23 | end 24 | 25 | # Returns statistics of the given *info_hashes*. Raises on error. 26 | def scrape(info_hashes : Enumerable(Bytes)) : Structure::ScrapeResponse 27 | response = do_get build_scrape_url(info_hashes) 28 | Structure::ScrapeResponse.from_bencode(response) 29 | end 30 | 31 | private def do_get(url) 32 | @log.debug "GET #{url}" 33 | response = HTTP::Client.get(url) 34 | 35 | if response.status_code != 200 36 | raise Error.new("Request failed with HTTP code #{response.status_code}") 37 | end 38 | 39 | response.body.to_slice 40 | end 41 | 42 | private def build_announce_url(transfer) 43 | port = transfer.manager.port 44 | raise Error.new("Manager is not listening on a port") if port.nil? 45 | 46 | url = @url.dup 47 | url.query = HTTP::Params.encode({ 48 | peer_id: transfer.peer_id, 49 | info_hash: String.new(transfer.info_hash), 50 | port: port.to_s, 51 | uploaded: transfer.uploaded.to_s, 52 | downloaded: transfer.downloaded.to_s, 53 | left: transfer.left.to_s, 54 | event: status_string(transfer.status), 55 | compact: "1", 56 | }) 57 | 58 | # Note: We only support `compact` responses. Nowadays, trackers either 59 | # don't support the non-compact form at all, or respond with 60 | # invalid Bencoded data. (Looking at you, "mimosa") 61 | 62 | url 63 | end 64 | 65 | private def build_scrape_url(info_hashes) 66 | url = @url.dup 67 | # This is the official, documented way of doing it :) 68 | url.path = url.path.not_nil!.sub("announce", "scrape") 69 | url.query = HTTP::Params.build do |form| 70 | info_hashes.each do |hsh| 71 | form.add("info_hash", String.new(hsh)) 72 | end 73 | end 74 | 75 | url 76 | end 77 | 78 | private def status_string(status : Transfer::Status) : String 79 | case status 80 | when Transfer::Status::Stopped 81 | "stopped" 82 | when Transfer::Status::Running 83 | "started" 84 | when Transfer::Status::Completed 85 | "completed" 86 | else 87 | raise "Torrent::Client::Tracker#status_string is seriously broken" 88 | end 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /src/torrent/client/protocol.cr: -------------------------------------------------------------------------------- 1 | require "./wire" 2 | 3 | module Torrent 4 | module Client 5 | module Protocol 6 | struct Handshake < Util::NetworkOrderStruct(Wire::Handshake) 7 | NAME = "BitTorrent protocol" 8 | NAME_LEN = 19 9 | 10 | fields( 11 | length : UInt8, 12 | name : UInt8[NAME_LEN], 13 | reserved : UInt8[8], 14 | ) 15 | 16 | # Builds a new handshake packet 17 | def self.create(dht = false) 18 | wire = Wire::Handshake.new 19 | wire.length = NAME_LEN.to_u8 20 | NAME.to_slice.copy_to(wire.name.to_slice) 21 | wire.reserved[5] = 0x10u8 # Extension protocol 22 | 23 | # Fast Extension 24 | wire.reserved[7] = 0x04u8 25 | # DHT? 26 | wire.reserved[7] |= 0x01u8 if dht 27 | 28 | self.new(wire) 29 | end 30 | 31 | # Verifies the handshake packet. Raises if anything looks wrong. 32 | def verify! 33 | raise Error.new("Wrong length: #{length}") if length != NAME_LEN 34 | 35 | protocol = String.new(name.to_slice) 36 | raise Error.new("Wrong name: #{protocol.inspect}") if protocol != NAME 37 | end 38 | 39 | def extension_protocol? 40 | (reserved[5] & 0x10u8) != 0 41 | end 42 | 43 | def fast_extension? 44 | (reserved[7] & 0x04u8) != 0 45 | end 46 | end 47 | 48 | # Dummy structure to store the packet size 49 | struct PacketSize < Util::NetworkOrderStruct(Wire::PacketSize) 50 | fields(size : UInt32) 51 | end 52 | 53 | struct PacketPreamble < Util::NetworkOrderStruct(Wire::PacketPreamble) 54 | fields( 55 | size : UInt32, 56 | id : UInt8, 57 | ) 58 | end 59 | 60 | struct Have < Util::NetworkOrderStruct(Wire::Have) 61 | fields(piece : UInt32) 62 | end 63 | 64 | # Same payload 65 | alias SuggestPiece = Have 66 | 67 | struct Request < Util::NetworkOrderStruct(Wire::Request) 68 | fields( 69 | index : UInt32, 70 | start : UInt32, 71 | length : UInt32, 72 | ) 73 | end 74 | 75 | # Same payloads 76 | alias Cancel = Request 77 | alias RejectRequest = Request 78 | 79 | struct Piece < Util::NetworkOrderStruct(Wire::Piece) 80 | fields( 81 | index : UInt32, 82 | offset : UInt32, 83 | # Data ... 84 | ) 85 | end 86 | 87 | struct Extended < Util::NetworkOrderStruct(Wire::Extended) 88 | fields( 89 | message_id : UInt8, 90 | ) 91 | end 92 | 93 | struct DhtPort < Util::NetworkOrderStruct(Wire::DhtPort) 94 | fields( 95 | port : UInt16 96 | ) 97 | end 98 | 99 | # UDP tracker 100 | 101 | struct TrackerConnectRequest < Util::NetworkOrderStruct(Wire::TrackerConnectRequest) 102 | fields( 103 | connection_id : UInt64, 104 | action : Int32, # = TrackerAction::Connect 105 | transaction_id : UInt32, 106 | ) 107 | end 108 | 109 | struct TrackerConnectResponse < Util::NetworkOrderStruct(Wire::TrackerConnectResponse) 110 | fields( 111 | action : Int32, # = TrackerAction::Connect 112 | transaction_id : UInt32, 113 | connection_id : UInt64, 114 | ) 115 | end 116 | 117 | struct TrackerAnnounceRequest < Util::NetworkOrderStruct(Wire::TrackerAnnounceRequest) 118 | fields( 119 | connection_id : UInt64, 120 | action : Int32, # = TrackerAction::Announce 121 | transaction_id : UInt32, 122 | info_hash : UInt8[20], 123 | peer_id : UInt8[20], 124 | downloaded : UInt64, 125 | left : UInt64, 126 | uploaded : UInt64, 127 | event : Int32, 128 | ip_address : UInt32, # default: 0 129 | key : UInt32, 130 | num_want : Int32, # default: -1 131 | port : UInt16, 132 | ) 133 | end 134 | 135 | struct TrackerAnnounceResponse < Util::NetworkOrderStruct(Wire::TrackerAnnounceResponse) 136 | fields( 137 | action : Int32, # = TrackerAction::Announce 138 | transaction_id : UInt32, 139 | interval : Int32, 140 | leechers : Int32, 141 | seeders : Int32, 142 | # v4 addresses: 4 * x Bytes 143 | # v4 ports: 2 * x Bytes 144 | ) 145 | end 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /src/torrent/client/tcp_peer.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Client 3 | class TcpPeer < Peer 4 | 5 | # The peer IO 6 | getter socket : IPSocket 7 | 8 | # Has the handshake been sent? 9 | getter? handshake_sent : Bool 10 | 11 | @io : Util::AsyncIoChannel 12 | 13 | # Creates a peer through *socket*. 14 | def initialize(manager : Manager::Base, transfer : Torrent::Transfer?, @socket : IPSocket) 15 | super manager, transfer 16 | @handshake_sent = false 17 | @state = State::NotConnected 18 | @io = Util::AsyncIoChannel.new(@socket) 19 | @log.context = "TCP/#{@socket.remote_address}" 20 | 21 | # Expect the handshake packet first. 22 | set_state State::Handshake 23 | 24 | Cute.connect @io.error_occured, on_io_error_occured(error : Exception) 25 | @io.start 26 | end 27 | 28 | private def on_io_error_occured(error : Exception) 29 | @log.error "IO read/write error occured - Connection lost!" 30 | @log.error error 31 | connection_lost.emit 32 | end 33 | 34 | def address : String 35 | @socket.remote_address.address 36 | end 37 | 38 | def port : UInt16 39 | @socket.remote_address.port.to_u16 40 | end 41 | 42 | def run_once 43 | select 44 | when incoming = @io.read_channel.receive 45 | handle_data(incoming) 46 | when command = @command_channel.receive 47 | handle_command(command) 48 | end 49 | end 50 | 51 | def handle_command(command) 52 | case command 53 | when CloseCommand 54 | @log.info "Received close command, closing connection to peer." 55 | @io.close 56 | else 57 | raise Error.new("No implementation for command type #{command.class}") 58 | end 59 | end 60 | 61 | # Sends the handshake. Raises if one has already been sent. 62 | def send_handshake 63 | raise Error.new("Handshake already sent") if @handshake_sent 64 | @handshake_sent = true 65 | 66 | @log.debug "Sending handshake" 67 | 68 | # See `Torrent::Wire` documentation for an explanation of the handshake 69 | # packet flow. 70 | handshake = Protocol::Handshake.create(dht: @manager.use_dht?) 71 | @socket.write handshake.to_bytes # Step 1 72 | @socket.write transfer.info_hash # Step 2 73 | @socket.write transfer.peer_id.to_slice # Step 3 74 | 75 | @log.debug "Waiting for handshake response..." 76 | handshake_sent.emit 77 | # Okay! 78 | end 79 | 80 | # Processes *data*. Note that this data must have come from the read 81 | # channel, else assumption made may be wrong and things will break. 82 | def handle_data(data : Bytes) 83 | @bytes_received += data.size 84 | 85 | if @state.packet_size? 86 | handle_packet_size(data) 87 | elsif @state.packet? 88 | handle_packet(data) 89 | else 90 | handle_handshake(data) 91 | end 92 | end 93 | 94 | def send_data(data : Bytes) 95 | preamble = Protocol::PacketSize.new(data.size.to_u32) 96 | 97 | packet = Bytes.new(sizeof(Wire::PacketSize) + data.size) 98 | packet.copy_from(preamble.to_bytes) 99 | (packet + sizeof(Wire::PacketSize)).copy_from(data) 100 | 101 | @bytes_sent += packet.size 102 | @io.write_later packet 103 | end 104 | 105 | def send_data(&block : -> Bytes?) 106 | @io.write_later(&block) 107 | end 108 | 109 | def send_packet(packet_id : UInt8, payload : Bytes? = nil) 110 | payload = payload.to_bytes if payload.responds_to?(:to_bytes) 111 | size = payload.try(&.size) || 0 112 | preamble = Protocol::PacketPreamble.new( 113 | id: packet_id, 114 | size: size.to_u32 + 1, 115 | ) 116 | 117 | packet = Bytes.new(sizeof(Wire::PacketPreamble) + size) 118 | packet.copy_from(preamble.to_bytes) 119 | (packet + sizeof(Wire::PacketPreamble)).copy_from(payload) if payload 120 | 121 | @bytes_sent += packet.size 122 | @io.write_later packet 123 | end 124 | 125 | private def handle_handshake(data : Bytes) 126 | case @state 127 | when State::Connecting # Nothing 128 | set_state State::Handshake # Expect the handshake next 129 | when State::Handshake 130 | handshake = Protocol::Handshake.from(data) 131 | handshake.verify! 132 | 133 | @extension_protocol = handshake.extension_protocol? 134 | @fast_extension = handshake.fast_extension? 135 | 136 | @log.info "Peer supports extension protocol: #{@extension_protocol}" 137 | @log.info "Peer supports fast extensions: #{@fast_extension}" 138 | 139 | handshake_received.emit 140 | set_state State::InfoHash 141 | when State::InfoHash 142 | unless @manager.accept_info_hash?(data) 143 | raise Error.new("Unknown info hash: #{data.inspect}") 144 | end 145 | 146 | found = @manager.transfer_for_info_hash(data) 147 | if t = @transfer 148 | raise Error.new("Wrong info hash") if t != found 149 | end 150 | 151 | @transfer = found 152 | info_hash_received.emit(data) 153 | set_state State::PeerId 154 | when State::PeerId 155 | set_state State::PacketSize 156 | 157 | @remote_peer_id = data 158 | if data == transfer.peer_id.to_slice 159 | @log.error "Lets not connect to ourself" 160 | close 161 | end 162 | 163 | send_handshake unless handshake_sent? 164 | connection_ready.emit(data) 165 | 166 | if @extension_protocol 167 | @log.debug "Peer supports the extension protocol, sending handshake" 168 | @manager.extensions.send_handshake(self) 169 | end 170 | end 171 | end 172 | 173 | private def set_state(state, force = false) 174 | return if !force && state == @state 175 | 176 | @state = state 177 | case state 178 | when State::NotConnected # Nothing 179 | when State::Connecting # Nothing 180 | when State::Handshake 181 | @io.expect_block instance_sizeof(Wire::Handshake) 182 | when State::InfoHash 183 | @io.expect_block 20 184 | when State::PeerId 185 | @io.expect_block 20 186 | when State::PacketSize 187 | @io.expect_block sizeof(UInt32) 188 | when State::Packet 189 | # Callers responsibility to set the block size! 190 | end 191 | end 192 | 193 | private def handle_packet_size(data : Bytes) 194 | packet = Protocol::PacketSize.from(data) 195 | 196 | if packet.size == 0 197 | @log.debug "Received ping" 198 | set_state State::PacketSize, force: true 199 | ping_received.emit 200 | elsif packet.size > MAX_PACKET_SIZE 201 | @log.error "Peer tried to send packet of size #{packet.size} Bytes, but hard maximum is #{MAX_PACKET_SIZE} - Killing!" 202 | close 203 | else 204 | @io.expect_block packet.size 205 | set_state State::Packet 206 | end 207 | end 208 | 209 | private def handle_packet(data : Bytes) 210 | set_state State::PacketSize 211 | packet_id = data[0] 212 | payload = data + 1 213 | 214 | handle_packet packet_id, payload 215 | end 216 | end 217 | end 218 | end 219 | -------------------------------------------------------------------------------- /src/torrent/client/tracker.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Client 3 | # Client for a Torrent Tracker to find peers for a specific torrent. 4 | abstract class Tracker 5 | 6 | # If no otherwise given, the seconds to wait between peer fetches. 7 | DEFAULT_RETRY_INTERVAL = 300 8 | 9 | # The tracker url 10 | getter url : URI 11 | 12 | # The last time a request was made from us. Or `nil`, if never. 13 | getter last_request : Time? 14 | 15 | # Desired time between peer fetches 16 | getter retry_interval : Int32 = DEFAULT_RETRY_INTERVAL 17 | 18 | # Builds a client for contacting the tracker at *url* 19 | def initialize(@url : URI) 20 | @log = Util::Logger.new("Tracker/#{@url.host}") 21 | end 22 | 23 | # Returns `true` if the last request is longer ago than the retry interval 24 | def retry_due? 25 | last = @last_request 26 | return true if last.nil? # Return true if never requested 27 | 28 | retry_span = Time::Span.new(0, 0, @retry_interval) 29 | (last + retry_span) < Time.now 30 | end 31 | 32 | # Asks the tracker for peers of *transfer*. Raises on error. 33 | abstract def get_peers(transfer : Torrent::Transfer) : Array(Structure::PeerInfo) 34 | 35 | # Returns statistics of the given *info_hashes*. Raises on error. 36 | abstract def scrape(info_hashes : Enumerable(Bytes)) : Structure::ScrapeResponse 37 | 38 | # Factory method to create a `Tracker` instance for the *url*. 39 | def self.from_url(url : URI) : Tracker 40 | case url.scheme 41 | when "http", "https" 42 | return HttpTracker.new(url) 43 | when "udp" 44 | return UdpTracker.new(url) 45 | else 46 | raise ArgumentError.new("Unknown tracker protocol #{url.scheme.inspect}") 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /src/torrent/client/wire.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | 3 | # Structure for the wire protocol 4 | # 5 | # ## The BitTorrent protocol 6 | # 7 | # The connection handshake is as follows: 8 | # 1. Send the Handshake packet 9 | # 2. Send the SHA-1 hash of the bencoded `info` dictionary of the .torrent file 10 | # 3. Send the 20-Byte peer id as was given to the tracker 11 | # 4. The connection is now ready 12 | # 13 | # All of the above packets are sent by both peer to the other peer. 14 | # If any of the data makes no sense, the connection is closed. 15 | # 16 | # **Note:** A seeding peer may wait for the SHA-1 hash so it can react to 17 | # download requests while offering many files at once. 18 | # 19 | # At this point, peers can exchange messages. The format is: 20 | # 1. UInt32: Size of the following data 21 | # 2. UInt8: The message type (See `Wire::MessageType`) 22 | # 3. Following bytes are the message payload 23 | # 24 | # **Note:** The protocol uses network byte-order (Big-Endian). 25 | lib Wire 26 | 27 | # Peer messages 28 | enum MessageType : UInt8 29 | 30 | # No payload. 31 | Choke = 0 32 | 33 | # No payload. 34 | Unchoke = 1 35 | 36 | # No payload. 37 | Interested = 2 38 | 39 | # No payload. 40 | NotInterested = 3 41 | 42 | Have = 4 43 | Bitfield = 5 44 | Request = 6 45 | Piece = 7 46 | Cancel = 8 47 | 48 | # BEP-0005: DHT Protocol 49 | Port = 9 50 | 51 | # BEP-0006: Fast Extension 52 | 53 | SuggestPiece = 0x0D 54 | HaveAll = 0x0E 55 | HaveNone = 0x0F 56 | RejectRequest = 0x10 57 | AllowedFast = 0x11 58 | 59 | # BEP-0010 60 | Extended = 20 61 | end 62 | 63 | # Protocol handshake, is the first package sent and received. 64 | @[Packed] 65 | struct Handshake 66 | length : UInt8 # == 19 67 | name : UInt8[19] # "BitTorrent protocol" 68 | reserved : UInt8[8] # == 0 69 | end 70 | 71 | @[Packed] 72 | struct PacketSize 73 | size : UInt32 # Size of the following packet 74 | end 75 | 76 | @[Packed] 77 | struct PacketPreamble 78 | size : UInt32 # Size of the following packet 79 | id : UInt8 80 | end 81 | 82 | @[Packed] 83 | struct Have 84 | piece : UInt32 85 | end 86 | 87 | @[Packed] 88 | struct Request 89 | index : UInt32 90 | start : UInt32 91 | length : UInt32 92 | end 93 | 94 | @[Packed] 95 | struct Piece 96 | index : UInt32 97 | offset : UInt32 98 | # data ... 99 | end 100 | 101 | @[Packed] 102 | struct Extended 103 | message_id : UInt8 104 | end 105 | 106 | @[Packed] 107 | struct DhtPort 108 | port : UInt16 109 | end 110 | 111 | # UDP tracker protocol 112 | enum TrackerAction : Int32 113 | Connect = 0 114 | Announce = 1 115 | Scrape = 2 116 | Error = 3 117 | end 118 | 119 | enum TrackerEvent : Int32 120 | None = 0 121 | Completed = 1 122 | Started = 2 123 | Stopped = 3 124 | end 125 | 126 | @[Packed] 127 | struct TrackerConnectRequest 128 | connection_id : UInt64 129 | action : Int32 # = TrackerAction::Connection 130 | transaction_id : UInt32 131 | end 132 | 133 | @[Packed] 134 | struct TrackerConnectResponse 135 | action : Int32 # = TrackerAction::Connection 136 | transaction_id : UInt32 137 | connection_id : UInt64 138 | end 139 | 140 | @[Packed] 141 | struct TrackerAnnounceRequest 142 | connection_id : UInt64 143 | action : Int32 # = TrackerAction::Announce 144 | transaction_id : UInt32 145 | info_hash : UInt8[20] 146 | peer_id : UInt8[20] 147 | downloaded : UInt64 148 | left : UInt64 149 | uploaded : UInt64 150 | event : Int32 151 | ip_address : UInt32 # default: 0 152 | key : UInt32 # default: 0 153 | num_want : Int32 # default: -1 154 | port : UInt16 155 | end 156 | 157 | @[Packed] 158 | struct TrackerAnnounceResponse 159 | action : Int32 # = TrackerAction::Announce 160 | transaction_id : UInt32 161 | interval : Int32 162 | leechers : Int32 163 | seeders : Int32 164 | # v4 addresses: 4 * x Bytes 165 | # v4 ports: 2 * x Bytes 166 | end 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /src/torrent/dht/bucket.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Dht 3 | # Contains known nodes 4 | class Bucket 5 | 6 | # Max nodes per bucket 7 | MAX_NODES = 8 8 | 9 | # Range of the smallest possible bucket 10 | SMALLEST_BUCKET = MAX_NODES 11 | 12 | # Range of nodes this bucket accepts 13 | property range : Range(BigInt, BigInt) 14 | 15 | # Time of the last bucket refresh 16 | property last_refresh : Time 17 | 18 | # Nodes in this bucket. Do not write to this directly. 19 | getter nodes : Array(Node) 20 | 21 | def initialize(@range) 22 | @nodes = Array(Node).new(MAX_NODES) 23 | @last_refresh = Time.now 24 | end 25 | 26 | protected def initialize(@range, @nodes, @last_refresh) 27 | end 28 | 29 | # Count of nodes in the bucket 30 | def node_count 31 | @nodes.size 32 | end 33 | 34 | # Is the bucket full? 35 | def full? 36 | @nodes.size >= MAX_NODES 37 | end 38 | 39 | # Returns `true` if *node* is a known node 40 | def includes?(node : Node) : Bool 41 | includes? node.id 42 | end 43 | 44 | # Returns `true` if *id* is a known node in the bucket. 45 | def includes?(id : BigInt) 46 | !find_node(id).nil? 47 | end 48 | 49 | # Finds a node by *id* 50 | def find_node(id : BigInt) : Node? 51 | @nodes.find{|node| node.id == id} 52 | end 53 | 54 | # Returns `true` if this bucket can be split 55 | def splittable? : Bool 56 | (range.end - range.begin) > SMALLEST_BUCKET 57 | end 58 | 59 | # Splits this bucket into two new ones. 60 | def split : Tuple(Bucket, Bucket) 61 | left = Array(Node).new(MAX_NODES) 62 | right = Array(Node).new(MAX_NODES) 63 | 64 | middle = calculate_middle 65 | l_range = range.begin...middle 66 | r_range = middle...range.end 67 | 68 | @nodes.each do |node| # Redistribute our nodes 69 | next if node.nil? 70 | if node.id > middle 71 | right << node 72 | else 73 | left << node 74 | end 75 | end 76 | 77 | { Bucket.new(l_range, left, @last_refresh), Bucket.new(r_range, right, @last_refresh) } 78 | end 79 | 80 | delegate covers?, :begin, :end, to: @range 81 | 82 | # Adds *node* into the bucket. Raises if the bucket is full. 83 | def add(node : Node) 84 | raise ArgumentError.new("Bucket is full") if full? 85 | raise IndexError.new("Node id not in bucket range") unless covers?(node.id) 86 | 87 | unless @nodes.includes?(node) 88 | @nodes << node 89 | @nodes.sort! 90 | end 91 | end 92 | 93 | # Removes *node* from the bucket. 94 | def delete(node : Node) 95 | @nodes.delete node 96 | end 97 | 98 | # Checks if a node *id* were added to the bucket, if it could split to 99 | # make space for it. Does not do the `#splittable?` and `#full?` checks! 100 | # 101 | # A split does not make sense if the bucket has only nodes in the left 102 | # or right side of it and the added node would fall into the same, 103 | # assuming that this bucket is currently full. 104 | # 105 | # **Note:** You still have to make sure that this processes node id lies 106 | # inside this bucket. Else, a split never occurs. 107 | def should_split?(id : BigInt) 108 | middle = calculate_middle 109 | 110 | if id <= middle 111 | !@nodes.all?{|node| node && node.id <= middle} 112 | else 113 | !@nodes.all?{|node| node && node.id > middle} 114 | end 115 | end 116 | 117 | # Refreshes the bucket. This means that all nodes, which we've not seen 118 | # for 15 minutes, will be pinged. If they respond everything is fine. 119 | # If they do not, their health will worsen. If their health goes 120 | # `Node::Health::Bad`, the node will be evicted from the bucket. 121 | # 122 | # This basically means that an unhealthy node is given two chances over 123 | # a period of 15 minutes to stabilize again. 124 | # 125 | # If however the "ping" invocation raises an error the node will be 126 | # evicted right away. This happens if the node changed its node id. 127 | def refresh(this_nodes_id) : Nil 128 | @nodes.select! do |node| 129 | if node.refresh_if_needed(this_nodes_id) 130 | true 131 | else 132 | node.close 133 | false 134 | end 135 | end 136 | end 137 | 138 | private def calculate_middle 139 | (@range.end - @range.begin) / 2 + @range.begin 140 | end 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /src/torrent/dht/default_rpc_methods.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Dht 3 | # Implementations of the RPC methods per BEP-0005. 4 | # You usually don't use this class directly, the `Dht::Manager` does it for 5 | # you. 6 | class DefaultRpcMethods 7 | def initialize(@manager : Manager, @dispatcher : Dispatcher, @log : Util::Logger) 8 | @my_id = Bencode::Any.new(Util::Gmp.export_sha1 @manager.nodes.node_id) 9 | end 10 | 11 | # Adds all default BitTorrent DHT methods 12 | def add_all 13 | add_ping 14 | add_find_node 15 | add_get_peers 16 | add_announce_peer 17 | end 18 | 19 | # Adds the "ping" method. All it does is to send response containing this 20 | # nodes id. 21 | def add_ping 22 | @dispatcher.add "ping" do |node, query| 23 | @log.info "Received ping from #{node.remote_address}" 24 | try_initialize_node node, query 25 | node.send query.response({ "id" => @my_id }) 26 | end 27 | end 28 | 29 | def add_find_node 30 | @dispatcher.add "find_node" do |node, query| 31 | try_initialize_node node, query 32 | target = Util::Gmp.import_sha1(query.args["target"].to_slice) 33 | @log.info "Received find_node from #{node.remote_address} for #{target}" 34 | nodes = @manager.nodes.closest_nodes(target) 35 | compact = NodeAddress.to_compact nodes.map(&.to_address).to_a 36 | 37 | node.send query.response({ 38 | "id" => @my_id, 39 | "nodes" => Bencode::Any.new(compact), 40 | }) 41 | end 42 | end 43 | 44 | def add_get_peers 45 | @dispatcher.add "get_peers" do |node, query| 46 | try_initialize_node node, query 47 | target = Util::Gmp.import_sha1 query.args["info_hash"].to_slice 48 | node.remote_token = token = Util::Random.bytes(4) 49 | 50 | response = query.response({ 51 | "id" => @my_id, 52 | "token" => Bencode::Any.new(token), 53 | }) 54 | 55 | if peers = @manager.peers.to_a.bsearch(&.info_hash.==(target)) 56 | response.data["values"] = Bencode::Any.new(peers.any_sample) 57 | else 58 | nodes = @manager.nodes.closest_nodes(target) 59 | compact = NodeAddress.to_compact nodes.map(&.to_address).to_a 60 | response.data["nodes"] = Bencode::Any.new(compact) 61 | end 62 | 63 | node.send response 64 | end 65 | end 66 | 67 | def add_announce_peer 68 | @dispatcher.add "announce_peer" do |node, query| 69 | token = query.args["token"]?.try(&.to_slice) 70 | 71 | if node.remote_token != token 72 | @log.warn "Node #{node.remote_address} announced peer, but given token #{token.inspect} does not match #{node.remote_token.inspect}" 73 | node.send query.error(ErrorCode::Generic, "Wrong Token") 74 | else 75 | add_announced_peer(node, query) 76 | node.send query.response({ "id" => @my_id }) 77 | end 78 | end 79 | end 80 | 81 | private def try_initialize_node(node, query) 82 | node_id = Util::Gmp.import_sha1(query.args["id"].to_slice) 83 | 84 | if node.id == -1 # New node? 85 | node.id = node_id 86 | @log.info "New node, id: #{node_id}" 87 | elsif node.id != node_id 88 | @log.warn "Wrong node id presented by node #{node.remote_address}" 89 | raise QueryHandlerError.new(201, "Wrong Id") 90 | end 91 | end 92 | 93 | private def add_announced_peer(node, query) 94 | port = query.args["port"].to_i 95 | implied = query.args["implied_port"]?.try(&.to_b) 96 | port = node.remote_address.port if implied 97 | info_hash = query.args["info_hash"].to_slice 98 | address = node.remote_address.address 99 | 100 | @manager.add_torrent_peer(node, info_hash, address, port.to_u16) 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /src/torrent/dht/dispatcher.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Dht 3 | # RPC dispatcher used to call named methods on behalf of remote nodes. 4 | class Dispatcher 5 | 6 | # Function handler used by `Dispatcher#add`. 7 | # See `Dispatcher#add` for further information. 8 | alias Method = Proc(Node, Structure::Query, Nil) 9 | 10 | # All methods the dispatcher knows about 11 | getter methods : Hash(String, Method) 12 | 13 | def initialize 14 | @methods = Hash(String, Method).new 15 | @log = Util::Logger.new("Dht/Dispatcher") 16 | end 17 | 18 | # Adds *method* to the dispatcher. The *func* is called whenever a 19 | # request comes in for *method*. The *func* shall then do the required 20 | # work, and send a response through `Node#send` by itself. 21 | # 22 | # If the *func* raises an exception, an error response is automatically 23 | # sent to the remote node. See `QueryHandlerError` to modify the sent 24 | # error data. Every other exception is signaled as server error. 25 | # 26 | # See also `Structure::Query#response` and `Structure::Query#error` to 27 | # easily build response messages out of the passed query in *func*. 28 | def add(method : String, func : Method) 29 | @methods[method] = func 30 | end 31 | 32 | # ditto 33 | def add(method : String, &block : Method) 34 | add method, block 35 | end 36 | 37 | # Invokes a method from *node* via *query*. Returns `true` if the method 38 | # has been called, else `false` is returned. 39 | # 40 | # **Note**: This method does not catch exceptions by itself. In general, 41 | # you'll probably want to use `#remote_invocation` over this method. 42 | def invoke_method(node : Node, query : Structure::Query) : Bool 43 | func = @methods[query.method]? 44 | return false if func.nil? 45 | func.call node, query 46 | true 47 | end 48 | 49 | # Invokes *query* on behalf of *node*. Sends an error response if the 50 | # called method was not found. Returns `true` on success, or `false` if 51 | # no method for *query* was found or if the called handler raised an 52 | # exception. 53 | # 54 | # **Warning**: If the handler sends an error response by itself but does 55 | # not raise, the method returns `true`. 56 | def remote_invocation(node : Node, query : Structure::Query) : Bool 57 | result = invoke_method node, query 58 | 59 | unless result 60 | @log.error "Node #{node.remote_address} invoked unknown method #{query.method.inspect} with #{query.args.size} arguments" 61 | node.send query.error(ErrorCode::MethodUnknown) 62 | end 63 | 64 | result 65 | rescue error 66 | @log.error "Error while handling invocation of #{query.method.inspect}: (#{error.class}) #{error}" 67 | @log.error error 68 | 69 | if error.is_a? QueryHandlerError 70 | node.send query.error(error.code, error.public_message) 71 | else 72 | node.send query.error(ErrorCode::Server) 73 | end 74 | 75 | false 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /src/torrent/dht/error.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Dht 3 | # DHT specific errors 4 | class Error < Torrent::Error 5 | end 6 | 7 | # Error during a `Node#remote_call!` 8 | class CallError < Error 9 | end 10 | 11 | # A call timed-out 12 | class CallTimeout < CallError 13 | end 14 | 15 | # A call returned an error 16 | class RemoteCallError < CallError 17 | getter data : Structure::Error 18 | 19 | def initialize(method, @data) 20 | super "Remote call error to #{method.inspect}: #{@data.code} - #{@data.message}" 21 | end 22 | end 23 | 24 | # An error in a DHT query handler. Can be thrown to easily set the replied 25 | # error code and message. 26 | class QueryHandlerError < Error 27 | # The DHT error code 28 | getter code : Int32 29 | 30 | # The DHT error message 31 | getter public_message : String 32 | 33 | def initialize(@code : Int32, @public_message : String) 34 | super "#{@code}: #{@public_message}" 35 | end 36 | 37 | def initialize(code : ErrorCode, public_message : String? = nil) 38 | @code = code.value 39 | @public_message = public_message || ErrorCode.message(code) 40 | super "#{@code}: #{@public_message}" 41 | end 42 | 43 | def initialize(message, code : ErrorCode, public_message : String? = nil) 44 | @code = code.value 45 | @public_message = public_message || ErrorCode.message(code) 46 | super "#{message} (#{@code}: #{@public_message}" 47 | end 48 | 49 | def initialize(message, @code : Int32, @public_message : String) 50 | super "#{message} (#{@code}: #{@public_message}" 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /src/torrent/dht/error_code.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Dht 3 | # Error codes for `Node#send_error` 4 | enum ErrorCode : Int32 5 | 6 | # Generic error 7 | Generic = 201 8 | 9 | # An error was encountered while handling the otherwise valid packet 10 | Server = 202 11 | 12 | # The packet is invalid or otherwise malformed 13 | Protocol = 203 14 | 15 | # The called method is unknown 16 | MethodUnknown = 204 17 | 18 | def self.message(code : ErrorCode) : String 19 | case code 20 | when ErrorCode::Generic 21 | "A Generic Error Occured" 22 | when ErrorCode::Server 23 | "A Server Error Occured" 24 | when ErrorCode::Protocol 25 | "The Packet Was Malformed" 26 | when ErrorCode::MethodUnknown 27 | "The Called Method Is Unknown" 28 | else 29 | raise "#error_code_message is broken" 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /src/torrent/dht/finder.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Dht 3 | # Class to find entities (nodes, peers, ...) in the DHT. 4 | abstract class Finder(T) 5 | # Count of parallel in-flight requests. 3 is the Kademlia default. 6 | ALPHA = 3 7 | 8 | # The find result. 9 | property result : T? = nil 10 | 11 | def initialize(@manager : Dht::Manager) 12 | @log = Util::Logger.new("Dht/Finder(#{T})") 13 | @candidates = Util::KademliaList(NodeAddress).new(0.to_big_i, Bucket::MAX_NODES * ALPHA) 14 | @seen = Array(NodeAddress).new 15 | end 16 | 17 | # Runs the finder, looking for *hash*. 18 | def run(hash : Bytes) : T? 19 | run(Util::Gmp.import_sha1 hash) 20 | end 21 | 22 | # ditto 23 | def run(hash : BigInt) : T? 24 | @candidates.clear(hash) 25 | @seen.clear 26 | 27 | @manager.nodes.closest_nodes(hash).each do |node| 28 | address = node.to_address 29 | @candidates << address 30 | @seen << address 31 | end 32 | 33 | # recurse_find(hash, nodes, min_distance(nodes, hash)) 34 | iterative_find(hash) 35 | end 36 | 37 | # Called in its own `Fiber`, this method shall query *node* to look for 38 | # *hash*. The method returns an array of `NodeAddress`es to ask next. 39 | # 40 | # The easiest way of ending the search is to set `#result` to something 41 | # non-nil. The default `#finished?` implementation will then return the 42 | # value back to the user of the class. A `nil` result is treated like 43 | # an empty array result. 44 | abstract def query_node(node : Node, hash : BigInt) : Array(NodeAddress)? 45 | 46 | # Called by `#recurse_find` to check if the search has finished. It 47 | # returns first if the find has finished (= `true`) or not (= `false`), 48 | # and then the (nilable) result itself. 49 | # 50 | # The default implementation uses the `#result` property, and declares the 51 | # find process finished if it's no longer `nil`. 52 | def finished? : Tuple(Bool, T?) 53 | { @result != nil, @result } 54 | end 55 | 56 | # FIXME 57 | # This method is ugly, but I don't think right now that having multiple 58 | # methods would actually improve the readability on this one. 59 | private def iterative_find(hash : BigInt) : T? 60 | result_channel = Channel(T?).new 61 | heartbeat = Channel(NodeAddress?).new 62 | stop = Channel(Nil).new 63 | running = true 64 | 65 | concurrency = Math.min(ALPHA, @candidates.size) 66 | concurrency.times do |idx| 67 | Util.spawn("Finder fiber##{idx}") do 68 | while running && !@candidates.empty? 69 | done, result, closest = iterate_find_step(@candidates.shift, hash) 70 | @log.debug "Now have #{@candidates.size} candidates, total seen #{@seen.size}" 71 | break unless running 72 | 73 | heartbeat.send closest 74 | 75 | if done 76 | @log.debug "Found result" 77 | stop.send nil 78 | result_channel.send result 79 | end 80 | end 81 | 82 | concurrency -= 1 83 | heartbeat.send nil if running 84 | @log.debug "Exiting finder fiber #{idx}: #{running} #{@candidates.empty?}" 85 | end 86 | end 87 | 88 | Util.spawn("Finder control fiber") do 89 | new_best = Array(NodeAddress?).new 90 | current_best = nil 91 | 92 | while running 93 | select 94 | when node = heartbeat.receive 95 | new_best << node 96 | 97 | if new_best.size == concurrency 98 | challenger = closest_node(new_best.compact, hash) 99 | new_best.clear 100 | 101 | if challenger == current_best 102 | @log.debug "Found no closer node - Breaking search" 103 | result_channel.send nil 104 | break 105 | end 106 | 107 | current_best = challenger 108 | end 109 | when stop.receive 110 | @log.debug "Stopping control fiber" 111 | break 112 | end 113 | end 114 | 115 | @log.debug "Exiting control fiber" 116 | end 117 | 118 | start = Time.now 119 | result = result_channel.receive 120 | @log.info "Finished search after #{Time.now - start}" 121 | running = false 122 | result 123 | end 124 | 125 | private def iterate_find_step(node_addr, hash) : Tuple(Bool, T?, NodeAddress?) 126 | @log.debug "Querying node at #{node_addr.address} #{node_addr.port}" 127 | node = @manager.find_or_connect_node(node_addr, ping: false).not_nil! 128 | nodes = query_node(node, hash) # Ask the node 129 | @manager.nodes.try_add(node) 130 | learn_nodes nodes if nodes 131 | 132 | done, result = finished? # Are we there yet? 133 | return { done, result, nil } if done 134 | 135 | # Remember candidates, repeat. 136 | if nodes.try(&.empty?) == false 137 | closest = closest_node(nodes, hash) 138 | 139 | nodes 140 | .reject{|node| @seen.includes? node} 141 | .each do |node| 142 | @candidates << node 143 | @seen << node 144 | end 145 | end 146 | 147 | { false, nil, closest } 148 | rescue error 149 | @log.error "Failed to step" 150 | @log.error error 151 | { false, nil, closest } 152 | end 153 | 154 | private def closest_node(nodes, hash) 155 | nodes.min_by?(&.kademlia_distance(hash)) 156 | end 157 | 158 | private def learn_nodes(nodes : Enumerable(NodeAddress)) : Nil 159 | Util.spawn("Node learner") do 160 | nodes.each{|addr| learn_node addr} 161 | end 162 | end 163 | 164 | private def learn_node(addr) 165 | return unless @manager.nodes.would_accept? addr.id 166 | node = @manager.find_or_connect_node(addr, ping: true) 167 | node.close if node && !@manager.nodes.includes?(node) 168 | rescue error 169 | @log.error "Failed to learn node at #{addr.address} #{addr.port}" 170 | @log.error error 171 | end 172 | end 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /src/torrent/dht/node_address.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Dht 3 | lib Native 4 | @[Packed] 5 | struct V4 6 | node_id : UInt8[20] 7 | address : UInt8[4] 8 | port : UInt16 9 | end 10 | end 11 | 12 | # Stores the remote address and the remote nodes id. Can be used to send 13 | # node information over the network, or to persist (and later load again) 14 | # it. 15 | class NodeAddress 16 | 17 | # The network address 18 | getter address : String 19 | 20 | # The network port 21 | getter port : UInt16 22 | 23 | # The remote nodes id 24 | getter id : BigInt 25 | 26 | # If the address uses IPv6, uses IPv4 if `false` 27 | getter? v6 : Bool 28 | 29 | def initialize(@address, @port, @id, @v6) 30 | end 31 | 32 | def_equals_and_hash @address, @port, @id 33 | 34 | # Reads the node address from its "compact" representation 35 | def initialize(native : Native::V4) 36 | @address = native.address.join '.' 37 | @port = Util::Endian.to_host native.port 38 | @id = Util::Gmp.import_sha1 native.node_id.to_slice 39 | @v6 = false 40 | end 41 | 42 | # Kademlia distance based on the `#id`. 43 | def kademlia_distance(other : BigInt) 44 | @id ^ other 45 | end 46 | 47 | # Returns the address as compact representation 48 | def to_native : Native::V4 49 | v4 = Native::V4.new 50 | parts = @address.split('.').map(&.to_u8).to_a 51 | v4.address = StaticArray[ parts[0], parts[1], parts[2], parts[3] ] 52 | 53 | exported = Util::Gmp.export_sha1 @id 54 | v4.node_id.to_slice.copy_from exported 55 | v4 56 | end 57 | 58 | # Loads multiple addresses from a slice of concatenated compact 59 | # representations. 60 | def self.from_compact(slice : Bytes) : Array(NodeAddress) 61 | raise ArgumentError.new("Slice size not divisble by 26") unless slice.size.divisible_by? 26 62 | 63 | count = slice.size / 26 64 | ptr = slice.pointer(slice.size).as(Native::V4*) 65 | natives = Slice(Native::V4).new(ptr, count) 66 | 67 | Array(NodeAddress).new(count){|idx| NodeAddress.new natives[idx]} 68 | end 69 | 70 | # Stores a *list* of node addresses into a concatenated compact byte 71 | # slice. 72 | def self.to_compact(list : Indexable(NodeAddress)) : Bytes 73 | natives = Slice(Native::V4).new(list.size) do |idx| 74 | list[idx].to_native 75 | end 76 | 77 | ptr = natives.pointer(natives.size).as(UInt8*) 78 | Bytes.new(ptr, list.size * 26) 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /src/torrent/dht/node_finder.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Dht 3 | class NodeFinder < Finder(Node) 4 | def query_node(node : Node, hash : BigInt) : Array(NodeAddress)? 5 | nodes = node.find_node(@manager.nodes.node_id, hash, timeout: 10.seconds) 6 | 7 | if found = nodes.find(&.id.==(hash)) 8 | @result = @manager.create_outgoing_node(found) 9 | end 10 | 11 | nodes 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /src/torrent/dht/node_list.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Dht 3 | class NodeList 4 | 5 | # Ping a node after this time and remove it if it doesn't respond 6 | NODE_TIMEOUT = 15.minutes 7 | 8 | # Ping each node in a bucket after this time 9 | BUCKET_REFRESH_INTERVAL = 15.minutes 10 | 11 | # Range of valid node ids. 12 | NODE_ID_RANGE = 0.to_big_i...2.to_big_i**160 13 | 14 | # Initial count of buckets 15 | INITIAL_CAPACITY = 1 16 | 17 | # Emitted when a node was added to the list 18 | Cute.signal node_added(node : Node) 19 | 20 | # Emitted when a node was removed from the list 21 | Cute.signal node_removed(node : Node) 22 | 23 | # Emitted when a node has been rejected. 24 | Cute.signal node_rejected(node : Node) 25 | 26 | # Buckets in the node list 27 | getter buckets : Array(Bucket) 28 | 29 | # Id of this peer 30 | getter node_id : BigInt 31 | 32 | # The *node_id* is this nodes id, and must be in range of `NODE_ID_RANGE`. 33 | def initialize(node_id : Int) 34 | @node_id = node_id.to_big_i 35 | 36 | unless NODE_ID_RANGE.includes? @node_id 37 | raise ArgumentError.new("Given node id is out of range") 38 | end 39 | 40 | @buckets = Array(Bucket).new(INITIAL_CAPACITY) 41 | @buckets << Bucket.new(NODE_ID_RANGE) 42 | end 43 | 44 | # Returns `true` if the list knows of a node *node_id*. 45 | def includes?(node_id : BigInt) 46 | find_bucket(node_id).includes?(node_id) 47 | end 48 | 49 | # Returns `true` if the list knows of the *node* by its id. 50 | def includes?(node : Node) 51 | find_bucket(node.id).includes?(node.id) 52 | end 53 | 54 | # Tries to add *node*. *node* must be fully initialized. Returns `true` 55 | # if the node was accepted, else `false` is returned. If the node is 56 | # already known, `false` is returned. 57 | def try_add(node : Node) : Bool 58 | return false if node.id == @node_id 59 | bucket = find_bucket(node.id) 60 | 61 | if bucket.includes?(node) 62 | node_rejected.emit node 63 | return false 64 | end 65 | 66 | if bucket.full? 67 | bucket = try_split(bucket, node.id) 68 | if bucket.nil? 69 | node_rejected.emit node 70 | return false 71 | end 72 | end 73 | 74 | bucket.add node 75 | node_added.emit(node) 76 | true 77 | end 78 | 79 | # Checks if a node with *node_id* would be accepted or flat out be 80 | # rejected. 81 | def would_accept?(node_id : BigInt) : Bool 82 | return false if node_id == @node_id 83 | bucket = find_bucket(node_id) 84 | return false if bucket.includes?(node_id) 85 | 86 | if bucket.full? 87 | do_split?(bucket, node_id) 88 | else 89 | true 90 | end 91 | end 92 | 93 | private def do_split?(bucket, node_id : BigInt) : Bool 94 | return false unless bucket.covers?(@node_id) 95 | return false unless bucket.splittable? 96 | return false unless bucket.should_split?(node_id) 97 | true 98 | end 99 | 100 | private def try_split(bucket, node_id) : Bucket? 101 | return nil unless do_split? bucket, node_id 102 | left, right = do_split(bucket, node_id) 103 | left.covers?(node_id) ? left : right 104 | end 105 | 106 | private def do_split(bucket, node_id) : Tuple(Bucket, Bucket) 107 | left, right = bucket.split 108 | idx = @buckets.index(bucket) 109 | 110 | raise IndexError.new("#do_split is broken") if idx.nil? 111 | @buckets[idx] = right 112 | @buckets.insert(idx, left) 113 | { left, right } 114 | end 115 | 116 | # Returns the `Node` if found, else `nil` is returned. 117 | def find_node(remote : Socket::IPAddress) : Node? 118 | @buckets.each do |bucket| 119 | bucket.nodes.each do |node| 120 | return node if node.remote_address == remote 121 | end 122 | end 123 | 124 | nil 125 | end 126 | 127 | # Returns the `Node` if found. 128 | def find_node(node_id : BigInt) : Node? 129 | find_bucket(node_id).find_node(node_id) 130 | end 131 | 132 | # Removes the *node* from the list 133 | def delete(node : Node) 134 | id = node.id 135 | return if id < 0 136 | 137 | find_bucket(id).delete(node) 138 | end 139 | 140 | # Returns the count of all nodes in the list. 141 | def count 142 | @buckets.map(&.node_count).sum 143 | end 144 | 145 | # Returns the `Bucket` which is responsible for *id*. This does not mean 146 | # a node is known by this *id*. Only fails with an `IndexError` if the 147 | # *id* falls outside the legal range. 148 | def find_bucket(id : BigInt) : Bucket 149 | raise IndexError.new("Node id is out of range: #{id.inspect}") unless NODE_ID_RANGE.includes?(id) 150 | # @buckets.bsearch{|bucket| bucket.covers? id}.not_nil! 151 | @buckets.find{|bucket| bucket.covers? id}.not_nil! 152 | end 153 | 154 | # Returns an array of nodes near *hash*. The distance function is the 155 | # one defined by Kademlia: `distance(A,B) = |A xor B|`, where **A** is the 156 | # hash and **B** is the currently compared nodes id. 157 | def closest_nodes(hash : BigInt, count = Bucket::MAX_NODES) : Array(Node) 158 | list = Util::KademliaList(Node).new(hash, count) 159 | each_node{|node| list << node} 160 | list.to_a 161 | end 162 | 163 | # Refreshes all buckets 164 | def refresh_buckets : Nil 165 | @buckets.each(&.refresh(@node_id)) 166 | end 167 | 168 | # Yields each `Node` in the node list. 169 | def each_node : Nil 170 | @buckets.each do |bucket| 171 | bucket.nodes.each{|node| yield node} 172 | end 173 | end 174 | 175 | # Yields each `Node` with the `Bucket` it is in as second argument. 176 | def each_node_with_bucket : Nil 177 | @buckets.each do |bucket| 178 | bucket.nodes.each{|node| yield node, bucket} 179 | end 180 | end 181 | 182 | # Helper method which prints a readable dump of all buckets and nodes 183 | # inside it. The block is called for every output line. 184 | def debug_dump(&block : String -> Nil) 185 | block.call "-- Node list dump --" 186 | block.call " This nodes id: #{Util::Gmp.export_sha1(node_id).hexstring}" 187 | block.call " #{count} Nodes in #{@buckets.size} Buckets:" 188 | 189 | @buckets.each do |bucket| 190 | block.call " - #{bucket.node_count} / #{Bucket::MAX_NODES}: #{bucket.range} #{"(self)" if bucket.covers? @node_id}" 191 | bucket.nodes.each do |node| 192 | block.call " + #{node.remote_address} => #{Util::Gmp.export_sha1(node.id).hexstring} [#{node.rtt}]" 193 | end 194 | end 195 | 196 | block.call "-- Dump end --" 197 | end 198 | end 199 | end 200 | end 201 | -------------------------------------------------------------------------------- /src/torrent/dht/peer_list.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Dht 3 | # Stores known peers for 4 | class PeerList 5 | alias PeerData = Torrent::Structure::PeerInfoConverter::Native::Data 6 | 7 | # Max count of peers per list. 8 | MAX_PEERS = 1000 9 | 10 | # Timeouts for peers in the list. 11 | PEER_TIMEOUT = 45.minutes 12 | 13 | # Peers in compact format, ready to be sent out. 14 | getter peers : Array(Tuple(Time, Bytes)) 15 | 16 | # The info hash 17 | getter info_hash : BigInt 18 | 19 | def initialize(@info_hash : BigInt) 20 | short = Util::Gmp.export_sha1(@info_hash)[0, 10] 21 | @log = Util::Logger.new("Dht/PeerList #{short.hexstring}") 22 | @peers = Array(Tuple(Time, Bytes)).new 23 | end 24 | 25 | def kademlia_distance(other) 26 | @info_hash ^ other 27 | end 28 | 29 | def any_sample(count = Bucket::MAX_NODES * 2) 30 | @peers.sample(count).map do |_ign, native| 31 | Bencode::Any.new(native) 32 | end.to_a 33 | end 34 | 35 | # Checks for timeouts, removing those which we have not seen in a while. 36 | def check_timeouts 37 | timeout_at = Time.now - PEER_TIMEOUT 38 | @peers.reject!{|time, _ign| time < timeout_at} 39 | end 40 | 41 | # Adds (or updates) a peer at *address:port* 42 | def add(address : String, port : UInt16) : Nil 43 | return if peers.size >= MAX_PEERS # Reject if list is full. 44 | 45 | native = Torrent::Structure::PeerInfoConverter.to_native(address, port) 46 | bytes = Bytes.new(sizeof(PeerData)) 47 | bytes.copy_from(pointerof(native).as(UInt8*), sizeof(PeerData)) 48 | 49 | if idx = peers.index{|_ign, data| data == bytes} 50 | @peers[idx] = { Time.now, @peers[idx][1] } 51 | @log.info "Heartbeat from #{address}:#{port}" 52 | else 53 | @peers << { Time.now, bytes } 54 | @log.info "Adding #{address}:#{port}" 55 | end 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /src/torrent/dht/peers_finder.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Dht 3 | class PeersFinder < Finder(Tuple(Array(Node), Array(Torrent::Structure::PeerInfo))) 4 | def query_node(node : Node, hash : BigInt) : Array(NodeAddress)? 5 | nodes, peers = node.get_peers(@manager.nodes.node_id, hash) 6 | 7 | unless peers.empty? 8 | if @result.nil? 9 | @result = { [ node ], peers } 10 | else 11 | old = @result 12 | @result = { old[0] << node, old[1] + peers } 13 | end 14 | end 15 | 16 | nodes 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /src/torrent/dht/structure.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Dht 3 | # DHT datagram structures 4 | module Structure 5 | 6 | # Abstract KRPC message structure 7 | abstract struct Message 8 | # The message transaction 9 | getter transaction : Bytes 10 | 11 | def initialize(@transaction : Bytes) 12 | end 13 | 14 | def initialize(any) 15 | @transaction = any["t"].to_slice 16 | end 17 | 18 | # Builds the correct `Message` sub-class for this *any*. 19 | def self.from(any) 20 | case type = any["y"].to_s 21 | when "q" 22 | Query.new(any) 23 | when "r" 24 | Response.new(any) 25 | when "e" 26 | Error.new(any) 27 | else 28 | raise ArgumentError.new("Unknown type #{type.inspect}") 29 | end 30 | end 31 | end 32 | 33 | # KRPC "q" - Query message 34 | struct Query < Message 35 | # The method to call 36 | getter method : String 37 | 38 | # The method arguments 39 | getter args : Bencode::Any::AnyHash 40 | 41 | def initialize(transaction, @method, @args) 42 | super transaction 43 | end 44 | 45 | def initialize(any) 46 | super 47 | @method = any["q"].to_s 48 | @args = any["a"].to_h 49 | end 50 | 51 | # Builds a `Response` to this query 52 | def response(data : Bencode::Any::AnyHash) 53 | Response.new(@transaction, data) 54 | end 55 | 56 | # Builds an `Error` to this query 57 | def error(code : ErrorCode, message : String? = nil) 58 | Error.new(@transaction, code.value, message || ErrorCode.message(code)) 59 | end 60 | 61 | # ditto 62 | def error(code : Int32, message : String) 63 | Error.new(@transaction, code, message) 64 | end 65 | 66 | def to_bencode 67 | { 68 | "t" => Bencode::Any.new(@transaction), 69 | "y" => Bencode::Any.new("q"), 70 | "q" => Bencode::Any.new(@method), 71 | "a" => @args, 72 | }.to_bencode 73 | end 74 | end 75 | 76 | # KRPC "r" - Response 77 | struct Response < Message 78 | # The response dictionary 79 | getter data : Bencode::Any::AnyHash 80 | 81 | def initialize(any) 82 | super 83 | @data = any["r"].to_h 84 | end 85 | 86 | def initialize(transaction, @data) 87 | super transaction 88 | end 89 | 90 | def to_bencode 91 | { 92 | "t" => Bencode::Any.new(@transaction), 93 | "y" => Bencode::Any.new("r"), 94 | "r" => @data, 95 | }.to_bencode 96 | end 97 | end 98 | 99 | # KRPC "e" - Error 100 | struct Error < Message 101 | # The error code 102 | getter code : Int32 103 | 104 | # The error message 105 | getter message : String 106 | 107 | def initialize(any) 108 | super 109 | 110 | err = any["e"] 111 | @code = err[0].to_i.to_i32 112 | @message = err[1].to_s 113 | end 114 | 115 | def initialize(transaction, @code, @message) 116 | super transaction 117 | end 118 | 119 | def to_bencode 120 | { 121 | "t" => Bencode::Any.new(@transaction), 122 | "y" => Bencode::Any.new("e"), 123 | "e" => Bencode::Any.new([ Bencode::Any.new(@code), Bencode::Any.new(@message) ]), 124 | }.to_bencode 125 | end 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /src/torrent/dht/udp_node.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Dht 3 | class UdpNode < Node 4 | 5 | getter local_address : Socket::IPAddress 6 | getter remote_address : Socket::IPAddress 7 | 8 | def initialize(@socket : Util::UdpSocket, @remote_address : Socket::IPAddress, @owns_socket = false) 9 | super(-1.to_big_i) 10 | @local_address = @socket.local_address 11 | 12 | @running = true 13 | @log.context = "Node/#{@remote_address}" 14 | Util.spawn("DHT-UDP/#{@remote_address}"){ start_receiving_data } if @owns_socket 15 | end 16 | 17 | def close 18 | @running = false 19 | @socket.close if @owns_socket 20 | super 21 | end 22 | 23 | def send(message : Structure::Message) 24 | datagram = message.to_bencode 25 | @socket.send datagram, @remote_address 26 | rescue error 27 | raise Error.new("Failed to send message: #{error}") 28 | end 29 | 30 | private def start_receiving_data 31 | while @running 32 | datagram = do_receive 33 | handle_incoming datagram if datagram 34 | end 35 | rescue error 36 | @log.error "Error occured while reading data." 37 | @log.error error 38 | close 39 | end 40 | 41 | private def do_receive 42 | buf = Bytes.new(Util::UdpSocket::MTU) 43 | bytes = @socket.read(buf) 44 | buf[0, bytes] 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /src/torrent/extension/handler.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Extension 3 | 4 | # Base class for extension handlers 5 | abstract class Handler 6 | 7 | # The public name of the handler 8 | getter name : String 9 | 10 | # The transfer manager 11 | getter manager : Torrent::Manager::Base 12 | 13 | def initialize(@name, @manager) 14 | end 15 | 16 | # Invokes the handler from *peer* with the *payload*. 17 | abstract def invoke(peer : Client::Peer, payload : Bytes) 18 | 19 | # Called when a peer is ready, has extension support and supports 20 | # this handler. 21 | def initialize_hook(peer : Client::Peer) 22 | # ... 23 | end 24 | 25 | # Called periodically by the transfer manager. Occurs roughly every two 26 | # minutes. 27 | def management_tick 28 | # ... 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /src/torrent/extension/manager.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Extension 3 | # Manages extensions to the BitTorrent protocol through the protocols 4 | # extension mechanism (BEP-0010). 5 | class Manager 6 | # Default name of the client sent to peers 7 | CLIENT_NAME = "torrent.cr #{Torrent::VERSION}" 8 | 9 | # Hardcoded id of the handshake packet 10 | HANDSHAKE_ID = 0u8 11 | 12 | # Emitted when a *handshake* has been received from *peer*. 13 | # Note that a peer can send multiple handshakes during a connection. 14 | Cute.signal handshake_received(peer : Client::Peer, handshake : Structure::ExtendedHandshake) 15 | 16 | # Emitted when *peer* sent an unknown message *id* with the *payload* 17 | Cute.signal unknown_message(peer : Client::Peer, id : UInt8, payload : Bytes) 18 | 19 | # Mapping of registered handlers from their message id 20 | getter handlers : Hash(UInt8, Handler) 21 | 22 | # Name of the client announced to peers 23 | property client_name : String = CLIENT_NAME 24 | 25 | def initialize 26 | @handlers = Hash(UInt8, Handler).new 27 | @log = Util::Logger.new("Ext/Manager") 28 | @log.context = self 29 | end 30 | 31 | # Invokes extension handler *id* for *peer* with the *payload* 32 | def invoke(peer : Client::Peer, id : UInt8, payload : Bytes) 33 | return handle_handshake(peer, payload) if id == HANDSHAKE_ID 34 | 35 | if handler = @handlers[id]? 36 | handler.invoke(peer, payload) 37 | else 38 | unknown_message.emit(peer, id, payload) 39 | end 40 | rescue error 41 | @log.error "Error while processing extension message #{id} from #{peer} with payload of #{payload.size} Bytes" 42 | @log.error error 43 | end 44 | 45 | # Sends the extension handshake message to *peer* 46 | def send_handshake(peer) 47 | handshake = Structure::ExtendedHandshake.new 48 | handshake.yourip = compact_address(peer.address) 49 | handshake.client = client_name 50 | 51 | @handlers.each do |id, handler| 52 | handshake.mapping[handler.name] = id 53 | end 54 | 55 | peer.send_extended(HANDSHAKE_ID, handshake.to_bencode) 56 | end 57 | 58 | # Finds a handler by name. 59 | def []?(name : String) 60 | @handlers.each_value do |handler| 61 | return handler if handler.name == name 62 | end 63 | 64 | nil 65 | end 66 | 67 | # ditto 68 | def [](name : String) 69 | handler = self[name]? 70 | raise Error.new("No known handler for #{name.inspect}") if handler.nil? 71 | handler 72 | end 73 | 74 | def [](id : UInt8); @handlers[id]; end 75 | def []?(id : UInt8); @handlers[id]?; end 76 | 77 | # Adds *handler* and returns the assigned message id. 78 | # Raises if no message id is available. 79 | def add(handler : Handler) : UInt8 80 | id = find_free_message_id 81 | @handlers[id] = handler 82 | id 83 | end 84 | 85 | # Calls the management tick of all handlers, letting them do some 86 | # work which is to be done periodically. 87 | # 88 | # **Note:** This tick is to be called by the `Manager::Base`. It occures 89 | # every two minutes 90 | def management_tick 91 | @handlers.each_value do |handler| 92 | begin 93 | handler.management_tick 94 | rescue error 95 | @log.error "Error in #{handler.class}#management_tick" 96 | @log.error error 97 | end 98 | end 99 | end 100 | 101 | # Adds the default extensions. Called by the owning transfer manager. 102 | def add_default_extensions(manager) 103 | add PeerExchange.new(manager) 104 | end 105 | 106 | private def find_free_message_id : UInt8 107 | (1u8..255u8).each do |i| 108 | return i unless @handlers.has_key?(i) 109 | end 110 | 111 | raise Error.new("No message id available") 112 | end 113 | 114 | # Handles the extension handshake message 115 | private def handle_handshake(peer, payload) 116 | handshake = Structure::ExtendedHandshake.from_bencode(payload) 117 | @log.info "Peer #{peer.address} #{peer.port} sent handshake" 118 | @log.info " - Peers client is #{handshake.client.inspect}" 119 | @log.info " - Peer sees us as #{handshake.yourip.inspect}" 120 | @log.info " - Peer accepts #{handshake.reqq.inspect} simultaneous requests" 121 | 122 | if count = handshake.reqq 123 | peer.max_concurrent_requests = count 124 | end 125 | 126 | merge_peer_extensions(peer, handshake.mapping) 127 | handshake_received.emit(peer, handshake) 128 | end 129 | 130 | private def merge_peer_extensions(peer, mapping) 131 | target = peer.extension_map 132 | mapping.each do |extension, id| 133 | if id == HANDSHAKE_ID # Removes an extension from a peer 134 | target.delete extension 135 | @log.debug "Peer knows no extension #{extension.inspect}" 136 | else 137 | @log.debug "Peer knows extension #{extension.inspect} as ID #{id}" 138 | target[extension] = id 139 | inititalize_extension(peer, extension) 140 | end 141 | end 142 | end 143 | 144 | private def inititalize_extension(peer, extension) 145 | if handler = self[extension]? 146 | handler.initialize_hook(peer) 147 | end 148 | end 149 | 150 | private def compact_address(address) : Bytes 151 | if address.includes?('.') 152 | ary = address.split('.').map(&.to_u8).to_a 153 | Bytes.new ary.to_unsafe.as(UInt8*), ary.size 154 | else 155 | ary = address.split(':').map(&.to_u16).to_a 156 | Bytes.new ary.to_unsafe.as(UInt8*), ary.size * 2 157 | end 158 | end 159 | end 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /src/torrent/extension/peer_exchange.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Extension 3 | # Implements BEP-0011: Peer Exchange (PEX) 4 | class PeerExchange < Handler 5 | def initialize(manager) 6 | super "ut_pex", manager 7 | 8 | @log = Util::Logger.new("Ext/PEX") 9 | @log.info "Added Peer Exchange extension to #{manager}" 10 | end 11 | 12 | def invoke(peer : Client::Peer, payload : Bytes) 13 | @log.info "Peer exchanged peers" 14 | 15 | if peer.transfer.private? 16 | @log.info "Ignoring as the torrent is private" 17 | return 18 | end 19 | 20 | peers = Payload.from_bencode(payload) 21 | add_peers peer, peers.added, "IPv4" 22 | add_peers peer, peers.added6, "IPv6" 23 | @manager.peer_list.connect_to_candidates 24 | end 25 | 26 | private def add_peers(peer, list, type) 27 | return if list.nil? 28 | 29 | @log.debug "Peer #{peer.address} #{peer.port} knows of #{list.size} #{type} peers" 30 | list.each do |info| 31 | @manager.peer_list.add_candidate_bulk peer.transfer, info.address, info.port.to_u16 32 | end 33 | end 34 | 35 | @[Flags] 36 | enum PeerFlag : UInt8 37 | 38 | # This peer wants to encrypt the connection 39 | PreferEncryption = 0x01 40 | 41 | # This peer is a seed-box 42 | SeedOnly = 0x02 43 | 44 | # This peer supports the µTP protocol 45 | SupportsUtp = 0x04 46 | 47 | # This peer supports the "ut_holepunch" extension. 48 | # This extension is undocumented. 49 | SupportsHolepunch = 0x08 50 | 51 | # This peer is reachable over the Internet 52 | Reachable = 0x10 53 | end 54 | 55 | struct Payload 56 | Bencode.mapping( 57 | added: { type: Array(Structure::PeerInfo), converter: Structure::PeerInfoConverter, nilable: true }, 58 | added_flags: { type: Array(PeerFlag), nilable: true }, 59 | added6: { type: Array(Structure::PeerInfo), converter: Structure::PeerInfo6Converter, nilable: true }, 60 | added6_flags: { type: Array(PeerFlag), nilable: true }, 61 | dropped: { type: Array(Structure::PeerInfo), converter: Structure::PeerInfoConverter, nilable: true }, 62 | dropped6: { type: Array(Structure::PeerInfo), converter: Structure::PeerInfo6Converter, nilable: true }, 63 | ) 64 | end 65 | 66 | module FlagConverter 67 | def self.from_bencode(pull) 68 | pull.read_byte_slice.map do |flag| 69 | PeerFlag.from_value(flag) 70 | end 71 | end 72 | 73 | def self.to_bencode(list, io) 74 | Slice(UInt8).new(list.to_unsafe.as(UInt8*), list.size).to_bencode(io) 75 | end 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /src/torrent/file.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | class File 3 | 4 | # Reads a .torrent file at *file_path* 5 | def self.from_file(file_path) : self 6 | File.open(file_path, "r"){|h| new h} 7 | end 8 | 9 | # List of all files 10 | getter files : Array(Structure::SingleFileInfo) 11 | 12 | # Wrapped data structure 13 | getter meta : Structure::MetaInfo 14 | 15 | # Reads the .torrent file from the *buffer*. 16 | def initialize(buffer : Bytes) 17 | @meta = Structure::MetaInfo.from_bencode(buffer) 18 | info = @meta.info 19 | 20 | range = info.raw_range.not_nil! # Is set at this point 21 | info.raw_data = buffer[range.begin, range.end - range.begin] 22 | 23 | if files = info.files 24 | @files = files 25 | elsif length = info.length 26 | @files = [ Structure::SingleFileInfo.new(info.name, length) ] 27 | else 28 | raise Error.new("Torrent file is neither a single-file nor multi-file one") 29 | end 30 | end 31 | 32 | # Reads a `File` from *file_path* 33 | def self.read(file_path) 34 | buf = Bytes.new ::File.size(file_path) 35 | ::File.open(file_path){|h| h.read_fully buf} 36 | Torrent::File.new(buf) 37 | end 38 | 39 | # Returns the list of announcing trackers for this torrent 40 | def announce_list 41 | @meta.announce_list || [ @meta.announce ] 42 | end 43 | 44 | # Returns the total size of all files in the torrent in Bytes 45 | def total_size 46 | @files.map(&.length).sum 47 | end 48 | 49 | # Returns the name of the torrent 50 | def name 51 | @meta.info.name 52 | end 53 | 54 | delegate created_by, created_at, to: @meta 55 | 56 | # Is this a private torrent? (As defined in BEP-0027) 57 | def private? 58 | @meta.private 59 | end 60 | 61 | # Returns the size of a piece (Size of a file block) in Bytes 62 | def piece_size 63 | @meta.info.piece_length 64 | end 65 | 66 | # Returns the count of pieces in this torrent 67 | def piece_count 68 | @meta.info.pieces.size 69 | end 70 | 71 | # Returns the SHA-1 hashsums of this torrent 72 | def sha1_sums 73 | @meta.info.pieces 74 | end 75 | 76 | # Calculates the `info hash` 77 | getter info_hash : Bytes do 78 | digest = Digest::SHA1.digest @meta.info.raw_data.not_nil! 79 | 80 | # `digest` is stored on the stack, so if we'd do #to_slice, we'd get a 81 | # slice pointing into our stack, and thus will get destroyed after we 82 | # leave this method. Thus, we copy that thing into the heap. 83 | Bytes.new(20).tap do |buf| 84 | buf.copy_from(digest.to_slice) 85 | end 86 | end 87 | 88 | # Decodes the *piece* index with the length into a list of file paths 89 | # and inner offsets. 90 | def decode_piece_to_paths(piece : Int, offset, length) 91 | raise Error.new("Piece index #{piece} is out of bounds: 0...#{piece_count}") if piece < 0 || piece >= piece_count 92 | 93 | byte_offset = piece_size * piece + offset 94 | file_idx, file_offset = file_at_offset(byte_offset) 95 | 96 | result = Array(Tuple(String, UInt64, UInt64)).new 97 | while length > 0 98 | file = @files[file_idx]? 99 | file_idx += 1 100 | break if file.nil? 101 | next if file.length == 0 102 | 103 | inner_length = Math.min(length, file.length - file_offset) 104 | result << { file.path, file_offset.to_u64, inner_length.to_u64 } 105 | 106 | file_offset = 0 107 | length -= inner_length 108 | end 109 | 110 | if length > 0 111 | raise Error.new("Can't decode block at piece #{piece} length #{length}: End position outside any torrent file") 112 | end 113 | 114 | result 115 | end 116 | 117 | private def file_at_offset(byte_offset : UInt64) 118 | offset = 0u64 119 | 120 | @files.each_with_index do |current, idx| 121 | next_offset = offset + current.length 122 | 123 | if byte_offset < next_offset 124 | return { idx, byte_offset - offset } 125 | end 126 | 127 | offset = next_offset 128 | end 129 | 130 | raise Error.new("Byte offset #{byte_offset} is greater than torrent size") 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /src/torrent/file_manager/base.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module FileManager 3 | # Manages a transfer directory. This is the "download" directory, where 4 | # downloaded files will be stored and read from. 5 | # 6 | # Feel free to reimplement this class to store downloads in a database or so. 7 | # 8 | # **Important note**: 9 | # Treat the *file_path* argument to methods as user input. This means, you 10 | # have to make sure that it is "sane" with regards to your implementation. 11 | # A common attack vector is "Directory Traversal", where the file path could 12 | # be a absolute path or a relative path using ".." to break out of a base 13 | # directory. 14 | abstract class Base 15 | 16 | # Reads *buffer.size* bytes into *buffer* from *file_path* at offset 17 | # *offset*. Raises `Errno` if file is not found. 18 | abstract def read_file(file_path : String, offset, buffer : Bytes) : Nil 19 | 20 | # Writes *buffer.size* bytes from *buffer* into *file_path* at *offset*. 21 | # 22 | # If the file already exists, it is **not** truncated. If the file does 23 | # not exist, it will be created. If the path leading to the file (its 24 | # parent directories) does not exist, they're automatically created. 25 | abstract def write_file(file_path : String, offset, buffer : Bytes) : Nil 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /src/torrent/file_manager/file_system.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module FileManager 3 | # Manages a transfer directory in the file system. 4 | class FileSystem < FileManager::Base 5 | HANDLE_CACHE_SIZE = 100 6 | 7 | # Base path of the transfer directory 8 | getter base_path : String 9 | 10 | def initialize(base_path) 11 | if base_path.ends_with? '/' 12 | @base_path = base_path 13 | else 14 | @base_path = base_path + '/' 15 | end 16 | 17 | @handle_cache = Hash(String, IO::FileDescriptor).new 18 | end 19 | 20 | def read_file(file_path : String, offset, buffer : Bytes) : Nil 21 | io = get_handle(file_path) 22 | io.seek offset 23 | io.read_fully buffer 24 | end 25 | 26 | def write_file(file_path : String, offset, buffer : Bytes) : Nil 27 | io = get_handle(file_path) 28 | io.seek offset 29 | io.write buffer 30 | io.flush 31 | end 32 | 33 | private def get_handle(path) 34 | long_path = full_path(path) 35 | handle = @handle_cache[long_path]? 36 | 37 | if handle.nil? 38 | if @handle_cache.size > HANDLE_CACHE_SIZE 39 | _path, to_close = @handle_cache.shift 40 | to_close.close 41 | end 42 | 43 | handle = open_for_write(long_path) 44 | @handle_cache[long_path] = handle 45 | end 46 | 47 | handle 48 | end 49 | 50 | private def open_for_write(full_path : String) 51 | flags = LibC::O_RDWR | LibC::O_CLOEXEC | LibC::O_CREAT 52 | perm = ::File::DEFAULT_CREATE_MODE 53 | 54 | Dir.mkdir_p ::File.dirname(full_path) 55 | fd = LibC.open(full_path, flags, perm) 56 | IO::FileDescriptor.new(fd, blocking: true) 57 | end 58 | 59 | private def full_path(path : String) : String 60 | @base_path + check_path(path) 61 | end 62 | 63 | private def check_path(path : String) : String 64 | path.check_no_null_byte 65 | 66 | if path.starts_with?("../") || path.ends_with?("/..") || path.includes?("/../") || path == ".." 67 | raise "Illegal file path: #{path.inspect}" 68 | end 69 | 70 | if path.starts_with?('/') 71 | path[1..-1] 72 | else 73 | path 74 | end 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /src/torrent/leech_strategy/base.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module LeechStrategy 3 | # Base class for leech-strategies. Their job is to control which peers are 4 | # connected to and which piece is requested from each peer. The 5 | # leech-strategy is specific to a `Torrent::Transfer`. 6 | # 7 | # See also `PiecePicker` 8 | abstract class Base 9 | @transfer : Torrent::Transfer? 10 | 11 | # Starts the strategy. 12 | def start(transfer : Torrent::Transfer) 13 | @transfer = transfer 14 | end 15 | 16 | # Returns the transfer after the start. 17 | def transfer 18 | @transfer.not_nil! 19 | end 20 | 21 | # Called by `Torrent::PeerList` to compute the ranking of a candidate. 22 | # The ranking is a number between 0 and 100 inclusive. If the ranking is 23 | # zero, the candidate will be skipped. If it's 100, it will be added to 24 | # the top of the peer list (after other peers with a 100 ranking), making 25 | # it much more likely to be connected to than a peer with a lower ranking. 26 | # 27 | # This mechanism can be used to connect to peers which are geographically 28 | # nearer to the local client. The default implementation always returns 29 | # 100. 30 | def candidate_ranking(address : String, port : UInt16) : Int32 31 | 100 32 | end 33 | 34 | # Called whenever a new *peer* has been connected to. 35 | abstract def peer_added(peer : Client::Peer) : Nil 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /src/torrent/leech_strategy/null.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module LeechStrategy 3 | # Null leech strategy. Will reject any candidate and not try to request any 4 | # piece at all. 5 | class Null < Base 6 | def candidate_ranking(address : String, port : UInt16) : Int32 7 | 0 8 | end 9 | 10 | def peer_added(peer : Client::Peer) : Nil 11 | # Do nothing. 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /src/torrent/manager/base.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Manager 3 | 4 | # Base error class for managers 5 | class Error < Torrent::Error 6 | end 7 | 8 | # Base class for connection managers 9 | abstract class Base 10 | # This is the specified port range, but many providers block those, some 11 | # trackers even ban peers using one of these ports. Instead, we're using 12 | # a different port range which seems to be used by other torrent clients 13 | # too. 14 | # PORT_RANGE = 6881..6889 15 | 16 | PORT_RANGE = 49160..65534 17 | 18 | # Send a PING every 2 minutes 19 | MANAGING_TICK = 2.minutes 20 | 21 | # Remove peers which have not sent a packet after some time 22 | PEER_TIMEOUT = 1.minutes 23 | 24 | # The peer list. If you want to replace the peer list, do so right after 25 | # creating the manager. 26 | property peer_list : PeerList 27 | 28 | # Extension manager 29 | property extensions : Extension::Manager 30 | 31 | # Shall the DHT be used? 32 | property? use_dht : Bool = false 33 | 34 | # The DHT manager 35 | property dht : Dht::Manager 36 | 37 | def initialize 38 | @log = Util::Logger.new("Torrent::Manager") 39 | @peer_list = PeerList.new 40 | @extensions = Extension::Manager.new 41 | @dht = Dht::Manager.new 42 | end 43 | 44 | # Returns `true` if the manager accepts the *info_hash*, meaning, it knows 45 | # .torrent file for this hash and can serve files from it. 46 | abstract def accept_info_hash?(info_hash : Bytes) : Bool 47 | 48 | # Returns the transfer for the *info_hash*. Raises an error if no transfer 49 | # is known for the given hash. 50 | abstract def transfer_for_info_hash(info_hash : Bytes) : Torrent::Transfer 51 | 52 | # Returns the listening port of this manager. 53 | # Returns `nil` if not listening. 54 | # Note that valid ports are in the range `6881..6889`. 55 | abstract def port : Int32? 56 | 57 | # List of connected peers. See also `Torrent::RequestList`. 58 | def peers 59 | @peer_list.active_peers 60 | end 61 | 62 | # Starts a managing fiber to periodically 63 | protected def start! 64 | @dht.start if @use_dht 65 | 66 | Util.spawn do 67 | loop do 68 | with_rescue("#ping_all_peers"){ ping_all_peers } 69 | with_rescue("#check_peer_timeouts"){ check_peer_timeouts } 70 | with_rescue("extensions.management_tick"){ extensions.management_tick } 71 | sleep MANAGING_TICK 72 | end 73 | end 74 | end 75 | 76 | # Sends a PING to all connected peers 77 | def ping_all_peers 78 | peers.each do |peer| 79 | with_rescue("peer.send_ping"){ peer.send_ping } 80 | end 81 | end 82 | 83 | # Checks all peers if the last received packet is too long ago. 84 | def check_peer_timeouts 85 | now = Time.now 86 | peers.each do |peer| 87 | if peer.last_received + PEER_TIMEOUT < now 88 | @log.info "Closing connection to peer #{peer}: Connection Timeout" 89 | peer.close 90 | end 91 | end 92 | end 93 | 94 | private def with_rescue(action) 95 | yield 96 | rescue error 97 | @log.error "Management tick failed in #{action} with error" 98 | @log.error error 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /src/torrent/piece_picker/base.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module PiecePicker 3 | abstract class Base 4 | # Called when a piece is to be picked for *peer*. The implementation then 5 | # returns the piece index to download, or `nil` if no piece shall be 6 | # requested. 7 | abstract def pick_piece(peer : Client::Peer) : UInt32? 8 | 9 | # Called when a piece timed out and should be put back into the picking 10 | # pool. 11 | abstract def unpick_piece(piece : UInt32) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /src/torrent/piece_picker/rarest_first.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module PiecePicker 3 | # Implements a (in terms of BitTorrent) well-behaved piece picker, which 4 | # randomly picks a piece while giving precedence to pieces which are rare 5 | # in the swarm. 6 | class RarestFirst < Base 7 | BUCKETS = 4 8 | RANDOM_TRIES = 5 9 | 10 | alias Bucket = Array(UInt32) 11 | alias Buckets = Array(Bucket) 12 | 13 | def initialize(@transfer : Torrent::Transfer) 14 | @buckets = Buckets.new(BUCKETS){ Bucket.new } 15 | @no_peer = Bucket.new 16 | @dirty = true 17 | @log = Util::Logger.new("PiecePicker/RarestFirst") 18 | 19 | @transfer.manager.peer_list.peer_added.on do |peer| 20 | peer.bitfield_received.on{ mark_dirty } 21 | peer.have_received.on{|piece| handle_have piece} 22 | end 23 | 24 | @transfer.manager.peer_list.peer_removed.on{|_peer| mark_dirty} 25 | end 26 | 27 | # Returns a list of pieces which are provided by no peer. 28 | def pieces_without_peers : Bucket 29 | buckets 30 | @no_peer 31 | end 32 | 33 | def pick_piece(peer : Client::Peer) : UInt32? 34 | buckets.each do |bucket| 35 | next if bucket.empty? 36 | if idx = pick_from_bucket(bucket, peer) 37 | return take_at(bucket, idx) 38 | end 39 | end 40 | 41 | nil # None found. 42 | end 43 | 44 | def unpick_piece(piece : UInt32) 45 | buckets 46 | 47 | count = count_peers_having_piece @transfer.manager.peers, piece 48 | put_piece_into_bucket(piece, count) 49 | end 50 | 51 | def mark_dirty 52 | @dirty = true 53 | end 54 | 55 | private def take_at(array, index) 56 | array.swap index, array.size - 1 57 | array.pop 58 | end 59 | 60 | private def buckets 61 | rebuild_buckets @transfer.manager.peers if @dirty 62 | @buckets 63 | end 64 | 65 | # Does a partial update, increasing the commonness of *piece* 66 | private def handle_have(piece : UInt32) 67 | buckets 68 | 69 | if @no_peer.delete piece 70 | @buckets[0] << piece # No peer had the piece before 71 | else 72 | @buckets.each_with_index do |bucket, bucket_idx| 73 | # Skip the last bucket. 74 | break if bucket_idx >= @buckets.size - 1 75 | 76 | if bucket.delete piece 77 | @buckets[bucket_idx + 1] << piece 78 | break 79 | end 80 | end 81 | end 82 | end 83 | 84 | private def pick_from_bucket(bucket, peer) 85 | pick_random(bucket, peer) || pick_linear(bucket, peer) 86 | end 87 | 88 | private def pick_random(bucket, peer) 89 | Math.min(bucket.size, RANDOM_TRIES).times do 90 | idx = rand(bucket.size) 91 | return idx if peer.bitfield[bucket[idx]] == true 92 | end 93 | 94 | nil 95 | end 96 | 97 | private def pick_linear(bucket, peer) 98 | bucket.each_with_index do |piece, idx| 99 | return idx if peer.bitfield[piece] 100 | end 101 | 102 | nil 103 | end 104 | 105 | private def count_peers_having_piece(peers, piece_index) 106 | count = peers.map{|peer| peer.bitfield[piece_index].hash}.sum 107 | return BUCKETS if count > BUCKETS 108 | count 109 | end 110 | 111 | private def put_piece_into_bucket(piece_index, count) 112 | if count > 0 113 | @buckets[count - 1] << piece_index 114 | else 115 | @no_peer << piece_index 116 | end 117 | end 118 | 119 | private def rebuild_buckets(peers : Enumerable(Client::Peer)) 120 | @dirty = false 121 | @buckets = Buckets.new(BUCKETS){ Bucket.new } 122 | @no_peer = Bucket.new 123 | 124 | @transfer.piece_count.to_u32.times do |piece_index| 125 | next if @transfer.requests.private_bitfield[piece_index] 126 | 127 | count = count_peers_having_piece(peers, piece_index) 128 | put_piece_into_bucket piece_index, count 129 | end 130 | 131 | @log.info "Rebuilt buckets. #{@no_peer.size} pieces without any peers, other buckets have #{@buckets.map(&.size)} pieces each" 132 | end 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /src/torrent/piece_picker/sequential.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module PiecePicker 3 | # A piece picker which picks pieces sequentially, from the first one till the 4 | # last one. 5 | # 6 | # Transfers using this picker are **not** regarded as being "well-behaved", 7 | # but this strategy can still be useful if the user wants to stream-read a 8 | # large file. 9 | class Sequential < Base 10 | def pick_piece(peer : Client::Peer) : UInt32? 11 | peer_bits = peer.bitfield 12 | my_bits = peer.transfer.requests.private_bitfield 13 | 14 | my_bits.each(false) do |idx| 15 | return idx.to_u32 if peer_bits[idx] 16 | end 17 | 18 | nil 19 | end 20 | 21 | def unpick_piece(piece : UInt32) 22 | # Do nothing. 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /src/torrent/util/async_io_channel.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Util 3 | class AsyncIoChannel 4 | BUFFER_SIZE = IO::Buffered::BUFFER_SIZE 5 | 6 | # The wrapped IO 7 | getter io : IO 8 | 9 | # Emitted when an error occures 10 | Cute.signal error_occured(error : Exception) 11 | 12 | # The read channel. Received data will be sent into this channel. 13 | getter read_channel : Channel(Bytes) 14 | 15 | # The write channel. Data sent into this channel will be written into the IO. 16 | getter write_channel : Channel::Buffered(Bytes | Proc(Bytes?) | Nil) 17 | 18 | @next_block : Bytes? 19 | @block_pos : Int32 = 0 20 | 21 | def initialize(@io : IO) 22 | @read_channel = Channel(Bytes).new 23 | @write_channel = Channel::Buffered(Bytes | Proc(Bytes?) | Nil).new 24 | end 25 | 26 | # The next channel message will have the required *size*. 27 | # This method is only valid for the **next** message, after that the 28 | # expectation will be unset. 29 | def expect_block(size : Int) : Nil 30 | @next_block = Bytes.new(size.to_i32) 31 | @block_pos = 0 32 | end 33 | 34 | # Will spawn a fiber to read on the given *io* and send received data 35 | # through the returned channel. 36 | def start 37 | Util.spawn do 38 | log = Util::Logger.new(self) 39 | with_rescue "reading" do 40 | loop do 41 | buffer = do_read 42 | # log.debug "-> #{buffer.try &.hexstring}" 43 | @read_channel.send buffer if buffer 44 | end 45 | end 46 | end 47 | 48 | Util.spawn do 49 | log = Util::Logger.new(self) 50 | with_rescue "writing" do 51 | loop do 52 | data = @write_channel.receive 53 | break if data.nil? 54 | data = data.call if data.is_a?(Proc) 55 | # log.debug "<- #{data.try &.hexstring}" 56 | @io.write data if data.is_a?(Bytes) 57 | end 58 | end 59 | end 60 | end 61 | 62 | # Closes the inner IO and stops all fibers 63 | def close 64 | @write_channel.send nil 65 | @io.close 66 | end 67 | 68 | # Sends *data* into the write channel for it to be sent. 69 | def write_later(data : Bytes) 70 | @write_channel.send(data) 71 | end 72 | 73 | # Sends the block into the channel. The block will be called to get the 74 | # data to be sent when it's scheduled to be sent. If the block returns 75 | # *nil*, then nothing will be sent. 76 | def write_later(&block : -> Bytes?) 77 | @write_channel.send(block) 78 | end 79 | 80 | private def do_read : Bytes? 81 | if block = @next_block 82 | read_size = @io.read(block + @block_pos) 83 | raise IO::EOFError.new if read_size < 1 84 | 85 | @block_pos += read_size 86 | return nil unless @block_pos == block.size 87 | 88 | # Done reading the block 89 | @block_pos = 0 90 | @next_block = nil 91 | block 92 | else 93 | buffer = Bytes.new(BUFFER_SIZE) 94 | read_size = @io.read(buffer) 95 | buffer[0, read_size] 96 | end 97 | end 98 | 99 | private def with_rescue(action) 100 | yield 101 | rescue error 102 | error_occured.emit(error) 103 | close 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /src/torrent/util/bitfield.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Util 3 | 4 | # Wraps a `Bytes` so we can work with it as bitfield in terms of BitTorrent. 5 | struct Bitfield 6 | include Enumerable(Bool) 7 | BITS_PER_BYTE = 8u64 8 | 9 | # The inner data 10 | getter data : Bytes 11 | 12 | # The bit count in the bitfield 13 | getter size : Int32 14 | 15 | def initialize(bit_size : Int32, byte : UInt8? = 0u8) 16 | @size = bit_size 17 | @data = Bytes.new(Bitfield.bytesize(bit_size), byte) 18 | end 19 | 20 | def initialize(data : Bytes, size : Int32? = nil) 21 | @data = data 22 | @size = size || (data.size * 8) 23 | 24 | raise ArgumentError.new("size must be <= data.size * 8") unless @size <= data.size * BITS_PER_BYTE 25 | end 26 | 27 | # Restores a bitfield from a hexstring, as built by `Slice#hexstring`. 28 | # 29 | # **Warning**: The *hexstring* must be a string containing only lower-case 30 | # hexdigits. 31 | def self.restore(bit_size : Int32, hexstring : String) 32 | "0123456789abcdef" 33 | bytes = hexstring.to_slice 34 | if bytes.size != bytesize(bit_size) * 2 35 | raise ArgumentError.new("Wrong hexstring length for bit count") 36 | end 37 | 38 | data = Bytes.new(bytes.size / 2) do |idx| 39 | from_hex(bytes[idx * 2]) << 4 | from_hex(bytes[idx * 2 + 1]) 40 | end 41 | 42 | new(data, bit_size) 43 | end 44 | 45 | # Mirrors `Slice#to_hex` 46 | private def self.from_hex(c : UInt8) : UInt8 47 | (c < 0x61) ? c - 0x30 : c - 0x61 + 10 48 | end 49 | 50 | # Returns the count of bytes needed to house a bitfield of *bit_count* 51 | # bits. 52 | def self.bytesize(bit_count) 53 | bytes = bit_count / BITS_PER_BYTE 54 | bytes += 1 unless bit_count.divisible_by? BITS_PER_BYTE 55 | bytes 56 | end 57 | 58 | def each 59 | @size.times do |idx| 60 | yield self[idx] 61 | end 62 | end 63 | 64 | def each(value : Bool) 65 | @size.times do |idx| 66 | yield idx if self[idx] == value 67 | end 68 | end 69 | 70 | def count(value : Bool) 71 | count = 0 72 | data, trailing, _trailing_off = uint64_slice 73 | 74 | data.each{|el| count += el.popcount} 75 | trailing.each{|el| count += el.popcount} 76 | 77 | value ? count : @size - count 78 | end 79 | 80 | # Returns the bitfield bytes 81 | def to_bytes 82 | @data 83 | end 84 | 85 | # Deep-copies the bitfield 86 | def clone 87 | copy = Bytes.new(@data.size) 88 | copy.copy_from(@data) 89 | Bitfield.new(copy, @size) 90 | end 91 | 92 | delegate bytesize, to: @data 93 | 94 | # Returns true if every bit is set in the bitfield 95 | def all_ones? 96 | all?(true) 97 | end 98 | 99 | # Returns true if no bit is set in the bitfield 100 | def all_zero? 101 | all?(false) 102 | end 103 | 104 | # Returns true if all bits are equal to *bit_value* 105 | def all?(bit_value : Bool) 106 | big_value = bit_value ? UInt64::MAX : UInt64::MIN 107 | small_value = bit_value ? UInt8::MAX : UInt8::MIN 108 | data, trailing, _trailing_off = uint64_slice 109 | 110 | found = data.find{|el| el != big_value} 111 | return false if found 112 | 113 | if trailing.size > 0 114 | all_trailing(trailing, small_value) 115 | else 116 | true 117 | end 118 | end 119 | 120 | private def all_trailing(trailing, small_value) 121 | found = trailing[0, trailing.size - 1].find{|el| el != small_value} 122 | return false if found 123 | 124 | remaining = @size % 8 125 | if remaining == 0 126 | (trailing[-1] == small_value) 127 | else 128 | mask = ~(UInt8::MAX >> remaining) 129 | (trailing[-1] & mask) == mask 130 | end 131 | end 132 | 133 | # Returns the bit at *index* 134 | def [](index : Int) : Bool 135 | byte, bit = bit_index(index) 136 | (@data[byte] & (1 << bit)) != 0 137 | end 138 | 139 | # Sets the bit at *index* to *value* 140 | def []=(index : Int, value : Bool) : Bool 141 | byte, bit = bit_index(index) 142 | mask = 1 << bit 143 | 144 | if value 145 | @data[byte] |= mask 146 | else 147 | @data[byte] &= ~mask 148 | end 149 | 150 | value 151 | end 152 | 153 | private def bit_index(index : Int) 154 | byte, bit = index.divmod BITS_PER_BYTE 155 | 156 | { byte, BITS_PER_BYTE - bit - 1 } 157 | end 158 | 159 | # Finds the index of the next bit, from the beginning, which is not set. 160 | # If no bit is unset, returns `nil`. 161 | def find_next_unset 162 | data, trailing, trailing_off = uint64_slice 163 | found = find_index(data){|el| el != UInt64::MAX } 164 | 165 | if found 166 | (found * 64) + find_next_unset_bit(data[found], 64u64) 167 | else 168 | found8 = find_next_slow(trailing) 169 | (data.size * 64) + found8 if found8 170 | end 171 | end 172 | 173 | private def find_next_slow(trailing) 174 | found = find_index(trailing){|el| el != UInt8::MAX } 175 | 176 | if found 177 | (found * 8) + find_next_unset_bit(trailing[found].to_u64, 8u64) 178 | end 179 | end 180 | 181 | @[AlwaysInline] 182 | private def find_next_unset_bit(uint : UInt64, size : UInt64) 183 | size.times do |off| 184 | return off if (uint & (1u64 << off)) == 0 185 | end 186 | 187 | raise "#find_next_* is broken" 188 | end 189 | 190 | private def find_index(list) 191 | list.each_with_index do |el, idx| 192 | return idx if yield(el) 193 | end 194 | 195 | nil 196 | end 197 | 198 | private def uint64_slice 199 | ptr = @data.pointer(@data.size).as(UInt64*) 200 | byte_off = @data.size & ~(BITS_PER_BYTE - 1) 201 | big_size = @data.size / sizeof(UInt64) 202 | 203 | # If the bit-count is NOT divisble by 64 bit, but the total byte count 204 | # is, let the trailer be the last 8 byte. 205 | if !@size.divisible_by?(64) && @data.size.divisible_by?(sizeof(UInt64)) 206 | big_size -= 1 207 | byte_off -= 8 208 | end 209 | 210 | slice = Slice(UInt64).new(ptr, big_size) 211 | trailing = @data + byte_off 212 | 213 | { slice, trailing, byte_off } 214 | end 215 | end 216 | end 217 | end 218 | -------------------------------------------------------------------------------- /src/torrent/util/endian.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Util 3 | 4 | # Fast integer endian conversions 5 | module Endian 6 | extend self 7 | 8 | # Transforms the value from host byte order to network byte order 9 | def to_network(value) 10 | {% if IO::ByteFormat::SystemEndian != IO::ByteFormat::NetworkEndian %} 11 | swap(value) 12 | {% else %} 13 | value 14 | {% end %} 15 | end 16 | 17 | # Transforms the value from network byte order to host byte order 18 | def to_host(value) 19 | {% if IO::ByteFormat::SystemEndian != IO::ByteFormat::NetworkEndian %} 20 | swap(value) 21 | {% else %} 22 | value 23 | {% end %} 24 | end 25 | 26 | # Reverses the byte-sequence in *val* 27 | def swap(val : UInt8 | Int8) 28 | val 29 | end 30 | 31 | # ditto 32 | def swap(val : UInt16) 33 | (val >> 8) | (val << 8) 34 | end 35 | 36 | # ditto 37 | def swap(val : UInt32) 38 | ((val & 0xFFu32) << 24) | \ 39 | ((val & 0xFF00u32) << 8) | \ 40 | ((val & 0xFF0000u32) >> 8) | \ 41 | ((val & 0xFF000000u32) >> 24) 42 | end 43 | 44 | # ditto 45 | def swap(val : UInt64) 46 | ((val & 0xFFu64) << 56) | \ 47 | ((val & 0xFF00u64) << 40) | \ 48 | ((val & 0xFF0000u64) << 24) | \ 49 | ((val & 0xFF000000u64) << 8) | \ 50 | ((val & 0xFF00000000u64) >> 8) | \ 51 | ((val & 0xFF0000000000u64) >> 24) | \ 52 | ((val & 0xFF000000000000u64) >> 40) | \ 53 | ((val & 0xFF00000000000000u64) >> 56) 54 | end 55 | 56 | # ditto 57 | def swap(val : Int16) 58 | swap(val.to_u16).to_i16 59 | end 60 | 61 | # ditto 62 | def swap(val : Int32) 63 | swap(val.to_u32).to_i32 64 | end 65 | 66 | # ditto 67 | def swap(val : Int64) 68 | swap(val.to_u64).to_i64 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /src/torrent/util/gmp.cr: -------------------------------------------------------------------------------- 1 | @[Link("gmp")] 2 | lib LibGMP 3 | # mpz_export (void *rop, size_t *countp, int order, size_t size, int endian, size_t nails, const mpz_t op) 4 | fun export = __gmpz_export(rop : Void*, countp : LibC::SizeT*, order : Int32, size : LibC::SizeT, endian : Int32, nails : LibC::SizeT, op : MPZ*) : Void* 5 | 6 | # void mpz_import (mpz_t rop, size_t count, int order, size_t size, int endian, size_t nails, const void *op) 7 | fun import = __gmpz_import(rop : MPZ*, count : LibC::SizeT, order : Int32, size : LibC::SizeT, endian : Int32, nails : LibC::SizeT, op : Void*) : Void 8 | end 9 | 10 | module Torrent 11 | module Util 12 | # `BigInt` import and export functionality. For documentation of the 13 | # arguments, please see: 14 | # https://gmplib.org/manual/Integer-Import-and-Export.html 15 | module Gmp 16 | SHA1_LEN = 20 17 | 18 | def self.export(integer : BigInt, order : Int32, size : Int32, endian : Int32, nails : Int32) : Bytes 19 | ptr = LibGMP.export( 20 | nil, 21 | out length, 22 | order, 23 | LibC::SizeT.new(size), 24 | endian, 25 | LibC::SizeT.new(nails), 26 | integer.to_unsafe 27 | ) 28 | 29 | Bytes.new(ptr.as(UInt8*), length) 30 | end 31 | 32 | def self.import(bytes : Bytes, order : Int32, size : Int32, endian : Int32, nails : Int32) : BigInt 33 | mpz = LibGMP::MPZ.new 34 | 35 | LibGMP.import( 36 | pointerof(mpz), 37 | bytes.size, 38 | order, 39 | LibC::SizeT.new(size), 40 | endian, 41 | LibC::SizeT.new(nails), 42 | bytes.pointer(bytes.size).as(Void*) 43 | ) 44 | 45 | BigInt.new(mpz) 46 | end 47 | 48 | # Exports a zero-padded SHA-1 byte-string in network byte order 49 | def self.export_sha1(integer : BigInt) : Bytes 50 | exported = export integer, 1, 1, 1, 0 51 | 52 | return exported if exported.size == SHA1_LEN 53 | raise IndexError.new("Integer too large") if exported.size > SHA1_LEN 54 | 55 | # Pad to 20 bytes 56 | bytes = Bytes.new(SHA1_LEN, 0u8) 57 | (bytes + (SHA1_LEN - exported.size)).copy_from exported 58 | bytes 59 | end 60 | 61 | # Imports a SHA-1 byte-string in network byte order 62 | def self.import_sha1(bytes : Bytes) : BigInt 63 | raise IndexError.new("Integer too large") if bytes.size > SHA1_LEN 64 | import bytes, 1, 1, 1, 0 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /src/torrent/util/kademlia_list.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Util 3 | # List which uses `T#kademlia_distance` to sort elements when they're added, 4 | # while enforcing a maximum size, discarding the farthest element when 5 | # additional space is needed. 6 | class KademliaList(T) 7 | include Enumerable(T) 8 | include Indexable(T) 9 | 10 | def initialize(@comparison : BigInt, @max_size : Int32) 11 | @distances = Array(BigInt).new(@max_size) 12 | @array = Array(T).new(@max_size) 13 | end 14 | 15 | # Returns the element list as `Array(T)` 16 | def to_a 17 | @array 18 | end 19 | 20 | delegate :[], :[]?, each, size, unsafe_at, to: @array 21 | 22 | # Clears the list of all elemenets. Also lets the user reset the 23 | # comparison value. 24 | def clear(comparison : BigInt = @comparison) 25 | @comparison = comparison 26 | @array.clear 27 | @distances.clear 28 | end 29 | 30 | # Takes out the first element of the list and returns it. 31 | def shift : T 32 | @distances.shift 33 | @array.shift 34 | end 35 | 36 | # Tries to add *element* to the list. On success returns `true`, else 37 | # returns `false`. If the list already contains *element*, `false` is 38 | # returned. 39 | def try_add(element : T) : Bool 40 | return false if @array.includes?(element) 41 | dist = element.kademlia_distance(@comparison) 42 | 43 | # Make room at the end 44 | if @array.size >= @max_size 45 | # Discard if the node list size has been reached and the distance 46 | # is greater than the farthest distance in the list. 47 | return false if dist > @distances.last 48 | 49 | @array.pop 50 | @distances.pop 51 | end 52 | 53 | idx = @distances.index(&.>=(dist)) 54 | idx = @array.size if idx.nil? 55 | 56 | @array.insert idx, element 57 | @distances.insert idx, dist 58 | 59 | true 60 | end 61 | 62 | # Adds *element* to the sorted element list 63 | def <<(element : T) : self 64 | try_add element 65 | self 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /src/torrent/util/logger.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Util 3 | # Wraps a `::Logger` so that we also have a context. 4 | class Logger 5 | getter context : String 6 | 7 | def initialize(@context : String) 8 | end 9 | 10 | def initialize(context) 11 | @context = Logger.format_context(context) 12 | end 13 | 14 | def context=(context : String) 15 | @context = context 16 | end 17 | 18 | def context=(context) 19 | @context = Logger.format_context(context) 20 | end 21 | 22 | {% for meth in %i[ debug info warn error fatal ] %} 23 | def {{ meth.id }}(message : String | Exception) 24 | sink = Torrent.logger 25 | return if sink.nil? 26 | 27 | message = format_exception(message) if message.is_a? Exception 28 | sink.{{ meth.id }}("[#{@context}] #{message}") 29 | end 30 | {% end %} 31 | 32 | private def format_exception(error) 33 | "#{error.class}: #{error.message}\n #{error.backtrace.join("\n ")}" 34 | end 35 | 36 | def self.format_context(context) 37 | "#{context.class}/0x#{pointerof(context).address.to_s 16}" 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /src/torrent/util/network_order_struct.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Util 3 | abstract struct NetworkOrderStruct(T) 4 | property inner : T 5 | 6 | def initialize(@inner : T) 7 | end 8 | 9 | def initialize 10 | @inner = T.new 11 | end 12 | 13 | # Casts *self* to `Bytes`. Caution: This will *not* copy any data, the 14 | # returned slice will point to the data structure. 15 | def to_bytes : Bytes 16 | Bytes.new(pointerof(@inner).as(UInt8*), instance_sizeof(T)) 17 | end 18 | 19 | def self.from(data : Bytes) : self 20 | inner = uninitialized T 21 | data.copy_to(pointerof(inner).as(UInt8*), instance_sizeof(T)) 22 | new(inner) 23 | end 24 | 25 | def write_to(io : IO) 26 | io.write to_bytes 27 | end 28 | 29 | def self.from(io : IO) : self 30 | inner = uninitialized T 31 | buf = Bytes.new(pointerof(inner).as(UInt8*), instance_sizeof(T)) 32 | io.read_fully(buf) 33 | new(inner) 34 | end 35 | 36 | # Declares the fields in the underlying structure. For integer types, 37 | # automatic conversion from host byte order to network byte order is done 38 | # when reading/writing a field. All other types are passed-through. 39 | macro fields(*type_decls) 40 | {% for type_decl in type_decls %} 41 | {% if %w[ Int16 Int32 Int64 UInt16 UInt32 UInt64 ].includes?(type_decl.type.id.stringify) %} 42 | def {{ type_decl.var.id }} : {{ type_decl.type }} 43 | Torrent::Util::Endian.to_host(@inner.{{ type_decl.var.id }}) 44 | end 45 | 46 | def {{ type_decl.var.id }}=(val : {{ type_decl.type }}) 47 | @inner.{{ type_decl.var.id }} = Torrent::Util::Endian.to_network(val) 48 | end 49 | {% else %} 50 | def {{ type_decl.var.id }} : {{ type_decl.type }} 51 | @inner.{{ type_decl.var.id }} 52 | end 53 | 54 | def {{ type_decl.var.id }}=(val : {{ type_decl.type }}) 55 | @inner.{{ type_decl.var.id }} = val 56 | end 57 | {% end %} 58 | {% end %} 59 | 60 | def initialize(@inner) 61 | end 62 | 63 | def initialize 64 | @inner = T.new 65 | end 66 | 67 | def initialize( 68 | {% for decl in type_decls %}{{ decl.var.id }} : {{ decl.type }}, {% end %} 69 | ) 70 | @inner = T.new 71 | 72 | {% for decl in type_decls %} 73 | @inner.{{ decl.var.id }} = {% if %w[ Int16 Int32 Int64 UInt16 UInt32 UInt64 ].includes?(decl.type.id.stringify) %} 74 | Torrent::Util::Endian.to_network({{ decl.var.id }}) 75 | {% else %} 76 | {{ decl.var.id }} 77 | {% end %} 78 | {% end %} 79 | end 80 | 81 | def ==(other_inner) 82 | @inner == other_inner 83 | end 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /src/torrent/util/random.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Util 3 | module Random 4 | # Returns *count* random bytes. 5 | def self.bytes(count, random = ::Random::DEFAULT) 6 | Bytes.new(count){ random.rand(0..0xFF).to_u8 } 7 | end 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /src/torrent/util/request_list.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Util 3 | class RequestList 4 | class Piece 5 | 6 | # The piece to copying 7 | getter index : UInt32 8 | 9 | # Total size of the piece 10 | getter size : UInt32 11 | 12 | # Size of each block in bytes 13 | getter block_size : UInt32 14 | 15 | # Block completion status 16 | getter progress : Array(Bool) 17 | 18 | # Count of blocks in the piece 19 | getter count : Int32 20 | 21 | # Moment the piece was requested 22 | getter request_time : Time 23 | 24 | def initialize(@index, @size, @block_size) 25 | @count = (@size / @block_size).to_i32 26 | @count += 1 unless @size.divisible_by? @block_size 27 | @progress = Array(Bool).new(@count, false) 28 | @request_time = Time.now 29 | end 30 | 31 | def complete? : Bool 32 | @progress.all?{|el| el == true} 33 | end 34 | 35 | def complete?(block_idx) : Bool 36 | @progress[block_idx] 37 | end 38 | 39 | def offset_to_block(offset) 40 | raise ArgumentError.new("Invalid offset: Not divisible by block size") unless offset.divisible_by?(@block_size) 41 | 42 | offset / @block_size 43 | end 44 | 45 | def mark_complete(block_idx) 46 | @progress[block_idx] = true 47 | complete? 48 | end 49 | 50 | # Count of block requests done 51 | def count_done 52 | @progress.count(&.itself) 53 | end 54 | 55 | def to_a 56 | Array.new(@count) do |idx| 57 | tuple(idx) 58 | end 59 | end 60 | 61 | def tuple(block_index) 62 | off = block_index.to_u32 * @block_size 63 | size = @block_size 64 | 65 | # The very last block is most likely shorter than the standard block 66 | # size. 67 | if !@size.divisible_by?(size) && block_index + 1 == @progress.size 68 | size = @size % @block_size 69 | end 70 | 71 | { @index, off, size } 72 | end 73 | end 74 | 75 | # Size of requested blocks in a piece. 16KiB looks to be a popular value. 76 | REQUEST_BLOCK_SIZE = (16 * 1024).to_u32 77 | 78 | # Bitfield given out to clients. Only contains completed pieces. 79 | getter public_bitfield : Bitfield 80 | 81 | # Bitfield for book-keeping. Contains completed and in-progress pieces. 82 | getter private_bitfield : Bitfield 83 | 84 | # Total size of the torrent 85 | getter total_size : UInt64 86 | 87 | # Open piece requests 88 | getter pieces : Array(Tuple(Client::Peer, Piece)) 89 | 90 | # Creates a RequestList for a *piece_count* long transfer of *total_size* 91 | # in bytes. If a *bitfield* is given, it is used, else a sufficient one 92 | # in size will be created. 93 | def initialize(piece_count, @total_size : UInt64, bitfield = nil) 94 | if bitfield.nil? 95 | bitfield = Bitfield.new(piece_count) 96 | else 97 | raise ArgumentError.new("Bitfield is not large enough") if bitfield.size < piece_count 98 | end 99 | 100 | @public_bitfield = bitfield 101 | @private_bitfield = bitfield.clone 102 | @pieces = Array(Tuple(Client::Peer, Piece)).new 103 | end 104 | 105 | def find_piece(piece_index : UInt32) 106 | @pieces.find do |_peer, piece| 107 | piece.index == piece_index 108 | end 109 | end 110 | 111 | def find_piece(peer : Client::Peer, piece_index : UInt32) 112 | @pieces.each do |remote_peer, piece| 113 | return piece if peer == remote_peer && piece.index == piece_index 114 | end 115 | 116 | nil 117 | end 118 | 119 | # Cancels the download of *piece* from *peer*. 120 | def cancel_piece(peer, piece : Piece) 121 | idx = find_index(peer, piece){ nil } 122 | return if idx.nil? 123 | @pieces.delete_at(idx) 124 | 125 | # Clear piece from private bitfield IF no other peer is downloading it. 126 | # This happens in end-game mode. 127 | if @pieces.find{|_peer, other_piece| other_piece.index == piece.index}.nil? 128 | @private_bitfield[piece.index] = false 129 | end 130 | end 131 | 132 | # Cancels all requests of *peer*. 133 | def cancel_all_pieces(peer) 134 | @pieces.reject! do |remote_peer, piece| 135 | if remote_peer == peer 136 | @private_bitfield[piece.index] = false 137 | true 138 | end 139 | end 140 | end 141 | 142 | # Adds a request to *peer* for *piece_index*, and returns the `Piece`. 143 | # Also updates the private bitfield. 144 | def add(peer, piece_index : UInt32) : Piece 145 | size = peer.transfer.piece_size.to_u32 146 | 147 | @private_bitfield[piece_index] = true # Mark as in progress 148 | if piece_index == @private_bitfield.size - 1 # Last piece? 149 | size = last_piece_size(size).to_u32 150 | end 151 | 152 | piece = Piece.new(piece_index, size, REQUEST_BLOCK_SIZE) 153 | @pieces << { peer, piece } 154 | piece 155 | end 156 | 157 | def finalize_piece(peer, piece) 158 | idx = find_index(peer, piece){ raise Error.new("Piece not found") } 159 | @pieces.delete_at(idx) 160 | @public_bitfield[piece.index] = true 161 | end 162 | 163 | def pieces_of_peer(peer) 164 | pieces = Array(Piece).new 165 | 166 | @pieces.each do |remote_peer, piece| 167 | pieces << piece if remote_peer == peer 168 | end 169 | 170 | pieces 171 | end 172 | 173 | # Used for end-game mode to find all peers where a piece was requested 174 | # from. 175 | def peers_with_piece(piece_index) 176 | peers = Array(Client::Peer).new 177 | 178 | @pieces.each do |peer, piece| 179 | peers << peer if piece.index == piece_index 180 | end 181 | 182 | peers 183 | end 184 | 185 | private def last_piece_size(piece_size) 186 | if @total_size.divisible_by? piece_size 187 | piece_size 188 | else 189 | @total_size % piece_size 190 | end 191 | end 192 | 193 | private def find_index(peer, piece) 194 | @pieces.each_with_index do |(remote_peer, other_piece), idx| 195 | return idx if peer == remote_peer && other_piece == piece 196 | end 197 | 198 | yield # Not found block 199 | end 200 | end 201 | end 202 | end 203 | -------------------------------------------------------------------------------- /src/torrent/util/spawn.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Util 3 | # Helper which returns the full name of the caller of the method which calls 4 | # this method. 5 | def self.caller_name(frames_up = 2) : String 6 | backtrace = caller 7 | idx = backtrace.size - 4 - frames_up 8 | line = backtrace[idx.clamp(0, backtrace.size - 1)] 9 | 10 | off = line.index(' ') 11 | return line if off.nil? 12 | 13 | off += 1 14 | off += 1 if line[off] == '*' 15 | 16 | bracket = line.index('<', off) 17 | 18 | return line[off..-1] if bracket.nil? 19 | line[off...bracket] 20 | end 21 | 22 | # Spawns the given block in a new fiber. The fiber is automatically named 23 | # based on the caller of this method. If the fiber crashes, it's logged to 24 | # `Torrent.logger`, and if *fatal* is `true` (Which is the default), the 25 | # program is ended through `exit`. 26 | def self.spawn(name : String = caller_name, fatal = true, &block) 27 | ::spawn(name: name) do 28 | begin 29 | block.call 30 | rescue error 31 | log = Logger.new("Fiber") 32 | log.error "Uncaught error in fiber #{Fiber.current}" 33 | log.error error 34 | 35 | exit 1 if fatal 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /src/torrent/util/udp_socket.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | module Util 3 | class UdpSocket < ::UDPSocket 4 | MTU = 1500 5 | 6 | def read(buffer, timeout) 7 | self.read_timeout = timeout 8 | read(buffer) 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /src/torrent/version.cr: -------------------------------------------------------------------------------- 1 | module Torrent 2 | VERSION = "0.2.0" 3 | end 4 | --------------------------------------------------------------------------------