Initial commit: Naut-Torrent — from-scratch 10 GbE BitTorrent client
A maintainable, extensible BitTorrent client (C11, Linux/io_uring) targeting 10 GbE saturation. All torrent functionality is built from scratch; liburing is the only linked third-party dependency on the data path. Implements Phases 1-7 of the roadmap: - core: page-aligned buffer pool, MPMC/Treiber queues, bitfields, worker pool - crypto: SHA-1/256 (SHA-NI + scalar), Merkle (BEP-52), RC4 (MSE) - bencode/metainfo: zero-copy parser, v1/v2/hybrid .torrent + magnet - peer: sans-IO wire codec, MSE/PE handshake state machine, BEP-10, ut_metadata, PEX - piece/storage: block-level multi-peer engine, rarest-first + endgame, per-file completion events + single-file relocate (move-as-you-finish) - tracker/dht: HTTP + UDP (BEP-15) trackers, BEP-5 KRPC iterative lookup - platform: io_uring reactor (SQPOLL, registered buffers, SEND_ZC) - surface: versioned RPC, native plugin ABI, sandboxed Lua scripting, nautd/nautctl Verified against libtorrent (single/multi/hybrid, MSE, magnet-via-DHT, swarm); unit + interop tests green; ASan/UBSan/TSan clean. Scripting reference in docs/scripting.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
2178d6a70c
121 changed files with 12644 additions and 0 deletions
38
tests/integration/dht_fixture.py
Normal file
38
tests/integration/dht_fixture.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Minimal BEP-5 get_peers responder for the trackerless magnet gate."""
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
|
||||
peer_port = int(sys.argv[1])
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
print("DHT_PORT %d" % sock.getsockname()[1], flush=True)
|
||||
|
||||
node_id = bytes(range(20))
|
||||
compact_peer = socket.inet_aton("127.0.0.1") + struct.pack("!H", peer_port)
|
||||
|
||||
while True:
|
||||
packet, address = sock.recvfrom(65535)
|
||||
marker = b"1:t"
|
||||
start = packet.find(marker)
|
||||
if start < 0:
|
||||
continue
|
||||
length_start = start + len(marker)
|
||||
colon = packet.find(b":", length_start)
|
||||
if colon < 0:
|
||||
continue
|
||||
try:
|
||||
tx_len = int(packet[length_start:colon])
|
||||
except ValueError:
|
||||
continue
|
||||
tx = packet[colon + 1:colon + 1 + tx_len]
|
||||
if len(tx) != tx_len:
|
||||
continue
|
||||
response = (
|
||||
b"d1:rd2:id20:" + node_id +
|
||||
b"6:valuesl6:" + compact_peer +
|
||||
b"ee1:t" + str(tx_len).encode() + b":" + tx +
|
||||
b"1:y1:re"
|
||||
)
|
||||
sock.sendto(response, address)
|
||||
69
tests/integration/echo_client.py
Normal file
69
tests/integration/echo_client.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Exercise the io_uring fixed-buffer/SEND_ZC echo path with exact data."""
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
port = int(sys.argv[1])
|
||||
total = int(sys.argv[2]) if len(sys.argv) > 2 else 64 * 1024 * 1024
|
||||
connections = int(sys.argv[3]) if len(sys.argv) > 3 else 1
|
||||
chunk = bytes((i * 31 + 7) & 0xFF for i in range(128 * 1024))
|
||||
|
||||
start = time.monotonic()
|
||||
errors = []
|
||||
|
||||
def connection_worker(connection_bytes):
|
||||
sock = socket.create_connection(("127.0.0.1", port), timeout=5)
|
||||
send_error = []
|
||||
|
||||
def sender():
|
||||
try:
|
||||
sent = 0
|
||||
while sent < connection_bytes:
|
||||
payload = chunk[:min(len(chunk), connection_bytes - sent)]
|
||||
sock.sendall(payload)
|
||||
sent += len(payload)
|
||||
sock.shutdown(socket.SHUT_WR)
|
||||
except Exception as error:
|
||||
send_error.append(error)
|
||||
|
||||
thread = threading.Thread(target=sender)
|
||||
thread.start()
|
||||
try:
|
||||
done = 0
|
||||
while done < connection_bytes:
|
||||
part = sock.recv(min(1024 * 1024, connection_bytes - done))
|
||||
if not part:
|
||||
raise RuntimeError("echo server closed early")
|
||||
offset = 0
|
||||
while offset < len(part):
|
||||
pattern_offset = (done + offset) % len(chunk)
|
||||
count = min(len(part) - offset, len(chunk) - pattern_offset)
|
||||
if part[offset:offset + count] != chunk[pattern_offset:pattern_offset + count]:
|
||||
raise RuntimeError("echo content mismatch")
|
||||
offset += count
|
||||
done += len(part)
|
||||
thread.join()
|
||||
if send_error:
|
||||
raise send_error[0]
|
||||
except Exception as error:
|
||||
errors.append(error)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
per_connection = total // connections
|
||||
workers = [
|
||||
threading.Thread(target=connection_worker, args=(per_connection,))
|
||||
for _ in range(connections)
|
||||
]
|
||||
for worker in workers:
|
||||
worker.start()
|
||||
for worker in workers:
|
||||
worker.join()
|
||||
if errors:
|
||||
raise errors[0]
|
||||
elapsed = time.monotonic() - start
|
||||
print("ECHO %.2f Gbit/s (%d connections)" %
|
||||
(per_connection * connections * 8 / elapsed / 1e9, connections),
|
||||
flush=True)
|
||||
31
tests/integration/http_tracker.py
Normal file
31
tests/integration/http_tracker.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Minimal compact-peer HTTP tracker for the Phase 4 integration test."""
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
|
||||
peer_port = int(sys.argv[1])
|
||||
body = (
|
||||
b"d8:intervali1800e5:peers6:"
|
||||
+ socket.inet_aton("127.0.0.1")
|
||||
+ struct.pack("!H", peer_port)
|
||||
+ b"e"
|
||||
)
|
||||
|
||||
|
||||
class Tracker(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
print("REQUEST " + (fmt % args), flush=True)
|
||||
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), Tracker)
|
||||
print("PORT %d" % server.server_port, flush=True)
|
||||
server.serve_forever()
|
||||
59
tests/integration/run_echo_scale.sh
Normal file
59
tests/integration/run_echo_scale.sh
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env bash
|
||||
# Phase 6 local platform gate: fixed-buffer recv + SEND_ZC echo integrity.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
ECHO="${1:-$ROOT/build/naut_echo}"
|
||||
PORT="${NAUT_ECHO_PORT:-39127}"
|
||||
LOG="$(mktemp /tmp/naut_echo_scale.XXXXXX)"
|
||||
cleanup() {
|
||||
[ -n "${server:-}" ] && kill -TERM "$server" 2>/dev/null || true
|
||||
[ -n "${server:-}" ] && wait "$server" 2>/dev/null || true
|
||||
rm -f "$LOG"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
NAUT_SQPOLL=1 "$ECHO" "$PORT" >"$LOG" 2>&1 &
|
||||
server=$!
|
||||
for _ in $(seq 1 100); do
|
||||
grep -q "echo listening" "$LOG" && break
|
||||
kill -0 "$server" 2>/dev/null || {
|
||||
echo "FAIL: echo server exited"; cat "$LOG"; exit 1;
|
||||
}
|
||||
sleep 0.05
|
||||
done
|
||||
grep -q "echo listening" "$LOG" || {
|
||||
echo "FAIL: echo server did not listen"; cat "$LOG"; exit 1;
|
||||
}
|
||||
|
||||
BYTES=$((256 * 1024 * 1024))
|
||||
CONNS=8
|
||||
python3 "$ROOT/tests/integration/echo_client.py" "$PORT" "$BYTES" "$CONNS"
|
||||
kill -TERM "$server"
|
||||
wait "$server"
|
||||
server=""
|
||||
grep -q "bytes echoed" "$LOG" || {
|
||||
echo "FAIL: echo server did not shut down cleanly"; cat "$LOG"; exit 1;
|
||||
}
|
||||
grep "bytes echoed" "$LOG"
|
||||
|
||||
# Server-side accounting must match exactly: every byte the client sent was
|
||||
# echoed back, and no connection was dropped mid-stream. (A truncated echo is
|
||||
# also caught client-side, but asserting the count here makes a server-side
|
||||
# drop a hard, deterministic failure rather than a timing-dependent one.)
|
||||
echoed=$(grep -oE '[0-9]+ bytes echoed' "$LOG" | grep -oE '^[0-9]+')
|
||||
if [ "$echoed" != "$BYTES" ]; then
|
||||
echo "FAIL: echoed $echoed bytes, expected $BYTES (a connection was dropped)"
|
||||
cat "$LOG"; exit 1
|
||||
fi
|
||||
conns=$(grep -oE '[0-9]+ conns' "$LOG" | grep -oE '^[0-9]+')
|
||||
if [ "$conns" != "$CONNS" ]; then
|
||||
echo "FAIL: served $conns connections, expected $CONNS"; cat "$LOG"; exit 1
|
||||
fi
|
||||
|
||||
# Report whether the SQPOLL path was actually exercised (it falls back cleanly
|
||||
# where the kernel/privileges disallow it — informational, not a failure).
|
||||
if grep -q "sqpoll" "$LOG"; then
|
||||
echo "PASS: io_uring SQPOLL fixed-buffer/SEND_ZC echo path is byte-correct"
|
||||
else
|
||||
echo "PASS: io_uring echo path byte-correct (SQPOLL unavailable, used fallback)"
|
||||
fi
|
||||
41
tests/integration/run_interop.sh
Normal file
41
tests/integration/run_interop.sh
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
#!/usr/bin/env bash
|
||||
# Phase 3 interop gate: seed each fixture torrent with libtorrent and download
|
||||
# it with the from-scratch naut_leech, asserting a byte-identical content tree.
|
||||
# Skips (exit 77 = ctest SKIP) if python libtorrent is unavailable.
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
LEECH="${1:-$ROOT/build/naut_leech}"
|
||||
SEEDER="$ROOT/tests/integration/seeder.py"
|
||||
DATA="$ROOT/tests/fixtures/data"
|
||||
|
||||
python3 -c 'import libtorrent' 2>/dev/null || { echo "SKIP: python libtorrent not available"; exit 77; }
|
||||
[ -x "$LEECH" ] || { echo "FAIL: $LEECH not built"; exit 1; }
|
||||
|
||||
fail=0
|
||||
run_one() {
|
||||
local tor="$1" cmp="$2"
|
||||
local out; out="$(mktemp -d /tmp/naut_interop.XXXXXX)"
|
||||
local log; log="$(mktemp /tmp/naut_seed.XXXXXX)"
|
||||
python3 "$SEEDER" "$ROOT/tests/fixtures/$tor" "$DATA" > "$log" 2>&1 &
|
||||
local seed=$!
|
||||
local port=""
|
||||
for _ in $(seq 1 100); do port=$(grep -oP 'PORT \K[0-9]+' "$log" 2>/dev/null); [ -n "$port" ] && break; sleep 0.1; done
|
||||
if [ -z "$port" ]; then echo "FAIL[$tor]: seeder did not start"; cat "$log"; kill "$seed" 2>/dev/null; fail=1; rm -rf "$out" "$log"; return; fi
|
||||
|
||||
if timeout 30 "$LEECH" "$ROOT/tests/fixtures/$tor" "$out" 127.0.0.1 "$port" 2>&1 | grep -q "COMPLETE"; then
|
||||
if diff -r "$out/$cmp" "$DATA/$cmp" >/dev/null; then
|
||||
echo "PASS[$tor]: byte-identical content tree"
|
||||
else
|
||||
echo "FAIL[$tor]: content mismatch"; fail=1
|
||||
fi
|
||||
else
|
||||
echo "FAIL[$tor]: download did not complete"; fail=1
|
||||
fi
|
||||
kill "$seed" 2>/dev/null; rm -rf "$out" "$log"
|
||||
}
|
||||
|
||||
run_one single_v1.torrent single.bin
|
||||
run_one multi_v1.torrent multi
|
||||
run_one hybrid.torrent multi
|
||||
|
||||
exit $fail
|
||||
65
tests/integration/run_magnet_dht.sh
Normal file
65
tests/integration/run_magnet_dht.sh
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env bash
|
||||
# Phase 5 gate: trackerless magnet -> DHT peer -> ut_metadata -> verified data.
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
SWARM="${1:-$ROOT/build/naut_swarm}"
|
||||
SEEDER="$ROOT/tests/integration/seeder.py"
|
||||
DHT="$ROOT/tests/integration/dht_fixture.py"
|
||||
TORRENT="$ROOT/tests/fixtures/single_v1.torrent"
|
||||
DATA="$ROOT/tests/fixtures/data"
|
||||
MAGNET="magnet:?xt=urn:btih:7f2555dfd18ba1c4a024264e939d7a5f8b25311b"
|
||||
|
||||
python3 -c 'import libtorrent' 2>/dev/null || {
|
||||
echo "SKIP: python libtorrent not available"; exit 77;
|
||||
}
|
||||
[ -x "$SWARM" ] || { echo "FAIL: $SWARM not built"; exit 1; }
|
||||
|
||||
out="$(mktemp -d /tmp/naut_magnet.XXXXXX)"
|
||||
seed_log="$(mktemp /tmp/naut_magnet_seed.XXXXXX)"
|
||||
dht_log="$(mktemp /tmp/naut_magnet_dht.XXXXXX)"
|
||||
client_log="$(mktemp /tmp/naut_magnet_client.XXXXXX)"
|
||||
cleanup() {
|
||||
[ -n "${seed:-}" ] && kill "$seed" 2>/dev/null || true
|
||||
[ -n "${dht_pid:-}" ] && kill "$dht_pid" 2>/dev/null || true
|
||||
rm -rf "$out" "$seed_log" "$dht_log" "$client_log"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
python3 "$SEEDER" "$TORRENT" "$DATA" >"$seed_log" 2>&1 &
|
||||
seed=$!
|
||||
peer_port=""
|
||||
for _ in $(seq 1 100); do
|
||||
peer_port=$(grep -oP 'PORT \K[0-9]+' "$seed_log" 2>/dev/null)
|
||||
[ -n "$peer_port" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[ -n "$peer_port" ] || {
|
||||
echo "FAIL: seeder did not start"; cat "$seed_log"; exit 1;
|
||||
}
|
||||
|
||||
python3 "$DHT" "$peer_port" >"$dht_log" 2>&1 &
|
||||
dht_pid=$!
|
||||
dht_port=""
|
||||
for _ in $(seq 1 100); do
|
||||
dht_port=$(grep -oP 'DHT_PORT \K[0-9]+' "$dht_log" 2>/dev/null)
|
||||
[ -n "$dht_port" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[ -n "$dht_port" ] || {
|
||||
echo "FAIL: DHT fixture did not start"; cat "$dht_log"; exit 1;
|
||||
}
|
||||
|
||||
if ! NAUT_DHT_BOOTSTRAP="127.0.0.1:$dht_port" \
|
||||
timeout 30 "$SWARM" "$MAGNET" "$out" >"$client_log" 2>&1; then
|
||||
echo "FAIL: trackerless magnet download did not complete"
|
||||
cat "$client_log"
|
||||
cat "$seed_log"
|
||||
exit 1
|
||||
fi
|
||||
grep -q "magnet metadata verified" "$client_log" || {
|
||||
echo "FAIL: metadata was not verified"; cat "$client_log"; exit 1;
|
||||
}
|
||||
diff -r "$out/single.bin" "$DATA/single.bin" >/dev/null || {
|
||||
echo "FAIL: magnet content mismatch"; exit 1;
|
||||
}
|
||||
echo "PASS: DHT-only magnet completed with verified metadata and content"
|
||||
49
tests/integration/run_mse.sh
Normal file
49
tests/integration/run_mse.sh
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
#!/usr/bin/env bash
|
||||
# Phase 5 MSE gate: a libtorrent seed requires encrypted incoming connections.
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
LEECH="${1:-$ROOT/build/naut_leech}"
|
||||
SEEDER="$ROOT/tests/integration/seeder.py"
|
||||
TORRENT="$ROOT/tests/fixtures/single_v1.torrent"
|
||||
DATA="$ROOT/tests/fixtures/data"
|
||||
|
||||
python3 -c 'import libtorrent' 2>/dev/null || {
|
||||
echo "SKIP: python libtorrent not available"; exit 77;
|
||||
}
|
||||
[ -x "$LEECH" ] || { echo "FAIL: $LEECH not built"; exit 1; }
|
||||
|
||||
out="$(mktemp -d /tmp/naut_mse.XXXXXX)"
|
||||
log="$(mktemp /tmp/naut_mse_seed.XXXXXX)"
|
||||
client_log="$(mktemp /tmp/naut_mse_client.XXXXXX)"
|
||||
cleanup() {
|
||||
[ -n "${seed:-}" ] && kill "$seed" 2>/dev/null || true
|
||||
rm -rf "$out" "$log" "$client_log"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
NAUT_FORCE_MSE=1 python3 "$SEEDER" "$TORRENT" "$DATA" > "$log" 2>&1 &
|
||||
seed=$!
|
||||
port=""
|
||||
for _ in $(seq 1 100); do
|
||||
port=$(grep -oP 'PORT \K[0-9]+' "$log" 2>/dev/null)
|
||||
[ -n "$port" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[ -n "$port" ] || { echo "FAIL: encrypted seeder did not start"; cat "$log"; exit 1; }
|
||||
|
||||
if ! timeout 30 "$LEECH" --mse "$TORRENT" "$out" 127.0.0.1 "$port" \
|
||||
>"$client_log" 2>&1; then
|
||||
echo "FAIL: MSE download did not complete"
|
||||
cat "$client_log"
|
||||
cat "$log"
|
||||
exit 1
|
||||
fi
|
||||
grep -q "COMPLETE" "$client_log" || {
|
||||
echo "FAIL: client exited without completing"
|
||||
cat "$client_log"
|
||||
exit 1
|
||||
}
|
||||
diff -r "$out/single.bin" "$DATA/single.bin" >/dev/null || {
|
||||
echo "FAIL: MSE content mismatch"; exit 1;
|
||||
}
|
||||
echo "PASS: forced MSE/RC4 download is byte-identical"
|
||||
98
tests/integration/run_phase7.sh
Normal file
98
tests/integration/run_phase7.sh
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
daemon=$1
|
||||
ctl=$2
|
||||
plugin=$3
|
||||
script=$4
|
||||
tmp=$(mktemp -d)
|
||||
socket="$tmp/nautd.sock"
|
||||
daemon_log="$tmp/nautd.log"
|
||||
events_log="$tmp/events.log"
|
||||
|
||||
cleanup() {
|
||||
result=$?
|
||||
if [[ -n "${daemon_pid:-}" ]]; then
|
||||
kill "$daemon_pid" 2>/dev/null || true
|
||||
wait "$daemon_pid" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n "${events_pid:-}" ]]; then
|
||||
kill "$events_pid" 2>/dev/null || true
|
||||
wait "$events_pid" 2>/dev/null || true
|
||||
fi
|
||||
if [[ "$result" -ne 0 ]]; then
|
||||
cat "$daemon_log" >&2 2>/dev/null || true
|
||||
cat "$events_log" >&2 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$tmp"
|
||||
return "$result"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
"$daemon" --socket "$socket" --plugin "$plugin" --script "$script" \
|
||||
>"$daemon_log" 2>&1 &
|
||||
daemon_pid=$!
|
||||
|
||||
for _ in $(seq 1 100); do
|
||||
[[ -S "$socket" ]] && break
|
||||
sleep 0.02
|
||||
done
|
||||
[[ -S "$socket" ]]
|
||||
|
||||
"$ctl" --socket "$socket" ping | grep -q '"service": "nautd"'
|
||||
"$ctl" --socket "$socket" plugins | grep -q '"memory"'
|
||||
|
||||
timeout 5 "$ctl" --socket "$socket" events >"$events_log" &
|
||||
events_pid=$!
|
||||
sleep 0.1
|
||||
"$ctl" --socket "$socket" emit \
|
||||
'{"type":"torrent_finished","torrent_id":7}' >/dev/null
|
||||
|
||||
status=
|
||||
for _ in $(seq 1 100); do
|
||||
status=$("$ctl" --socket "$socket" status)
|
||||
if grep -q '"move_commands": 1' <<<"$status" &&
|
||||
grep -q '"handled": 1' <<<"$status"; then
|
||||
break
|
||||
fi
|
||||
sleep 0.02
|
||||
done
|
||||
grep -q '"move_commands": 1' <<<"$status"
|
||||
grep -q '"handled": 1' <<<"$status"
|
||||
grep -q '"errors": 0' <<<"$status"
|
||||
|
||||
plugin_status=$("$ctl" --socket "$socket" example.events)
|
||||
grep -q '"finished": 1' <<<"$plugin_status"
|
||||
|
||||
for _ in $(seq 1 100); do
|
||||
grep -q '"event": "torrent_finished"' "$events_log" && break
|
||||
sleep 0.02
|
||||
done
|
||||
grep -q '"event": "torrent_finished"' "$events_log"
|
||||
|
||||
# --- end-to-end move-as-you-finish: register a real torrent's storage, fire a
|
||||
# file_complete event, and confirm the script-driven move actually relocates the
|
||||
# file on disk (script thread -> bounded queue -> owner thread -> storage). ----
|
||||
fixtures=$(dirname "$script")
|
||||
root="$tmp/torrent-data"
|
||||
"$ctl" --socket "$socket" add_torrent \
|
||||
"{\"torrent_id\":42,\"torrent\":\"$fixtures/single_v1.torrent\",\"root\":\"$root\"}" \
|
||||
| grep -q '"ok": true'
|
||||
|
||||
src=$(find "$root" -type f | head -n1)
|
||||
[[ -n "$src" ]]
|
||||
|
||||
"$ctl" --socket "$socket" emit \
|
||||
"{\"type\":\"file_complete\",\"torrent_id\":42,\"index\":0,\"path\":\"$src\"}" \
|
||||
>/dev/null
|
||||
|
||||
for _ in $(seq 1 100); do
|
||||
[[ -f "$src.moved" ]] && break
|
||||
sleep 0.02
|
||||
done
|
||||
[[ -f "$src.moved" ]]
|
||||
[[ ! -f "$src" ]]
|
||||
|
||||
"$ctl" --socket "$socket" shutdown >/dev/null
|
||||
wait "$daemon_pid"
|
||||
daemon_pid=
|
||||
69
tests/integration/run_swarm.sh
Normal file
69
tests/integration/run_swarm.sh
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
#!/usr/bin/env bash
|
||||
# Phase 4 interop gate: download one torrent concurrently from two independent
|
||||
# libtorrent seeds and require both peers to contribute blocks.
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
SWARM="${1:-$ROOT/build/naut_swarm}"
|
||||
SEEDER="$ROOT/tests/integration/seeder.py"
|
||||
TOR="$ROOT/tests/fixtures/single_v1.torrent"
|
||||
DATA="$ROOT/tests/fixtures/data"
|
||||
|
||||
python3 -c 'import libtorrent' 2>/dev/null || {
|
||||
echo "SKIP: python libtorrent not available"
|
||||
exit 77
|
||||
}
|
||||
[ -x "$SWARM" ] || { echo "FAIL: $SWARM not built"; exit 1; }
|
||||
|
||||
out="$(mktemp -d /tmp/naut_swarm.XXXXXX)"
|
||||
log1="$(mktemp /tmp/naut_seed1.XXXXXX)"
|
||||
log2="$(mktemp /tmp/naut_seed2.XXXXXX)"
|
||||
slog="$(mktemp /tmp/naut_swarm_log.XXXXXX)"
|
||||
seed1=""
|
||||
seed2=""
|
||||
|
||||
cleanup() {
|
||||
[ -n "$seed1" ] && kill "$seed1" 2>/dev/null || true
|
||||
[ -n "$seed2" ] && kill "$seed2" 2>/dev/null || true
|
||||
rm -rf "$out" "$log1" "$log2" "$slog"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
python3 "$SEEDER" "$TOR" "$DATA" > "$log1" 2>&1 &
|
||||
seed1=$!
|
||||
python3 "$SEEDER" "$TOR" "$DATA" > "$log2" 2>&1 &
|
||||
seed2=$!
|
||||
|
||||
port1=""
|
||||
port2=""
|
||||
for _ in $(seq 1 100); do
|
||||
port1="$(grep -oP 'PORT \K[0-9]+' "$log1" 2>/dev/null || true)"
|
||||
port2="$(grep -oP 'PORT \K[0-9]+' "$log2" 2>/dev/null || true)"
|
||||
[ -n "$port1" ] && [ "$port1" != 0 ] &&
|
||||
[ -n "$port2" ] && [ "$port2" != 0 ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
if [ -z "$port1" ] || [ "$port1" = 0 ] ||
|
||||
[ -z "$port2" ] || [ "$port2" = 0 ]; then
|
||||
echo "FAIL: seeders did not start"
|
||||
cat "$log1" "$log2"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! timeout 30 "$SWARM" "$TOR" "$out" \
|
||||
"127.0.0.1:$port1" "127.0.0.1:$port2" > "$slog" 2>&1; then
|
||||
echo "FAIL: swarm download did not complete"
|
||||
cat "$slog"
|
||||
exit 1
|
||||
fi
|
||||
if ! diff -r "$out/single.bin" "$DATA/single.bin" >/dev/null; then
|
||||
echo "FAIL: content mismatch"
|
||||
exit 1
|
||||
fi
|
||||
contributors="$(grep -Ec 'delivered [1-9][0-9]* blocks' "$slog" || true)"
|
||||
if [ "$contributors" -lt 2 ]; then
|
||||
echo "FAIL: expected both peers to contribute"
|
||||
cat "$slog"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PASS: two-peer swarm produced byte-identical output"
|
||||
129
tests/integration/run_tracker_swarm.sh
Normal file
129
tests/integration/run_tracker_swarm.sh
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
#!/usr/bin/env bash
|
||||
# Phase 4 tracker gate: discover a real libtorrent seed from a localhost HTTP
|
||||
# tracker, then complete the download without explicit peer arguments.
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
SWARM="${1:-$ROOT/build/naut_swarm}"
|
||||
MODE="${2:-http}"
|
||||
SEEDER="$ROOT/tests/integration/seeder.py"
|
||||
if [ "$MODE" = "udp" ]; then
|
||||
TRACKER="$ROOT/tests/integration/udp_tracker.py"
|
||||
else
|
||||
TRACKER="$ROOT/tests/integration/http_tracker.py"
|
||||
fi
|
||||
SOURCE_TOR="$ROOT/tests/fixtures/single_v1.torrent"
|
||||
DATA="$ROOT/tests/fixtures/data"
|
||||
|
||||
python3 -c 'import libtorrent' 2>/dev/null || {
|
||||
echo "SKIP: python libtorrent not available"
|
||||
exit 77
|
||||
}
|
||||
[ -x "$SWARM" ] || { echo "FAIL: $SWARM not built"; exit 1; }
|
||||
|
||||
out="$(mktemp -d /tmp/naut_tracker_swarm.XXXXXX)"
|
||||
seed_log="$(mktemp /tmp/naut_tracker_seed.XXXXXX)"
|
||||
tracker_log="$(mktemp /tmp/naut_http_tracker.XXXXXX)"
|
||||
swarm_log="$(mktemp /tmp/naut_tracker_client.XXXXXX)"
|
||||
tor="$(mktemp /tmp/naut_tracker.XXXXXX.torrent)"
|
||||
seed=""
|
||||
tracker=""
|
||||
|
||||
cleanup() {
|
||||
[ -n "$seed" ] && kill "$seed" 2>/dev/null || true
|
||||
[ -n "$tracker" ] && kill "$tracker" 2>/dev/null || true
|
||||
rm -rf "$out" "$seed_log" "$tracker_log" "$swarm_log" "$tor"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
python3 "$SEEDER" "$SOURCE_TOR" "$DATA" > "$seed_log" 2>&1 &
|
||||
seed=$!
|
||||
seed_port=""
|
||||
for _ in $(seq 1 100); do
|
||||
seed_port="$(grep -oP 'PORT \K[0-9]+' "$seed_log" 2>/dev/null || true)"
|
||||
[ -n "$seed_port" ] && [ "$seed_port" != 0 ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
if [ -z "$seed_port" ] || [ "$seed_port" = 0 ]; then
|
||||
echo "FAIL: seeder did not start"
|
||||
cat "$seed_log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 "$TRACKER" "$seed_port" > "$tracker_log" 2>&1 &
|
||||
tracker=$!
|
||||
tracker_port=""
|
||||
for _ in $(seq 1 100); do
|
||||
tracker_port="$(grep -oP 'PORT \K[0-9]+' "$tracker_log" 2>/dev/null || true)"
|
||||
[ -n "$tracker_port" ] && break
|
||||
sleep 0.05
|
||||
done
|
||||
if [ -z "$tracker_port" ]; then
|
||||
echo "FAIL: tracker did not start"
|
||||
cat "$tracker_log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Replace only the top-level dictionary. The raw info dictionary is copied
|
||||
# byte-for-byte so its SHA-1 info hash remains unchanged.
|
||||
if [ "$MODE" = "udp" ]; then
|
||||
announce="udp://127.0.0.1:$tracker_port/announce"
|
||||
else
|
||||
announce="http://127.0.0.1:$tracker_port/announce"
|
||||
fi
|
||||
python3 - "$SOURCE_TOR" "$tor" "$announce" <<'PY'
|
||||
import sys
|
||||
|
||||
|
||||
def skip(data, pos):
|
||||
token = data[pos]
|
||||
if token == ord("i"):
|
||||
return data.index(b"e", pos) + 1
|
||||
if token in (ord("l"), ord("d")):
|
||||
pos += 1
|
||||
while data[pos] != ord("e"):
|
||||
pos = skip(data, pos)
|
||||
if token == ord("d"):
|
||||
pos = skip(data, pos)
|
||||
return pos + 1
|
||||
colon = data.index(b":", pos)
|
||||
size = int(data[pos:colon])
|
||||
return colon + 1 + size
|
||||
|
||||
|
||||
source, target, announce = sys.argv[1], sys.argv[2], sys.argv[3].encode()
|
||||
data = open(source, "rb").read()
|
||||
pos = 1
|
||||
raw_info = None
|
||||
while data[pos] != ord("e"):
|
||||
colon = data.index(b":", pos)
|
||||
key_len = int(data[pos:colon])
|
||||
key_start = colon + 1
|
||||
key = data[key_start:key_start + key_len]
|
||||
pos = key_start + key_len
|
||||
value_start = pos
|
||||
pos = skip(data, pos)
|
||||
if key == b"info":
|
||||
raw_info = data[value_start:pos]
|
||||
assert raw_info is not None
|
||||
rewritten = (
|
||||
b"d8:announce" + str(len(announce)).encode() + b":" + announce
|
||||
+ b"4:info" + raw_info + b"e"
|
||||
)
|
||||
open(target, "wb").write(rewritten)
|
||||
PY
|
||||
|
||||
if ! timeout 30 "$SWARM" "$tor" "$out" > "$swarm_log" 2>&1; then
|
||||
echo "FAIL: tracker-discovered swarm did not complete"
|
||||
cat "$swarm_log" "$tracker_log"
|
||||
exit 1
|
||||
fi
|
||||
if ! diff -r "$out/single.bin" "$DATA/single.bin" >/dev/null; then
|
||||
echo "FAIL: content mismatch"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "REQUEST" "$tracker_log"; then
|
||||
echo "FAIL: tracker received no announce"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PASS: $MODE tracker discovery produced byte-identical output"
|
||||
37
tests/integration/seeder.py
Normal file
37
tests/integration/seeder.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Seed a .torrent with libtorrent on 127.0.0.1 and print the listen port.
|
||||
Usage: seeder.py <file.torrent> <save_path>
|
||||
Runs until killed. Prints 'PORT <n>' once it is seeding."""
|
||||
import libtorrent as lt, os, sys, time
|
||||
|
||||
enc_policy = 0 if os.environ.get("NAUT_FORCE_MSE") == "1" else 1
|
||||
|
||||
torrent, save_path = sys.argv[1], sys.argv[2]
|
||||
ses = lt.session({
|
||||
"listen_interfaces": "127.0.0.1:0",
|
||||
"unchoke_slots_limit": 64, # unchoke leechers fast
|
||||
"in_enc_policy": enc_policy,
|
||||
"out_enc_policy": enc_policy,
|
||||
"alert_mask": lt.alert_category.all,
|
||||
})
|
||||
|
||||
atp = lt.add_torrent_params()
|
||||
atp.ti = lt.torrent_info(torrent)
|
||||
atp.save_path = save_path
|
||||
atp.flags |= lt.torrent_flags.seed_mode # data already present; skip recheck
|
||||
atp.flags &= ~lt.torrent_flags.paused # must be active to accept peers
|
||||
atp.flags &= ~lt.torrent_flags.auto_managed # don't let the queue re-pause it
|
||||
h = ses.add_torrent(atp)
|
||||
h.resume()
|
||||
|
||||
deadline = time.time() + 30
|
||||
while not h.status().is_seeding and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
if not h.status().is_seeding:
|
||||
print("ERROR: not seeding", flush=True); sys.exit(1)
|
||||
|
||||
print("PORT %d" % ses.listen_port(), flush=True)
|
||||
while True:
|
||||
for a in ses.pop_alerts():
|
||||
print("ALERT %s: %s" % (type(a).__name__, a.message()), flush=True)
|
||||
time.sleep(0.2)
|
||||
27
tests/integration/udp_tracker.py
Normal file
27
tests/integration/udp_tracker.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Minimal BEP-15 UDP tracker for the Phase 4 integration test."""
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
|
||||
peer_port = int(sys.argv[1])
|
||||
connection_id = 0x0102030405060708
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
print("PORT %d" % sock.getsockname()[1], flush=True)
|
||||
|
||||
while True:
|
||||
packet, addr = sock.recvfrom(2048)
|
||||
if len(packet) >= 16 and packet[8:12] == b"\x00\x00\x00\x00":
|
||||
sock.sendto(struct.pack("!IIQ", 0, struct.unpack("!I", packet[12:16])[0],
|
||||
connection_id), addr)
|
||||
continue
|
||||
if len(packet) >= 98 and packet[8:12] == b"\x00\x00\x00\x01":
|
||||
txid = struct.unpack("!I", packet[12:16])[0]
|
||||
response = (
|
||||
struct.pack("!IIIII", 1, txid, 1800, 0, 1)
|
||||
+ socket.inet_aton("127.0.0.1")
|
||||
+ struct.pack("!H", peer_port)
|
||||
)
|
||||
sock.sendto(response, addr)
|
||||
print("REQUEST announce", flush=True)
|
||||
Loading…
Add table
Add a link
Reference in a new issue