From b633b7d2168323260fe3c52663edd1b5433fa143 Mon Sep 17 00:00:00 2001 From: ookami125 Date: Sun, 21 Jun 2026 23:19:41 -0400 Subject: [PATCH] nautd/webui: scripting, labels, settings, set-location, pause fix Session checkpoint on webui-plugin: - engine dump (nautctl dump) + engine endgame integration - per-file move locations persistence; torrent-level "Set location" with reset/keep-relative/leave-separate handling + residual prune - Lua: naut.get_labels, define_settings/get_setting (script_host struct) - daemon-owned labels (category+tags) + taxonomy persistence; webui write-through - fix: pausing a completed/seeding torrent now sticks (stop wins over result) - automation tab responsive layout; anime_sort label gating + settings Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 +- CMakeLists.txt | 117 +- ISSUES.md | 10 + apps/echo/main.c | 214 --- apps/leech/main.c | 212 --- apps/nautctl/main.c | 21 +- apps/nautd/main.c | 1882 +++++++++++++++++-- apps/swarm/main.c | 1157 +++--------- docs/scripting.md | 72 + examples/anime_sort.lua | 73 +- examples/test_anime_sort.lua | 51 +- include/naut/dht.h | 51 +- include/naut/mse.h | 88 - include/naut/piece.h | 13 + include/naut/pipeline.h | 29 - include/naut/script.h | 50 +- include/naut/storage.h | 6 + include/naut/swarm.h | 26 + include/naut/tracker.h | 25 +- plugins/webui/webui.c | 449 ++++- src/dht/dht.c | 227 --- src/{dht/fetch.c => discovery/dht_client.c} | 51 +- src/discovery/tracker_client.c | 257 +++ src/peer/mse.c | 466 ----- src/peer/pipeline.c | 64 - src/piece/piece.c | 101 +- src/script/script.c | 120 +- src/storage/storage.c | 15 +- src/tracker/fetch.c | 162 -- src/tracker/tracker.c | 120 -- src/tracker/udp.c | 81 - tests/fixtures/phase7.lua | 5 +- tests/integration/run_phase7.sh | 2 +- tests/unit/test_dht.c | 98 - tests/unit/test_download.c | 14 + tests/unit/test_filemove.c | 16 + tests/unit/test_mse.c | 56 - tests/unit/test_pipeline.c | 30 - tests/unit/test_script.c | 22 +- tests/unit/test_tracker.c | 115 -- 40 files changed, 3305 insertions(+), 3267 deletions(-) create mode 100644 ISSUES.md delete mode 100644 apps/echo/main.c delete mode 100644 apps/leech/main.c delete mode 100644 include/naut/mse.h delete mode 100644 include/naut/pipeline.h delete mode 100644 src/dht/dht.c rename src/{dht/fetch.c => discovery/dht_client.c} (74%) create mode 100644 src/discovery/tracker_client.c delete mode 100644 src/peer/mse.c delete mode 100644 src/peer/pipeline.c delete mode 100644 src/tracker/fetch.c delete mode 100644 src/tracker/tracker.c delete mode 100644 src/tracker/udp.c delete mode 100644 tests/unit/test_dht.c delete mode 100644 tests/unit/test_mse.c delete mode 100644 tests/unit/test_pipeline.c delete mode 100644 tests/unit/test_tracker.c diff --git a/.gitignore b/.gitignore index 4bf5b31..316ae56 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ compile_commands.json /package/ /package.zip # Stray torrents dropped at the repo root (fixtures under tests/ stay tracked) -torrents/*.torrent \ No newline at end of file +torrents/*.torrent +# Downloaded torrent data (capital-D dir used at runtime) +/Downloads/ diff --git a/CMakeLists.txt b/CMakeLists.txt index e4df484..2c767a2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,6 +39,23 @@ if(NOT URING_LIB OR NOT URING_INC) endif() find_package(OpenSSL REQUIRED COMPONENTS Crypto) +# --- external download engine + tracker/DHT protocol libraries -------------- +# torrent-peer: multi-peer download engine (engine.h) — replaces Naut's own peer +# poll loop, request pipeline, and MSE transport. +# torrent-tracker: tracker/DHT wire codec (tracker.h) — drives Naut's announce +# and get_peers glue in src/discovery. +set(PEER_NATIVE ${NAUT_NATIVE} CACHE BOOL "" FORCE) +set(TRACKER_NATIVE ${NAUT_NATIVE} CACHE BOOL "" FORCE) +set(TRACKER_TESTS OFF CACHE BOOL "" FORCE) +if(NAUT_SAN STREQUAL "address" OR NAUT_SAN STREQUAL "undefined") + set(PEER_ASAN ON CACHE BOOL "" FORCE) + set(TRACKER_ASAN ON CACHE BOOL "" FORCE) +endif() +add_subdirectory(${CMAKE_SOURCE_DIR}/../torrent-peer + ${CMAKE_BINARY_DIR}/torrent-peer) +add_subdirectory(${CMAKE_SOURCE_DIR}/../torrent-tracker + ${CMAKE_BINARY_DIR}/torrent-tracker) + if(NAUT_STANDALONE) include(FetchContent) @@ -141,24 +158,16 @@ target_link_libraries(naut_bencode PUBLIC naut_core) add_library(naut_metainfo STATIC src/metainfo/metainfo.c src/metainfo/magnet.c) target_link_libraries(naut_metainfo PUBLIC naut_bencode naut_crypto) -# --- tracker: HTTP + UDP announce (codec + blocking fetch) ------------------ -add_library(naut_tracker STATIC - src/tracker/tracker.c src/tracker/udp.c src/tracker/fetch.c) -target_link_libraries(naut_tracker PUBLIC naut_bencode) +# --- discovery: tracker announce + DHT get_peers glue over torrent-tracker --- +add_library(naut_discovery STATIC + src/discovery/tracker_client.c src/discovery/dht_client.c) +target_link_libraries(naut_discovery PUBLIC naut_core torrenttracker) -# --- dht: BEP-5 KRPC codec + bounded iterative peer lookup ------------------ -add_library(naut_dht STATIC src/dht/dht.c src/dht/fetch.c) -target_link_libraries(naut_dht PUBLIC naut_bencode naut_tracker) - -# --- peer: wire protocol codec (sans-IO) ------------------------------------ +# --- peer: wire protocol codec (sans-IO), retained for magnet metadata ------- add_library(naut_peer STATIC - src/peer/wire.c src/peer/extension.c src/peer/metadata.c src/peer/pipeline.c) + src/peer/wire.c src/peer/extension.c src/peer/metadata.c) target_link_libraries(naut_peer PUBLIC - naut_core naut_crypto naut_bencode naut_tracker m) - -# MSE is optional at the application boundary and is the only OpenSSL user. -add_library(naut_mse STATIC src/peer/mse.c) -target_link_libraries(naut_mse PUBLIC naut_peer OpenSSL::Crypto) + naut_core naut_crypto naut_bencode m) # --- storage: file backend -------------------------------------------------- add_library(naut_storage STATIC src/storage/storage.c) @@ -209,26 +218,12 @@ target_include_directories(naut_webui PRIVATE ${CMAKE_SOURCE_DIR}/include) target_link_libraries(naut_webui PRIVATE ${NAUT_JANSSON_TARGET} pthread) set_target_properties(naut_webui PROPERTIES PREFIX "") -# --- echo: Phase 1 gate (io_uring echo server on the buffer pool) ----------- -# add_executable(naut_echo apps/echo/main.c) -# target_link_libraries(naut_echo PRIVATE naut_platform naut_core) - -# --- leech: Phase 3 gate (single-peer download, byte-correct + verified) ---- -# add_executable(naut_leech apps/leech/main.c) -# target_link_libraries(naut_leech PRIVATE -# naut_piece naut_peer naut_mse naut_metainfo) - -# --- swarm: Phase 4 gate (multi-peer download, rarest-first + endgame) ------- +# --- swarm: multi-peer download driver over the torrent-peer engine --------- add_library(naut_swarm_engine STATIC apps/swarm/main.c) target_compile_definitions(naut_swarm_engine PRIVATE NAUT_SWARM_LIBRARY) target_link_libraries(naut_swarm_engine PUBLIC - naut_piece naut_peer naut_metainfo naut_tracker naut_dht naut_system - naut_session) - -# add_executable(naut_swarm apps/swarm/main.c) -# target_link_libraries(naut_swarm PRIVATE -# naut_piece naut_peer naut_metainfo naut_tracker naut_dht naut_system -# naut_session) + naut_piece naut_peer naut_metainfo naut_discovery naut_system + naut_session torrentpeer) # --- daemon + CLI: Phase 7 extensibility surface --------------------------- add_executable(nautd apps/nautd/main.c) @@ -250,10 +245,6 @@ add_executable(test_worker tests/unit/test_worker.c) target_link_libraries(test_worker PRIVATE naut_core naut_crypto) add_test(NAME test_worker COMMAND test_worker) -add_executable(test_pipeline tests/unit/test_pipeline.c) -target_link_libraries(test_pipeline PRIVATE naut_peer) -add_test(NAME test_pipeline COMMAND test_pipeline) - add_executable(test_rpc tests/unit/test_rpc.c) target_link_libraries(test_rpc PRIVATE naut_rpc) add_test(NAME test_rpc COMMAND test_rpc) @@ -300,18 +291,6 @@ add_executable(test_extension tests/unit/test_extension.c) target_link_libraries(test_extension PRIVATE naut_peer) add_test(NAME test_extension COMMAND test_extension) -add_executable(test_mse tests/unit/test_mse.c) -target_link_libraries(test_mse PRIVATE naut_mse) -add_test(NAME test_mse COMMAND test_mse) - -add_executable(test_tracker tests/unit/test_tracker.c) -target_link_libraries(test_tracker PRIVATE naut_tracker) -add_test(NAME test_tracker COMMAND test_tracker) - -add_executable(test_dht tests/unit/test_dht.c) -target_link_libraries(test_dht PRIVATE naut_dht) -add_test(NAME test_dht COMMAND test_dht) - add_executable(test_storage tests/unit/test_storage.c) target_link_libraries(test_storage PRIVATE naut_storage) add_test(NAME test_storage COMMAND test_storage) @@ -332,48 +311,6 @@ add_executable(test_picker tests/unit/test_picker.c) target_link_libraries(test_picker PRIVATE naut_piece) add_test(NAME test_picker COMMAND test_picker) -# Phase 3 interop gate: download from a real libtorrent seed (SKIPs without it). -# These gates exercise the standalone naut_leech/naut_swarm/naut_echo binaries, -# which are currently folded into the daemon — register them only when built. -if(TARGET naut_leech) - add_test(NAME interop_leech - COMMAND bash ${CMAKE_SOURCE_DIR}/tests/integration/run_interop.sh $) - set_tests_properties(interop_leech PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 180) - - add_test(NAME interop_mse - COMMAND bash ${CMAKE_SOURCE_DIR}/tests/integration/run_mse.sh $) - set_tests_properties(interop_mse PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 60) -endif() - -if(TARGET naut_swarm) - add_test(NAME interop_magnet_dht - COMMAND bash ${CMAKE_SOURCE_DIR}/tests/integration/run_magnet_dht.sh - $) - set_tests_properties(interop_magnet_dht PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 60) - - # Phase 4 interop gate: both libtorrent seeds must contribute to one download. - add_test(NAME interop_swarm - COMMAND bash ${CMAKE_SOURCE_DIR}/tests/integration/run_swarm.sh $) - set_tests_properties(interop_swarm PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 60) - - add_test(NAME interop_tracker_swarm - COMMAND bash ${CMAKE_SOURCE_DIR}/tests/integration/run_tracker_swarm.sh - $ http) - set_tests_properties(interop_tracker_swarm PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 60) - - add_test(NAME interop_udp_tracker_swarm - COMMAND bash ${CMAKE_SOURCE_DIR}/tests/integration/run_tracker_swarm.sh - $ udp) - set_tests_properties(interop_udp_tracker_swarm PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 60) -endif() - -if(TARGET naut_echo) - add_test(NAME interop_echo_scale - COMMAND bash ${CMAKE_SOURCE_DIR}/tests/integration/run_echo_scale.sh - $) - set_tests_properties(interop_echo_scale PROPERTIES TIMEOUT 30) -endif() - add_test(NAME phase7_extensibility COMMAND bash ${CMAKE_SOURCE_DIR}/tests/integration/run_phase7.sh $ $ diff --git a/ISSUES.md b/ISSUES.md new file mode 100644 index 0000000..9af04cb --- /dev/null +++ b/ISSUES.md @@ -0,0 +1,10 @@ +- ✅ Set Location doesn't work, it should also show the current location. +- ⬛ I need a way to modify a category (including Uncategorozied). +- ⬛ Adding a Category means I can't have none selected on adding a torrent. +- ⬛ Category default download location does nothing as changing it doesn't change the download location. Download location should be greyed out by default with the default location shown. Clicking should allow you to change the location. If the location is set it shouldn't change if the category is changed. +- ✅ Automation variables at half window width makes the script unseeable. It should be displayed above the script if the window is to narrow. +- ⬛ We need to implement the RSS and Search Tabs. +- ⬛ I need to be able to add Tags on adding a torrent. +- ⬛ Categories aren't saved across restart. +- ⬛ Pausing a torrent will go back into Downloading and Seeding. +- ⬛ A torrents data could overlap with another existing torrent. This should be blocked to avoid \ No newline at end of file diff --git a/apps/echo/main.c b/apps/echo/main.c deleted file mode 100644 index a27b579..0000000 --- a/apps/echo/main.c +++ /dev/null @@ -1,214 +0,0 @@ -/* naut_echo — Phase 1 gate. - * - * A single-reactor io_uring echo server that proves the foundation works end to - * end: multishot accept, recv/send driven entirely off the page-aligned buffer - * pool with ZERO per-operation allocation in steady state. Throughput on - * loopback should be limited by memory bandwidth / the single core, not by the - * allocator or syscalls. - * - * It is intentionally one-in-flight-op-per-connection (recv -> send -> recv). - * The real peer reactor (later phase) uses multishot recv + provided buffers - * and pipelines; this is the minimal honest exercise of the primitives. - * - * usage: naut_echo [port] (default 9000) - */ -#include "naut/uring.h" -#include "naut/net.h" -#include "naut/buf.h" -#include "naut/log.h" -#include "naut/system.h" - -#include -#include -#include -#include -#include -#include -#include - -#define ECHO_BLOCK (128u * 1024u) -#define ECHO_BUFS 4096u -#define RING_ENTRIES 4096u - -/* user_data tagging: low 3 bits = op, high bits = conn* (16-byte aligned). */ -enum { TAG_ACCEPT = 1, TAG_RECV = 2, TAG_SEND = 3 }; -#define UD(p, tag) ((__u64)(uintptr_t)(p) | (unsigned)(tag)) -#define UD_TAG(ud) ((unsigned)((ud) & 0x7u)) -#define UD_PTR(ud) ((conn *)(uintptr_t)((ud) & ~(__u64)0x7u)) - -typedef struct conn { - int fd; - uint32_t sent; /* bytes of buf->len already written (partial sends) */ - naut_buf *buf; - bool awaiting_notif; - bool recv_fixed; /* the in-flight recv used the fixed buffer */ -} conn; - -static volatile sig_atomic_t g_stop = 0; -static void on_signal(int s) { (void)s; g_stop = 1; } - -static naut_bufpool *g_pool; -static _Atomic uint64_t g_bytes = 0, g_conns = 0, g_zc_copied = 0; - -static void arm_recv(naut_ring *owner, conn *c) { - struct io_uring_sqe *sqe = io_uring_get_sqe(&owner->ring); - c->recv_fixed = - naut_ring_prep_recv(owner, sqe, c->fd, c->buf->data, c->buf->cap, 0); - io_uring_sqe_set_data64(sqe, UD(c, TAG_RECV)); -} - -static void arm_send(naut_ring *owner, conn *c) { - struct io_uring_sqe *sqe = io_uring_get_sqe(&owner->ring); - c->awaiting_notif = naut_ring_prep_send( - owner, sqe, c->fd, c->buf->data + c->sent, - c->buf->len - c->sent, MSG_NOSIGNAL, true); - io_uring_sqe_set_data64(sqe, UD(c, TAG_SEND)); -} - -static void conn_close(conn *c) { - close(c->fd); - naut_buf_put(c->buf); - free(c); -} - -int main(int argc, char **argv) { - uint16_t port = (argc > 1) ? (uint16_t)atoi(argv[1]) : 9000; - int cpu = getenv("NAUT_CPU") ? atoi(getenv("NAUT_CPU")) : -1; - int numa_node = - getenv("NAUT_NUMA_NODE") ? atoi(getenv("NAUT_NUMA_NODE")) : -1; - bool sqpoll = getenv("NAUT_SQPOLL") != NULL; - bool hugepages = getenv("NAUT_HUGEPAGES") != NULL; - signal(SIGINT, on_signal); - signal(SIGTERM, on_signal); - signal(SIGPIPE, SIG_IGN); - - if (cpu >= 0 && naut_pin_current_thread(cpu) != NAUT_OK) - NAUT_WARN("failed to pin reactor to CPU %d", cpu); - naut_ring r; - if (naut_ring_init_cpu(&r, RING_ENTRIES, sqpoll, cpu) != NAUT_OK) - return 1; - if (naut_ring_probe(&r) != NAUT_OK) { naut_ring_close(&r); return 1; } - struct io_uring *ring = &r.ring; - - int lfd = naut_net_listen(port, 1024, true); - if (lfd < 0) { naut_ring_close(&r); return 1; } - - g_pool = naut_bufpool_create_on_node( - ECHO_BLOCK, ECHO_BUFS, hugepages, numa_node); - if (!g_pool) { close(lfd); naut_ring_close(&r); return 1; } - (void)naut_ring_register_bufpool(&r, g_pool); - - /* prime the multishot accept */ - struct io_uring_sqe *sqe = io_uring_get_sqe(ring); - io_uring_prep_multishot_accept(sqe, lfd, NULL, NULL, 0); - io_uring_sqe_set_data64(sqe, UD(NULL, TAG_ACCEPT)); - - NAUT_INFO("echo listening on :%u", port); - - while (!g_stop) { - int rc = io_uring_submit_and_wait(ring, 1); - if (rc < 0 && rc != -EINTR) { NAUT_ERROR("submit_and_wait: %s", strerror(-rc)); break; } - - unsigned head, count = 0; - struct io_uring_cqe *cqe; - io_uring_for_each_cqe(ring, head, cqe) { - count++; - __u64 ud = cqe->user_data; - int res = cqe->res; - - switch (UD_TAG(ud)) { - case TAG_ACCEPT: { - if (res < 0) { - if (res != -ECANCELED) NAUT_WARN("accept: %s", strerror(-res)); - } else { - int cfd = res; - naut_net_tune_peer(cfd); - naut_buf *b = naut_buf_get(g_pool); - if (!b) { NAUT_WARN("pool exhausted, dropping conn"); close(cfd); } - else { - conn *c = calloc(1, sizeof(*c)); - c->fd = cfd; c->buf = b; - atomic_fetch_add(&g_conns, 1); - arm_recv(&r, c); - } - } - /* re-arm if the kernel dropped the multishot registration */ - if (!(cqe->flags & IORING_CQE_F_MORE)) { - struct io_uring_sqe *s = io_uring_get_sqe(ring); - io_uring_prep_multishot_accept(s, lfd, NULL, NULL, 0); - io_uring_sqe_set_data64(s, UD(NULL, TAG_ACCEPT)); - } - break; - } - case TAG_RECV: { - conn *c = UD_PTR(ud); - if (res <= 0) { - /* A fixed-buffer recv rejected with -EINVAL means this - * kernel doesn't support IORING_RECVSEND_FIXED_BUF on plain - * recv. Disable it ring-wide and retry THIS connection - * unfixed. We key off the per-conn flag, not the ring flag, - * so every connection that armed a fixed recv before the - * flag flipped recovers too (otherwise all but the first - * would be torn down). */ - if (res == -EINVAL && c->recv_fixed) { - if (r.recv_fixed) { - NAUT_WARN("fixed-buffer recv unsupported at runtime; " - "falling back to normal recv"); - r.recv_fixed = false; - } - arm_recv(&r, c); - break; - } - if (res < 0) - NAUT_WARN("recv completion: %s", strerror(-res)); - conn_close(c); - break; - } - c->buf->len = (uint32_t)res; - c->sent = 0; - arm_send(&r, c); - break; - } - case TAG_SEND: { - conn *c = UD_PTR(ud); - if (cqe->flags & IORING_CQE_F_NOTIF) { - if (res & IORING_NOTIF_USAGE_ZC_COPIED) { - uint64_t copied = - atomic_fetch_add(&g_zc_copied, 1) + 1; - if (copied == 8) { - NAUT_WARN("SEND_ZC is copying on this transport; " - "disabling it for this ring"); - r.send_zc = false; - } - } - c->awaiting_notif = false; - if (c->sent < c->buf->len) arm_send(&r, c); - else { c->buf->len = 0; arm_recv(&r, c); } - break; - } - if (res <= 0) { conn_close(c); break; } - c->sent += (uint32_t)res; - atomic_fetch_add(&g_bytes, (uint64_t)res); - if (!c->awaiting_notif) { - if (c->sent < c->buf->len) arm_send(&r, c); - else { c->buf->len = 0; arm_recv(&r, c); } - } - break; - } - default: - NAUT_PANIC("bad user_data tag %u", UD_TAG(ud)); - } - } - io_uring_cq_advance(ring, count); - } - - NAUT_INFO("shutting down: %llu conns, %llu bytes echoed, %llu SEND_ZC copied notifications", - (unsigned long long)atomic_load(&g_conns), - (unsigned long long)atomic_load(&g_bytes), - (unsigned long long)atomic_load(&g_zc_copied)); - close(lfd); - naut_ring_unregister_buffers(&r); - naut_bufpool_destroy(g_pool); - naut_ring_close(&r); - return 0; -} diff --git a/apps/leech/main.c b/apps/leech/main.c deleted file mode 100644 index cb980cc..0000000 --- a/apps/leech/main.c +++ /dev/null @@ -1,212 +0,0 @@ -/* naut_leech — Phase 3 gate: download a torrent from a single peer and write a - * byte-correct, hash-verified file to disk. - * - * Blocking-socket driver around the sans-IO peer codec + download engine. The - * point of this phase is protocol correctness and interop (it downloads from a - * libtorrent seed in the integration test), not peak throughput — the io_uring - * reactor that drives thousands of these comes in Phase 6. - * - * usage: naut_leech [--mse] - */ -#include "naut/metainfo.h" -#include "naut/storage.h" -#include "naut/piece.h" -#include "naut/peer.h" -#include "naut/mse.h" -#include "naut/log.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define PIPELINE_DEPTH 512 /* outstanding requests (~8 MiB in flight) */ - -static double now(void) { - struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); - return t.tv_sec + t.tv_nsec * 1e-9; -} - -static uint8_t *slurp(const char *path, size_t *len) { - FILE *f = fopen(path, "rb"); - if (!f) { NAUT_ERROR("open %s: %s", path, strerror(errno)); return NULL; } - fseek(f, 0, SEEK_END); long n = ftell(f); fseek(f, 0, SEEK_SET); - uint8_t *b = malloc(n); - if (fread(b, 1, n, f) != (size_t)n) { fclose(f); free(b); return NULL; } - fclose(f); *len = (size_t)n; return b; -} - -static int connect_peer(const char *ip, uint16_t port) { - int fd = socket(AF_INET, SOCK_STREAM, 0); - if (fd < 0) return -1; - struct sockaddr_in a; memset(&a, 0, sizeof a); - a.sin_family = AF_INET; a.sin_port = htons(port); - if (inet_pton(AF_INET, ip, &a.sin_addr) != 1) { close(fd); return -1; } - if (connect(fd, (struct sockaddr *)&a, sizeof a) != 0) { - NAUT_ERROR("connect %s:%u: %s", ip, port, strerror(errno)); - close(fd); return -1; - } - int one = 1; setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one); - return fd; -} - -/* send up to PIPELINE_DEPTH outstanding requests */ -static bool refill(int fd, naut_mse_stream *mse, - naut_download *d, int *outstanding) { - uint32_t idx, begin, len; - while (*outstanding < PIPELINE_DEPTH) { - if (!naut_download_next_request(d, &idx, &begin, &len)) break; - uint8_t req[17]; - naut_peer_msg_request(req, idx, begin, len); - if (!naut_mse_send_all(fd, mse, req, sizeof req)) return false; - (*outstanding)++; - } - return true; -} - -int main(int argc, char **argv) { - bool use_mse = argc > 1 && strcmp(argv[1], "--mse") == 0; - int arg = use_mse ? 2 : 1; - if (argc - arg != 4) { - fprintf(stderr, "usage: %s [--mse] \n", - argv[0]); - return 2; - } - naut_log_set_level(NAUT_LOG_INFO); - - size_t tlen; - uint8_t *tor = slurp(argv[arg], &tlen); - if (!tor) return 1; - naut_metainfo mi; - if (naut_metainfo_parse(tor, tlen, &mi) != NAUT_OK) { NAUT_ERROR("bad torrent"); return 1; } - free(tor); - - char hex[41]; naut_infohash_v1_hex(&mi, hex); - NAUT_INFO("torrent '%s': %u pieces, %lld bytes, infohash %s", - mi.name, mi.num_pieces, (long long)mi.total_length, hex); - - naut_err err; - naut_storage_opts storage_opts = { - .direct_io = getenv("NAUT_DIRECT_IO") != NULL, - .preallocate = true, - }; - naut_storage *st = naut_storage_open_opts( - mi.files, mi.num_files, argv[arg + 1], &storage_opts, &err); - if (!st) { NAUT_ERROR("storage: %s", naut_strerror(err)); return 1; } - naut_download *d = naut_download_create(&mi, st); - if (!d) return 1; - - int fd = connect_peer(argv[arg + 2], (uint16_t)atoi(argv[arg + 3])); - if (fd < 0) return 1; - - /* handshake */ - uint8_t peerid[20]; memcpy(peerid, "-NT0001-", 8); - for (int i = 8; i < 20; i++) peerid[i] = (uint8_t)(rand() & 0xff); - uint8_t hs[NAUT_HANDSHAKE_LEN]; - naut_peer_handshake_build(hs, mi.infohash_v1, peerid, 0); - naut_mse_stream mse = {0}; - uint8_t remote_hs[NAUT_HANDSHAKE_LEN]; - bool hs_done = false; - if (use_mse) { - naut_err mse_err = naut_mse_client_handshake( - fd, mi.infohash_v1, peerid, 0, &mse, remote_hs); - if (mse_err != NAUT_OK) { - NAUT_ERROR("MSE handshake failed: %s", naut_strerror(mse_err)); - return 1; - } - hs_done = true; - NAUT_INFO("MSE/RC4 peer transport established"); - uint8_t intr[5]; - naut_peer_msg_simple(intr, NAUT_MSG_INTERESTED); - if (!naut_mse_send_all(fd, &mse, intr, sizeof intr)) { - NAUT_ERROR("interested send failed"); - return 1; - } - } else if (!naut_mse_send_all(fd, &mse, hs, sizeof hs)) { - NAUT_ERROR("handshake send failed"); - return 1; - } - - /* recv buffer */ - size_t cap = 4u << 20, len = 0; - uint8_t *buf = malloc(cap); - bool unchoked = false; - int outstanding = 0; - double t0 = now(); - - while (!naut_download_complete(d)) { - if (len == cap) { cap *= 2; buf = realloc(buf, cap); } - ssize_t r = naut_mse_recv(fd, &mse, buf + len, cap - len); - if (r < 0) { NAUT_ERROR("recv: %s", strerror(errno)); break; } - if (r == 0) { NAUT_ERROR("peer closed (%.1f%% done)", - 100.0 * naut_download_pieces_done(d) / mi.num_pieces); break; } - len += (size_t)r; - - size_t pos = 0; - if (!hs_done) { - if (len < NAUT_HANDSHAKE_LEN) continue; - uint8_t ih[20], pid[20]; - if (!naut_peer_handshake_parse(buf, ih, pid, NULL) || - memcmp(ih, mi.infohash_v1, 20) != 0) { - NAUT_ERROR("handshake mismatch"); break; - } - pos = NAUT_HANDSHAKE_LEN; - hs_done = true; - uint8_t intr[5]; naut_peer_msg_simple(intr, NAUT_MSG_INTERESTED); - if (!naut_mse_send_all(fd, &mse, intr, 5)) break; - } - - /* parse all complete messages */ - for (;;) { - naut_msg m; - int c = naut_peer_msg_parse(buf + pos, len - pos, &m); - if (c == 0) break; - if (c < 0) { NAUT_ERROR("protocol error"); goto done; } - pos += (size_t)c; - switch (m.type) { - case NAUT_MSG_UNCHOKE: unchoked = true; break; - case NAUT_MSG_CHOKE: unchoked = false; break; - case NAUT_MSG_PIECE: { - outstanding--; - bool pdone = false; - naut_err e = naut_download_on_block(d, m.index, m.begin, m.payload, - (uint32_t)m.payload_len, &pdone); - if (e != NAUT_OK) { NAUT_ERROR("block rejected: %s", naut_strerror(e)); goto done; } - break; - } - default: break; /* bitfield/have/keepalive/port: ignore for a seed */ - } - } - /* compact consumed bytes */ - memmove(buf, buf + pos, len - pos); - len -= pos; - - if (unchoked && !refill(fd, &mse, d, &outstanding)) { - NAUT_ERROR("request send failed"); break; - } - } -done:; - double dt = now() - t0; - bool ok = naut_download_complete(d); - if (ok) { - double mb = (double)mi.total_length / 1e6; - NAUT_INFO("COMPLETE: %u/%u pieces, %.1f MB in %.2fs (%.1f MB/s), all SHA-1 verified", - naut_download_pieces_done(d), mi.num_pieces, mb, dt, mb / dt); - } else { - NAUT_ERROR("INCOMPLETE: %u/%u pieces", naut_download_pieces_done(d), mi.num_pieces); - } - - naut_storage_sync(st); - close(fd); - naut_download_destroy(d); - naut_storage_close(st); - naut_metainfo_free(&mi); - free(buf); - return ok ? 0 : 1; -} diff --git a/apps/nautctl/main.c b/apps/nautctl/main.c index 1f60b78..088808f 100644 --- a/apps/nautctl/main.c +++ b/apps/nautctl/main.c @@ -17,6 +17,7 @@ static void usage(const char *program) { " add SOURCE OUTPUT [IP:PORT ...]\n" " list\n" " show TORRENT_ID\n" + " dump TORRENT_ID\n" " remove TORRENT_ID\n" " script PATH | unscript\n" " status | events | shutdown\n" @@ -93,6 +94,7 @@ int main(int argc, char **argv) { return stream_events(socket_path); json_t *params = NULL; + bool raw_dump = false; if (strcmp(method, "add") == 0) { if (arg + 1 >= argc) { usage(argv[0]); return 2; } method = "add_torrent"; @@ -113,14 +115,16 @@ int main(int argc, char **argv) { method = "torrents"; params = json_object(); } else if (strcmp(method, "show") == 0 || + strcmp(method, "dump") == 0 || strcmp(method, "remove") == 0) { json_int_t id; if (arg + 1 != argc || !parse_id(argv[arg], &id)) { usage(argv[0]); return 2; } - method = strcmp(method, "show") == 0 ? "torrent" : - "remove_torrent"; + if (strcmp(method, "show") == 0) method = "torrent"; + else if (strcmp(method, "dump") == 0) { method = "dump_torrent"; raw_dump = true; } + else method = "remove_torrent"; params = json_pack("{s:I}", "torrent_id", id); } else if (strcmp(method, "script") == 0) { if (arg + 1 != argc) { usage(argv[0]); return 2; } @@ -145,8 +149,19 @@ int main(int argc, char **argv) { fprintf(stderr, "nautctl: RPC failed: %s\n", naut_strerror(error)); return 1; } - int result = print_json(reply); bool ok = json_is_true(json_object_get(reply, "ok")); + int result; + /* `dump` returns a multi-line text blob; print it raw instead of escaped JSON. */ + const char *dump = raw_dump + ? json_string_value(json_object_get( + json_object_get(reply, "result"), "dump")) + : NULL; + if (dump) { + fputs(dump, stdout); + result = 0; + } else { + result = print_json(reply); + } json_decref(reply); return result || !ok; } diff --git a/apps/nautd/main.c b/apps/nautd/main.c index 1c25c79..464aaad 100644 --- a/apps/nautd/main.c +++ b/apps/nautd/main.c @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -29,13 +30,22 @@ typedef struct { char destination[PATH_MAX]; } move_command; +/* Last known on-disk location of a file after a relocate, persisted so a moved + * file is reopened in place across restarts instead of re-downloaded. */ +typedef struct { + uint32_t file_index; + char *path; +} file_location; + typedef enum { TORRENT_QUEUED, TORRENT_RUNNING, + TORRENT_STALLED, TORRENT_COMPLETE, TORRENT_STOPPING, TORRENT_STOPPED, TORRENT_ERROR, + TORRENT_PAUSED, } torrent_state; typedef struct daemon_state daemon_state; @@ -44,7 +54,10 @@ typedef struct { daemon_state *daemon; uint64_t id; char *source; - bool source_is_temp; /* source is a daemon-owned upload; unlink on destroy */ + bool source_is_temp; /* ephemeral /tmp upload; unlink on any destroy */ + bool source_managed; /* durable upload under state_dir/uploads; unlink only + * when the torrent is removed (not on shutdown) */ + char *name; /* optional display name (persisted for the UI) */ char *output_dir; char **peers; size_t num_peers; @@ -56,12 +69,29 @@ typedef struct { naut_err result; bool stop_requested; bool remove_requested; + bool paused; /* user-paused: never auto-activated (persisted) */ + bool force_start; /* bypass the queue cap (persisted) */ + bool restart_requested; /* one-shot stop->start (recheck) */ + int queue_pos; /* ordering within the download queue (persisted) */ + uint64_t rate_share; /* engine download cap for this torrent, bytes/sec */ naut_swarm_stats stats; move_command moves[MOVE_QUEUE_CAPACITY]; size_t move_head; size_t move_count; uint64_t moves_processed; uint64_t moves_failed; + file_location *locations; /* last known location of each relocated file */ + size_t num_locations; + char *pending_save_path; /* "Set location" target; the worker moves the + * torrent's files there on its next control pass */ + bool pending_save_path_reset; /* true: reset every file to its original + * download relpath; false: keep per-file moves */ + char *category; /* single category (qBittorrent-style), may be ""*/ + char **tags; /* user tags (multiple) */ + size_t num_tags; /* category + tags are the flat labels Lua sees */ + uint64_t dump_seq; /* bumped by a dump RPC; > dump_done_seq => pending */ + uint64_t dump_done_seq; /* highest dump_seq the worker has rendered */ + char *dump_text; /* latest rendered dump (owner: task) */ } torrent_task; struct daemon_state { @@ -69,6 +99,22 @@ struct daemon_state { naut_rpc_registry *rpc; naut_plugin_manager *plugins; naut_script *script; + char *script_path; + pthread_mutex_t script_lock; + /* Script settings: the loaded script declares a schema via + * naut.define_settings; the user edits values in the web UI. The schema is + * rebuilt on each (re)load; values persist independently. */ + pthread_mutex_t settings_lock; + json_t *script_settings_schema; /* array of {key,label,type,default} */ + json_t *script_settings; /* object: key -> value string (user-set)*/ + char settings_file[PATH_MAX]; /* /script_settings.json */ + /* Label taxonomy: the web UI's full category + tag lists (including ones + * created but not yet assigned). Web-layer schema; the daemon just persists + * it so they survive restarts. */ + pthread_mutex_t taxonomy_lock; + json_t *label_categories; /* array of {name, savePath} */ + json_t *label_tags; /* array of tag name strings */ + char taxonomy_file[PATH_MAX]; /* /labels.json */ pthread_mutex_t torrent_lock; torrent_task *torrents[MAX_TORRENTS]; size_t torrent_count; @@ -77,12 +123,29 @@ struct daemon_state { int subscribers[MAX_SUBSCRIBERS]; size_t subscriber_count; bool stopping; + bool persist_enabled; + char state_file[PATH_MAX]; /* /torrents.json */ + char uploads_dir[PATH_MAX]; /* /uploads */ + char prefs_file[PATH_MAX]; /* /prefs.json */ + /* Daemon preferences (queue + throttle). Upload limits are stored but inert: + * the engine is leech-only (no seeding) so only download limits take effect. */ + uint32_t max_active; /* max concurrent downloading torrents */ + uint64_t dl_limit; /* global download cap, bytes/sec (0=off)*/ + uint64_t alt_dl_limit; /* alt download cap, bytes/sec */ + uint64_t up_limit; /* stored, inert */ + uint64_t alt_up_limit; /* stored, inert */ + bool alt_speed_enabled; /* use alt_* limits when true */ }; +#define DEFAULT_MAX_ACTIVE 5 + static volatile sig_atomic_t interrupted; static naut_err queue_move(void *opaque, uint64_t torrent_id, uint32_t file_index, const char *destination); +static void service_lifecycle(daemon_state *state); +static void persist_torrents(daemon_state *state); +static json_t *script_settings_json(daemon_state *state); static void on_signal(int signal_number) { (void)signal_number; @@ -93,10 +156,12 @@ static const char *torrent_state_name(torrent_state state) { static const char *names[] = { [TORRENT_QUEUED] = "queued", [TORRENT_RUNNING] = "downloading", + [TORRENT_STALLED] = "stalled", [TORRENT_COMPLETE] = "complete", [TORRENT_STOPPING] = "stopping", [TORRENT_STOPPED] = "stopped", [TORRENT_ERROR] = "error", + [TORRENT_PAUSED] = "paused", }; return (size_t)state < NAUT_ARRAY_LEN(names) ? names[state] : "unknown"; } @@ -114,7 +179,10 @@ static void torrent_progress(void *opaque, const naut_swarm_stats *stats) { if (stats->total_pieces > 0 && stats->pieces_done == stats->total_pieces) task->state = TORRENT_COMPLETE; - else if (task->state == TORRENT_QUEUED) + else if (stats->stalled) + task->state = TORRENT_STALLED; + else if (task->state == TORRENT_QUEUED || + task->state == TORRENT_STALLED) task->state = TORRENT_RUNNING; pthread_mutex_unlock(&task->lock); } @@ -127,14 +195,276 @@ static bool torrent_should_stop(void *opaque) { return stop; } +static bool torrent_should_dump(void *opaque) { + torrent_task *task = opaque; + pthread_mutex_lock(&task->lock); + bool pending = task->dump_seq != task->dump_done_seq; + pthread_mutex_unlock(&task->lock); + return pending; +} + +static void torrent_on_dump(void *opaque, const char *text) { + torrent_task *task = opaque; + char *copy = text ? strdup(text) : NULL; + pthread_mutex_lock(&task->lock); + free(task->dump_text); + task->dump_text = copy; + task->dump_done_seq = task->dump_seq; + pthread_mutex_unlock(&task->lock); +} + +/* The download throttle the reconciler computed for this torrent (its share of + * the global limit). 0 = unlimited. */ +static uint64_t torrent_download_rate(void *opaque) { + torrent_task *task = opaque; + pthread_mutex_lock(&task->lock); + uint64_t rate = task->rate_share; + pthread_mutex_unlock(&task->lock); + return rate; +} + +/* Record (or update) the last known location of a relocated file. Caller holds + * task->lock. */ +static void task_set_location(torrent_task *task, uint32_t file_index, + const char *path) { + char *copy = strdup(path); + if (!copy) return; + for (size_t i = 0; i < task->num_locations; i++) { + if (task->locations[i].file_index == file_index) { + free(task->locations[i].path); + task->locations[i].path = copy; + return; + } + } + file_location *grown = realloc(task->locations, + (task->num_locations + 1) * sizeof *grown); + if (!grown) { free(copy); return; } + task->locations = grown; + task->locations[task->num_locations].file_index = file_index; + task->locations[task->num_locations].path = copy; + task->num_locations++; +} + +/* Replace the task's tag set from a JSON array of strings (empty/duplicate + * entries dropped). NULL leaves the tags unchanged. Caller holds task->lock. */ +static void task_set_tags(torrent_task *task, const json_t *tags_json) { + if (!json_is_array(tags_json)) return; + size_t n = json_array_size(tags_json); + char **next = n ? calloc(n, sizeof *next) : NULL; + size_t count = 0; + if (next) { + for (size_t i = 0; i < n; i++) { + const char *s = json_string_value(json_array_get(tags_json, i)); + if (!s || !*s) continue; + bool dup = false; + for (size_t j = 0; j < count; j++) + if (strcmp(next[j], s) == 0) { dup = true; break; } + if (dup) continue; + char *copy = strdup(s); + if (copy) next[count++] = copy; + } + } + for (size_t i = 0; i < task->num_tags; i++) free(task->tags[i]); + free(task->tags); + task->tags = next; + task->num_tags = count; +} + +/* Set the task's category. NULL leaves it unchanged. Caller holds task->lock. */ +static void task_set_category(torrent_task *task, const char *category) { + if (!category) return; + char *copy = strdup(category); + if (!copy) return; + free(task->category); + task->category = copy; +} + +/* JSON array of the task's tags. Caller holds task->lock. */ +static json_t *task_tags_json(const torrent_task *task) { + json_t *tags = json_array(); + if (tags) + for (size_t i = 0; i < task->num_tags; i++) + json_array_append_new(tags, json_string(task->tags[i])); + return tags; +} + +/* Copy a torrent's labels (category + tags, flattened) out for the Lua + * `naut.get_labels` accessor. Runs on the script worker thread. */ +static char **script_labels(void *opaque, uint64_t torrent_id, size_t *count) { + daemon_state *state = opaque; + *count = 0; + char **out = NULL; + pthread_mutex_lock(&state->torrent_lock); + torrent_task *task = find_torrent_locked(state, torrent_id); + if (task) { + pthread_mutex_lock(&task->lock); + bool has_cat = task->category && *task->category; + size_t cap = task->num_tags + (has_cat ? 1 : 0); + if (cap && (out = calloc(cap, sizeof *out))) { + size_t c = 0; + if (has_cat) { + char *copy = strdup(task->category); + if (copy) out[c++] = copy; + } + for (size_t i = 0; i < task->num_tags; i++) { + char *copy = strdup(task->tags[i]); + if (copy) out[c++] = copy; + } + *count = c; + if (c == 0) { free(out); out = NULL; } + } + pthread_mutex_unlock(&task->lock); + } + pthread_mutex_unlock(&state->torrent_lock); + return out; +} + +/* Remove directories left empty after moving a file out, walking up from the + * file's old parent but never reaching or passing `base` (the download root, + * which may be shared). rmdir only deletes empty dirs, so this is safe. */ +static void prune_empty_dirs(const char *old_file_path, const char *base, + size_t base_len) { + char dir[PATH_MAX]; + if ((size_t)snprintf(dir, sizeof dir, "%s", old_file_path) >= sizeof dir) + return; + char *slash = strrchr(dir, '/'); + if (!slash) return; + *slash = '\0'; /* dir = the file's parent directory */ + while (strlen(dir) > base_len && + strncmp(dir, base, base_len) == 0 && dir[base_len] == '/') { + if (rmdir(dir) != 0) break; /* non-empty / busy: stop pruning */ + slash = strrchr(dir, '/'); + if (!slash) break; + *slash = '\0'; + } +} + +/* Apply a pending "Set location": move the torrent's files under the new base + * directory, then adopt it as the output dir. Runs on the worker thread (the + * sole owner of `storage`), so it never races engine writes. A one-time move, + * with no rule that re-locates later. + * + * Per file (relative to the old base): + * - reset: -> new_base/ + * - kept, under old base: -> new_base/ (preserve moves) + * - kept, separate dir: left exactly where it is. + * Empty residual folders under the old base are pruned. */ +static void apply_pending_save_path(torrent_task *task, naut_storage *storage) { + pthread_mutex_lock(&task->lock); + if (!task->pending_save_path || task->stats.file_count == 0) { + pthread_mutex_unlock(&task->lock); + return; /* nothing to do, or file list not known yet — retry next pass */ + } + char *target = task->pending_save_path; /* take ownership */ + task->pending_save_path = NULL; + bool reset = task->pending_save_path_reset; + char *old_base = strdup(task->output_dir ? task->output_dir : ""); + size_t nfiles = task->stats.file_count; + if (nfiles > NAUT_SWARM_MAX_FILE_STATS) nfiles = NAUT_SWARM_MAX_FILE_STATS; + char **orig_rel = calloc(nfiles, sizeof *orig_rel); + char **cur = calloc(nfiles, sizeof *cur); /* each file's current abs path */ + bool ok = old_base && orig_rel && cur; + for (size_t i = 0; ok && i < nfiles; i++) { + orig_rel[i] = strdup(task->stats.file_stats[i].path); + const char *ov = NULL; + for (size_t j = 0; j < task->num_locations; j++) + if (task->locations[j].file_index == i) { + ov = task->locations[j].path; + break; + } + char tmp[PATH_MAX]; + if (ov) cur[i] = strdup(ov); + else if (orig_rel[i] && + (size_t)snprintf(tmp, sizeof tmp, "%s/%s", old_base, + orig_rel[i]) < sizeof tmp) + cur[i] = strdup(tmp); + if (!orig_rel[i] || !cur[i]) ok = false; + } + pthread_mutex_unlock(&task->lock); + if (!ok) { + for (size_t i = 0; i < nfiles; i++) { free(orig_rel[i]); free(cur[i]); } + free(orig_rel); free(cur); free(old_base); free(target); + return; + } + + /* Normalize trailing slashes for clean prefix comparisons. */ + size_t blen = strlen(old_base); + while (blen > 1 && old_base[blen - 1] == '/') old_base[--blen] = '\0'; + size_t tlen = strlen(target); + while (tlen > 1 && target[tlen - 1] == '/') target[--tlen] = '\0'; + + file_location *newloc = NULL; + size_t nnew = 0, moved = 0; + for (size_t i = 0; i < nfiles; i++) { + char def[PATH_MAX], final[PATH_MAX]; + snprintf(def, sizeof def, "%s/%s", target, orig_rel[i]); + bool under = strlen(cur[i]) > blen && + strncmp(cur[i], old_base, blen) == 0 && cur[i][blen] == '/'; + if (reset) + snprintf(final, sizeof final, "%s", def); + else if (under) + snprintf(final, sizeof final, "%s/%s", target, cur[i] + blen + 1); + else + snprintf(final, sizeof final, "%s", cur[i]); /* separate dir: leave */ + + bool did_move = false; + if (strcmp(final, cur[i]) != 0) { + naut_err e = naut_storage_relocate(storage, (size_t)i, final); + if (e == NAUT_OK) { did_move = true; moved++; } + else { + NAUT_WARN("set-location torrent=%llu file=%zu -> %s: %s", + (unsigned long long)task->id, i, final, + naut_strerror(e)); + snprintf(final, sizeof final, "%s", cur[i]); /* stayed put */ + } + } + if (did_move && under) + prune_empty_dirs(cur[i], old_base, blen); + + if (strcmp(final, def) != 0) { /* not at the default path -> track it */ + file_location *grown = realloc(newloc, (nnew + 1) * sizeof *grown); + char *p = strdup(final); + if (grown && p) { + newloc = grown; + newloc[nnew].file_index = (uint32_t)i; + newloc[nnew].path = p; + nnew++; + } else { + free(p); + if (grown) newloc = grown; + } + } + free(orig_rel[i]); + free(cur[i]); + } + free(orig_rel); + free(cur); + + pthread_mutex_lock(&task->lock); + free(task->output_dir); + task->output_dir = target; /* take ownership */ + for (size_t i = 0; i < task->num_locations; i++) free(task->locations[i].path); + free(task->locations); + task->locations = newloc; + task->num_locations = nnew; + pthread_mutex_unlock(&task->lock); + + NAUT_INFO("set-location torrent=%llu -> %s (%zu/%zu files moved%s)", + (unsigned long long)task->id, target, moved, nfiles, + reset ? ", reset to original paths" : ""); + free(old_base); + persist_torrents(task->daemon); +} + static void torrent_control(void *opaque, naut_storage *storage) { torrent_task *task = opaque; + bool moved = false; for (;;) { move_command command; pthread_mutex_lock(&task->lock); if (task->move_count == 0) { pthread_mutex_unlock(&task->lock); - return; + break; } command = task->moves[task->move_head]; task->move_head = (task->move_head + 1) % MOVE_QUEUE_CAPACITY; @@ -144,10 +474,13 @@ static void torrent_control(void *opaque, naut_storage *storage) { naut_err error = naut_storage_relocate( storage, command.file_index, command.destination); pthread_mutex_lock(&task->lock); - if (error == NAUT_OK) + if (error == NAUT_OK) { task->moves_processed++; - else + task_set_location(task, command.file_index, command.destination); + moved = true; + } else { task->moves_failed++; + } pthread_mutex_unlock(&task->lock); if (error == NAUT_OK) NAUT_INFO("moved torrent=%llu file=%u -> %s", @@ -158,12 +491,31 @@ static void torrent_control(void *opaque, naut_storage *storage) { (unsigned long long)task->id, command.file_index, command.destination, naut_strerror(error)); } + /* Persist the new locations so a restart reopens the files in place. */ + if (moved) persist_torrents(task->daemon); + + apply_pending_save_path(task, storage); } static void *torrent_worker(void *opaque) { torrent_task *task = opaque; + /* Snapshot the saved moved-file locations so the swarm reopens them in + * place instead of re-downloading. Copied so a later move (processed on this + * same thread) reallocating task->locations can't invalidate them. */ + naut_swarm_file_location *locations = NULL; + size_t num_locations = 0; pthread_mutex_lock(&task->lock); task->state = TORRENT_RUNNING; + if (task->num_locations && + (locations = calloc(task->num_locations, sizeof *locations))) { + for (size_t i = 0; i < task->num_locations; i++) { + char *path = strdup(task->locations[i].path); + if (!path) continue; + locations[num_locations].file_index = task->locations[i].file_index; + locations[num_locations].path = path; + num_locations++; + } + } pthread_mutex_unlock(&task->lock); naut_swarm_config config = { @@ -171,22 +523,34 @@ static void *torrent_worker(void *opaque) { .output_dir = task->output_dir, .peers = (const char *const *)task->peers, .num_peers = task->num_peers, + .locations = locations, + .num_locations = num_locations, .torrent_id = task->id, .events = task->daemon->events, .keep_alive = true, .on_progress = torrent_progress, .on_control = torrent_control, .should_stop = torrent_should_stop, + .download_rate = torrent_download_rate, + .should_dump = torrent_should_dump, + .on_dump = torrent_on_dump, .context = task, }; naut_err result = naut_swarm_run(&config); + for (size_t i = 0; i < num_locations; i++) free((char *)locations[i].path); + free(locations); + pthread_mutex_lock(&task->lock); task->result = result; - if (result == NAUT_OK) + /* A requested stop wins over the run result: a completed torrent returns + * NAUT_OK even when paused/stopped, and marking it COMPLETE would make the + * lifecycle reconciler immediately relaunch its keep-alive worker (clearing + * `paused`) — i.e. pause wouldn't stick for seeding torrents. */ + if (task->stop_requested) + task->state = task->paused ? TORRENT_PAUSED : TORRENT_STOPPED; + else if (result == NAUT_OK) task->state = TORRENT_COMPLETE; - else if (task->stop_requested) - task->state = TORRENT_STOPPED; else task->state = TORRENT_ERROR; task->thread_done = true; @@ -203,8 +567,15 @@ static json_t *torrent_json(torrent_task *task) { json_object_set_new(result, "source", json_string(task->source)); json_object_set_new(result, "output", json_string(task->output_dir)); + if (task->name) + json_object_set_new(result, "name", json_string(task->name)); json_object_set_new(result, "state", json_string(torrent_state_name(task->state))); + json_object_set_new(result, "paused", json_boolean(task->paused)); + json_object_set_new(result, "force_start", + json_boolean(task->force_start)); + json_object_set_new(result, "queue_pos", + json_integer(task->queue_pos)); json_object_set_new(result, "bytes_done", json_integer((json_int_t)task->stats.bytes_done)); json_object_set_new(result, "total_bytes", @@ -305,6 +676,23 @@ static json_t *torrent_json(torrent_task *task) { json_integer((json_int_t)task->moves_processed)); json_object_set_new(result, "moves_failed", json_integer((json_int_t)task->moves_failed)); + if (task->num_locations) { + json_t *locations = json_array(); + if (locations) { + for (size_t i = 0; i < task->num_locations; i++) + json_array_append_new(locations, json_pack( + "{s:i,s:s}", + "file", (int)task->locations[i].file_index, + "path", task->locations[i].path)); + json_object_set_new(result, "locations", locations); + } + } + json_object_set_new(result, "category", + json_string(task->category ? task->category : "")); + json_object_set_new(result, "tags", task_tags_json(task)); + if (task->pending_save_path) + json_object_set_new(result, "pending_save_path", + json_string(task->pending_save_path)); if (task->state == TORRENT_ERROR) json_object_set_new(result, "error", json_string(naut_strerror(task->result))); @@ -327,12 +715,76 @@ static json_t *rpc_ping(void *opaque, const json_t *params, naut_err *error) { return result; } +static char *read_text_file_limited(const char *path, size_t max_bytes) { + FILE *file = fopen(path, "rb"); + if (!file) return NULL; + char *buf = malloc(max_bytes + 1); + if (!buf) { + fclose(file); + return NULL; + } + size_t n = fread(buf, 1, max_bytes, file); + bool too_large = !feof(file); + bool error = ferror(file); + fclose(file); + if (error) { + free(buf); + return NULL; + } + buf[n] = 0; + if (too_large) { + const char suffix[] = "\n-- truncated --\n"; + size_t suffix_len = sizeof suffix - 1; + if (max_bytes >= suffix_len) { + memcpy(buf + max_bytes - suffix_len, suffix, suffix_len + 1); + } + } + return buf; +} + +static json_t *script_status_json(daemon_state *state) { + naut_script_stats stats = {0}; + char last_error[256] = {0}; + char *path = NULL; + bool loaded = false; + + pthread_mutex_lock(&state->script_lock); + loaded = state->script != NULL; + if (state->script) { + naut_script_get_stats(state->script, &stats); + snprintf(last_error, sizeof last_error, "%s", + naut_script_last_error(state->script)); + } + if (state->script_path) + path = strdup(state->script_path); + pthread_mutex_unlock(&state->script_lock); + + char *source = path ? read_text_file_limited(path, 256 * 1024) : NULL; + json_t *script = json_pack( + "{s:b,s:s,s:s,s:I,s:I,s:I,s:I,s:I,s:s}", + "loaded", loaded, + "path", path ? path : "", + "source", source ? source : "", + "queued", (json_int_t)stats.queued, + "handled", (json_int_t)stats.handled, + "dropped", (json_int_t)stats.dropped, + "errors", (json_int_t)stats.errors, + "move_requests", (json_int_t)stats.move_requests, + "last_error", last_error); + if (script) { + json_t *settings = script_settings_json(state); + json_object_set_new(script, "settings", + settings ? settings : json_array()); + } + free(source); + free(path); + return script; +} + static json_t *rpc_status(void *opaque, const json_t *params, naut_err *error) { (void)params; daemon_state *state = opaque; - naut_script_stats stats = {0}; - if (state->script) naut_script_get_stats(state->script, &stats); size_t torrent_count; size_t active = 0; uint64_t moves = 0; @@ -343,6 +795,7 @@ static json_t *rpc_status(void *opaque, const json_t *params, torrent_task *task = state->torrents[i]; pthread_mutex_lock(&task->lock); if (task->state == TORRENT_RUNNING || + task->state == TORRENT_STALLED || task->state == TORRENT_STOPPING) active++; moves += task->moves_processed; @@ -351,7 +804,7 @@ static json_t *rpc_status(void *opaque, const json_t *params, } pthread_mutex_unlock(&state->torrent_lock); json_t *result = json_object(); - json_t *script = json_object(); + json_t *script = script_status_json(state); if (!result || !script) { json_decref(result); json_decref(script); @@ -370,13 +823,8 @@ static json_t *rpc_status(void *opaque, const json_t *params, json_object_set_new(result, "active_torrents", json_integer((json_int_t)active)); json_object_set_new(result, "script_loaded", - json_boolean(state->script != NULL)); - json_object_set_new(script, "queued", json_integer(stats.queued)); - json_object_set_new(script, "handled", json_integer(stats.handled)); - json_object_set_new(script, "dropped", json_integer(stats.dropped)); - json_object_set_new(script, "errors", json_integer(stats.errors)); - json_object_set_new(script, "move_requests", - json_integer(stats.move_requests)); + json_boolean(json_boolean_value( + json_object_get(script, "loaded")))); json_object_set_new(result, "script", script); json_object_set_new(result, "move_commands", json_integer(moves)); json_object_set_new(result, "pending_move_commands", @@ -385,6 +833,15 @@ static json_t *rpc_status(void *opaque, const json_t *params, return result; } +static json_t *rpc_script_status(void *opaque, const json_t *params, + naut_err *error) { + (void)params; + daemon_state *state = opaque; + json_t *script = script_status_json(state); + *error = script ? NAUT_OK : NAUT_ERR_NOMEM; + return script; +} + static json_t *rpc_plugins(void *opaque, const json_t *params, naut_err *error) { (void)params; @@ -496,24 +953,574 @@ static bool write_all_fd(int fd, const void *buf, size_t len) { } /* A browser uploads a .torrent's bytes as base64 in "data"; the daemon writes - * its own temp file and owns its lifecycle (no shared path with the client). - * Returns the temp path in `out` (size cap) or sets *error. */ -static bool add_torrent_write_upload(const char *data_b64, char *out, - size_t cap, naut_err *error) { + * its own file and owns its lifecycle (no shared path with the client). When + * persistence is enabled the file goes under /uploads so it survives + * a restart (*managed = true, unlink only on remove); otherwise it lands in /tmp + * (*managed = false, unlink on any destroy). Returns the path in `out`. */ +static bool add_torrent_write_upload(daemon_state *state, const char *data_b64, + char *out, size_t cap, bool *managed, + naut_err *error) { size_t raw_len = 0; unsigned char *raw = b64_decode(data_b64, &raw_len); if (!raw || raw_len == 0) { free(raw); *error = NAUT_ERR_INVAL; return false; } - char tmpl[] = "/tmp/naut-upload-XXXXXX"; + char tmpl[PATH_MAX + 32]; + if (state->persist_enabled) + snprintf(tmpl, sizeof tmpl, "%s/upload-XXXXXX", state->uploads_dir); + else + snprintf(tmpl, sizeof tmpl, "/tmp/naut-upload-XXXXXX"); int fd = mkstemp(tmpl); if (fd < 0) { free(raw); *error = NAUT_ERR_IO; return false; } bool ok = write_all_fd(fd, raw, raw_len); close(fd); free(raw); if (!ok) { unlink(tmpl); *error = NAUT_ERR_IO; return false; } - snprintf(out, cap, "%s", tmpl); + size_t path_len = strlen(tmpl); + if (path_len + 1 > cap) { + unlink(tmpl); + *error = NAUT_ERR_INVAL; + return false; + } + memcpy(out, tmpl, path_len + 1); + *managed = state->persist_enabled; return true; } +/* --- persistence --------------------------------------------------------- */ + +/* On-disk record for one torrent (caller holds task->lock). */ +static json_t *torrent_record(const torrent_task *task) { + json_t *rec = json_object(); + if (!rec) return NULL; + json_object_set_new(rec, "torrent_id", json_integer((json_int_t)task->id)); + json_object_set_new(rec, "source", json_string(task->source)); + json_object_set_new(rec, "output", json_string(task->output_dir)); + json_object_set_new(rec, "source_managed", + json_boolean(task->source_managed)); + if (task->name) json_object_set_new(rec, "name", json_string(task->name)); + json_object_set_new(rec, "paused", json_boolean(task->paused)); + json_object_set_new(rec, "force_start", json_boolean(task->force_start)); + json_object_set_new(rec, "queue_pos", json_integer(task->queue_pos)); + json_t *peers = json_array(); + if (peers) { + for (size_t i = 0; i < task->num_peers; i++) + json_array_append_new(peers, json_string(task->peers[i])); + json_object_set_new(rec, "peers", peers); + } + if (task->num_locations) { + json_t *locations = json_array(); + if (locations) { + for (size_t i = 0; i < task->num_locations; i++) + json_array_append_new(locations, json_pack( + "{s:i,s:s}", + "file", (int)task->locations[i].file_index, + "path", task->locations[i].path)); + json_object_set_new(rec, "locations", locations); + } + } + if (task->category && *task->category) + json_object_set_new(rec, "category", json_string(task->category)); + if (task->num_tags) + json_object_set_new(rec, "tags", task_tags_json(task)); + if (task->pending_save_path) { + json_object_set_new(rec, "pending_save_path", + json_string(task->pending_save_path)); + json_object_set_new(rec, "pending_save_path_reset", + json_boolean(task->pending_save_path_reset)); + } + return rec; +} + +/* Atomically write the current (non-removed) torrent set to state_file. */ +static void persist_torrents(daemon_state *state) { + if (!state->persist_enabled) return; + json_t *array = json_array(); + if (!array) return; + pthread_mutex_lock(&state->torrent_lock); + for (size_t i = 0; i < state->torrent_count; i++) { + torrent_task *task = state->torrents[i]; + pthread_mutex_lock(&task->lock); + json_t *rec = task->remove_requested ? NULL : torrent_record(task); + pthread_mutex_unlock(&task->lock); + if (rec) json_array_append_new(array, rec); + } + pthread_mutex_unlock(&state->torrent_lock); + + char tmp[PATH_MAX + 8]; + snprintf(tmp, sizeof tmp, "%s.tmp", state->state_file); + if (json_dump_file(array, tmp, JSON_INDENT(2)) != 0) { + NAUT_WARN("persist: write %s failed", tmp); + unlink(tmp); + } else if (rename(tmp, state->state_file) != 0) { + NAUT_WARN("persist: rename to %s failed: %s", + state->state_file, strerror(errno)); + unlink(tmp); + } + json_decref(array); +} + +/* --- daemon preferences (queue limit + throttle) ------------------------ */ + +static void persist_prefs(daemon_state *state) { + if (!state->persist_enabled) return; + json_t *p = json_pack( + "{s:i,s:I,s:I,s:I,s:I,s:b}", + "max_active", (json_int_t)state->max_active, + "dl_limit", (json_int_t)state->dl_limit, + "alt_dl_limit", (json_int_t)state->alt_dl_limit, + "up_limit", (json_int_t)state->up_limit, + "alt_up_limit", (json_int_t)state->alt_up_limit, + "alt_speed_enabled", state->alt_speed_enabled); + if (!p) return; + char tmp[PATH_MAX + 8]; + snprintf(tmp, sizeof tmp, "%s.tmp", state->prefs_file); + if (json_dump_file(p, tmp, JSON_INDENT(2)) != 0 || + rename(tmp, state->prefs_file) != 0) { + NAUT_WARN("persist: write %s failed", state->prefs_file); + unlink(tmp); + } + json_decref(p); +} + +static void load_prefs(daemon_state *state) { + if (!state->persist_enabled) return; + json_error_t jerr; + json_t *p = json_load_file(state->prefs_file, 0, &jerr); + if (!p) return; + json_t *v; + if ((v = json_object_get(p, "max_active")) && json_is_integer(v) && + json_integer_value(v) > 0) + state->max_active = (uint32_t)json_integer_value(v); + if ((v = json_object_get(p, "dl_limit")) && json_is_integer(v)) + state->dl_limit = (uint64_t)json_integer_value(v); + if ((v = json_object_get(p, "alt_dl_limit")) && json_is_integer(v)) + state->alt_dl_limit = (uint64_t)json_integer_value(v); + if ((v = json_object_get(p, "up_limit")) && json_is_integer(v)) + state->up_limit = (uint64_t)json_integer_value(v); + if ((v = json_object_get(p, "alt_up_limit")) && json_is_integer(v)) + state->alt_up_limit = (uint64_t)json_integer_value(v); + if ((v = json_object_get(p, "alt_speed_enabled"))) + state->alt_speed_enabled = json_boolean_value(v); + json_decref(p); +} + +static json_t *prefs_json(daemon_state *state) { + return json_pack( + "{s:i,s:I,s:I,s:I,s:I,s:b}", + "max_active", (json_int_t)state->max_active, + "dl_limit", (json_int_t)state->dl_limit, + "alt_dl_limit", (json_int_t)state->alt_dl_limit, + "up_limit", (json_int_t)state->up_limit, + "alt_up_limit", (json_int_t)state->alt_up_limit, + "alt_speed_enabled", state->alt_speed_enabled); +} + +/* --- script settings (schema declared by the script, values set by the UI) - */ + +static const char *jstr(const json_t *obj, const char *key, + const char *fallback) { + const char *v = json_string_value(json_object_get(obj, key)); + return v ? v : fallback; +} + +/* Coerce any JSON scalar to a freshly allocated string ("true"/"false" for + * bools, plain digits for numbers). Returns NULL for non-scalars. */ +static char *json_scalar_to_string(const json_t *v) { + if (json_is_string(v)) return strdup(json_string_value(v)); + if (json_is_true(v)) return strdup("true"); + if (json_is_false(v)) return strdup("false"); + if (json_is_integer(v)) { + char buf[32]; + snprintf(buf, sizeof buf, "%lld", (long long)json_integer_value(v)); + return strdup(buf); + } + if (json_is_real(v)) { + char buf[32]; + snprintf(buf, sizeof buf, "%g", json_real_value(v)); + return strdup(buf); + } + return NULL; +} + +/* Find a schema entry by key (caller holds settings_lock). */ +static json_t *settings_schema_entry(daemon_state *state, const char *key) { + if (!state->script_settings_schema) return NULL; + size_t i; + json_t *entry; + json_array_foreach(state->script_settings_schema, i, entry) + if (strcmp(jstr(entry, "key", ""), key) == 0) return entry; + return NULL; +} + +static void persist_script_settings(daemon_state *state) { + if (!state->persist_enabled) return; + pthread_mutex_lock(&state->settings_lock); + json_t *copy = state->script_settings + ? json_deep_copy(state->script_settings) : json_object(); + pthread_mutex_unlock(&state->settings_lock); + if (!copy) return; + char tmp[PATH_MAX + 8]; + snprintf(tmp, sizeof tmp, "%s.tmp", state->settings_file); + if (json_dump_file(copy, tmp, JSON_INDENT(2)) != 0 || + rename(tmp, state->settings_file) != 0) { + NAUT_WARN("persist: write %s failed", state->settings_file); + unlink(tmp); + } + json_decref(copy); +} + +static void load_script_settings(daemon_state *state) { + if (!state->persist_enabled) return; + json_error_t jerr; + json_t *v = json_load_file(state->settings_file, 0, &jerr); + if (!v) return; + if (json_is_object(v)) { + pthread_mutex_lock(&state->settings_lock); + json_decref(state->script_settings); + state->script_settings = v; + pthread_mutex_unlock(&state->settings_lock); + } else { + json_decref(v); + } +} + +/* Host callback: the script (re)declared its settings schema. */ +static void daemon_define_settings(void *opaque, + const naut_script_setting_def *defs, + size_t count) { + daemon_state *state = opaque; + json_t *schema = json_array(); + if (!schema) return; + for (size_t i = 0; i < count; i++) { + const char *type = defs[i].type ? defs[i].type : "string"; + json_t *entry = json_pack( + "{s:s,s:s,s:s,s:s}", + "key", defs[i].key, + "label", defs[i].label ? defs[i].label : defs[i].key, + "type", type, + "default", defs[i].default_value ? defs[i].default_value : ""); + if (entry) json_array_append_new(schema, entry); + } + pthread_mutex_lock(&state->settings_lock); + json_decref(state->script_settings_schema); + state->script_settings_schema = schema; + pthread_mutex_unlock(&state->settings_lock); +} + +/* Host callback: resolve a setting (user value, else declared default). */ +static char *daemon_get_setting(void *opaque, const char *key, + naut_setting_type *type) { + daemon_state *state = opaque; + char *out = NULL; + *type = NAUT_SETTING_STRING; + pthread_mutex_lock(&state->settings_lock); + json_t *entry = settings_schema_entry(state, key); + const char *tname = entry ? jstr(entry, "type", "string") + : "string"; + if (strcmp(tname, "bool") == 0) *type = NAUT_SETTING_BOOL; + else if (strcmp(tname, "number") == 0) *type = NAUT_SETTING_NUMBER; + json_t *value = state->script_settings + ? json_object_get(state->script_settings, key) : NULL; + if (value) + out = json_scalar_to_string(value); + else if (entry) + out = strdup(jstr(entry, "default", "")); + pthread_mutex_unlock(&state->settings_lock); + return out; +} + +/* The settings block for script_status: schema fields plus the effective value + * (user-set if present, otherwise the declared default). */ +static json_t *script_settings_json(daemon_state *state) { + json_t *out = json_array(); + if (!out) return NULL; + pthread_mutex_lock(&state->settings_lock); + if (state->script_settings_schema) { + size_t i; + json_t *entry; + json_array_foreach(state->script_settings_schema, i, entry) { + const char *key = jstr(entry, "key", ""); + const char *def = jstr(entry, "default", ""); + json_t *uv = state->script_settings + ? json_object_get(state->script_settings, key) : NULL; + char *vs = uv ? json_scalar_to_string(uv) : NULL; + json_t *item = json_pack( + "{s:s,s:s,s:s,s:s,s:s}", + "key", key, + "label", jstr(entry, "label", key), + "type", jstr(entry, "type", "string"), + "default", def, + "value", vs ? vs : def); + free(vs); + if (item) json_array_append_new(out, item); + } + } + pthread_mutex_unlock(&state->settings_lock); + return out; +} + +static naut_script_host script_host(daemon_state *state) { + naut_script_host host = { + .move_file = queue_move, + .labels = script_labels, + .define_settings = daemon_define_settings, + .get_setting = daemon_get_setting, + .context = state, + }; + return host; +} + +/* Merge user-supplied values (keys must exist in the schema) and persist. */ +static json_t *rpc_set_script_settings(void *opaque, const json_t *params, + naut_err *error) { + daemon_state *state = opaque; + json_t *settings = json_is_object(params) + ? json_object_get(params, "settings") : NULL; + if (!json_is_object(settings)) { *error = NAUT_ERR_INVAL; return NULL; } + + pthread_mutex_lock(&state->settings_lock); + if (!state->script_settings) state->script_settings = json_object(); + if (state->script_settings) { + const char *key; + json_t *value; + json_object_foreach(settings, key, value) { + if (!settings_schema_entry(state, key)) continue; /* unknown key */ + char *vs = json_scalar_to_string(value); + if (vs) { + json_object_set_new(state->script_settings, key, + json_string(vs)); + free(vs); + } + } + } + pthread_mutex_unlock(&state->settings_lock); + persist_script_settings(state); + *error = NAUT_OK; + return script_status_json(state); +} + +/* --- label taxonomy (web-layer category + tag lists, persisted here) ------- */ + +static void persist_taxonomy(daemon_state *state) { + if (!state->persist_enabled) return; + pthread_mutex_lock(&state->taxonomy_lock); + json_t *cats = state->label_categories + ? json_deep_copy(state->label_categories) : json_array(); + json_t *tags = state->label_tags + ? json_deep_copy(state->label_tags) : json_array(); + pthread_mutex_unlock(&state->taxonomy_lock); + json_t *doc = json_object(); + if (!doc) { json_decref(cats); json_decref(tags); return; } + json_object_set_new(doc, "categories", cats ? cats : json_array()); + json_object_set_new(doc, "tags", tags ? tags : json_array()); + char tmp[PATH_MAX + 8]; + snprintf(tmp, sizeof tmp, "%s.tmp", state->taxonomy_file); + if (json_dump_file(doc, tmp, JSON_INDENT(2)) != 0 || + rename(tmp, state->taxonomy_file) != 0) { + NAUT_WARN("persist: write %s failed", state->taxonomy_file); + unlink(tmp); + } + json_decref(doc); +} + +static void load_taxonomy(daemon_state *state) { + if (!state->persist_enabled) return; + json_error_t jerr; + json_t *doc = json_load_file(state->taxonomy_file, 0, &jerr); + if (!doc) return; + json_t *cats = json_object_get(doc, "categories"); + json_t *tags = json_object_get(doc, "tags"); + pthread_mutex_lock(&state->taxonomy_lock); + if (json_is_array(cats)) { + json_decref(state->label_categories); + state->label_categories = json_deep_copy(cats); + } + if (json_is_array(tags)) { + json_decref(state->label_tags); + state->label_tags = json_deep_copy(tags); + } + pthread_mutex_unlock(&state->taxonomy_lock); + json_decref(doc); +} + +static json_t *rpc_get_label_taxonomy(void *opaque, const json_t *params, + naut_err *error) { + (void)params; + daemon_state *state = opaque; + pthread_mutex_lock(&state->taxonomy_lock); + json_t *cats = state->label_categories + ? json_deep_copy(state->label_categories) : json_array(); + json_t *tags = state->label_tags + ? json_deep_copy(state->label_tags) : json_array(); + pthread_mutex_unlock(&state->taxonomy_lock); + json_t *out = json_object(); + if (!out) { json_decref(cats); json_decref(tags); *error = NAUT_ERR_NOMEM; return NULL; } + json_object_set_new(out, "categories", cats ? cats : json_array()); + json_object_set_new(out, "tags", tags ? tags : json_array()); + *error = NAUT_OK; + return out; +} + +static json_t *rpc_set_label_taxonomy(void *opaque, const json_t *params, + naut_err *error) { + daemon_state *state = opaque; + if (!json_is_object(params)) { *error = NAUT_ERR_INVAL; return NULL; } + json_t *cats = json_object_get(params, "categories"); + json_t *tags = json_object_get(params, "tags"); + pthread_mutex_lock(&state->taxonomy_lock); + if (json_is_array(cats)) { + json_decref(state->label_categories); + state->label_categories = json_deep_copy(cats); + } + if (json_is_array(tags)) { + json_decref(state->label_tags); + state->label_tags = json_deep_copy(tags); + } + pthread_mutex_unlock(&state->taxonomy_lock); + persist_taxonomy(state); + *error = NAUT_OK; + return json_object(); +} + +/* Build a torrent_task from validated inputs, register it under an id, and start + * its worker. Returns the task (added to state->torrents) or NULL + *error. All + * inputs are copied; the source file is never unlinked here (the caller owns + * that decision so a failed restore does not delete a durable upload). */ +static torrent_task *spawn_torrent(daemon_state *state, const char *source, + bool source_managed, bool source_is_temp, + const char *output, const json_t *peers_json, + const json_t *locations_json, + const json_t *meta_json, + const json_t *id_opt, const char *name, + bool start_paused, bool force_start, + int queue_pos, naut_err *error) { + torrent_task *task = calloc(1, sizeof(*task)); + if (!task) { *error = NAUT_ERR_NOMEM; return NULL; } + task->daemon = state; + task->state = start_paused ? TORRENT_PAUSED : TORRENT_QUEUED; + task->result = NAUT_ERR_AGAIN; + task->paused = start_paused; + task->force_start = force_start; + task->source = strdup(source); + task->source_is_temp = source_is_temp; + task->source_managed = source_managed; + task->output_dir = strdup(output); + task->name = (name && *name) ? strdup(name) : NULL; + if (!task->source || !task->output_dir || (name && *name && !task->name)) { + *error = NAUT_ERR_NOMEM; + goto fail_early; + } + if (pthread_mutex_init(&task->lock, NULL) != 0) { + *error = NAUT_ERR_NOMEM; + goto fail_early; + } + + task->num_peers = peers_json ? json_array_size(peers_json) : 0; + if (task->num_peers) { + task->peers = calloc(task->num_peers, sizeof(*task->peers)); + if (!task->peers) { *error = NAUT_ERR_NOMEM; goto fail_task; } + for (size_t i = 0; i < task->num_peers; i++) { + const char *peer = + json_string_value(json_array_get(peers_json, i)); + if (!peer || !*peer) { *error = NAUT_ERR_INVAL; goto fail_task; } + task->peers[i] = strdup(peer); + if (!task->peers[i]) { *error = NAUT_ERR_NOMEM; goto fail_task; } + } + } + + size_t nloc = locations_json ? json_array_size(locations_json) : 0; + for (size_t i = 0; i < nloc; i++) { + json_t *entry = json_array_get(locations_json, i); + json_t *fidx = json_object_get(entry, "file"); + const char *path = json_string_value(json_object_get(entry, "path")); + if (!json_is_integer(fidx) || json_integer_value(fidx) < 0 || !path) + continue; /* skip malformed entries rather than fail the restore */ + task_set_location(task, (uint32_t)json_integer_value(fidx), path); + } + if (meta_json) { + task_set_category(task, + json_string_value(json_object_get(meta_json, "category"))); + json_t *tags = json_object_get(meta_json, "tags"); + /* Migrate the old flat "labels" record into tags. */ + if (!json_is_array(tags)) tags = json_object_get(meta_json, "labels"); + task_set_tags(task, tags); + const char *pending = + json_string_value(json_object_get(meta_json, "pending_save_path")); + if (pending && *pending) { + task->pending_save_path = strdup(pending); + task->pending_save_path_reset = + json_boolean_value(json_object_get(meta_json, + "pending_save_path_reset")); + } + } + + pthread_mutex_lock(&state->torrent_lock); + if (state->torrent_count == MAX_TORRENTS) { + pthread_mutex_unlock(&state->torrent_lock); + *error = NAUT_ERR_FULL; + goto fail_task; + } + if (id_opt) { + if (!json_is_integer(id_opt) || json_integer_value(id_opt) < 0) { + pthread_mutex_unlock(&state->torrent_lock); + *error = NAUT_ERR_INVAL; + goto fail_task; + } + task->id = (uint64_t)json_integer_value(id_opt); + if (task->id < (uint64_t)INT64_MAX && + task->id >= state->next_torrent_id) + state->next_torrent_id = task->id + 1; + } else { + while (find_torrent_locked(state, state->next_torrent_id)) + state->next_torrent_id++; + if (state->next_torrent_id > (uint64_t)INT64_MAX) { + pthread_mutex_unlock(&state->torrent_lock); + *error = NAUT_ERR_FULL; + goto fail_task; + } + task->id = state->next_torrent_id++; + } + if (find_torrent_locked(state, task->id)) { + pthread_mutex_unlock(&state->torrent_lock); + *error = NAUT_ERR_INVAL; + goto fail_task; + } + if (queue_pos >= 0) { + task->queue_pos = queue_pos; + } else { /* append to the tail of the queue */ + int max_pos = 0; + for (size_t i = 0; i < state->torrent_count; i++) + if (state->torrents[i]->queue_pos > max_pos) + max_pos = state->torrents[i]->queue_pos; + task->queue_pos = max_pos + 1; + } + state->torrents[state->torrent_count++] = task; + pthread_mutex_unlock(&state->torrent_lock); + + /* Register without a worker; the lifecycle reconciler starts it when it is + * within the active-download budget (and not paused). thread_done=true marks + * it as cleanly (re)startable. */ + task->thread_done = true; + *error = NAUT_OK; + return task; + +fail_task: + for (size_t i = 0; i < task->num_peers; i++) free(task->peers[i]); + free(task->peers); + for (size_t i = 0; i < task->num_locations; i++) free(task->locations[i].path); + free(task->locations); + for (size_t i = 0; i < task->num_tags; i++) free(task->tags[i]); + free(task->tags); + free(task->category); + free(task->pending_save_path); + pthread_mutex_destroy(&task->lock); +fail_early: + free(task->name); + free(task->source); + free(task->output_dir); + free(task); + return NULL; +} + static json_t *rpc_add_torrent(void *opaque, const json_t *params, naut_err *error) { daemon_state *state = opaque; @@ -538,114 +1545,34 @@ static json_t *rpc_add_torrent(void *opaque, const json_t *params, return NULL; } - /* materialize an upload into a daemon-owned temp .torrent */ + const char *name = json_string_value(json_object_get(params, "name")); + bool start_paused = + json_boolean_value(json_object_get(params, "paused")); + + /* materialize an upload into a daemon-owned .torrent (durable under + * state_dir/uploads when persistence is on, else an ephemeral /tmp file). */ char temp_source[PATH_MAX]; - bool is_temp = false; + bool managed = false, is_temp = false; if ((!source || !*source) && data_b64 && *data_b64) { - if (!add_torrent_write_upload(data_b64, temp_source, sizeof temp_source, - error)) + if (!add_torrent_write_upload(state, data_b64, temp_source, + sizeof temp_source, &managed, error)) return NULL; source = temp_source; - is_temp = true; + is_temp = !managed; /* /tmp fallback when persistence is disabled */ } - torrent_task *task = calloc(1, sizeof(*task)); + torrent_task *task = spawn_torrent( + state, source, managed, is_temp, output, peers_json, + /*locations_json=*/NULL, /*meta_json=*/params, + json_object_get(params, "torrent_id"), name, + start_paused, /*force_start=*/false, /*queue_pos=*/-1, error); if (!task) { - if (is_temp) unlink(source); - *error = NAUT_ERR_NOMEM; + if (managed || is_temp) unlink(source); return NULL; } - task->daemon = state; - task->state = TORRENT_QUEUED; - task->result = NAUT_ERR_AGAIN; - task->source = strdup(source); - task->source_is_temp = is_temp; - task->output_dir = strdup(output); - if (!task->source || !task->output_dir) { - if (is_temp) unlink(temp_source); - free(task->source); - free(task->output_dir); - free(task); - *error = NAUT_ERR_NOMEM; - return NULL; - } - if (pthread_mutex_init(&task->lock, NULL) != 0) { - if (is_temp) unlink(temp_source); - free(task->source); - free(task->output_dir); - free(task); - *error = NAUT_ERR_NOMEM; - return NULL; - } - - task->num_peers = peers_json ? json_array_size(peers_json) : 0; - if (task->num_peers) { - task->peers = calloc(task->num_peers, sizeof(*task->peers)); - if (!task->peers) { *error = NAUT_ERR_NOMEM; goto fail_task; } - for (size_t i = 0; i < task->num_peers; i++) { - const char *peer = - json_string_value(json_array_get(peers_json, i)); - if (!peer || !*peer) { *error = NAUT_ERR_INVAL; goto fail_task; } - task->peers[i] = strdup(peer); - if (!task->peers[i]) { *error = NAUT_ERR_NOMEM; goto fail_task; } - } - } - - json_t *id_json = json_object_get(params, "torrent_id"); - pthread_mutex_lock(&state->torrent_lock); - if (state->torrent_count == MAX_TORRENTS) { - pthread_mutex_unlock(&state->torrent_lock); - *error = NAUT_ERR_FULL; - goto fail_task; - } - if (id_json) { - if (!json_is_integer(id_json) || json_integer_value(id_json) < 0) { - pthread_mutex_unlock(&state->torrent_lock); - *error = NAUT_ERR_INVAL; - goto fail_task; - } - task->id = (uint64_t)json_integer_value(id_json); - if (task->id < (uint64_t)INT64_MAX && - task->id >= state->next_torrent_id) - state->next_torrent_id = task->id + 1; - } else { - while (find_torrent_locked(state, state->next_torrent_id)) - state->next_torrent_id++; - if (state->next_torrent_id > (uint64_t)INT64_MAX) { - pthread_mutex_unlock(&state->torrent_lock); - *error = NAUT_ERR_FULL; - goto fail_task; - } - task->id = state->next_torrent_id++; - } - if (find_torrent_locked(state, task->id)) { - pthread_mutex_unlock(&state->torrent_lock); - *error = NAUT_ERR_INVAL; - goto fail_task; - } - state->torrents[state->torrent_count++] = task; - pthread_mutex_unlock(&state->torrent_lock); - - if (pthread_create(&task->thread, NULL, torrent_worker, task) != 0) { - pthread_mutex_lock(&state->torrent_lock); - state->torrent_count--; - pthread_mutex_unlock(&state->torrent_lock); - *error = NAUT_ERR_NOMEM; - goto fail_task; - } - task->thread_started = true; - *error = NAUT_OK; + persist_torrents(state); + service_lifecycle(state); /* start it now if within the active budget */ return torrent_json(task); - -fail_task: - if (is_temp) unlink(temp_source); - for (size_t i = 0; i < task->num_peers; i++) free(task->peers[i]); - free(task->peers); - free(task->source); - free(task->output_dir); - pthread_mutex_destroy(&task->lock); - free(task); - return NULL; } static json_t *rpc_torrents(void *opaque, const json_t *params, @@ -717,35 +1644,380 @@ static json_t *rpc_remove_torrent(void *opaque, const json_t *params, *error = NAUT_ERR_NOTFOUND; return NULL; } + persist_torrents(state); /* drop the removed torrent from disk now */ *error = NAUT_OK; return torrent_json(task); } +/* Find a torrent by id, run `apply` under its lock, persist, and return its + * json. Shared by pause/resume/recheck. */ +static json_t *torrent_flag_op(daemon_state *state, const json_t *params, + void (*apply)(torrent_task *, const json_t *), + naut_err *error) { + uint64_t id; + if (!parse_torrent_id(params, &id)) { *error = NAUT_ERR_INVAL; return NULL; } + pthread_mutex_lock(&state->torrent_lock); + torrent_task *task = find_torrent_locked(state, id); + if (task) { + pthread_mutex_lock(&task->lock); + apply(task, params); + pthread_mutex_unlock(&task->lock); + } + pthread_mutex_unlock(&state->torrent_lock); + if (!task) { *error = NAUT_ERR_NOTFOUND; return NULL; } + persist_torrents(state); + service_lifecycle(state); /* apply the desired-state change immediately */ + *error = NAUT_OK; + return torrent_json(task); +} + +static void apply_pause(torrent_task *task, const json_t *params) { + (void)params; + task->paused = true; + task->force_start = false; + if (!task->thread_done) { + task->stop_requested = true; + task->state = TORRENT_STOPPING; + } else { + task->state = TORRENT_PAUSED; + } +} + +static void apply_resume(torrent_task *task, const json_t *params) { + task->paused = false; + task->force_start = json_boolean_value(json_object_get(params, "force")); + /* The reconciler activates it (subject to the queue, or immediately if + * forced); leaving the flags is enough. */ +} + +static void apply_recheck(torrent_task *task, const json_t *params) { + (void)params; + /* Force a stop->start cycle; a fresh run re-hashes via the resume scan. */ + task->paused = false; + task->restart_requested = true; + if (!task->thread_done) { + task->stop_requested = true; + task->state = TORRENT_STOPPING; + } +} + +static void apply_set_labels(torrent_task *task, const json_t *params) { + /* category and/or tags; absent fields leave that part unchanged. */ + task_set_category(task, + json_string_value(json_object_get(params, "category"))); + json_t *tags = json_object_get(params, "tags"); + if (!json_is_array(tags)) tags = json_object_get(params, "labels"); + task_set_tags(task, tags); +} + +static json_t *rpc_pause_torrent(void *opaque, const json_t *params, + naut_err *error) { + return torrent_flag_op(opaque, params, apply_pause, error); +} +static json_t *rpc_set_labels(void *opaque, const json_t *params, + naut_err *error) { + return torrent_flag_op(opaque, params, apply_set_labels, error); +} + +/* "Set location": queue a one-time move of the torrent's files to a new base. + * The worker performs it on its next control pass (see apply_pending_save_path); + * a stopped torrent applies it when it next runs. */ +static void apply_set_save_path(torrent_task *task, const json_t *params) { + const char *path = json_string_value(json_object_get(params, "savePath")); + if (!path || !*path || strlen(path) >= PATH_MAX) return; + bool reset = json_boolean_value(json_object_get(params, "reset")); + /* Same base with nothing to reset is a no-op; with reset it still pulls any + * individually-moved files back to their original relpaths. */ + if (!reset && task->output_dir && strcmp(task->output_dir, path) == 0) return; + free(task->pending_save_path); + task->pending_save_path = strdup(path); + task->pending_save_path_reset = reset; +} +static json_t *rpc_set_save_path(void *opaque, const json_t *params, + naut_err *error) { + return torrent_flag_op(opaque, params, apply_set_save_path, error); +} +static json_t *rpc_resume_torrent(void *opaque, const json_t *params, + naut_err *error) { + return torrent_flag_op(opaque, params, apply_resume, error); +} +static json_t *rpc_recheck_torrent(void *opaque, const json_t *params, + naut_err *error) { + return torrent_flag_op(opaque, params, apply_recheck, error); +} + +/* Render a diagnostic state dump for one torrent. The rich engine + piece state + * lives on the swarm worker thread, so we bump the task's dump request and wait + * for the worker to render it (via torrent_should_dump/torrent_on_dump), then + * return the text. Re-resolves the task by id on every poll so a concurrently + * reaped torrent is detected rather than dereferenced. */ +static json_t *dump_result(const char *text) { + json_t *result = json_object(); + if (result) json_object_set_new(result, "dump", json_string(text)); + return result; +} + +static json_t *rpc_dump_torrent(void *opaque, const json_t *params, + naut_err *error) { + daemon_state *state = opaque; + uint64_t id; + if (!parse_torrent_id(params, &id)) { *error = NAUT_ERR_INVAL; return NULL; } + + pthread_mutex_lock(&state->torrent_lock); + torrent_task *task = find_torrent_locked(state, id); + bool active = false; + uint64_t want = 0; + if (task) { + pthread_mutex_lock(&task->lock); + active = task->thread_started && !task->thread_done; + want = ++task->dump_seq; + pthread_mutex_unlock(&task->lock); + } + pthread_mutex_unlock(&state->torrent_lock); + if (!task) { *error = NAUT_ERR_NOTFOUND; return NULL; } + if (!active) { + *error = NAUT_OK; + return dump_result("torrent is not running; no live engine state to dump\n"); + } + + /* Wait for the worker thread to service the request (~3s budget). */ + char *text = NULL; + bool vanished = false; + for (int i = 0; i < 300 && !text && !vanished; i++) { + usleep(10000); /* 10 ms */ + pthread_mutex_lock(&state->torrent_lock); + torrent_task *t = find_torrent_locked(state, id); + if (!t) { + vanished = true; + } else { + pthread_mutex_lock(&t->lock); + if (t->dump_done_seq >= want && t->dump_text) + text = strdup(t->dump_text); + pthread_mutex_unlock(&t->lock); + } + pthread_mutex_unlock(&state->torrent_lock); + } + + if (!text) { + *error = NAUT_OK; + return dump_result(vanished + ? "torrent was removed before the dump completed\n" + : "dump timed out: worker did not respond\n"); + } + json_t *result = dump_result(text); + free(text); + *error = result ? NAUT_OK : NAUT_ERR_NOMEM; + return result; +} + +/* Reorder the download queue: op = top | bottom | up | down. */ +static json_t *rpc_queue_move(void *opaque, const json_t *params, + naut_err *error) { + daemon_state *state = opaque; + uint64_t id; + const char *op = json_is_object(params) + ? json_string_value(json_object_get(params, "op")) : NULL; + if (!parse_torrent_id(params, &id) || !op) { + *error = NAUT_ERR_INVAL; + return NULL; + } + pthread_mutex_lock(&state->torrent_lock); + torrent_task *task = find_torrent_locked(state, id); + if (!task) { + pthread_mutex_unlock(&state->torrent_lock); + *error = NAUT_ERR_NOTFOUND; + return NULL; + } + int self = task->queue_pos, lo = self, hi = self; + torrent_task *prev = NULL, *next = NULL; /* nearest neighbors by position */ + for (size_t i = 0; i < state->torrent_count; i++) { + torrent_task *o = state->torrents[i]; + if (o == task) continue; + if (o->queue_pos < lo) lo = o->queue_pos; + if (o->queue_pos > hi) hi = o->queue_pos; + if (o->queue_pos < self && (!prev || o->queue_pos > prev->queue_pos)) + prev = o; + if (o->queue_pos > self && (!next || o->queue_pos < next->queue_pos)) + next = o; + } + if (strcmp(op, "top") == 0) { + task->queue_pos = lo - 1; + } else if (strcmp(op, "bottom") == 0) { + task->queue_pos = hi + 1; + } else if (strcmp(op, "up") == 0 && prev) { + int tmp = task->queue_pos; task->queue_pos = prev->queue_pos; + prev->queue_pos = tmp; + } else if (strcmp(op, "down") == 0 && next) { + int tmp = task->queue_pos; task->queue_pos = next->queue_pos; + next->queue_pos = tmp; + } + pthread_mutex_unlock(&state->torrent_lock); + persist_torrents(state); + service_lifecycle(state); /* reordering may change the active set */ + *error = NAUT_OK; + return torrent_json(task); +} + +static json_t *rpc_get_preferences(void *opaque, const json_t *params, + naut_err *error) { + (void)params; + daemon_state *state = opaque; + json_t *result = prefs_json(state); + *error = result ? NAUT_OK : NAUT_ERR_NOMEM; + return result; +} + +static json_t *rpc_set_preferences(void *opaque, const json_t *params, + naut_err *error) { + daemon_state *state = opaque; + if (!json_is_object(params)) { *error = NAUT_ERR_INVAL; return NULL; } + json_t *v; + if ((v = json_object_get(params, "max_active")) && json_is_integer(v) && + json_integer_value(v) > 0) + state->max_active = (uint32_t)json_integer_value(v); + if ((v = json_object_get(params, "dl_limit")) && json_is_integer(v) && + json_integer_value(v) >= 0) + state->dl_limit = (uint64_t)json_integer_value(v); + if ((v = json_object_get(params, "alt_dl_limit")) && json_is_integer(v) && + json_integer_value(v) >= 0) + state->alt_dl_limit = (uint64_t)json_integer_value(v); + if ((v = json_object_get(params, "up_limit")) && json_is_integer(v) && + json_integer_value(v) >= 0) + state->up_limit = (uint64_t)json_integer_value(v); + if ((v = json_object_get(params, "alt_up_limit")) && json_is_integer(v) && + json_integer_value(v) >= 0) + state->alt_up_limit = (uint64_t)json_integer_value(v); + if ((v = json_object_get(params, "alt_speed_enabled"))) + state->alt_speed_enabled = json_boolean_value(v); + persist_prefs(state); + service_lifecycle(state); /* apply new budget / throttle shares now */ + json_t *result = prefs_json(state); + *error = result ? NAUT_OK : NAUT_ERR_NOMEM; + return result; +} + +static json_t *rpc_toggle_altspeed(void *opaque, const json_t *params, + naut_err *error) { + (void)params; + daemon_state *state = opaque; + state->alt_speed_enabled = !state->alt_speed_enabled; + persist_prefs(state); + service_lifecycle(state); /* switch the active throttle immediately */ + *error = NAUT_OK; + return json_pack("{s:b}", "alt_speed_enabled", state->alt_speed_enabled); +} + static json_t *rpc_load_script(void *opaque, const json_t *params, naut_err *error) { daemon_state *state = opaque; const char *path = json_is_object(params) ? json_string_value(json_object_get(params, "path")) : NULL; if (!path || !*path) { *error = NAUT_ERR_INVAL; return NULL; } + char *path_copy = strdup(path); + if (!path_copy) { *error = NAUT_ERR_NOMEM; return NULL; } + naut_script_host host = script_host(state); naut_script *script = naut_script_create( - state->events, path, 256, queue_move, state, error); - if (!script) return NULL; + state->events, path, 256, &host, error); + if (!script) { + free(path_copy); + return NULL; + } + pthread_mutex_lock(&state->script_lock); naut_script *old = state->script; + char *old_path = state->script_path; state->script = script; - naut_script_destroy(old); + state->script_path = path_copy; + pthread_mutex_unlock(&state->script_lock); *error = NAUT_OK; - return json_string(path); + naut_script_destroy(old); + free(old_path); + return script_status_json(state); } static json_t *rpc_unload_script(void *opaque, const json_t *params, naut_err *error) { (void)params; daemon_state *state = opaque; + pthread_mutex_lock(&state->script_lock); naut_script *old = state->script; + char *old_path = state->script_path; state->script = NULL; + state->script_path = NULL; + pthread_mutex_unlock(&state->script_lock); naut_script_destroy(old); + free(old_path); *error = NAUT_OK; - return json_true(); + return script_status_json(state); +} + +static json_t *rpc_update_script(void *opaque, const json_t *params, + naut_err *error) { + daemon_state *state = opaque; + json_t *source_json = json_is_object(params) + ? json_object_get(params, "source") : NULL; + if (!json_is_string(source_json)) { + *error = NAUT_ERR_INVAL; + return NULL; + } + const char *source = json_string_value(source_json); + size_t source_len = json_string_length(source_json); + + pthread_mutex_lock(&state->script_lock); + char *path = state->script_path ? strdup(state->script_path) : NULL; + pthread_mutex_unlock(&state->script_lock); + if (!path) { + *error = NAUT_ERR_NOTFOUND; + return NULL; + } + + char tmp_path[PATH_MAX + 32]; + int n = snprintf(tmp_path, sizeof tmp_path, "%s.update-XXXXXX", path); + if (n < 0 || (size_t)n >= sizeof tmp_path) { + free(path); + *error = NAUT_ERR_RANGE; + return NULL; + } + + int fd = mkstemp(tmp_path); + if (fd < 0) { + free(path); + *error = NAUT_ERR_IO; + return NULL; + } + bool ok = write_all_fd(fd, source, source_len); + if (close(fd) != 0) ok = false; + if (!ok) { + unlink(tmp_path); + free(path); + *error = NAUT_ERR_IO; + return NULL; + } + + naut_script_host host = script_host(state); + naut_script *script = naut_script_create( + state->events, tmp_path, 256, &host, error); + if (!script) { + unlink(tmp_path); + free(path); + return NULL; + } + + if (rename(tmp_path, path) != 0) { + naut_script_destroy(script); + unlink(tmp_path); + free(path); + *error = NAUT_ERR_IO; + return NULL; + } + + pthread_mutex_lock(&state->script_lock); + naut_script *old = state->script; + state->script = script; + pthread_mutex_unlock(&state->script_lock); + naut_script_destroy(old); + free(path); + *error = NAUT_OK; + return script_status_json(state); } static naut_err queue_move(void *opaque, uint64_t torrent_id, @@ -893,11 +2165,170 @@ static bool register_commands(daemon_state *state) { naut_rpc_register(state->rpc, "torrents", rpc_torrents, state) == NAUT_OK && naut_rpc_register(state->rpc, "torrent", rpc_torrent, state) == NAUT_OK && naut_rpc_register(state->rpc, "remove_torrent", rpc_remove_torrent, state) == NAUT_OK && + naut_rpc_register(state->rpc, "pause_torrent", rpc_pause_torrent, state) == NAUT_OK && + naut_rpc_register(state->rpc, "resume_torrent", rpc_resume_torrent, state) == NAUT_OK && + naut_rpc_register(state->rpc, "recheck_torrent", rpc_recheck_torrent, state) == NAUT_OK && + naut_rpc_register(state->rpc, "set_labels", rpc_set_labels, state) == NAUT_OK && + naut_rpc_register(state->rpc, "set_save_path", rpc_set_save_path, state) == NAUT_OK && + naut_rpc_register(state->rpc, "dump_torrent", rpc_dump_torrent, state) == NAUT_OK && + naut_rpc_register(state->rpc, "queue_move", rpc_queue_move, state) == NAUT_OK && + naut_rpc_register(state->rpc, "get_preferences", rpc_get_preferences, state) == NAUT_OK && + naut_rpc_register(state->rpc, "set_preferences", rpc_set_preferences, state) == NAUT_OK && + naut_rpc_register(state->rpc, "toggle_altspeed", rpc_toggle_altspeed, state) == NAUT_OK && naut_rpc_register(state->rpc, "load_script", rpc_load_script, state) == NAUT_OK && + naut_rpc_register(state->rpc, "script_status", rpc_script_status, state) == NAUT_OK && + naut_rpc_register(state->rpc, "update_script", rpc_update_script, state) == NAUT_OK && + naut_rpc_register(state->rpc, "set_script_settings", rpc_set_script_settings, state) == NAUT_OK && + naut_rpc_register(state->rpc, "get_label_taxonomy", rpc_get_label_taxonomy, state) == NAUT_OK && + naut_rpc_register(state->rpc, "set_label_taxonomy", rpc_set_label_taxonomy, state) == NAUT_OK && naut_rpc_register(state->rpc, "unload_script", rpc_unload_script, state) == NAUT_OK && naut_rpc_register(state->rpc, "shutdown", rpc_shutdown, state) == NAUT_OK; } +/* --- queue / lifecycle reconciler (main thread only) -------------------- */ + +/* (Re)start a torrent's worker. The task must have no live worker + * (thread_done). Joins any prior thread first. Main thread only. */ +static bool start_worker(torrent_task *task) { + if (task->thread_started) { + pthread_join(task->thread, NULL); + task->thread_started = false; + } + pthread_mutex_lock(&task->lock); + task->stop_requested = false; + task->restart_requested = false; + task->thread_done = false; + task->paused = false; + task->result = NAUT_ERR_AGAIN; + task->state = TORRENT_RUNNING; + pthread_mutex_unlock(&task->lock); + if (pthread_create(&task->thread, NULL, torrent_worker, task) != 0) { + pthread_mutex_lock(&task->lock); + task->state = TORRENT_ERROR; + task->thread_done = true; + pthread_mutex_unlock(&task->lock); + return false; + } + task->thread_started = true; + return true; +} + +/* Ask a running worker to stop; it exits asynchronously (revisited next tick). */ +static void request_stop(torrent_task *task) { + pthread_mutex_lock(&task->lock); + if (!task->thread_done) { + task->stop_requested = true; + task->state = TORRENT_STOPPING; + } + pthread_mutex_unlock(&task->lock); +} + +typedef enum { ACT_NONE, ACT_START, ACT_STOP, ACT_SETSTATE } lifecycle_act; + +/* Reconcile desired vs actual run-state for every torrent: enforce the + * max-active download queue, honor pause/force-start, run recheck restarts, and + * recompute each active torrent's share of the global download limit. Drives + * worker start/stop to match. Main thread only (beside reap_torrents). */ +static void service_lifecycle(daemon_state *state) { + torrent_task *tasks[MAX_TORRENTS]; + bool want_run[MAX_TORRENTS], complete[MAX_TORRENTS], forced[MAX_TORRENTS]; + bool eligible[MAX_TORRENTS]; + int qpos[MAX_TORRENTS]; + lifecycle_act act[MAX_TORRENTS]; + int target[MAX_TORRENTS]; + + pthread_mutex_lock(&state->torrent_lock); + size_t n = state->torrent_count; + for (size_t i = 0; i < n; i++) { + torrent_task *t = state->torrents[i]; + tasks[i] = t; + pthread_mutex_lock(&t->lock); + bool removed = t->remove_requested; + complete[i] = (t->state == TORRENT_COMPLETE); + forced[i] = t->force_start; + qpos[i] = t->queue_pos; + bool paused = t->paused; + pthread_mutex_unlock(&t->lock); + eligible[i] = !removed && !paused && !complete[i]; + /* Completed torrents keep their keep-alive worker but never occupy an + * active download slot. */ + want_run[i] = (!removed && complete[i]); + act[i] = ACT_NONE; + target[i] = 0; + if (removed) eligible[i] = false; /* reap owns removed tasks */ + } + + /* Choose the active set: forced torrents always run; otherwise the + * lowest-queue_pos eligible torrents up to max_active. */ + size_t order[MAX_TORRENTS], ec = 0; + for (size_t i = 0; i < n; i++) if (eligible[i]) order[ec++] = i; + for (size_t a = 1; a < ec; a++) { /* insertion sort by queue_pos */ + size_t v = order[a]; + size_t b = a; + while (b > 0 && qpos[order[b - 1]] > qpos[v]) { + order[b] = order[b - 1]; + b--; + } + order[b] = v; + } + uint32_t budget = state->max_active ? state->max_active : DEFAULT_MAX_ACTIVE; + uint32_t chosen = 0; + for (size_t k = 0; k < ec; k++) { + size_t i = order[k]; + if (forced[i]) { want_run[i] = true; } + else if (chosen < budget) { want_run[i] = true; chosen++; } + } + + /* Split the global download limit across torrents that will actually run. */ + size_t active_dl = 0; + for (size_t i = 0; i < n; i++) if (want_run[i] && !complete[i]) active_dl++; + uint64_t eff = state->alt_speed_enabled ? state->alt_dl_limit + : state->dl_limit; + uint64_t share = eff == 0 ? 0 : eff / (active_dl ? active_dl : 1); + + for (size_t i = 0; i < n; i++) { + torrent_task *t = tasks[i]; + pthread_mutex_lock(&t->lock); + t->rate_share = (want_run[i] && !complete[i]) ? share : 0; + bool removed = t->remove_requested; + bool started = t->thread_started, done = t->thread_done; + bool stopping = t->stop_requested, restart = t->restart_requested; + bool paused = t->paused; + torrent_state st = t->state; + pthread_mutex_unlock(&t->lock); + if (removed) continue; + bool running = started && !done; + if (restart) { + if (running && !stopping) act[i] = ACT_STOP; + else if (done) act[i] = ACT_START; + } else if (want_run[i]) { + if (!running && done) act[i] = ACT_START; + } else { + if (running && !stopping) { + act[i] = ACT_STOP; + } else if (done) { + int want = paused ? TORRENT_PAUSED : TORRENT_QUEUED; + if ((int)st != want) { act[i] = ACT_SETSTATE; target[i] = want; } + } + } + } + pthread_mutex_unlock(&state->torrent_lock); + + /* Apply outside torrent_lock (start_worker joins/creates threads). */ + for (size_t i = 0; i < n; i++) { + switch (act[i]) { + case ACT_START: start_worker(tasks[i]); break; + case ACT_STOP: request_stop(tasks[i]); break; + case ACT_SETSTATE: + pthread_mutex_lock(&tasks[i]->lock); + tasks[i]->state = (torrent_state)target[i]; + pthread_mutex_unlock(&tasks[i]->lock); + break; + case ACT_NONE: break; + } + } +} + static void stop_torrents(daemon_state *state) { pthread_mutex_lock(&state->torrent_lock); for (size_t i = 0; i < state->torrent_count; i++) { @@ -913,18 +2344,31 @@ static void stop_torrents(daemon_state *state) { static void destroy_torrent(torrent_task *task) { if (task->thread_started) pthread_join(task->thread, NULL); - /* uploaded torrents live in a daemon-owned temp file; remove it now that - * the worker has finished reading it */ - if (task->source_is_temp && task->source) unlink(task->source); + /* Ephemeral /tmp uploads always go. Durable managed uploads are kept across + * a normal shutdown (for restore) and removed only when the torrent was + * explicitly removed. */ + if (task->source && + (task->source_is_temp || + (task->source_managed && task->remove_requested))) + unlink(task->source); for (size_t p = 0; p < task->num_peers; p++) free(task->peers[p]); free(task->peers); + for (size_t i = 0; i < task->num_locations; i++) free(task->locations[i].path); + free(task->locations); + for (size_t i = 0; i < task->num_tags; i++) free(task->tags[i]); + free(task->tags); + free(task->category); + free(task->pending_save_path); + free(task->name); free(task->source); free(task->output_dir); + free(task->dump_text); pthread_mutex_destroy(&task->lock); free(task); } static void reap_torrents(daemon_state *state) { + bool reaped = false; for (;;) { torrent_task *task = NULL; pthread_mutex_lock(&state->torrent_lock); @@ -941,9 +2385,11 @@ static void reap_torrents(daemon_state *state) { break; } pthread_mutex_unlock(&state->torrent_lock); - if (!task) return; + if (!task) break; destroy_torrent(task); + reaped = true; } + if (reaped) persist_torrents(state); } static void destroy_torrents(daemon_state *state) { @@ -952,15 +2398,132 @@ static void destroy_torrents(daemon_state *state) { state->torrent_count = 0; } +/* Recursively create a directory path (like `mkdir -p`). */ +static int mkdir_p(const char *path, mode_t mode) { + char tmp[PATH_MAX]; + size_t len = snprintf(tmp, sizeof tmp, "%s", path); + if (len == 0 || len >= sizeof tmp) return -1; + if (tmp[len - 1] == '/') tmp[len - 1] = 0; + for (char *p = tmp + 1; *p; p++) { + if (*p != '/') continue; + *p = 0; + if (mkdir(tmp, mode) != 0 && errno != EEXIST) return -1; + *p = '/'; + } + return (mkdir(tmp, mode) != 0 && errno != EEXIST) ? -1 : 0; +} + +/* Resolve and prepare the persistence directory; fills state->state_file and + * state->uploads_dir. Returns true if persistence can be used. */ +static bool resolve_state_dir(daemon_state *state, const char *override) { + char dir[PATH_MAX]; + if (override && *override) { + if ((size_t)snprintf(dir, sizeof dir, "%s", override) >= sizeof dir) + return false; + } else { + const char *xdg = getenv("XDG_DATA_HOME"); + const char *home = getenv("HOME"); + int n; + if (xdg && *xdg) + n = snprintf(dir, sizeof dir, "%s/naut", xdg); + else if (home && *home) + n = snprintf(dir, sizeof dir, "%s/.local/share/naut", home); + else + return false; + if (n < 0 || (size_t)n >= sizeof dir) return false; + } + if (mkdir_p(dir, 0700) != 0) { + NAUT_WARN("state dir %s: %s", dir, strerror(errno)); + return false; + } + if ((size_t)snprintf(state->uploads_dir, sizeof state->uploads_dir, + "%s/uploads", dir) >= sizeof state->uploads_dir) + return false; + if (mkdir(state->uploads_dir, 0700) != 0 && errno != EEXIST) { + NAUT_WARN("uploads dir %s: %s", state->uploads_dir, strerror(errno)); + return false; + } + if ((size_t)snprintf(state->state_file, sizeof state->state_file, + "%s/torrents.json", dir) >= sizeof state->state_file) + return false; + if ((size_t)snprintf(state->prefs_file, sizeof state->prefs_file, + "%s/prefs.json", dir) >= sizeof state->prefs_file) + return false; + if ((size_t)snprintf(state->settings_file, sizeof state->settings_file, + "%s/script_settings.json", dir) >= + sizeof state->settings_file) + return false; + if ((size_t)snprintf(state->taxonomy_file, sizeof state->taxonomy_file, + "%s/labels.json", dir) >= sizeof state->taxonomy_file) + return false; + return true; +} + +/* Re-create torrents recorded in state_file (called once at startup). */ +static void restore_torrents(daemon_state *state) { + if (!state->persist_enabled) return; + json_error_t jerr; + json_t *array = json_load_file(state->state_file, 0, &jerr); + if (!array) return; /* no prior state, or unreadable */ + if (!json_is_array(array)) { + NAUT_WARN("state file %s is not a torrent array; ignoring", + state->state_file); + json_decref(array); + return; + } + size_t restored = 0, dropped = 0, index; + json_t *rec; + json_array_foreach(array, index, rec) { + const char *source = json_string_value(json_object_get(rec, "source")); + const char *output = json_string_value(json_object_get(rec, "output")); + if (!source || !output) { dropped++; continue; } + bool managed = + json_boolean_value(json_object_get(rec, "source_managed")); + /* A path/upload source that no longer exists can't be restored; magnets + * carry no file to check. */ + if (strncmp(source, "magnet:", 7) != 0 && access(source, R_OK) != 0) { + NAUT_WARN("restore: source missing, dropping: %s", source); + dropped++; + continue; + } + bool paused = json_boolean_value(json_object_get(rec, "paused")); + bool force_start = + json_boolean_value(json_object_get(rec, "force_start")); + json_t *qp = json_object_get(rec, "queue_pos"); + int queue_pos = json_is_integer(qp) ? (int)json_integer_value(qp) : -1; + naut_err error = NAUT_OK; + if (spawn_torrent(state, source, managed, false, output, + json_object_get(rec, "peers"), + json_object_get(rec, "locations"), + /*meta_json=*/rec, + json_object_get(rec, "torrent_id"), + json_string_value(json_object_get(rec, "name")), + paused, force_start, queue_pos, &error)) + restored++; + else { + NAUT_WARN("restore: %s failed: %s", source, naut_strerror(error)); + dropped++; + } + } + json_decref(array); + if (restored || dropped) + NAUT_INFO("restore: %zu torrents restored, %zu dropped", + restored, dropped); + if (dropped) persist_torrents(state); /* prune dropped records */ +} + static void usage(const char *program) { fprintf(stderr, - "usage: %s [--socket PATH] [--plugin PATH]... [--script PATH]\n", + "usage: %s [--socket PATH] [--plugin PATH]... [--script PATH] " + "[--state-dir PATH] [--max-active N]\n", program); } int main(int argc, char **argv) { const char *socket_path = DEFAULT_SOCKET; const char *script_path = NULL; + const char *state_dir = NULL; + long max_active_arg = 0; /* 0 => use default/persisted */ const char *plugin_paths[64]; size_t plugin_count = 0; for (int i = 1; i < argc; i++) { @@ -971,6 +2534,10 @@ int main(int argc, char **argv) { plugin_paths[plugin_count++] = argv[++i]; else if (strcmp(argv[i], "--script") == 0 && i + 1 < argc) script_path = argv[++i]; + else if (strcmp(argv[i], "--state-dir") == 0 && i + 1 < argc) + state_dir = argv[++i]; + else if (strcmp(argv[i], "--max-active") == 0 && i + 1 < argc) + max_active_arg = strtol(argv[++i], NULL, 10); else { usage(argv[0]); return 2; @@ -982,8 +2549,19 @@ int main(int argc, char **argv) { signal(SIGPIPE, SIG_IGN); daemon_state state = {0}; state.next_torrent_id = 1; + state.max_active = DEFAULT_MAX_ACTIVE; + state.persist_enabled = resolve_state_dir(&state, state_dir); + if (!state.persist_enabled) + NAUT_WARN("persistence disabled: torrents will not survive a restart"); + load_prefs(&state); /* override defaults with any saved prefs */ + if (max_active_arg > 0) state.max_active = (uint32_t)max_active_arg; pthread_mutex_init(&state.torrent_lock, NULL); pthread_mutex_init(&state.subscriber_lock, NULL); + pthread_mutex_init(&state.script_lock, NULL); + pthread_mutex_init(&state.settings_lock, NULL); + load_script_settings(&state); /* user-set values; schema comes from the script */ + pthread_mutex_init(&state.taxonomy_lock, NULL); + load_taxonomy(&state); /* persisted category + tag lists for the web UI */ state.events = naut_event_bus_create(); state.rpc = naut_rpc_registry_create(); state.plugins = naut_plugin_manager_create(state.rpc, state.events); @@ -992,6 +2570,19 @@ int main(int argc, char **argv) { fprintf(stderr, "nautd: failed to initialize control plane\n"); return 1; } + if (script_path) { + naut_err error = NAUT_OK; + json_t *params = json_pack("{s:s}", "path", script_path); + json_t *loaded = params ? rpc_load_script(&state, params, &error) + : (error = NAUT_ERR_NOMEM, NULL); + json_decref(params); + json_decref(loaded); + if (error != NAUT_OK) { + fprintf(stderr, "nautd: failed to load script %s: %s\n", + script_path, naut_strerror(error)); + return 1; + } + } for (size_t i = 0; i < plugin_count; i++) { if (naut_plugin_load(state.plugins, plugin_paths[i]) != NAUT_OK) { fprintf(stderr, "nautd: failed to load plugin %s\n", @@ -999,16 +2590,6 @@ int main(int argc, char **argv) { return 1; } } - if (script_path) { - naut_err error; - state.script = naut_script_create(state.events, script_path, 256, - queue_move, &state, &error); - if (!state.script) { - fprintf(stderr, "nautd: failed to load script %s: %s\n", - script_path, naut_strerror(error)); - return 1; - } - } uint64_t event_subscription; if (naut_event_subscribe(state.events, broadcast_event, &state, &event_subscription) != NAUT_OK) @@ -1018,6 +2599,7 @@ int main(int argc, char **argv) { perror("nautd: listen"); return 1; } + restore_torrents(&state); /* re-load torrents saved by a previous run */ NAUT_INFO("nautd listening on %s", socket_path); while (!state.stopping && !interrupted) { struct pollfd pollfd = {.fd = listener, .events = POLLIN}; @@ -1028,6 +2610,7 @@ int main(int argc, char **argv) { } else if (ready < 0 && errno != EINTR) { break; } + service_lifecycle(&state); /* enforce queue, pause/resume, throttle */ reap_torrents(&state); } @@ -1038,8 +2621,14 @@ int main(int argc, char **argv) { * threads joined) before we free the torrent tasks those calls touch. */ naut_plugin_manager_destroy(state.plugins); state.plugins = NULL; - naut_script_destroy(state.script); + pthread_mutex_lock(&state.script_lock); + naut_script *script = state.script; + char *loaded_script_path = state.script_path; state.script = NULL; + state.script_path = NULL; + pthread_mutex_unlock(&state.script_lock); + naut_script_destroy(script); + free(loaded_script_path); stop_torrents(&state); destroy_torrents(&state); naut_event_unsubscribe(state.events, event_subscription); @@ -1051,5 +2640,12 @@ int main(int argc, char **argv) { naut_event_bus_destroy(state.events); pthread_mutex_destroy(&state.subscriber_lock); pthread_mutex_destroy(&state.torrent_lock); + pthread_mutex_destroy(&state.script_lock); + json_decref(state.script_settings); + json_decref(state.script_settings_schema); + pthread_mutex_destroy(&state.settings_lock); + json_decref(state.label_categories); + json_decref(state.label_tags); + pthread_mutex_destroy(&state.taxonomy_lock); return 0; } diff --git a/apps/swarm/main.c b/apps/swarm/main.c index 8317fb8..cedeea9 100644 --- a/apps/swarm/main.c +++ b/apps/swarm/main.c @@ -1,9 +1,13 @@ -/* naut_swarm — Phase 4 gate: download from a SWARM of peers concurrently. +/* naut_swarm — multi-peer download driver built on the torrent-peer engine. * - * A poll()-based multi-socket driver around the same sans-IO peer codec and the - * multi-peer engine (rarest-first + bounded endgame duplication). Peers can be - * supplied explicitly for deterministic testing, or discovered from the - * torrent's HTTP/UDP trackers. + * The engine (../torrent-peer, engine.h) owns all peer sockets, the wire + * protocol, the request pipeline, transports (TCP/µTP/MSE), and piece selection. + * This driver: + * - parses the .torrent / magnet and (for magnet) fetches the info dict, + * - discovers peers via HTTP/UDP trackers + DHT (src/discovery), + * - feeds discovered endpoints + a piece-priority vector to the engine, + * - drains delivered blocks, verifies+persists them through naut_download, + * - re-arms hash-failed pieces and reports progress for nautd / the web UI. * * usage: naut_swarm [ ...] */ @@ -12,72 +16,37 @@ #include "naut/metainfo.h" #include "naut/storage.h" #include "naut/piece.h" -#include "naut/peer.h" #include "naut/tracker.h" -#include "naut/bitfield.h" #include "naut/log.h" -#include "naut/pipeline.h" #include "naut/system.h" #include "naut/swarm.h" -#include "naut/worker.h" + +#include "engine.h" /* torrent-peer multi-peer engine */ #include #include #include #include -#include #include #include #include #include #include #include -#include -#include -#include -#define REQUEST_TIMEOUT 15.0 -#define CONNECT_TIMEOUT_MS 5000 -#define EXT_RESERVED 0x0000000000100000ULL #define DEFAULT_TARGET_PEERS 80 #define MAX_TARGET_PEERS 512 #define TRACKER_DEFAULT_INTERVAL 1800.0 #define TRACKER_MIN_INTERVAL 60.0 #define TRACKER_FAILURE_RETRY_INTERVAL 300.0 #define DHT_REFRESH_INTERVAL 300.0 - -typedef struct { - uint32_t piece, begin, length; - double sent_at; -} req_t; +#define READY_BATCH 64 typedef struct { naut_peer_addr addr; char name[32]; } endpoint_t; -typedef struct { - int fd; - naut_peer_addr addr; - char name[40]; - uint8_t peer_id[20]; - bool have_peer_id; - bool connecting; - uint8_t *rbuf; size_t rcap, rlen; - bool hs_done, peer_choking; - naut_bitfield have; - req_t *inflight; size_t nflight, cflight; - uint64_t blocks_received; - uint64_t bytes_received; - uint64_t rate_last_bytes; - double rate_last_at; - double download_rate; - naut_ext_handshake extensions; - uint64_t pex_received; - naut_pipeline pipeline; - bool dead, availability_removed; -} peer_t; - static double now(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return t.tv_sec + t.tv_nsec*1e-9; } static uint32_t target_peer_count(void) { @@ -152,89 +121,7 @@ static void on_piece_complete(void *opaque, uint32_t index) { emit_event(config, NAUT_EVENT_PIECE_COMPLETE, index, NULL, NULL); } -static void peer_client_label(const peer_t *peer, char out[64]) { - if (!peer || !peer->have_peer_id) { - snprintf(out, 64, "Unknown"); - return; - } - if (peer->peer_id[0] == '-' && peer->peer_id[7] == '-') { - char code[3] = { - (char)peer->peer_id[1], - (char)peer->peer_id[2], - 0, - }; - char version[5]; - memcpy(version, peer->peer_id + 3, 4); - version[4] = 0; - for (size_t i = 0; i < sizeof version - 1; i++) - if (!isprint((unsigned char)version[i])) version[i] = '?'; - snprintf(out, 64, "%s %s", code, version); - return; - } - char id[21]; - memcpy(id, peer->peer_id, sizeof peer->peer_id); - id[20] = 0; - for (size_t i = 0; i < sizeof id - 1; i++) - if (!isprint((unsigned char)id[i])) id[i] = '.'; - snprintf(out, 64, "%s", id); -} - -static void peer_flags(const peer_t *peer, char out[16]) { - size_t n = 0; - if (peer && !peer->peer_choking && n + 1 < 16) out[n++] = 'D'; - if (peer && (peer->extensions.ut_pex || peer->pex_received) && n + 1 < 16) - out[n++] = 'X'; - out[n] = 0; -} - -static void peer_update_rate(peer_t *peer, double sampled_at) { - if (!peer || peer->dead) return; - if (peer->rate_last_at <= 0) { - peer->rate_last_at = sampled_at; - peer->rate_last_bytes = peer->bytes_received; - return; - } - double dt = sampled_at - peer->rate_last_at; - if (dt < 0.25) return; - uint64_t delta = peer->bytes_received - peer->rate_last_bytes; - double rate = (double)delta / dt; - peer->download_rate = peer->download_rate <= 0.0 - ? rate : peer->download_rate * 0.7 + rate * 0.3; - peer->rate_last_at = sampled_at; - peer->rate_last_bytes = peer->bytes_received; -} - -static void snapshot_peer_stats(naut_swarm_stats *stats, - peer_t *peers, int npeers, - uint32_t total_pieces) { - if (!stats || !peers || npeers <= 0) return; - double sampled_at = now(); - for (int i = 0; i < npeers && - stats->peer_count < NAUT_SWARM_MAX_PEER_STATS; i++) { - peer_t *peer = &peers[i]; - if (peer->dead || !peer->hs_done) continue; - peer_update_rate(peer, sampled_at); - naut_swarm_peer_stats *out = - &stats->peer_stats[stats->peer_count++]; - snprintf(out->ip, sizeof out->ip, "%u.%u.%u.%u", - peer->addr.ip[0], peer->addr.ip[1], - peer->addr.ip[2], peer->addr.ip[3]); - out->port = peer->addr.port; - peer_client_label(peer, out->client); - snprintf(out->connection, sizeof out->connection, "TCP"); - peer_flags(peer, out->flags); - size_t have = peer->have.words ? naut_bitfield_count(&peer->have) : 0; - double ratio = total_pieces - ? (double)have / (double)total_pieces : 0.0; - if (ratio > 1.0) ratio = 1.0; - out->progress = ratio; - out->relevance = ratio; - out->dlspeed = peer->download_rate; - out->upspeed = 0.0; - out->downloaded = peer->bytes_received; - out->uploaded = 0; - } -} +/* --- tracker stats ------------------------------------------------------- */ static uint32_t init_tracker_stats(char *const *trackers, const uint32_t *tracker_tiers, @@ -330,34 +217,39 @@ static void snapshot_file_stats(naut_swarm_stats *stats, stats->file_count = count; } +/* Fill and deliver a progress snapshot from engine status + naut_download. + * Per-peer detail collapses to the engine's aggregate counts for now; rich + * per-peer rows are a later web-UI feature. */ static void report_progress(const naut_swarm_config *config, + engine *eng, uint32_t torrent_id, const naut_download *download, const naut_metainfo *metainfo, - uint32_t peers_total, uint32_t peers_connecting, - uint32_t peers_active, uint32_t peers_failed, - peer_t *peers, int npeers, const naut_swarm_tracker_stats *trackers, uint32_t tracker_count, double started_at) { if (!config->on_progress) return; + torrent_status ts; + memset(&ts, 0, sizeof ts); + if (eng) engine_torrent_status(eng, torrent_id, &ts); + uint32_t connecting = ts.peers > ts.peers_connected + ts.peers_failed + ? ts.peers - ts.peers_connected - ts.peers_failed : 0; naut_swarm_stats stats = { .total_bytes = (uint64_t)metainfo->total_length, .bytes_done = download ? naut_download_bytes_done(download) : 0, .total_pieces = metainfo->num_pieces, .pieces_done = download ? naut_download_pieces_done(download) : 0, - .peers_total = peers_total, - .peers_connecting = peers_connecting, - .peers_active = peers_active, - .peers_failed = peers_failed, + .peers_total = ts.peers, + .peers_connecting = connecting, + .peers_active = ts.peers_connected, + .peers_failed = ts.peers_failed, + .stalled = ts.peers_connected == 0, .elapsed_seconds = now() - started_at, }; - snapshot_peer_stats(&stats, peers, npeers, metainfo->num_pieces); snapshot_tracker_stats(&stats, trackers, tracker_count); snapshot_file_stats(&stats, download, metainfo); - if (download) { + if (download) stats.piece_state_count = (uint32_t)naut_download_piece_states( download, stats.piece_states, NAUT_SWARM_MAX_PIECE_STATS); - } config->on_progress(config->context, &stats); } @@ -370,6 +262,28 @@ static void service_control(const naut_swarm_config *config, if (config->on_control) config->on_control(config->context, storage); } +/* Render a full diagnostic snapshot (block assembly + engine piece selection) + * when the caller requests one, and hand the text back through on_dump. Runs on + * the owner thread, the only place engine + download state can be read safely. */ +static void service_dump(const naut_swarm_config *config, engine *eng, + uint32_t torrent_id, const naut_download *download) { + if (!config->should_dump || !config->on_dump) return; + if (!config->should_dump(config->context)) return; + + char *buf = NULL; + size_t len = 0; + FILE *f = open_memstream(&buf, &len); + if (!f) { + config->on_dump(config->context, "dump: out of memory\n"); + return; + } + naut_download_dump(download, f); + engine_dump_torrent(eng, torrent_id, f); + fclose(f); + config->on_dump(config->context, buf ? buf : "dump: render failed\n"); + free(buf); +} + static uint8_t *slurp(const char *path, size_t *len) { FILE *f = fopen(path, "rb"); if (!f) return NULL; fseek(f, 0, SEEK_END); long n = ftell(f); fseek(f, 0, SEEK_SET); @@ -377,12 +291,8 @@ static uint8_t *slurp(const char *path, size_t *len) { if (fread(b, 1, n, f) != (size_t)n) { fclose(f); free(b); return NULL; } fclose(f); *len = (size_t)n; return b; } -static bool send_all(int fd, const void *p, size_t n) { - const uint8_t *b = p; - while (n) { ssize_t w = send(fd, b, n, MSG_NOSIGNAL); - if (w < 0) { if (errno == EINTR) continue; return false; } b += w; n -= (size_t)w; } - return true; -} + +/* --- endpoint collection ------------------------------------------------- */ static bool endpoint_add(endpoint_t **v, size_t *n, size_t *cap, const naut_peer_addr *addr) { @@ -593,446 +503,17 @@ static bool discover_dht(const uint8_t info_hash[20], return true; } -static bool peer_reserve_inflight(peer_t *p) { - if (p->nflight == p->cflight) { - size_t cap = p->cflight ? p->cflight * 2 : 64; - req_t *v = realloc(p->inflight, cap * sizeof(*v)); - if (!v) return false; - p->inflight = v; - p->cflight = cap; +/* Hand every not-yet-fed endpoint to the engine, which owns the connection. */ +static void feed_engine(engine *eng, uint32_t torrent_id, + const endpoint_t *eps, size_t neps, size_t *fed) { + for (size_t i = *fed; i < neps; i++) { + char ip[16]; + snprintf(ip, sizeof ip, "%u.%u.%u.%u", + eps[i].addr.ip[0], eps[i].addr.ip[1], + eps[i].addr.ip[2], eps[i].addr.ip[3]); + engine_add_peer(eng, torrent_id, ip, eps[i].addr.port); } - return true; -} - -static void peer_add_inflight(peer_t *p, uint32_t piece, uint32_t begin, - uint32_t length) { - p->inflight[p->nflight].piece = piece; - p->inflight[p->nflight].begin = begin; - p->inflight[p->nflight].length = length; - p->inflight[p->nflight].sent_at = now(); - p->nflight++; -} -static bool peer_del_inflight(peer_t *p, uint32_t piece, uint32_t begin, - req_t *removed) { - for (size_t i = 0; i < p->nflight; i++) - if (p->inflight[i].piece == piece && p->inflight[i].begin == begin) { - if (removed) *removed = p->inflight[i]; - p->inflight[i] = p->inflight[--p->nflight]; - return true; - } - return false; -} - -static bool peer_has_request(void *ctx, uint32_t piece, uint32_t begin) { - peer_t *p = ctx; - for (size_t i = 0; i < p->nflight; i++) - if (p->inflight[i].piece == piece && p->inflight[i].begin == begin) - return true; - return false; -} - -/* return remaining inflight blocks to the picker (choke / disconnect) */ -static void peer_release(naut_download *d, peer_t *p) { - for (size_t i = 0; i < p->nflight; i++) - naut_download_unrequest(d, p->inflight[i].piece, p->inflight[i].begin); - p->nflight = 0; -} - -static void peer_drop(naut_download *d, peer_t *p) { - if (!p->availability_removed) { - naut_download_remove_bitfield(d, &p->have); - p->availability_removed = true; - } - peer_release(d, p); - if (p->fd >= 0) close(p->fd); - p->fd = -1; - p->connecting = false; - p->dead = true; -} - -static bool refill_one(naut_download *d, peer_t *p) { - if (p->dead || !p->hs_done || p->peer_choking || - p->nflight >= naut_pipeline_depth(&p->pipeline)) - return false; - if (!peer_reserve_inflight(p)) { - peer_drop(d, p); - return false; - } - uint32_t i, b, l; - if (!naut_download_pick_for_peer(d, &p->have, peer_has_request, p, - &i, &b, &l)) - return false; - uint8_t req[17]; - naut_peer_msg_request(req, i, b, l); - if (!send_all(p->fd, req, sizeof req)) { - naut_download_unrequest(d, i, b); - peer_drop(d, p); - return false; - } - peer_add_inflight(p, i, b, l); - return true; -} - -static void expire_requests(naut_download *d, peer_t *p, double t) { - size_t i = 0; - while (i < p->nflight) { - if (t - p->inflight[i].sent_at < REQUEST_TIMEOUT) { - i++; - continue; - } - naut_download_unrequest(d, p->inflight[i].piece, p->inflight[i].begin); - p->inflight[i] = p->inflight[--p->nflight]; - } -} - -static int connect_start(const endpoint_t *ep, bool *connected) { - *connected = false; - int fd = socket(AF_INET, SOCK_STREAM, 0); - if (fd < 0) return -1; - int flags = fcntl(fd, F_GETFL, 0); - if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) != 0) { - close(fd); - return -1; - } - struct sockaddr_in a; memset(&a, 0, sizeof a); - a.sin_family = AF_INET; - a.sin_port = htons(ep->addr.port); - memcpy(&a.sin_addr, ep->addr.ip, sizeof ep->addr.ip); - if (connect(fd, (struct sockaddr *)&a, sizeof a) == 0) { - *connected = true; - } else if (errno != EINPROGRESS) { - close(fd); - return -1; - } - return fd; -} - -static bool connect_finish(int fd) { - int error = 0; - socklen_t length = sizeof error; - if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &error, &length) != 0 || - error != 0) - return false; - int flags = fcntl(fd, F_GETFL, 0); - if (flags < 0 || fcntl(fd, F_SETFL, flags & ~O_NONBLOCK) != 0) - return false; - int one = 1; setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one); - return true; -} - -static bool peer_start(naut_download *download, const naut_metainfo *metainfo, - const uint8_t peerid[20], const endpoint_t *endpoint, - peer_t *peer, int fd) { - peer->fd = fd; - peer->addr = endpoint->addr; - snprintf(peer->name, sizeof peer->name, "%s", endpoint->name); - peer->connecting = false; - peer->peer_choking = true; - naut_pipeline_init(&peer->pipeline, NAUT_BLOCK, 4, 1024, 32); - peer->rcap = 1 << 18; - peer->rbuf = malloc(peer->rcap); - if (!peer->rbuf || - naut_bitfield_init(&peer->have, metainfo->num_pieces) != NAUT_OK) { - free(peer->rbuf); - peer->rbuf = NULL; - close(fd); - peer->fd = -1; - peer->dead = true; - return false; - } - uint8_t handshake[NAUT_HANDSHAKE_LEN]; - naut_peer_handshake_build(handshake, metainfo->infohash_v1, peerid, - EXT_RESERVED); - uint8_t interested[5]; - naut_peer_msg_simple(interested, NAUT_MSG_INTERESTED); - uint8_t *extension = NULL; - size_t extension_length = 0; - naut_err extension_error = naut_ext_build_handshake( - NAUT_EXT_UT_METADATA, NAUT_EXT_UT_PEX, 0, 0, &extension, - &extension_length); - bool sent = extension_error == NAUT_OK && - send_all(fd, handshake, sizeof handshake) && - send_all(fd, extension, extension_length) && - send_all(fd, interested, sizeof interested); - free(extension); - if (!sent) { - peer_drop(download, peer); - return false; - } - return true; -} - -static bool ensure_peer_capacity(peer_t **peers, struct pollfd **pfd, - int **idx_map, int *capacity, int want) { - if (want <= *capacity) return true; - int next = *capacity > 0 ? *capacity : 1; - while (next < want) next *= 2; - peer_t *next_peers = realloc(*peers, (size_t)next * sizeof(*next_peers)); - if (!next_peers) return false; - for (int i = *capacity; i < next; i++) { - memset(&next_peers[i], 0, sizeof(next_peers[i])); - next_peers[i].fd = -1; - next_peers[i].dead = true; - } - struct pollfd *next_pfd = - realloc(*pfd, ((size_t)next + 1) * sizeof(*next_pfd)); - if (!next_pfd) { - *peers = next_peers; - return false; - } - int *next_idx = realloc(*idx_map, ((size_t)next + 1) * sizeof(*next_idx)); - if (!next_idx) { - *peers = next_peers; - *pfd = next_pfd; - return false; - } - *peers = next_peers; - *pfd = next_pfd; - *idx_map = next_idx; - *capacity = next; - return true; -} - -static bool peer_addr_seen(const peer_t *peers, int npeers, - const naut_peer_addr *addr); - -static uint32_t peer_count_active(const peer_t *peers, int npeers, - bool include_connecting) { - uint32_t count = 0; - for (int i = 0; i < npeers; i++) { - if (peers[i].dead) continue; - if (peers[i].connecting && !include_connecting) continue; - count++; - } - return count; -} - -static bool connect_one_candidate(const naut_swarm_config *config, - naut_download *download, - const naut_metainfo *metainfo, - const uint8_t peerid[20], - peer_t **peers, int *npeers, - int *peer_capacity, - struct pollfd **pfd, int **idx_map, - endpoint_t endpoint, - uint32_t *failed) { - if (!ensure_peer_capacity(peers, pfd, idx_map, peer_capacity, - *npeers + 1)) - return false; - peer_t *peer = &(*peers)[*npeers]; - memset(peer, 0, sizeof(*peer)); - peer->fd = -1; - peer->addr = endpoint.addr; - snprintf(peer->name, sizeof peer->name, "%s", endpoint.name); - bool connected = false; - int fd = connect_start(&endpoint, &connected); - if (fd < 0) { - if (failed) (*failed)++; - return true; - } - peer->fd = fd; - (*npeers)++; - if (connected) { - if (!connect_finish(fd) || - !peer_start(download, metainfo, peerid, &endpoint, peer, fd)) { - if (peer->fd >= 0) close(peer->fd); - peer->fd = -1; - peer->dead = true; - if (failed) (*failed)++; - return true; - } - emit_event(config, NAUT_EVENT_PEER_CONNECTED, 0, peer->name, NULL); - return true; - } - peer->connecting = true; - return true; -} - -static bool connect_pending_peers(const naut_swarm_config *config, - naut_download *download, - const naut_metainfo *metainfo, - const uint8_t peerid[20], - peer_t **peers, int *npeers, - int *peer_capacity, - struct pollfd **pfd, int **idx_map, - endpoint_t *pending, size_t *npending, - uint32_t target, uint32_t *failed) { - while (*npending > 0 && - peer_count_active(*peers, *npeers, true) < target) { - endpoint_t endpoint = pending[--(*npending)]; - if (peer_addr_seen(*peers, *npeers, &endpoint.addr)) continue; - if (!connect_one_candidate(config, download, metainfo, peerid, - peers, npeers, peer_capacity, pfd, idx_map, - endpoint, failed)) - return false; - } - return true; -} - -/* process all complete messages currently buffered for peer p */ -static void cancel_block(naut_download *d, peer_t *peers, int npeers, - peer_t *source, uint32_t piece, uint32_t begin) { - for (int i = 0; i < npeers; i++) { - peer_t *p = &peers[i]; - if (p == source || p->dead) continue; - req_t old; - if (!peer_del_inflight(p, piece, begin, &old)) continue; - uint8_t msg[17]; - naut_peer_msg_cancel(msg, old.piece, old.begin, old.length); - if (!send_all(p->fd, msg, sizeof msg)) peer_drop(d, p); - } -} - -static void cancel_piece(naut_download *d, peer_t *peers, int npeers, - uint32_t piece) { - for (int i = 0; i < npeers; i++) { - peer_t *p = &peers[i]; - size_t j = 0; - while (j < p->nflight) { - req_t old = p->inflight[j]; - if (old.piece != piece) { - j++; - continue; - } - p->inflight[j] = p->inflight[--p->nflight]; - naut_download_unrequest(d, old.piece, old.begin); - if (!p->dead) { - uint8_t msg[17]; - naut_peer_msg_cancel(msg, old.piece, old.begin, old.length); - if (!send_all(p->fd, msg, sizeof msg)) { - peer_drop(d, p); - break; - } - } - } - } -} - -static void merge_bitfield(naut_download *d, const naut_metainfo *mi, - peer_t *p, const uint8_t *wire, size_t wire_len) { - naut_bitfield incoming; - if (naut_bitfield_init(&incoming, mi->num_pieces) != NAUT_OK) { - peer_drop(d, p); - return; - } - naut_bitfield_from_wire(&incoming, wire, wire_len); - for (uint32_t piece = 0; piece < mi->num_pieces; piece++) { - if (naut_bitfield_test(&incoming, piece) && - !naut_bitfield_test(&p->have, piece)) { - naut_bitfield_set(&p->have, piece); - naut_download_inc_avail(d, piece); - } - } - naut_bitfield_free(&incoming); -} - -static bool peer_addr_seen(const peer_t *peers, int npeers, - const naut_peer_addr *addr) { - for (int i = 0; i < npeers; i++) - if (peers[i].addr.port == addr->port && - memcmp(peers[i].addr.ip, addr->ip, sizeof addr->ip) == 0) - return true; - return false; -} - -static naut_err peer_process(naut_download *d, const naut_metainfo *mi, - peer_t *peers, int npeers, peer_t *p, - endpoint_t **pending, size_t *npending, - size_t *pending_cap) { - size_t pos = 0; - if (!p->hs_done) { - if (p->rlen < NAUT_HANDSHAKE_LEN) return NAUT_OK; - uint8_t ih[20], pid[20]; - if (!naut_peer_handshake_parse(p->rbuf, ih, pid, NULL) || - memcmp(ih, mi->infohash_v1, 20) != 0) { - p->dead = true; - return NAUT_OK; - } - memcpy(p->peer_id, pid, sizeof p->peer_id); - p->have_peer_id = true; - pos = NAUT_HANDSHAKE_LEN; - p->hs_done = true; - } - for (;;) { - naut_msg m; - int c = naut_peer_msg_parse(p->rbuf + pos, p->rlen - pos, &m); - if (c == 0) break; - if (c < 0) { p->dead = true; break; } - pos += (size_t)c; - switch (m.type) { - case NAUT_MSG_BITFIELD: - merge_bitfield(d, mi, p, m.payload, m.payload_len); - if (p->dead) goto parsed; - break; - case NAUT_MSG_HAVE: - if (m.index < mi->num_pieces && !naut_bitfield_test(&p->have, m.index)) { - naut_bitfield_set(&p->have, m.index); - naut_download_inc_avail(d, m.index); - } - break; - case NAUT_MSG_UNCHOKE: p->peer_choking = false; break; - case NAUT_MSG_CHOKE: p->peer_choking = true; peer_release(d, p); break; - case NAUT_MSG_EXTENDED: - if (m.payload_len < 1) { - peer_drop(d, p); - goto parsed; - } - if (m.payload[0] == 0) { - if (naut_ext_parse_handshake( - m.payload + 1, m.payload_len - 1, - &p->extensions) != NAUT_OK) { - peer_drop(d, p); - goto parsed; - } - } else if (p->extensions.ut_pex && - m.payload[0] == p->extensions.ut_pex) { - naut_pex_msg pex; - if (naut_pex_parse(m.payload + 1, m.payload_len - 1, - &pex) != NAUT_OK) { - peer_drop(d, p); - goto parsed; - } - for (size_t i = 0; i < pex.num_added; i++) - if (!peer_addr_seen(peers, npeers, &pex.added[i]) && - endpoint_add(pending, npending, pending_cap, - &pex.added[i])) { - p->pex_received++; - } - naut_pex_free(&pex); - } - break; - case NAUT_MSG_PIECE: { - req_t request; - bool expected = peer_del_inflight(p, m.index, m.begin, &request); - if (!expected) break; - naut_pipeline_on_block(&p->pipeline, request.length, - request.sent_at, now()); - naut_download_unrequest(d, m.index, m.begin); - if (request.length != m.payload_len) { - peer_drop(d, p); - goto parsed; - } - bool pdone = false; - naut_err e = naut_download_on_block(d, m.index, m.begin, m.payload, - (uint32_t)m.payload_len, &pdone); - if (e == NAUT_OK) { - p->blocks_received++; - p->bytes_received += m.payload_len; - cancel_block(d, peers, npeers, p, m.index, m.begin); - } else if (e == NAUT_ERR_PROTO) { - cancel_piece(d, peers, npeers, m.index); - } else { - return e; - } - break; - } - default: break; - } - } -parsed: - memmove(p->rbuf, p->rbuf + pos, p->rlen - pos); - p->rlen -= pos; - return NAUT_OK; + *fed = neps; } naut_err naut_swarm_run(const naut_swarm_config *config) { @@ -1101,22 +582,25 @@ naut_err naut_swarm_run(const naut_swarm_config *config) { tracker_count = init_tracker_stats(trackers, tracker_tiers, num_trackers, tracker_stats, NAUT_SWARM_MAX_TRACKER_STATS); - uint64_t total = from_magnet ? 0 : (uint64_t)mi.total_length; - if (!discover_trackers(hash, total, trackers, num_trackers, - tracker_tiers, peerid, 0, total, - NAUT_TEV_STARTED, &endpoints, &neps, &epcap, - &tracker_interval, tracker_stats, - tracker_count) || - (neps < target_peers && - !discover_dht(hash, &endpoints, &neps, &epcap))) { - NAUT_ERROR("out of memory collecting discovered peers"); - naut_metainfo_free(&mi); - naut_magnet_free(&magnet); - free(endpoints); - return NAUT_ERR_NOMEM; + if (from_magnet) { + if (!discover_trackers(hash, 0, trackers, num_trackers, + tracker_tiers, peerid, 0, 0, + NAUT_TEV_STARTED, &endpoints, &neps, &epcap, + &tracker_interval, tracker_stats, + tracker_count) || + (neps < target_peers && + !discover_dht(hash, &endpoints, &neps, &epcap))) { + NAUT_ERROR("out of memory collecting discovered peers"); + naut_metainfo_free(&mi); + naut_magnet_free(&magnet); + free(endpoints); + return NAUT_ERR_NOMEM; + } } } + /* Magnet: resolve the info dict from a peer before we can size the torrent. + * Neither the engine nor torrent-tracker does BEP-9; use Naut's own fetch. */ if (from_magnet && neps) { uint8_t *info = NULL; size_t info_len = 0; @@ -1146,20 +630,36 @@ naut_err naut_swarm_run(const naut_swarm_config *config) { naut_magnet_free(&magnet); if (neps == 0) { - NAUT_ERROR(config->num_peers > 0 ? "no valid peer addresses" : - "tracker and DHT discovery returned no peers"); - naut_metainfo_free(&mi); - free(endpoints); - return NAUT_ERR_NOTFOUND; + if (from_magnet) { + NAUT_ERROR("magnet metadata unavailable: no peers discovered"); + naut_metainfo_free(&mi); + free(endpoints); + return NAUT_ERR_NOTFOUND; + } + if (config->num_peers > 0) + NAUT_WARN("no valid peer addresses; torrent stalled"); } naut_err err; + /* Reopen any files relocated on a prior run in place (no re-download). */ + const char **overrides = NULL; + if (config->num_locations && mi.num_files) { + overrides = calloc(mi.num_files, sizeof *overrides); + if (overrides) + for (size_t i = 0; i < config->num_locations; i++) { + const naut_swarm_file_location *loc = &config->locations[i]; + if (loc->path && loc->file_index < mi.num_files) + overrides[loc->file_index] = loc->path; + } + } naut_storage_opts storage_opts = { .direct_io = getenv("NAUT_DIRECT_IO") != NULL, .preallocate = true, + .overrides = overrides, }; naut_storage *st = naut_storage_open_opts( mi.files, mi.num_files, config->output_dir, &storage_opts, &err); + free(overrides); if (!st) { NAUT_ERROR("storage: %s", naut_strerror(err)); naut_metainfo_free(&mi); @@ -1175,62 +675,82 @@ naut_err naut_swarm_run(const naut_swarm_config *config) { } naut_download_set_file_cb(d, on_file_complete, (void *)config); naut_download_set_piece_cb(d, on_piece_complete, (void *)config); - int online_cpus = naut_online_cpus(); - uint32_t worker_count = - (uint32_t)NAUT_MAX(1, NAUT_MIN(8, online_cpus / 2)); - if (getenv("NAUT_WORKERS")) { - unsigned long configured = strtoul(getenv("NAUT_WORKERS"), NULL, 10); - if (configured > 0 && configured <= 256) - worker_count = (uint32_t)configured; - } - int worker_cpu_base = getenv("NAUT_WORKER_CPU_BASE") - ? atoi(getenv("NAUT_WORKER_CPU_BASE")) : -1; - naut_worker_pool *workers = - naut_worker_pool_create(worker_count, 1024, worker_cpu_base); - if (!workers) { - NAUT_ERROR("unable to create hash worker pool"); + err = naut_download_resume(d); + if (err != NAUT_OK) { + NAUT_ERROR("resume scan: %s", naut_strerror(err)); naut_download_destroy(d); naut_storage_close(st); naut_metainfo_free(&mi); free(endpoints); - return NAUT_ERR_NOMEM; + return err; } - naut_download_set_worker_pool(d, workers); + uint64_t resumed_bytes = naut_download_bytes_done(d); + uint64_t resumed_left = (uint64_t)mi.total_length > resumed_bytes + ? (uint64_t)mi.total_length - resumed_bytes : 0; + NAUT_INFO("resume scan complete: %u/%u pieces correct, %llu bytes left", + naut_download_pieces_done(d), mi.num_pieces, + (unsigned long long)resumed_left); - endpoint_t *pending = NULL; - size_t npending = 0, pending_cap = 0; - size_t initial_endpoints = neps; - if (config->num_peers == 0 && initial_endpoints > target_peers) - initial_endpoints = target_peers; - for (size_t i = neps; i > initial_endpoints; i--) { - if (!endpoint_add(&pending, &npending, &pending_cap, - &endpoints[i - 1].addr)) { - NAUT_ERROR("out of memory queueing discovered peers"); - naut_worker_pool_destroy(workers); + if (!from_magnet && config->num_peers == 0 && !naut_download_complete(d)) { + if (!discover_trackers(mi.infohash_v1, (uint64_t)mi.total_length, + mi.trackers, mi.num_trackers, + mi.tracker_tiers, peerid, + resumed_bytes, resumed_left, + NAUT_TEV_STARTED, &endpoints, &neps, &epcap, + &tracker_interval, tracker_stats, + tracker_count) || + (neps < target_peers && + !discover_dht(mi.infohash_v1, &endpoints, &neps, &epcap))) { + NAUT_ERROR("out of memory collecting discovered peers"); naut_download_destroy(d); naut_storage_close(st); naut_metainfo_free(&mi); free(endpoints); return NAUT_ERR_NOMEM; } + if (neps == 0) + NAUT_WARN("tracker and DHT discovery returned no peers; torrent stalled"); } - int npeers = (int)initial_endpoints; - int peer_capacity = (int)initial_endpoints; - peer_t *peers = calloc(initial_endpoints, sizeof(*peers)); - struct pollfd *pfd = calloc(initial_endpoints + 1, sizeof(*pfd)); - int *idx_map = calloc(initial_endpoints + 1, sizeof(*idx_map)); - if (!peers || !pfd || !idx_map) { - NAUT_ERROR("out of memory creating swarm"); - free(peers); free(pfd); free(idx_map); free(pending); - naut_worker_pool_destroy(workers); + /* Spin up the engine and register the torrent. The engine owns sockets, + * the wire protocol, the pipeline, transports, and piece selection. */ + engine_config ecfg; + memset(&ecfg, 0, sizeof ecfg); + ecfg.encryption = 1; /* offer MSE (RC4) + plaintext: most compatible */ + ecfg.fallback = 1; /* retry transport/encryption combos per endpoint */ + engine *eng = engine_create(&ecfg); + int32_t tid = eng ? engine_add_torrent(eng, mi.infohash_v1, peerid, + (uint64_t)mi.piece_length, + (uint64_t)mi.total_length, + mi.num_pieces) + : -1; + if (!eng || tid < 0) { + NAUT_ERROR("unable to create download engine"); + if (eng) engine_destroy(eng); naut_download_destroy(d); naut_storage_close(st); naut_metainfo_free(&mi); free(endpoints); return NAUT_ERR_NOMEM; } - for (int i = 0; i < npeers; i++) peers[i].fd = -1; + uint32_t torrent_id = (uint32_t)tid; + + /* Priority vector: skip what resume already verified, request the rest. */ + uint8_t *prio = malloc(mi.num_pieces ? mi.num_pieces : 1); + if (!prio) { + engine_destroy(eng); + naut_download_destroy(d); + naut_storage_close(st); + naut_metainfo_free(&mi); + free(endpoints); + return NAUT_ERR_NOMEM; + } + for (uint32_t p = 0; p < mi.num_pieces; p++) + prio[p] = naut_download_have(d, p) ? 0 : 1; + engine_set_priorities(eng, torrent_id, prio, mi.num_pieces); + + size_t fed = 0; + feed_engine(eng, torrent_id, endpoints, neps, &fed); double t0 = now(); double next_tracker_announce = @@ -1240,319 +760,126 @@ naut_err naut_swarm_run(const naut_swarm_config *config) { double next_dht_lookup = t0 + DHT_REFRESH_INTERVAL; naut_err run_error = NAUT_OK; bool cancelled = false; - uint32_t connecting = 0; - uint32_t active = 0; - uint32_t failed = 0; - struct pollfd *connect_fds = pfd; - for (int i = 0; i < npeers; i++) connect_fds[i].fd = -1; - report_progress(config, d, &mi, (uint32_t)npeers, 0, 0, 0, - peers, npeers, tracker_stats, tracker_count, t0); - for (int i = 0; i < npeers; i++) { - bool connected = false; - int fd = connect_start(&endpoints[i], &connected); - if (fd < 0) { - NAUT_WARN("connect %s failed", endpoints[i].name); - peers[i].dead = true; - failed++; - continue; - } - peers[i].fd = fd; - if (connected) { - if (!connect_finish(fd) || - !peer_start(d, &mi, peerid, &endpoints[i], &peers[i], fd)) { - NAUT_WARN("connect %s failed", endpoints[i].name); - if (peers[i].fd >= 0) close(peers[i].fd); - peers[i].fd = -1; - peers[i].dead = true; - failed++; - continue; - } - active++; - emit_event(config, NAUT_EVENT_PEER_CONNECTED, 0, - peers[i].name, NULL); - } else { - connect_fds[i].fd = fd; - connect_fds[i].events = POLLOUT; - connecting++; - } - } - double connect_deadline = now() + CONNECT_TIMEOUT_MS / 1000.0; - while (connecting > 0 && now() < connect_deadline && - !stop_requested(config)) { - report_progress(config, d, &mi, (uint32_t)npeers, connecting, - active, failed, peers, npeers, - tracker_stats, tracker_count, t0); - int ready = poll(connect_fds, (nfds_t)neps, 100); - if (ready < 0) { - if (errno == EINTR) continue; - run_error = NAUT_ERR_IO; - break; - } - if (ready == 0) continue; - for (int i = 0; i < npeers; i++) { - if (connect_fds[i].fd < 0 || - !(connect_fds[i].revents & - (POLLOUT | POLLERR | POLLHUP | POLLNVAL))) - continue; - int fd = connect_fds[i].fd; - connect_fds[i].fd = -1; - connecting--; - if (!connect_finish(fd) || - !peer_start(d, &mi, peerid, &endpoints[i], &peers[i], fd)) { - NAUT_WARN("connect %s failed", endpoints[i].name); - if (peers[i].fd >= 0) close(peers[i].fd); - peers[i].fd = -1; - peers[i].dead = true; - failed++; - continue; - } - active++; - emit_event(config, NAUT_EVENT_PEER_CONNECTED, 0, - peers[i].name, NULL); - } - } - for (int i = 0; i < npeers; i++) { - if (connect_fds[i].fd < 0) continue; - close(connect_fds[i].fd); - connect_fds[i].fd = -1; - peers[i].fd = -1; - peers[i].dead = true; - connecting--; - failed++; - NAUT_WARN("connect %s timed out", endpoints[i].name); - } - free(endpoints); - if (!active) { - NAUT_ERROR("no peers reachable"); - if (run_error == NAUT_OK) run_error = NAUT_ERR_IO; - goto done; - } - NAUT_INFO("swarm: %u/%d peers connected, %u pieces, %lld bytes", - active, npeers, mi.num_pieces, (long long)mi.total_length); + NAUT_INFO("swarm: engine started, %zu peers queued, %u pieces, %lld bytes", + neps, mi.num_pieces, (long long)mi.total_length); emit_event(config, NAUT_EVENT_TORRENT_ADDED, 0, NULL, NULL); - report_progress(config, d, &mi, (uint32_t)npeers, 0, active, failed, - peers, npeers, tracker_stats, tracker_count, t0); + report_progress(config, eng, torrent_id, d, &mi, + tracker_stats, tracker_count, t0); + + engine_block blocks[READY_BATCH]; + uint64_t applied_rate = UINT64_MAX; /* force first apply */ while (!naut_download_complete(d) && run_error == NAUT_OK) { service_control(config, st); - if (stop_requested(config)) { - cancelled = true; - break; - } - if (config->num_peers == 0 && npending == 0 && - peer_count_active(peers, npeers, true) < target_peers) { - double t = now(); - if (mi.num_trackers > 0 && t >= next_tracker_announce) { - uint64_t downloaded = naut_download_bytes_done(d); - uint64_t left = (uint64_t)mi.total_length > downloaded - ? (uint64_t)mi.total_length - downloaded : 0; - int32_t interval = 0; - size_t before = npending; - if (!discover_trackers(mi.infohash_v1, - (uint64_t)mi.total_length, - mi.trackers, mi.num_trackers, - mi.tracker_tiers, peerid, - downloaded, left, NAUT_TEV_NONE, - &pending, &npending, &pending_cap, - &interval, tracker_stats, - tracker_count)) { - run_error = NAUT_ERR_NOMEM; - break; - } - next_tracker_announce = t + (interval > 0 - ? tracker_delay_seconds(interval) - : TRACKER_FAILURE_RETRY_INTERVAL); - if (npending > before) - NAUT_INFO("tracker refresh queued %zu peers", - npending - before); + service_dump(config, eng, torrent_id, d); + if (stop_requested(config)) { cancelled = true; break; } + + /* Apply the live download throttle when it changes. */ + if (config->download_rate) { + uint64_t rate = config->download_rate(config->context); + if (rate != applied_rate) { + engine_set_download_rate(eng, rate); + applied_rate = rate; } - if (npending == 0 && t >= next_dht_lookup) { - size_t before = npending; - if (!discover_dht(mi.infohash_v1, &pending, &npending, - &pending_cap)) { - run_error = NAUT_ERR_NOMEM; - break; - } - next_dht_lookup = t + DHT_REFRESH_INTERVAL; - if (npending > before) - NAUT_INFO("DHT refresh queued %zu peers", - npending - before); - } - } - if (!connect_pending_peers(config, d, &mi, peerid, &peers, &npeers, - &peer_capacity, &pfd, &idx_map, - pending, &npending, target_peers, &failed)) { - run_error = NAUT_ERR_NOMEM; - break; - } - int nf = 0; - int connecting_peers = 0; - int live_peers = 0; - for (int i = 0; i < npeers; i++) { - if (peers[i].dead) continue; - pfd[nf].fd = peers[i].fd; - pfd[nf].events = peers[i].connecting - ? (POLLOUT | POLLERR | POLLHUP | POLLNVAL) - : POLLIN; - pfd[nf].revents = 0; - idx_map[nf] = i; nf++; - if (peers[i].connecting) connecting_peers++; - else live_peers++; - } - pfd[nf].fd = naut_worker_eventfd(workers); - pfd[nf].events = POLLIN; - pfd[nf].revents = 0; - idx_map[nf] = -1; - nf++; - if (live_peers == 0 && connecting_peers == 0) { - uint32_t completed = 0; - run_error = naut_download_poll(d, &completed); - if (naut_download_complete(d)) break; - NAUT_ERROR("all peers gone (%.0f%% done)", - 100.0 * naut_download_pieces_done(d) / mi.num_pieces); - run_error = NAUT_ERR_IO; - break; - } - report_progress(config, d, &mi, (uint32_t)npeers, - (uint32_t)connecting_peers, - (uint32_t)live_peers, - failed, - peers, npeers, tracker_stats, tracker_count, t0); - int r = poll(pfd, nf, 200); - if (r < 0) { - if (errno == EINTR) continue; - run_error = NAUT_ERR_IO; - break; } - for (int k = 0; k < nf; k++) { - if (idx_map[k] < 0) { - if (pfd[k].revents & POLLIN) { - uint64_t count; - (void)read(pfd[k].fd, &count, sizeof count); - uint32_t completed = 0; - run_error = naut_download_poll(d, &completed); + /* Top up the swarm from trackers / DHT when it runs thin. */ + if (config->num_peers == 0) { + torrent_status ts; + engine_torrent_status(eng, torrent_id, &ts); + if (ts.peers_connected < target_peers) { + double t = now(); + if (mi.num_trackers > 0 && t >= next_tracker_announce) { + uint64_t downloaded = naut_download_bytes_done(d); + uint64_t left = (uint64_t)mi.total_length > downloaded + ? (uint64_t)mi.total_length - downloaded : 0; + int32_t interval = 0; + if (!discover_trackers(mi.infohash_v1, + (uint64_t)mi.total_length, + mi.trackers, mi.num_trackers, + mi.tracker_tiers, peerid, + downloaded, left, NAUT_TEV_NONE, + &endpoints, &neps, &epcap, + &interval, tracker_stats, + tracker_count)) { + run_error = NAUT_ERR_NOMEM; + break; + } + next_tracker_announce = t + (interval > 0 + ? tracker_delay_seconds(interval) + : TRACKER_FAILURE_RETRY_INTERVAL); + feed_engine(eng, torrent_id, endpoints, neps, &fed); } - continue; - } - peer_t *p = &peers[idx_map[k]]; - if (p->connecting) { - if (!(pfd[k].revents & - (POLLOUT | POLLERR | POLLHUP | POLLNVAL))) - continue; - endpoint_t endpoint = { .addr = p->addr }; - for (size_t c = 0; c + 1 < sizeof endpoint.name; c++) { - endpoint.name[c] = p->name[c]; - if (p->name[c] == 0) break; + if (t >= next_dht_lookup) { + if (!discover_dht(mi.infohash_v1, &endpoints, &neps, + &epcap)) { + run_error = NAUT_ERR_NOMEM; + break; + } + next_dht_lookup = t + DHT_REFRESH_INTERVAL; + feed_engine(eng, torrent_id, endpoints, neps, &fed); } - endpoint.name[sizeof endpoint.name - 1] = 0; - if (!connect_finish(p->fd) || - !peer_start(d, &mi, peerid, &endpoint, p, p->fd)) { - NAUT_WARN("connect %s failed", p->name); - peer_drop(d, p); - failed++; - continue; + } + } + + engine_wait(eng, 200); + uint32_t n; + while ((n = engine_poll_ready(eng, blocks, READY_BATCH)) > 0) { + for (uint32_t i = 0; i < n; i++) { + engine_block *b = &blocks[i]; + uint8_t *data = (uint8_t *)engine_arena_base(eng, b->loop) + + (uint64_t)b->slot * PEER_BLOCK_SIZE; + bool done = false; + naut_err be = naut_download_on_block(d, b->piece, b->begin, + data, b->len, &done); + engine_release_slot(eng, b->loop, b->slot); + if (be == NAUT_ERR_PROTO) { + /* Bad/failed piece: re-arm it for another fetch. */ + engine_request_piece(eng, torrent_id, b->piece); + } else if (be != NAUT_OK && be != NAUT_ERR_RANGE) { + run_error = be; + break; + } else if (done) { + engine_set_priority(eng, torrent_id, b->piece, 0); } - emit_event(config, NAUT_EVENT_PEER_CONNECTED, 0, - p->name, NULL); - continue; } - if (!(pfd[k].revents & (POLLIN | POLLHUP | POLLERR))) continue; - if (p->rlen == p->rcap) { - size_t cap = p->rcap * 2; - uint8_t *buf = realloc(p->rbuf, cap); - if (!buf) { peer_drop(d, p); continue; } - p->rbuf = buf; - p->rcap = cap; - } - ssize_t got = recv(p->fd, p->rbuf + p->rlen, p->rcap - p->rlen, 0); - if (got <= 0) { peer_drop(d, p); continue; } - p->rlen += (size_t)got; - run_error = peer_process(d, &mi, peers, npeers, p, - &pending, &npending, &pending_cap); if (run_error != NAUT_OK) break; - if (p->dead) peer_drop(d, p); - } - if (run_error != NAUT_OK) break; - double t = now(); - for (int i = 0; i < npeers; i++) { - peer_t *p = &peers[i]; - if (!p->dead) expire_requests(d, p, t); - } - for (;;) { - bool sent = false; - for (int i = 0; i < npeers; i++) - if (refill_one(d, &peers[i])) sent = true; - if (!sent) break; } + report_progress(config, eng, torrent_id, d, &mi, + tracker_stats, tracker_count, t0); } double dt = now() - t0; bool ok = naut_download_complete(d); if (ok) { double mb = (double)mi.total_length / 1e6; - NAUT_INFO("COMPLETE: %u/%u pieces from swarm in %.2fs (%.1f MB/s), all SHA-1 verified%s", - naut_download_pieces_done(d), mi.num_pieces, dt, mb/dt, - naut_download_in_endgame(d) ? " (passed through endgame)" : ""); - report_progress(config, d, &mi, (uint32_t)npeers, 0, 0, - (uint32_t)npeers, peers, npeers, + NAUT_INFO("COMPLETE: %u/%u pieces in %.2fs (%.1f MB/s), all SHA-1 verified", + naut_download_pieces_done(d), mi.num_pieces, dt, + dt > 0 ? mb / dt : 0.0); + report_progress(config, eng, torrent_id, d, &mi, tracker_stats, tracker_count, t0); emit_event(config, NAUT_EVENT_TORRENT_FINISHED, 0, NULL, NULL); while (config->keep_alive && !stop_requested(config)) { service_control(config, st); + service_dump(config, eng, torrent_id, d); usleep(100000); } } else { if (run_error != NAUT_OK) NAUT_ERROR("swarm stopped: %s", naut_strerror(run_error)); - NAUT_ERROR("INCOMPLETE: %u/%u pieces", naut_download_pieces_done(d), mi.num_pieces); + NAUT_ERROR("INCOMPLETE: %u/%u pieces", + naut_download_pieces_done(d), mi.num_pieces); } -done: - ok = naut_download_complete(d); service_control(config, st); naut_storage_sync(st); - double elapsed = now() - t0; - for (int i = 0; i < npeers; i++) { - if (peers[i].blocks_received) - NAUT_INFO("peer %s delivered %llu blocks (pipeline %u, RTT %.1f ms, avg %.1f MB/s)", - peers[i].name, - (unsigned long long)peers[i].blocks_received, - naut_pipeline_depth(&peers[i].pipeline), - peers[i].pipeline.rtt_seconds * 1000.0, - elapsed > 0 ? peers[i].bytes_received / elapsed / 1e6 : 0.0); - if (peers[i].pex_received) - NAUT_INFO("peer %s advertised %llu peers through PEX", - peers[i].name, - (unsigned long long)peers[i].pex_received); - if (!peers[i].dead) peer_drop(d, &peers[i]); - free(peers[i].rbuf); free(peers[i].inflight); - if (peers[i].have.words) naut_bitfield_free(&peers[i].have); - } - free(peers); free(pfd); free(idx_map); - free(pending); - naut_worker_pool_destroy(workers); - naut_download_destroy(d); naut_storage_close(st); naut_metainfo_free(&mi); + engine_destroy(eng); + free(prio); + free(endpoints); + naut_download_destroy(d); + naut_storage_close(st); + naut_metainfo_free(&mi); if (ok) return NAUT_OK; if (cancelled) return NAUT_ERR_AGAIN; return run_error != NAUT_OK ? run_error : NAUT_ERR_IO; } - -#ifndef NAUT_SWARM_LIBRARY -int main(int argc, char **argv) { - if (argc < 3) { - fprintf(stderr, - "usage: %s [ip:port ...]\n", - argv[0]); - return 2; - } - naut_log_set_level(NAUT_LOG_INFO); - naut_swarm_config config = { - .source = argv[1], - .output_dir = argv[2], - .peers = argc > 3 ? (const char *const *)&argv[3] : NULL, - .num_peers = argc > 3 ? (size_t)(argc - 3) : 0, - }; - return naut_swarm_run(&config) == NAUT_OK ? 0 : 1; -} -#endif diff --git a/docs/scripting.md b/docs/scripting.md index f072cf1..2743913 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -200,6 +200,78 @@ nautctl --socket /tmp/nautd.sock add file.torrent /downloads/42 If `torrent_id` is unknown or is being removed, `naut.move_file` raises a `move_file failed` error in the hook. +### `naut.get_labels(torrent_id)` + +Return the torrent's labels as a **plain array (table) of strings**. Labels are +the user-assigned tags/category set in the web UI (or via the `set_labels` RPC); +they are stored on the daemon, persisted across restarts, and surfaced here so a +script can branch on them (e.g. route a finished file by its label). + +**Arguments** + +| # | Name | Lua type | Notes | +|---|---|---|---| +| 1 | `torrent_id` | `integer` | must be ≥ 0 | + +**Return value:** a sequence table of strings, e.g. `{"anime", "airing"}`. The +table is **empty** (`#labels == 0`) when the torrent has no labels or is unknown +— it is never `nil`, so it is always safe to iterate. + +```lua +function on_file_complete(event) + local labels = naut.get_labels(event.torrent_id) + for _, label in ipairs(labels) do + if label == "anime" then + naut.move_file(event.torrent_id, event.index, + "/archive/anime/" .. event.path) + return + end + end +end +``` + +Labels reflect the daemon's current state at call time (re-read on every call), +so a script always sees the latest assignment. + +### `naut.define_settings({ ... })` + +Declare the user-configurable variables the script reads, so they can be edited +in the web UI (**Automation ▸ Settings**) instead of by hand in the source. Pass +an array of entries: + +| Field | Lua type | Meaning | +|---|---|---| +| `key` | `string` | identifier passed to `naut.get_setting` | +| `label` | `string` | human label shown in the form (defaults to `key`) | +| `type` | `string` | `"string"`, `"bool"`, or `"number"` (drives the widget + the type returned by `get_setting`) | +| `default` | string/bool/number | value used until the user sets one | + +Call it once at load time (re-declaring replaces the schema). The values the user +saves persist across restarts, independently of the script source. + +### `naut.get_setting(key)` + +Return the current value of a declared setting: the user-saved value if present, +otherwise the declared default. The result is **typed** per the schema — a Lua +`boolean` for `bool`, a `number` for `number`, a `string` otherwise — or `nil` +if the key was never declared. Re-read it on each use so live edits take effect +without reloading the script. + +```lua +naut.define_settings({ + { key = "library_root", label = "Library root", type = "string", + default = "/media/anime" }, + { key = "only_video", label = "Only video files", type = "bool", + default = true }, +}) + +function on_file_complete(event) + if naut.get_setting("only_video") and not is_video(event.path) then return end + local root = naut.get_setting("library_root") + naut.move_file(event.torrent_id, event.index, root .. "/" .. basename(event.path)) +end +``` + --- ## 7. Error handling & observability diff --git a/examples/anime_sort.lua b/examples/anime_sort.lua index 8a08ed1..03a7017 100644 --- a/examples/anime_sort.lua +++ b/examples/anime_sort.lua @@ -9,6 +9,9 @@ -- file's last piece verifies, episodes are sorted the moment they're done — -- without waiting for the rest of the torrent. -- +-- By default it only sorts torrents you have labelled "anime" (REQUIRE_LABEL +-- below), read via naut.get_labels(), so non-anime downloads are left untouched. +-- -- Install: -- nautd --socket /tmp/nautd.sock -- nautctl --socket /tmp/nautd.sock script examples/anime_sort.lua @@ -19,13 +22,42 @@ -- test_anime_sort.lua asserts they agree. ---------------------------------------------------------------------- --- CONFIG — edit these +-- CONFIG — these are exposed in the web UI (Automation ▸ Settings) through +-- naut.define_settings, so you can change them there WITHOUT editing this file. +-- The values below are only the defaults used until you set them in the UI. ---------------------------------------------------------------------- -local SORTED_ROOT = "/workspaces/source/ai-garbo/Naut-Torrent/sorted" -- destination library root -local ONLY_VIDEO = true -- skip non-video files (subs, nfo, samples) -local KEEP_ORIGINAL_NAME = false -- true: keep the original filename; - -- false: rename to "Title - SNNENN.ext" +local DEFAULTS = { + sorted_root = "/workspaces/source/ai-garbo/Naut-Torrent/Downloads/Sorted/", + only_video = true, -- skip non-video files (subs, nfo, samples) + keep_original_name = false, -- false: rename to "Title - SNNENN.ext" + require_label = "anime", -- only sort torrents with this label + -- (case-insensitive); blank = any torrent +} + +-- Read a setting from the host live (so UI edits apply without a reload), +-- falling back to the default when unset or running on an older daemon. +local function setting(key) + if type(naut) == "table" and type(naut.get_setting) == "function" then + local v = naut.get_setting(key) + if v ~= nil then return v end + end + return DEFAULTS[key] +end + +-- Declare the configurable variables so the web UI can render a form for them. +if type(naut) == "table" and type(naut.define_settings) == "function" then + naut.define_settings({ + { key = "sorted_root", label = "Library root", type = "string", + default = DEFAULTS.sorted_root }, + { key = "only_video", label = "Only video files", type = "bool", + default = DEFAULTS.only_video }, + { key = "keep_original_name", label = "Keep original filename", + type = "bool", default = DEFAULTS.keep_original_name }, + { key = "require_label", label = "Required label (blank = any)", + type = "string", default = DEFAULTS.require_label }, + }) +end ---------------------------------------------------------------------- -- embedded anitomy parser (== examples/anitomy.lua) @@ -240,6 +272,8 @@ local function sanitize(s) end local function destination(parsed) + local root = setting("sorted_root") + local keep_original = setting("keep_original_name") local title = sanitize(parsed.title) local ext = parsed.extension and ("." .. parsed.extension) or "" local original = sanitize(basename(parsed.file_name)) @@ -247,18 +281,18 @@ local function destination(parsed) if parsed.episode == nil then -- movie / special: //<file> local fname - if KEEP_ORIGINAL_NAME then + if keep_original then fname = original else fname = title .. (parsed.year and (" (" .. parsed.year .. ")") or "") .. ext end - return SORTED_ROOT .. "/" .. title .. "/" .. fname + return root .. "/" .. title .. "/" .. fname end local season = parsed.season or 1 local sdir = string.format("Season %02d", season) local fname - if KEEP_ORIGINAL_NAME then + if keep_original then fname = original else fname = string.format("%s - S%02dE%02d", title, season, parsed.episode) @@ -267,11 +301,25 @@ local function destination(parsed) end fname = fname .. ext end - return SORTED_ROOT .. "/" .. title .. "/" .. sdir .. "/" .. fname + return root .. "/" .. title .. "/" .. sdir .. "/" .. fname +end + +-- True if the torrent carries `want` among its labels (case-insensitive). When +-- `want` is nil the gate is disabled. If the daemon predates naut.get_labels we +-- can't check, so we sort anyway rather than silently dropping every file. +local function has_label(torrent_id, want) + if not want or want == "" then return true end + if type(naut.get_labels) ~= "function" then return true end + want = want:lower() + for _, label in ipairs(naut.get_labels(torrent_id)) do + if label:lower() == want then return true end + end + return false end -- exposed for tests; harmless in the daemon -_G.anime_sort = { anitomy = anitomy, destination = destination } +_G.anime_sort = { anitomy = anitomy, destination = destination, + has_label = has_label } ---------------------------------------------------------------------- -- event hook @@ -279,10 +327,13 @@ _G.anime_sort = { anitomy = anitomy, destination = destination } function on_file_complete(event) if not event.path then return end + if not has_label(event.torrent_id, setting("require_label")) then + return -- not labelled "anime": leave this torrent's files alone + end local name = basename(event.path) local parsed = anitomy.parse(name) - if ONLY_VIDEO and not anitomy.is_video(parsed.extension) then + if setting("only_video") and not anitomy.is_video(parsed.extension) then return -- leave subtitles, .nfo, samples, etc. where they are end diff --git a/examples/test_anime_sort.lua b/examples/test_anime_sort.lua index 202a8dd..a845510 100644 --- a/examples/test_anime_sort.lua +++ b/examples/test_anime_sort.lua @@ -7,7 +7,16 @@ local ref = require("anitomy") -- stub the host API the script calls, and silence its prints local captured -_G.naut = { move_file = function(tid, idx, dest) captured = dest end } +-- Per-torrent label stub: torrent 1 is labelled "anime"; others are unlabelled. +local LABELS = { [1] = { "anime" } } +-- Optional per-key setting overrides; nil falls back to the script's defaults. +local SETTINGS = {} +_G.naut = { + move_file = function(tid, idx, dest) captured = dest end, + get_labels = function(tid) return LABELS[tid] or {} end, + define_settings = function(_) end, -- schema declaration: no-op here + get_setting = function(key) return SETTINGS[key] end, +} local realprint = print _G.print = function() end @@ -23,9 +32,9 @@ local configured_root = _G.anime_sort.destination({ assert(configured_root, "could not derive SORTED_ROOT from anime_sort.lua") local fails = 0 -local function fire(path) +local function fire(path, torrent_id) captured = nil - on_file_complete({ torrent_id = 1, index = 0, path = path }) + on_file_complete({ torrent_id = torrent_id or 1, index = 0, path = path }) return captured end local function expect(path, want_dest) @@ -69,6 +78,42 @@ do end end +-- label gate: a torrent without the "anime" label is left alone (no move), +-- while an "anime"-labelled torrent is still sorted. +do + local episode = "/downloads/[HorribleSubs] Boku no Hero Academia - 12 [1080p].mkv" + local unlabelled = fire(episode, 2) -- torrent 2 has no labels + if unlabelled ~= nil then + fails = fails + 1 + realprint("FAIL unlabelled torrent should be skipped, got: " + .. tostring(unlabelled)) + else + realprint("ok unlabelled torrent skipped") + end + local labelled = fire(episode, 1) -- torrent 1 is labelled "anime" + if labelled == nil then + fails = fails + 1 + realprint("FAIL anime-labelled torrent should be sorted") + else + realprint("ok anime-labelled torrent sorted") + end +end + +-- settings: a value configured in the UI (delivered via naut.get_setting) takes +-- effect live, without editing the script. +do + SETTINGS.sorted_root = "/custom/lib" + local got = fire("/downloads/[HorribleSubs] Boku no Hero Academia - 12 [1080p].mkv", 1) + SETTINGS.sorted_root = nil + local want = "/custom/lib/Boku no Hero Academia/Season 01/Boku no Hero Academia - S01E12.mkv" + if got ~= want then + fails = fails + 1 + realprint("FAIL sorted_root override not applied, got: " .. tostring(got)) + else + realprint("ok sorted_root setting override applied") + end +end + -- drift guard: embedded parser must agree with anitomy.lua local embedded = _G.anime_sort.anitomy local drift = 0 diff --git a/include/naut/dht.h b/include/naut/dht.h index d5b7a38..e2d7545 100644 --- a/include/naut/dht.h +++ b/include/naut/dht.h @@ -1,4 +1,5 @@ -/* dht.h - BEP-5 KRPC codec and bounded IPv4 get_peers traversal. */ +/* dht.h - bounded IPv4 BEP-5 get_peers traversal (KRPC codec from + * torrent-tracker; iterative walk + UDP socket in src/discovery). */ #ifndef NAUT_DHT_H #define NAUT_DHT_H @@ -9,54 +10,6 @@ #define NAUT_DHT_MAX_NODES 256 #define NAUT_DHT_MAX_PEERS 256 -typedef struct { - uint8_t id[NAUT_DHT_ID_LEN]; - uint8_t ip[4]; - uint16_t port; -} naut_dht_node; - -typedef enum { - NAUT_DHT_RESPONSE, - NAUT_DHT_ERROR -} naut_dht_message_type; - -typedef struct { - naut_dht_message_type type; - uint8_t transaction[8]; - size_t transaction_len; - uint8_t id[NAUT_DHT_ID_LEN]; - bool has_id; - uint8_t token[64]; - size_t token_len; - naut_dht_node *nodes; - size_t num_nodes; - naut_peer_addr *peers; - size_t num_peers; - int error_code; -} naut_dht_response; - -naut_err naut_dht_build_ping(const uint8_t *tx, size_t tx_len, - const uint8_t id[20], - uint8_t **out, size_t *out_len); -naut_err naut_dht_build_find_node(const uint8_t *tx, size_t tx_len, - const uint8_t id[20], - const uint8_t target[20], - uint8_t **out, size_t *out_len); -naut_err naut_dht_build_get_peers(const uint8_t *tx, size_t tx_len, - const uint8_t id[20], - const uint8_t info_hash[20], - uint8_t **out, size_t *out_len); -naut_err naut_dht_build_announce_peer(const uint8_t *tx, size_t tx_len, - const uint8_t id[20], - const uint8_t info_hash[20], - uint16_t port, bool implied_port, - const void *token, size_t token_len, - uint8_t **out, size_t *out_len); - -naut_err naut_dht_parse_response(const uint8_t *data, size_t len, - naut_dht_response *out); -void naut_dht_response_free(naut_dht_response *response); - /* Query bootstrap endpoints ("host:port") and iteratively follow returned * compact nodes until peers are found or the bounded traversal is exhausted. */ naut_err naut_dht_get_peers(const char *const *bootstrap, size_t num_bootstrap, diff --git a/include/naut/mse.h b/include/naut/mse.h deleted file mode 100644 index 7d76dc3..0000000 --- a/include/naut/mse.h +++ /dev/null @@ -1,88 +0,0 @@ -/* mse.h - BitTorrent Message Stream Encryption (MSE/PE) transport. */ -#ifndef NAUT_MSE_H -#define NAUT_MSE_H - -#include "naut/common.h" -#include "naut/peer.h" -#include "naut/rc4.h" - -#include <sys/types.h> - -#define NAUT_MSE_DH_LEN 96 - -typedef struct { - naut_rc4 send; - naut_rc4 recv; - bool active; -} naut_mse_stream; - -/* ---- sans-IO handshake state machine ------------------------------------- * - * The outgoing MSE/PE handshake as a pure state machine over byte buffers — no - * sockets — so the same logic drives the blocking apps and the io_uring reactor - * (where blocking in a handshake would stall a whole core's worth of peers). - * - * Drive it like a codec: pump NEED_WRITE bytes out, feed NEED_READ bytes in, - * repeat until DONE or ERROR, then call _finish(). - * - * h = naut_mse_handshake_begin(info_hash, peer_id, reserved); - * for (;;) switch (naut_mse_handshake_status(h)) { - * case NAUT_MSE_HS_NEED_WRITE: pull bytes, write them to the peer; break; - * case NAUT_MSE_HS_NEED_READ: read bytes from the peer, feed them; break; - * case NAUT_MSE_HS_DONE: naut_mse_handshake_finish(h, ...); goto ok; - * case NAUT_MSE_HS_ERROR: ... ; goto err; - * } - */ -typedef enum { - NAUT_MSE_HS_NEED_READ, - NAUT_MSE_HS_NEED_WRITE, - NAUT_MSE_HS_DONE, - NAUT_MSE_HS_ERROR, -} naut_mse_hs_status; - -typedef struct naut_mse_handshake naut_mse_handshake; - -naut_mse_handshake *naut_mse_handshake_begin( - const uint8_t info_hash[20], - const uint8_t peer_id[NAUT_PEERID_LEN], - uint64_t reserved); -void naut_mse_handshake_free(naut_mse_handshake *h); - -naut_mse_hs_status naut_mse_handshake_status(const naut_mse_handshake *h); - -/* Copy pending outgoing bytes into buf (up to cap); returns the count, 0 when - * nothing is queued. Call repeatedly until it returns 0. */ -size_t naut_mse_handshake_pull(naut_mse_handshake *h, uint8_t *buf, size_t cap); - -/* Feed received bytes; *consumed reports how many were absorbed (the rest, if - * any, must be re-fed — after DONE that remainder is the start of the encrypted - * payload stream). Returns the new status. */ -naut_mse_hs_status naut_mse_handshake_feed(naut_mse_handshake *h, - const uint8_t *data, size_t len, - size_t *consumed); - -/* Valid once status is DONE: hand out the negotiated stream and the peer's - * decrypted BitTorrent handshake. */ -naut_err naut_mse_handshake_finish(naut_mse_handshake *h, - naut_mse_stream *stream, - uint8_t remote_handshake[NAUT_HANDSHAKE_LEN]); - -/* Blocking convenience wrapper over the state machine: perform the whole - * outgoing handshake on a blocking socket, offering RC4 only. The BitTorrent - * handshake is carried as IA; the peer's decrypted handshake is returned in - * remote_handshake. */ -naut_err naut_mse_client_handshake( - int fd, - const uint8_t info_hash[20], - const uint8_t peer_id[NAUT_PEERID_LEN], - uint64_t reserved, - naut_mse_stream *stream, - uint8_t remote_handshake[NAUT_HANDSHAKE_LEN]); - -/* Stream I/O after a successful handshake. Encryption/decryption is in-place - * with connection-owned RC4 state. send_all preserves the caller's buffer. */ -bool naut_mse_send_all(int fd, naut_mse_stream *stream, - const void *data, size_t len); -ssize_t naut_mse_recv(int fd, naut_mse_stream *stream, - void *data, size_t len); - -#endif /* NAUT_MSE_H */ diff --git a/include/naut/piece.h b/include/naut/piece.h index b4423b9..8080219 100644 --- a/include/naut/piece.h +++ b/include/naut/piece.h @@ -13,11 +13,17 @@ #include "naut/bitfield.h" #include "naut/worker.h" +#include <stdio.h> + typedef struct naut_download naut_download; naut_download *naut_download_create(const naut_metainfo *mi, naut_storage *st); void naut_download_destroy(naut_download *d); +/* Scan existing storage and mark SHA-1 verified pieces complete before + * requesting from peers. Invalid or missing pieces are left for download. */ +naut_err naut_download_resume(naut_download *d); + /* Optional hash offload. Completed-piece SHA-1 jobs run on the worker pool; * naut_download_poll() finalizes verified pieces on the owning engine thread. * The pool must outlive the download. */ @@ -90,4 +96,11 @@ uint64_t naut_download_bytes_done(const naut_download *d); size_t naut_download_piece_states(const naut_download *d, uint8_t *out, size_t capacity); +/* Diagnostic: write a human-readable dump of block-assembly state to `out` — + * overall progress plus, for every piece not yet verified, how many of its + * blocks have arrived and how many requests are outstanding. Pairs with + * engine_dump_torrent() (which covers piece selection across peers) to + * investigate pieces that never finish downloading. */ +void naut_download_dump(const naut_download *d, FILE *out); + #endif /* NAUT_PIECE_H */ diff --git a/include/naut/pipeline.h b/include/naut/pipeline.h deleted file mode 100644 index 76305dd..0000000 --- a/include/naut/pipeline.h +++ /dev/null @@ -1,29 +0,0 @@ -/* pipeline.h - adaptive request window based on observed bandwidth-delay product. */ -#ifndef NAUT_PIPELINE_H -#define NAUT_PIPELINE_H - -#include "naut/common.h" - -typedef struct { - double rtt_seconds; - double bytes_per_second; - double last_sample_at; - uint32_t depth; - uint32_t min_depth; - uint32_t max_depth; - uint32_t block_size; -} naut_pipeline; - -void naut_pipeline_init(naut_pipeline *p, uint32_t block_size, - uint32_t min_depth, uint32_t max_depth, - uint32_t initial_depth); - -/* Record one completed request. sent_at and received_at are monotonic seconds. - * The controller smooths RTT and delivery rate, then targets 2x BDP to absorb - * scheduling jitter without allowing an unbounded request window. */ -void naut_pipeline_on_block(naut_pipeline *p, uint32_t bytes, - double sent_at, double received_at); - -uint32_t naut_pipeline_depth(const naut_pipeline *p); - -#endif /* NAUT_PIPELINE_H */ diff --git a/include/naut/script.h b/include/naut/script.h index 2ec8bb8..3776ed8 100644 --- a/include/naut/script.h +++ b/include/naut/script.h @@ -11,6 +11,50 @@ typedef naut_err (*naut_script_move_file_cb)(void *context, uint32_t file_index, const char *destination); +/* Resolve a torrent's labels for `naut.get_labels(id)`. Returns a heap array of + * `*count` heap strings (caller frees each string then the array), or NULL with + * *count==0 if the torrent has no labels / is unknown. */ +typedef char **(*naut_script_labels_cb)(void *context, uint64_t torrent_id, + size_t *count); + +/* One user-configurable setting a script declares via naut.define_settings. */ +typedef struct { + const char *key; /* stable identifier read by naut.get_setting */ + const char *label; /* human label for the web UI form */ + const char *type; /* "string" | "bool" | "number" */ + const char *default_value; /* stringified default ("true"/"false" for bool)*/ +} naut_script_setting_def; + +/* The script (re)declared its settings schema. The host stores it and renders a + * form; `defs` is valid only for the duration of the call. */ +typedef void (*naut_script_define_settings_cb)(void *context, + const naut_script_setting_def *defs, + size_t count); + +/* Setting value kinds, so the Lua side can push the right type. */ +typedef enum { + NAUT_SETTING_STRING = 0, + NAUT_SETTING_BOOL = 1, + NAUT_SETTING_NUMBER = 2, +} naut_setting_type; + +/* Resolve a setting for `naut.get_setting(key)`: the user-set value if present, + * else the declared default. Returns a heap string (caller frees) and sets + * *type, or NULL if the key is unknown. */ +typedef char *(*naut_script_get_setting_cb)(void *context, const char *key, + naut_setting_type *type); + +/* Host callbacks the sandboxed script may invoke. Any may be NULL (the matching + * naut.* function then reports it is unavailable). `context` is passed back to + * each callback. */ +typedef struct { + naut_script_move_file_cb move_file; + naut_script_labels_cb labels; + naut_script_define_settings_cb define_settings; + naut_script_get_setting_cb get_setting; + void *context; +} naut_script_host; + typedef struct { uint64_t queued; uint64_t handled; @@ -20,12 +64,12 @@ typedef struct { } naut_script_stats; /* script_path is loaded before the worker starts. queue_capacity bounds copied - * events and must be non-zero. The VM owns no filesystem or process APIs. */ + * events and must be non-zero. The VM owns no filesystem or process APIs. `host` + * is copied; its callbacks are invoked from the script worker thread. */ naut_script *naut_script_create(naut_event_bus *events, const char *script_path, size_t queue_capacity, - naut_script_move_file_cb move_file, - void *move_context, + const naut_script_host *host, naut_err *error); void naut_script_destroy(naut_script *script); diff --git a/include/naut/storage.h b/include/naut/storage.h index 5130d29..4e849d4 100644 --- a/include/naut/storage.h +++ b/include/naut/storage.h @@ -18,6 +18,12 @@ typedef struct naut_storage naut_storage; typedef struct { bool direct_io; bool preallocate; + /* Optional per-file path overrides, e.g. files moved out of `root` on a + * prior run. If non-NULL the array has one entry per file: where overrides[i] + * is non-NULL the file is opened at that path instead of `root`/<rel-path>, + * so a relocated file is picked up in place (no re-download, no placeholder + * recreated under `root`). A NULL entry uses the default location. */ + const char *const *overrides; } naut_storage_opts; /* Open (creating + preallocating) all files under `root`. */ diff --git a/include/naut/swarm.h b/include/naut/swarm.h index 82a9407..a14bd18 100644 --- a/include/naut/swarm.h +++ b/include/naut/swarm.h @@ -58,6 +58,7 @@ typedef struct { uint32_t peers_connecting; uint32_t peers_active; uint32_t peers_failed; + bool stalled; double elapsed_seconds; uint32_t peer_count; naut_swarm_peer_stats peer_stats[NAUT_SWARM_MAX_PEER_STATS]; @@ -78,17 +79,42 @@ typedef void (*naut_swarm_control_cb)(void *context, naut_storage *storage); typedef bool (*naut_swarm_stop_cb)(void *context); +/* Optional: return the desired engine-wide download cap in bytes/sec (0 = + * unlimited). Polled on the swarm owner thread; the engine limit is updated + * whenever the returned value changes. */ +typedef uint64_t (*naut_swarm_rate_cb)(void *context); + +/* Optional diagnostics. should_dump is polled on the swarm owner thread; when it + * returns true the swarm renders a full engine + piece-assembly state dump and + * hands the text to on_dump (also on the owner thread, where engine and download + * state can be read safely). Used by `nautctl dump` to investigate why a few + * pieces never finish downloading. */ +typedef bool (*naut_swarm_dump_cb)(void *context); +typedef void (*naut_swarm_dump_sink)(void *context, const char *text); + +/* A file's last known on-disk location, from a relocate on a prior run. Passed + * back in so the file is reopened in place instead of re-downloaded. */ +typedef struct { + uint32_t file_index; + const char *path; +} naut_swarm_file_location; + typedef struct { const char *source; /* .torrent path or magnet URI */ const char *output_dir; const char *const *peers; /* optional explicit ip:port endpoints */ size_t num_peers; + const naut_swarm_file_location *locations; /* optional moved-file locations */ + size_t num_locations; uint64_t torrent_id; naut_event_bus *events; /* optional */ bool keep_alive; /* retain completed storage until stopped */ naut_swarm_progress_cb on_progress; naut_swarm_control_cb on_control; naut_swarm_stop_cb should_stop; + naut_swarm_rate_cb download_rate; /* optional download throttle provider */ + naut_swarm_dump_cb should_dump; /* optional state-dump request poll */ + naut_swarm_dump_sink on_dump; /* optional rendered-dump sink */ void *context; } naut_swarm_config; diff --git a/include/naut/tracker.h b/include/naut/tracker.h index 3141e69..ea16ee9 100644 --- a/include/naut/tracker.h +++ b/include/naut/tracker.h @@ -1,9 +1,9 @@ -/* tracker.h — HTTP and UDP tracker clients (BEP-3/BEP-23, BEP-15). +/* tracker.h — HTTP and UDP tracker announce client. * - * Split into pure codec (URL building, bencode response parsing, UDP packet - * encode/decode — all unit-testable without a socket) and thin blocking fetch - * helpers used by the swarm app. HTTPS/TLS is deferred to a later transport - * backend; the built-ins in this phase are plaintext HTTP and UDP. + * The wire codec (query building, bencode/UDP packet encode+decode) lives in the + * sibling `torrent-tracker` library; the implementation here (src/discovery) + * owns only the socket glue. HTTPS/TLS is deferred to a later transport backend; + * the built-ins are plaintext HTTP and UDP. */ #ifndef NAUT_TRACKER_H #define NAUT_TRACKER_H @@ -37,25 +37,10 @@ typedef struct { void naut_tracker_response_free(naut_tracker_response *r); -/* --- HTTP --- */ /* Build the full announce GET URL (base?...params) with percent-encoded binary * info_hash/peer_id. Returns bytes written (excl NUL) or 0 on overflow. */ size_t naut_tracker_http_url(const char *base, const naut_announce_req *req, char *out, size_t outsz); -/* Parse a bencoded HTTP tracker response body (compact or dict peer list). */ -naut_err naut_tracker_parse_http(const uint8_t *body, size_t len, - naut_tracker_response *out); - -/* --- UDP (BEP-15): pure packet codec --- */ -#define NAUT_UDP_CONNECT_REQ_LEN 16 -#define NAUT_UDP_ANNOUNCE_REQ_LEN 98 -void naut_udp_build_connect(uint8_t out[16], uint32_t txid); -naut_err naut_udp_parse_connect(const uint8_t *in, size_t len, uint32_t txid, - uint64_t *connection_id); -void naut_udp_build_announce(uint8_t out[98], uint64_t connection_id, - uint32_t txid, const naut_announce_req *req); -naut_err naut_udp_parse_announce(const uint8_t *in, size_t len, uint32_t txid, - naut_tracker_response *out); /* --- live fetch helpers (blocking) --- */ /* HTTP GET the announce URL; fills out. Only http:// (no TLS yet). */ diff --git a/plugins/webui/webui.c b/plugins/webui/webui.c index 3c22b8c..7cccae6 100644 --- a/plugins/webui/webui.c +++ b/plugins/webui/webui.c @@ -442,6 +442,13 @@ static uint64_t json_u64(const json_t *obj, const char *key) { ? (uint64_t)json_integer_value(value) : 0; } +static int64_t json_i64_or(const json_t *obj, const char *key, + int64_t fallback) { + json_t *value = json_object_get(obj, key); + return json_is_integer(value) ? (int64_t)json_integer_value(value) + : fallback; +} + static double json_number_or(const json_t *obj, const char *key, double fallback) { json_t *value = json_object_get(obj, key); @@ -456,6 +463,11 @@ static const char *base_name(const char *path) { } static char *torrent_name(const json_t *torrent) { + /* The daemon persists the display name captured at add time; prefer it so + * restored torrents (where the in-process name cache is empty) read right + * instead of falling back to an upload path's basename. */ + const char *saved = json_string_value(json_object_get(torrent, "name")); + if (saved && *saved) return strdup(saved); const char *source = json_string_or(torrent, "source", "torrent"); if (strncmp(source, "magnet:", 7) == 0) { const char *dn = strstr(source, "dn="); @@ -475,9 +487,13 @@ static char *torrent_name(const json_t *torrent) { static const char *ui_state(const char *state, double progress) { if (!state) return "stalledDL"; if (strcmp(state, "complete") == 0) return "uploading"; + if (strcmp(state, "paused") == 0) + return progress >= 1.0 ? "pausedUP" : "pausedDL"; if (strcmp(state, "stopped") == 0) return progress >= 1.0 ? "pausedUP" : "pausedDL"; if (strcmp(state, "stopping") == 0) return "pausedDL"; + if (strcmp(state, "stalled") == 0) + return progress >= 1.0 ? "stalledUP" : "stalledDL"; if (strcmp(state, "queued") == 0) return "queuedDL"; if (strcmp(state, "error") == 0) return "error"; return "downloading"; @@ -786,6 +802,89 @@ static void store_set_name(uint64_t id, const char *name) { pthread_mutex_unlock(&g_webui.meta_lock); } +static bool parse_id(const char *text, uint64_t *id); + +/* Mirror a torrent's category + tags into the daemon (which persists them and + * exposes the flattened set to Lua via naut.get_labels). The web layer is the + * editing surface; the daemon is the source of truth. Snapshots the assignment + * under meta_lock, then RPCs without it held. */ +static void webui_sync_labels(uint64_t id) { + char key[32]; + snprintf(key, sizeof key, "%llu", (unsigned long long)id); + pthread_mutex_lock(&g_webui.meta_lock); + json_t *entry = json_object_get(g_webui.assignments, key); + char *category = strdup(entry ? json_string_or(entry, "category", "") : ""); + json_t *tags_src = entry ? json_object_get(entry, "tags") : NULL; + json_t *tags = tags_src ? json_deep_copy(tags_src) : json_array(); + pthread_mutex_unlock(&g_webui.meta_lock); + + json_t *params = json_pack("{s:I,s:s,s:o}", "torrent_id", (json_int_t)id, + "category", category ? category : "", + "tags", tags); + free(category); + if (!params) { json_decref(tags); return; } + json_t *reply = rpc_call_json("set_labels", params); + json_decref(params); + if (reply) json_decref(reply); +} + +/* Re-push every torrent's labels (after a global category/tag removal that can + * touch many assignments at once). */ +static void webui_sync_all_labels(void) { + pthread_mutex_lock(&g_webui.meta_lock); + size_t n = json_object_size(g_webui.assignments); + uint64_t *ids = n ? malloc(n * sizeof *ids) : NULL; + size_t count = 0; + if (ids) { + const char *key; + json_t *entry; + json_object_foreach(g_webui.assignments, key, entry) { + uint64_t id = 0; + if (parse_id(key, &id)) ids[count++] = id; + } + } + pthread_mutex_unlock(&g_webui.meta_lock); + for (size_t i = 0; i < count; i++) webui_sync_labels(ids[i]); + free(ids); +} + +/* Persist the full category + tag lists (including unassigned ones) to the + * daemon so they survive restarts. */ +static void webui_sync_taxonomy(void) { + pthread_mutex_lock(&g_webui.meta_lock); + json_t *cats = json_deep_copy(g_webui.categories); + json_t *tags = json_deep_copy(g_webui.tags); + pthread_mutex_unlock(&g_webui.meta_lock); + json_t *params = json_pack("{s:o,s:o}", + "categories", cats ? cats : json_array(), + "tags", tags ? tags : json_array()); + if (!params) { json_decref(cats); json_decref(tags); return; } + json_t *reply = rpc_call_json("set_label_taxonomy", params); + json_decref(params); + if (reply) json_decref(reply); +} + +/* Seed the category + tag lists from the daemon's persisted copy at startup. */ +static void webui_load_taxonomy(void) { + json_t *params = json_object(); + json_t *reply = rpc_call_json("get_label_taxonomy", params); + json_decref(params); + if (!json_is_object(reply)) { json_decref(reply); return; } + json_t *cats = json_object_get(reply, "categories"); + json_t *tags = json_object_get(reply, "tags"); + pthread_mutex_lock(&g_webui.meta_lock); + if (json_is_array(cats)) { + json_decref(g_webui.categories); + g_webui.categories = json_deep_copy(cats); + } + if (json_is_array(tags)) { + json_decref(g_webui.tags); + g_webui.tags = json_deep_copy(tags); + } + pthread_mutex_unlock(&g_webui.meta_lock); + json_decref(reply); +} + static bool store_get_name(uint64_t id, char *out, size_t out_size) { bool found = false; pthread_mutex_lock(&g_webui.meta_lock); @@ -808,6 +907,35 @@ static void store_forget(uint64_t id) { pthread_mutex_unlock(&g_webui.meta_lock); } +/* On first sight of a torrent (e.g. right after a restart, when the in-memory + * store is empty), seed its assignment + the global category/tag lists from the + * daemon's persisted category/tags. Only creates a missing entry, so live web + * edits are never clobbered. */ +static void seed_assignment_from_daemon(uint64_t id, json_t *torrent) { + const char *category = json_string_or(torrent, "category", ""); + json_t *tags = json_object_get(torrent, "tags"); + char key[32]; + snprintf(key, sizeof key, "%llu", (unsigned long long)id); + pthread_mutex_lock(&g_webui.meta_lock); + if (!json_object_get(g_webui.assignments, key)) { + json_t *entry = json_pack( + "{s:s,s:o}", "category", category, + "tags", json_is_array(tags) ? json_deep_copy(tags) : json_array()); + if (entry) json_object_set_new(g_webui.assignments, key, entry); + if (category && *category && find_category(category) < 0) + json_array_append_new(g_webui.categories, json_pack( + "{s:s,s:s}", "name", category, "savePath", "")); + size_t i; + json_t *v; + json_array_foreach(tags, i, v) { + const char *t = json_string_value(v); + if (t && *t && find_tag(t) < 0) + json_array_append_new(g_webui.tags, json_string(t)); + } + } + pthread_mutex_unlock(&g_webui.meta_lock); +} + /* Fill in category + tags for a torrent from the assignment store. */ static void apply_assignment(json_t *out, uint64_t id) { pthread_mutex_lock(&g_webui.meta_lock); @@ -838,9 +966,13 @@ static json_t *map_torrent(json_t *torrent, bool detail, double dlspeed) { char *better = strdup(override); if (better) { free(name); name = better; } } + bool force_start = + json_boolean_value(json_object_get(torrent, "force_start")); const char *state = ui_state(json_string_value(json_object_get(torrent, "state")), progress); + if (force_start && strcmp(state, "downloading") == 0) + state = progress >= 1.0 ? "forcedUP" : "forcedDL"; json_t *trackers = NULL; json_t *files = NULL; @@ -899,18 +1031,24 @@ static json_t *map_torrent(json_t *torrent, bool detail, double dlspeed) { json_object_set_new(out, "downloaded", json_integer((json_int_t)done)); json_object_set_new(out, "uploaded", json_integer(0)); json_object_set_new(out, "availability", json_real(1.0)); - json_object_set_new(out, "priority", json_integer(1)); + int64_t queue_pos = json_i64_or(torrent, "queue_pos", 0); + json_object_set_new(out, "priority", + json_integer((json_int_t)(queue_pos < 0 + ? 1 : queue_pos + 1))); + json_object_set_new(out, "queuePos", json_integer((json_int_t)queue_pos)); json_object_set_new(out, "trackerHosts", hosts ? hosts : json_array()); json_object_set_new(out, "seqDl", json_false()); json_object_set_new(out, "superSeeding", json_false()); - json_object_set_new(out, "forceStart", json_false()); + json_object_set_new(out, "forceStart", json_boolean(force_start)); json_object_set_new(out, "timeActive", json_integer((json_int_t)json_u64(torrent, "elapsed_seconds"))); json_object_set_new(out, "pieceSize", json_integer(pieces ? (json_int_t)(total / pieces) : 0)); json_object_set_new(out, "state", json_string(state)); json_object_set_new(out, "contentPath", json_string(output)); - /* category + tags come from the web-layer assignment store */ + /* Seed the web-layer store from the daemon's persisted category/tags the + * first time we see a torrent (survives restarts), then apply it. */ + seed_assignment_from_daemon(id, torrent); apply_assignment(out, id); if (detail) { json_object_set_new(out, "comment", json_string("")); @@ -934,6 +1072,8 @@ static json_t *map_torrent(json_t *torrent, bool detail, double dlspeed) { return out; } +static json_t *preferences_json(void); + /* Build a fresh snapshot (grid + global stats) with live download rates. */ static json_t *build_snapshot(void) { json_t *params = json_object(); @@ -945,6 +1085,7 @@ static json_t *build_snapshot(void) { } speed_retain(torrents); + json_t *prefs = preferences_json(); json_t *items = json_array(); uint64_t active = 0; uint64_t total_rate = 0; @@ -958,20 +1099,35 @@ static json_t *build_snapshot(void) { json_t *mapped = map_torrent(torrent, false, dlspeed); if (!mapped) continue; const char *state = json_string_value(json_object_get(mapped, "state")); - if (state && strcmp(state, "downloading") == 0) active++; + if (state && (strcmp(state, "downloading") == 0 || + strcmp(state, "forcedDL") == 0)) active++; total_rate += (uint64_t)dlspeed; total_data += done; - json_array_append_new(items, mapped); + int64_t q = json_i64_or(mapped, "queuePos", 0); + size_t pos = 0; + for (; pos < json_array_size(items); pos++) { + json_t *cur = json_array_get(items, pos); + if (q < json_i64_or(cur, "queuePos", 0)) break; + } + if (json_array_insert_new(items, pos, mapped) != 0) + json_decref(mapped); } json_decref(torrents); + bool alt_speed = prefs && + json_boolean_value(json_object_get(prefs, "alt_speed_enabled")); + uint64_t dl_limit = prefs ? json_u64(prefs, alt_speed ? "alt_dl_limit" + : "dl_limit") : 0; + uint64_t up_limit = prefs ? json_u64(prefs, alt_speed ? "alt_up_limit" + : "up_limit") : 0; json_t *server = json_pack( - "{s:I,s:i,s:I,s:i,s:i,s:i,s:f,s:i,s:s,s:i,s:I,s:i,s:i,s:s,s:i}", + "{s:I,s:i,s:I,s:i,s:I,s:I,s:b,s:f,s:i,s:s,s:i,s:I,s:i,s:i,s:s,s:i}", "dl_info_speed", (json_int_t)total_rate, "up_info_speed", 0, "dl_info_data", (json_int_t)total_data, "up_info_data", 0, - "dl_rate_limit", 0, - "up_rate_limit", 0, + "dl_rate_limit", (json_int_t)dl_limit, + "up_rate_limit", (json_int_t)up_limit, + "alt_speed_enabled", alt_speed, "global_ratio", 0.0, "dht_nodes", 0, "connection_status", "connected", @@ -981,6 +1137,7 @@ static json_t *build_snapshot(void) { "total_torrents", (int)json_array_size(items), "read_cache_hits", "0.0", "queued_io_jobs", 0); + json_decref(prefs); return json_pack("{s:I,s:o,s:o}", "ts", (json_int_t)time(NULL) * 1000, "server", server, "torrents", items); } @@ -1043,9 +1200,49 @@ static json_t *full_torrent_by_hash(const char *hash) { return mapped; } +static json_t *preferences_json(void) { + json_t *params = json_object(); + json_t *prefs = rpc_call_json("get_preferences", params); + json_decref(params); + if (!json_is_object(prefs)) { + json_decref(prefs); + prefs = json_object(); + } + if (!prefs) return NULL; + + json_t *max_active = json_object_get(prefs, "max_active"); + if (json_is_integer(max_active) && + !json_object_get(prefs, "max_active_downloads")) { + json_object_set_new(prefs, "max_active_downloads", + json_integer(json_integer_value(max_active))); + } + if (!json_object_get(prefs, "save_path")) + json_object_set_new(prefs, "save_path", + json_string(getenv("NAUT_WEBUI_SAVE_PATH") + ? getenv("NAUT_WEBUI_SAVE_PATH") : ".")); + if (!json_object_get(prefs, "dl_limit")) + json_object_set_new(prefs, "dl_limit", json_integer(0)); + if (!json_object_get(prefs, "up_limit")) + json_object_set_new(prefs, "up_limit", json_integer(0)); + if (!json_object_get(prefs, "alt_dl_limit")) + json_object_set_new(prefs, "alt_dl_limit", json_integer(0)); + if (!json_object_get(prefs, "alt_up_limit")) + json_object_set_new(prefs, "alt_up_limit", json_integer(0)); + if (!json_object_get(prefs, "alt_speed_enabled")) + json_object_set_new(prefs, "alt_speed_enabled", json_false()); + json_object_set_new(prefs, "max_connec", json_integer(500)); + json_object_set_new(prefs, "max_connec_per_torrent", json_integer(100)); + json_object_set_new(prefs, "max_uploads", json_integer(20)); + json_object_set_new(prefs, "max_active_uploads", json_integer(10)); + json_object_set_new(prefs, "max_active_torrents", + json_integer((json_int_t)json_i64_or( + prefs, "max_active_downloads", 5))); + return prefs; +} + static void api_meta(int fd) { json_t *json = json_object(); - json_t *preferences = json_object(); + json_t *preferences = preferences_json(); if (!json || !preferences) { json_decref(json); json_decref(preferences); @@ -1063,12 +1260,6 @@ static void api_meta(int fd) { json_t *trackers = tracker_summary(torrents); json_decref(torrents); json_object_set_new(json, "trackers", trackers ? trackers : json_array()); - json_object_set_new(preferences, "save_path", - json_string(getenv("NAUT_WEBUI_SAVE_PATH") - ? getenv("NAUT_WEBUI_SAVE_PATH") : ".")); - json_object_set_new(preferences, "dl_limit", json_integer(0)); - json_object_set_new(preferences, "up_limit", json_integer(0)); - json_object_set_new(preferences, "alt_speed_enabled", json_false()); json_object_set_new(json, "preferences", preferences); json_object_set_new(json, "searchPlugins", json_array()); http_json(fd, 200, json); @@ -1190,6 +1381,13 @@ static void api_add(int fd, const char *body, size_t len) { json_object_set_new(params, "output", json_string(save_path)); if (source && *source) json_object_set_new(params, "source", json_string(source)); if (data && *data) json_object_set_new(params, "data", json_string(data)); + if (json_object_get(req, "paused")) + json_object_set_new(params, "paused", + json_boolean(json_boolean_value( + json_object_get(req, "paused")))); + /* Forward the display name so the daemon persists it for restore. */ + if (display_name[0]) + json_object_set_new(params, "name", json_string(display_name)); json_t *result = rpc_call_json("add_torrent", params); json_decref(params); json_decref(req); @@ -1236,10 +1434,33 @@ static void api_delete(int fd, const char *body, size_t len) { if (removed) publish_snapshot(); } -/* Category/tag assignment is web-layer state the plugin owns, so those verbs - * are honored here. Engine-level verbs (pause/resume/recheck/queue/rate - * limits) have no daemon support yet, so we return 501 instead of pretending - * they worked; the front end shows that as an honest "Action failed" toast. */ +static bool rpc_for_torrent(const char *method, uint64_t id, json_t *extra) { + json_t *params = json_object(); + if (!params) return false; + json_object_set_new(params, "torrent_id", json_integer((json_int_t)id)); + if (json_is_object(extra)) { + const char *key; + json_t *value; + json_object_foreach(extra, key, value) + json_object_set(params, key, value); + } + json_t *result = rpc_call_json(method, params); + json_decref(params); + if (!result) return false; + json_decref(result); + return true; +} + +static const char *queue_op_for_action(const char *action) { + if (strcmp(action, "topPriority") == 0) return "top"; + if (strcmp(action, "bottomPriority") == 0) return "bottom"; + if (strcmp(action, "increasePriority") == 0) return "up"; + if (strcmp(action, "decreasePriority") == 0) return "down"; + return NULL; +} + +/* Category/tag assignment is web-layer state. Engine-backed verbs delegate to + * nautd RPCs so toolbar actions mutate the real queue/lifecycle state. */ static void api_action(int fd, const char *body, size_t len) { json_t *req = read_body_json(body, len); const char *raw_action = json_string_value(json_object_get(req, "action")); @@ -1248,6 +1469,7 @@ static void api_action(int fd, const char *body, size_t len) { json_t *hashes = json_object_get(req, "hashes"); json_t *params = json_object_get(req, "params"); bool handled = false; + int affected = 0; if (json_is_array(hashes) && (strcmp(action, "setCategory") == 0 || strcmp(action, "addTags") == 0 || @@ -1263,12 +1485,54 @@ static void api_action(int fd, const char *body, size_t len) { else store_update_tags(id, json_object_get(params, "tags"), strcmp(action, "addTags") == 0); + webui_sync_labels(id); /* mirror to the daemon (persist + Lua) */ + affected++; } handled = true; } + if (json_is_array(hashes) && !handled) { + const char *rpc = NULL; + json_t *extra = NULL; + if (strcmp(action, "pause") == 0) { + rpc = "pause_torrent"; + } else if (strcmp(action, "resume") == 0) { + rpc = "resume_torrent"; + } else if (strcmp(action, "forceStart") == 0) { + rpc = "resume_torrent"; + extra = json_pack("{s:b}", "force", 1); + } else if (strcmp(action, "recheck") == 0) { + rpc = "recheck_torrent"; + } else if (strcmp(action, "setSavePath") == 0) { + const char *sp = + json_string_value(json_object_get(params, "savePath")); + if (sp && *sp) { + rpc = "set_save_path"; + extra = json_pack("{s:s,s:b}", "savePath", sp, "reset", + json_boolean_value( + json_object_get(params, "reset"))); + } + } else { + const char *op = queue_op_for_action(action); + if (op) { + rpc = "queue_move"; + extra = json_pack("{s:s}", "op", op); + } + } + if (rpc) { + size_t index; + json_t *hash; + json_array_foreach(hashes, index, hash) { + uint64_t id = 0; + if (!parse_id(json_string_value(hash), &id)) continue; + if (rpc_for_torrent(rpc, id, extra)) affected++; + } + json_decref(extra); + handled = true; + } + } json_decref(req); if (handled) { - json_t *json = json_pack("{s:b}", "ok", 1); + json_t *json = json_pack("{s:b,s:i}", "ok", 1, "affected", affected); http_json(fd, 200, json); json_decref(json); publish_snapshot(); @@ -1282,14 +1546,143 @@ static void api_action(int fd, const char *body, size_t len) { json_decref(json); } +static void api_preferences(int fd, const char *method, + const char *body, size_t len) { + if (strcmp(method, "GET") == 0) { + json_t *prefs = preferences_json(); + if (!prefs) { + http_text(fd, 502, "Bad Gateway", "get_preferences failed"); + return; + } + http_json(fd, 200, prefs); + json_decref(prefs); + return; + } + if (strcmp(method, "POST") != 0) { + http_text(fd, 405, "Method Not Allowed", "method not allowed"); + return; + } + json_t *req = read_body_json(body, len); + json_t *params = json_object(); + if (!req || !params) { + json_decref(req); + json_decref(params); + http_text(fd, 500, "Internal Server Error", "oom"); + return; + } + const char *keys[] = { + "dl_limit", "up_limit", "alt_dl_limit", "alt_up_limit", + "alt_speed_enabled", "max_active" + }; + for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); i++) { + json_t *v = json_object_get(req, keys[i]); + if (v) json_object_set(params, keys[i], v); + } + json_t *max = json_object_get(req, "max_active_downloads"); + if (max) json_object_set(params, "max_active", max); + json_t *result = rpc_call_json("set_preferences", params); + json_decref(params); + json_decref(req); + if (!json_is_object(result)) { + json_decref(result); + http_text(fd, 502, "Bad Gateway", "set_preferences failed"); + return; + } + json_decref(result); + json_t *prefs = preferences_json(); + http_json(fd, 200, prefs); + json_decref(prefs); + publish_snapshot(); +} + +static void api_altspeed(int fd) { + json_t *params = json_object(); + json_t *result = rpc_call_json("toggle_altspeed", params); + json_decref(params); + if (!json_is_object(result)) { + json_decref(result); + http_text(fd, 502, "Bad Gateway", "toggle_altspeed failed"); + return; + } + http_json(fd, 200, result); + json_decref(result); + publish_snapshot(); +} + +/* POST /api/script/settings — persist user-edited script setting values. Body: + * { "settings": { "<key>": "<value>", ... } }. */ +static void api_script_settings(int fd, const char *method, const char *body, + size_t len) { + if (strcmp(method, "POST") != 0) { + http_text(fd, 405, "Method Not Allowed", "method not allowed"); + return; + } + json_t *req = read_body_json(body, len); + json_t *settings = req ? json_object_get(req, "settings") : NULL; + if (!json_is_object(settings)) { + json_decref(req); + http_text(fd, 400, "Bad Request", "missing settings object"); + return; + } + json_t *params = json_object(); + json_object_set(params, "settings", settings); + json_decref(req); + json_t *result = rpc_call_json("set_script_settings", params); + json_decref(params); + if (!json_is_object(result)) { + json_decref(result); + http_text(fd, 502, "Bad Gateway", "set_script_settings failed"); + return; + } + http_json(fd, 200, result); + json_decref(result); +} + +static void api_script(int fd, const char *method, const char *body, size_t len) { + json_t *params = NULL; + json_t *result = NULL; + const char *rpc_name = "script_status"; + if (strcmp(method, "GET") == 0) { + params = json_object(); + result = rpc_call_json("script_status", params); + } else if (strcmp(method, "POST") == 0) { + rpc_name = "update_script"; + json_t *req = read_body_json(body, len); + const char *source = json_string_value(json_object_get(req, "source")); + if (!source) { + json_decref(req); + http_text(fd, 400, "Bad Request", "missing source"); + return; + } + params = json_object(); + json_object_set(params, "source", json_object_get(req, "source")); + json_decref(req); + result = rpc_call_json("update_script", params); + } else { + http_text(fd, 405, "Method Not Allowed", "method not allowed"); + return; + } + json_decref(params); + if (!json_is_object(result)) { + json_decref(result); + char msg[96]; + snprintf(msg, sizeof msg, "%s failed", rpc_name); + http_text(fd, 502, "Bad Gateway", msg); + return; + } + http_json(fd, 200, result); + json_decref(result); +} + /* POST /api/categories and /api/categories/delete */ static void api_categories(int fd, const char *body, size_t len, bool remove) { json_t *req = read_body_json(body, len); const char *name = json_string_value(json_object_get(req, "name")); if (name && *name) { - if (remove) store_remove_category(name); + if (remove) { store_remove_category(name); webui_sync_all_labels(); } else store_add_category(name, json_string_value(json_object_get(req, "savePath"))); + webui_sync_taxonomy(); /* persist the category list via the daemon */ } json_decref(req); pthread_mutex_lock(&g_webui.meta_lock); @@ -1304,8 +1697,9 @@ static void api_tags(int fd, const char *body, size_t len, bool remove) { json_t *req = read_body_json(body, len); const char *name = json_string_value(json_object_get(req, "name")); if (name && *name) { - if (remove) store_remove_tag(name); + if (remove) { store_remove_tag(name); webui_sync_all_labels(); } else store_add_tag(name); + webui_sync_taxonomy(); /* persist the tag list via the daemon */ } json_decref(req); pthread_mutex_lock(&g_webui.meta_lock); @@ -1428,11 +1822,13 @@ static void handle_api(int fd, const char *method, char *path, } else if (strcmp(path, "/api/meta") == 0 && strcmp(method, "GET") == 0) { api_meta(fd); } else if (strcmp(path, "/api/preferences") == 0) { - api_meta(fd); + api_preferences(fd, method, body, body_len); } else if (strcmp(path, "/api/altspeed") == 0 && strcmp(method, "POST") == 0) { - json_t *json = json_pack("{s:b}", "alt_speed_enabled", 0); - http_json(fd, 200, json); - json_decref(json); + api_altspeed(fd); + } else if (strcmp(path, "/api/script/settings") == 0) { + api_script_settings(fd, method, body, body_len); + } else if (strcmp(path, "/api/script") == 0) { + api_script(fd, method, body, body_len); } else if (strcmp(path, "/api/categories") == 0 && strcmp(method, "POST") == 0) { api_categories(fd, body, body_len, false); @@ -1720,6 +2116,7 @@ naut_err naut_plugin_register(const naut_host_api *host) { if (error != NAUT_OK) goto fail_store; error = start_server(); if (error != NAUT_OK) goto fail_store; + webui_load_taxonomy(); /* restore category + tag lists from the daemon */ return NAUT_OK; fail_store: diff --git a/src/dht/dht.c b/src/dht/dht.c deleted file mode 100644 index 2a9922d..0000000 --- a/src/dht/dht.c +++ /dev/null @@ -1,227 +0,0 @@ -#include "naut/dht.h" -#include "naut/bencode.h" - -#include <stdlib.h> -#include <string.h> - -static naut_err finish(naut_bc_writer *w, uint8_t **out, size_t *out_len) { - if (w->err != NAUT_OK) { - naut_err e = w->err; - naut_bc_w_free(w); - return e; - } - *out = w->buf; - *out_len = w->len; - w->buf = NULL; - naut_bc_w_free(w); - return NAUT_OK; -} - -static bool valid_common(const uint8_t *tx, size_t tx_len, - const uint8_t id[20], uint8_t **out, size_t *out_len) { - return tx && tx_len > 0 && tx_len <= 8 && id && out && out_len; -} - -naut_err naut_dht_build_ping(const uint8_t *tx, size_t tx_len, - const uint8_t id[20], - uint8_t **out, size_t *out_len) { - if (!valid_common(tx, tx_len, id, out, out_len)) return NAUT_ERR_INVAL; - naut_bc_writer w; naut_bc_w_init(&w); - naut_bc_w_dict_begin(&w); - naut_bc_w_cstr(&w, "a"); naut_bc_w_dict_begin(&w); - naut_bc_w_cstr(&w, "id"); naut_bc_w_bytes(&w, id, 20); - naut_bc_w_end(&w); - naut_bc_w_cstr(&w, "q"); naut_bc_w_cstr(&w, "ping"); - naut_bc_w_cstr(&w, "t"); naut_bc_w_bytes(&w, tx, tx_len); - naut_bc_w_cstr(&w, "y"); naut_bc_w_cstr(&w, "q"); - naut_bc_w_end(&w); - return finish(&w, out, out_len); -} - -static naut_err build_target_query(const char *query, const char *target_key, - const uint8_t *tx, size_t tx_len, - const uint8_t id[20], - const uint8_t target[20], - uint8_t **out, size_t *out_len) { - if (!query || !target_key || !target || - !valid_common(tx, tx_len, id, out, out_len)) return NAUT_ERR_INVAL; - naut_bc_writer w; naut_bc_w_init(&w); - naut_bc_w_dict_begin(&w); - naut_bc_w_cstr(&w, "a"); naut_bc_w_dict_begin(&w); - naut_bc_w_cstr(&w, "id"); naut_bc_w_bytes(&w, id, 20); - naut_bc_w_cstr(&w, target_key); naut_bc_w_bytes(&w, target, 20); - naut_bc_w_end(&w); - naut_bc_w_cstr(&w, "q"); naut_bc_w_cstr(&w, query); - naut_bc_w_cstr(&w, "t"); naut_bc_w_bytes(&w, tx, tx_len); - naut_bc_w_cstr(&w, "y"); naut_bc_w_cstr(&w, "q"); - naut_bc_w_end(&w); - return finish(&w, out, out_len); -} - -naut_err naut_dht_build_find_node(const uint8_t *tx, size_t tx_len, - const uint8_t id[20], - const uint8_t target[20], - uint8_t **out, size_t *out_len) { - return build_target_query("find_node", "target", tx, tx_len, id, target, - out, out_len); -} - -naut_err naut_dht_build_get_peers(const uint8_t *tx, size_t tx_len, - const uint8_t id[20], - const uint8_t info_hash[20], - uint8_t **out, size_t *out_len) { - return build_target_query("get_peers", "info_hash", tx, tx_len, id, - info_hash, out, out_len); -} - -naut_err naut_dht_build_announce_peer(const uint8_t *tx, size_t tx_len, - const uint8_t id[20], - const uint8_t info_hash[20], - uint16_t port, bool implied_port, - const void *token, size_t token_len, - uint8_t **out, size_t *out_len) { - if (!valid_common(tx, tx_len, id, out, out_len) || !info_hash || - !token || token_len == 0 || token_len > 64 || (!implied_port && port == 0)) - return NAUT_ERR_INVAL; - naut_bc_writer w; naut_bc_w_init(&w); - naut_bc_w_dict_begin(&w); - naut_bc_w_cstr(&w, "a"); naut_bc_w_dict_begin(&w); - naut_bc_w_cstr(&w, "id"); naut_bc_w_bytes(&w, id, 20); - naut_bc_w_cstr(&w, "implied_port"); naut_bc_w_int(&w, implied_port ? 1 : 0); - naut_bc_w_cstr(&w, "info_hash"); naut_bc_w_bytes(&w, info_hash, 20); - naut_bc_w_cstr(&w, "port"); naut_bc_w_int(&w, port); - naut_bc_w_cstr(&w, "token"); naut_bc_w_bytes(&w, token, token_len); - naut_bc_w_end(&w); - naut_bc_w_cstr(&w, "q"); naut_bc_w_cstr(&w, "announce_peer"); - naut_bc_w_cstr(&w, "t"); naut_bc_w_bytes(&w, tx, tx_len); - naut_bc_w_cstr(&w, "y"); naut_bc_w_cstr(&w, "q"); - naut_bc_w_end(&w); - return finish(&w, out, out_len); -} - -static naut_err parse_nodes(const uint8_t *p, size_t n, - naut_dht_node **out, size_t *count) { - if (n % 26 != 0 || n / 26 > NAUT_DHT_MAX_NODES) return NAUT_ERR_PROTO; - size_t num = n / 26; - naut_dht_node *nodes = calloc(num ? num : 1, sizeof(*nodes)); - if (!nodes) return NAUT_ERR_NOMEM; - for (size_t i = 0; i < num; i++) { - const uint8_t *entry = p + i * 26; - memcpy(nodes[i].id, entry, 20); - memcpy(nodes[i].ip, entry + 20, 4); - nodes[i].port = ((uint16_t)entry[24] << 8) | entry[25]; - if (nodes[i].port == 0) { - free(nodes); - return NAUT_ERR_PROTO; - } - } - *out = nodes; - *count = num; - return NAUT_OK; -} - -static bool peer_duplicate(const naut_peer_addr *peers, size_t n, - const naut_peer_addr *candidate) { - for (size_t i = 0; i < n; i++) - if (peers[i].port == candidate->port && - memcmp(peers[i].ip, candidate->ip, 4) == 0) - return true; - return false; -} - -static naut_err parse_values(const naut_bc *values, - naut_peer_addr **out, size_t *count) { - if (!values || values->type != NAUT_BC_LIST || - values->v.list.count > NAUT_DHT_MAX_PEERS) return NAUT_ERR_PROTO; - naut_peer_addr *peers = calloc(values->v.list.count ? values->v.list.count : 1, - sizeof(*peers)); - if (!peers) return NAUT_ERR_NOMEM; - size_t num = 0; - for (size_t i = 0; i < values->v.list.count; i++) { - const uint8_t *p; size_t n; - if (!naut_bc_get_str(naut_bc_list_at(values, i), &p, &n) || n != 6) { - free(peers); - return NAUT_ERR_PROTO; - } - naut_peer_addr peer; - memcpy(peer.ip, p, 4); - peer.port = ((uint16_t)p[4] << 8) | p[5]; - if (peer.port && !peer_duplicate(peers, num, &peer)) - peers[num++] = peer; - } - *out = peers; - *count = num; - return NAUT_OK; -} - -naut_err naut_dht_parse_response(const uint8_t *data, size_t len, - naut_dht_response *out) { - if (!data || !out) return NAUT_ERR_INVAL; - memset(out, 0, sizeof(*out)); - naut_bc_doc *doc = NULL; - naut_err err = naut_bc_parse(data, len, &doc); - if (err != NAUT_OK) return err; - const naut_bc *root = naut_bc_root(doc); - const uint8_t *p; size_t n; - if (!root || root->type != NAUT_BC_DICT || - !naut_bc_get_str(naut_bc_dict_get(root, "t"), &p, &n) || - n == 0 || n > sizeof out->transaction) { - err = NAUT_ERR_PROTO; - goto done; - } - memcpy(out->transaction, p, n); - out->transaction_len = n; - const naut_bc *y = naut_bc_dict_get(root, "y"); - if (naut_bc_str_eq(y, "e")) { - const naut_bc *e = naut_bc_dict_get(root, "e"); - int64_t code; - if (!e || e->type != NAUT_BC_LIST || e->v.list.count < 1 || - !naut_bc_get_int(naut_bc_list_at(e, 0), &code)) { - err = NAUT_ERR_PROTO; - goto done; - } - out->type = NAUT_DHT_ERROR; - out->error_code = (int)code; - goto done; - } - if (!naut_bc_str_eq(y, "r")) { - err = NAUT_ERR_PROTO; - goto done; - } - out->type = NAUT_DHT_RESPONSE; - const naut_bc *r = naut_bc_dict_get(root, "r"); - if (!r || r->type != NAUT_BC_DICT) { - err = NAUT_ERR_PROTO; - goto done; - } - if (naut_bc_get_str(naut_bc_dict_get(r, "id"), &p, &n)) { - if (n != 20) { err = NAUT_ERR_PROTO; goto done; } - memcpy(out->id, p, 20); - out->has_id = true; - } - if (naut_bc_get_str(naut_bc_dict_get(r, "token"), &p, &n)) { - if (n == 0 || n > sizeof out->token) { err = NAUT_ERR_PROTO; goto done; } - memcpy(out->token, p, n); - out->token_len = n; - } - if (naut_bc_get_str(naut_bc_dict_get(r, "nodes"), &p, &n)) { - err = parse_nodes(p, n, &out->nodes, &out->num_nodes); - if (err != NAUT_OK) goto done; - } - const naut_bc *values = naut_bc_dict_get(r, "values"); - if (values) { - err = parse_values(values, &out->peers, &out->num_peers); - if (err != NAUT_OK) goto done; - } -done: - naut_bc_free(doc); - if (err != NAUT_OK) naut_dht_response_free(out); - return err; -} - -void naut_dht_response_free(naut_dht_response *response) { - if (!response) return; - free(response->nodes); - free(response->peers); - memset(response, 0, sizeof(*response)); -} diff --git a/src/dht/fetch.c b/src/discovery/dht_client.c similarity index 74% rename from src/dht/fetch.c rename to src/discovery/dht_client.c index c76b8bd..42437c8 100644 --- a/src/dht/fetch.c +++ b/src/discovery/dht_client.c @@ -1,5 +1,11 @@ +/* dht_client.c — bounded iterative BEP-5 get_peers traversal. + * + * The KRPC message codec comes from the sibling `torrent-tracker` library; this + * file owns the UDP socket, the candidate frontier, and the bounded walk. */ #include "naut/dht.h" +#include "tracker.h" /* torrent-tracker DHT codec (dht_*) */ + #include <arpa/inet.h> #include <errno.h> #include <fcntl.h> @@ -10,6 +16,8 @@ #include <sys/socket.h> #include <unistd.h> +#define DHT_MAX_QUERIES 64 + typedef struct { struct sockaddr_in addr; bool queried; @@ -91,6 +99,9 @@ naut_err naut_dht_get_peers(const char *const *bootstrap, size_t num_bootstrap, int fd = socket(AF_INET, SOCK_DGRAM, 0); if (fd < 0) return NAUT_ERR_IO; + dht_message *msg = malloc(sizeof *msg); + if (!msg) { close(fd); return NAUT_ERR_NOMEM; } + naut_peer_addr found[NAUT_DHT_MAX_PEERS]; size_t found_count = 0; uint8_t id[20]; @@ -98,7 +109,7 @@ naut_err naut_dht_get_peers(const char *const *bootstrap, size_t num_bootstrap, uint16_t tx_counter = 1; size_t queries = 0; - while (queries < 64 && found_count < NAUT_DHT_MAX_PEERS) { + while (queries < DHT_MAX_QUERIES && found_count < NAUT_DHT_MAX_PEERS) { size_t index = SIZE_MAX; for (size_t i = 0; i < node_count; i++) if (!nodes[i].queried) { index = i; break; } @@ -107,42 +118,46 @@ naut_err naut_dht_get_peers(const char *const *bootstrap, size_t num_bootstrap, queries++; uint8_t tx[2] = { (uint8_t)(tx_counter >> 8), (uint8_t)tx_counter }; tx_counter++; - uint8_t *query = NULL; size_t query_len = 0; - if (naut_dht_build_get_peers(tx, sizeof tx, id, info_hash, - &query, &query_len) != NAUT_OK) + uint8_t query[256]; + size_t query_len = 0; + if (dht_write_get_peers_query(tx, sizeof tx, id, info_hash, 1, 0, + query, sizeof query, &query_len) != + TRACKER_OK) continue; ssize_t sent = sendto(fd, query, query_len, 0, (struct sockaddr *)&nodes[index].addr, sizeof(nodes[index].addr)); - free(query); if (sent < 0) continue; struct pollfd pfd = { .fd = fd, .events = POLLIN }; if (poll(&pfd, 1, 1000) <= 0) continue; - uint8_t packet[65536]; + uint8_t packet[2048]; ssize_t received = recv(fd, packet, sizeof packet, 0); if (received <= 0) continue; - naut_dht_response response; - if (naut_dht_parse_response(packet, (size_t)received, &response) != NAUT_OK) + if (dht_parse_message(packet, (size_t)received, msg) != TRACKER_OK) continue; - if (response.transaction_len != sizeof tx || - memcmp(response.transaction, tx, sizeof tx) != 0 || - response.type != NAUT_DHT_RESPONSE) { - naut_dht_response_free(&response); + if (msg->type != DHT_MSG_RESPONSE || + msg->transaction_len != sizeof tx || + memcmp(msg->transaction, tx, sizeof tx) != 0) continue; + for (size_t i = 0; i < msg->peer_count; i++) { + if (msg->peers[i].family != TRACKER_ADDR_IPV4) continue; + naut_peer_addr p; + memcpy(p.ip, msg->peers[i].addr, 4); + p.port = msg->peers[i].port; + add_peer(found, &found_count, &p); } - for (size_t i = 0; i < response.num_peers; i++) - add_peer(found, &found_count, &response.peers[i]); - for (size_t i = 0; i < response.num_nodes; i++) { + for (size_t i = 0; i < msg->node_count; i++) { + if (msg->nodes[i].family != TRACKER_ADDR_IPV4) continue; struct sockaddr_in addr; memset(&addr, 0, sizeof addr); addr.sin_family = AF_INET; - memcpy(&addr.sin_addr, response.nodes[i].ip, 4); - addr.sin_port = htons(response.nodes[i].port); + memcpy(&addr.sin_addr, msg->nodes[i].addr, 4); + addr.sin_port = htons(msg->nodes[i].port); add_candidate(nodes, &node_count, &addr); } - naut_dht_response_free(&response); } + free(msg); close(fd); if (found_count == 0) return NAUT_ERR_EMPTY; naut_peer_addr *result = malloc(found_count * sizeof(*result)); diff --git a/src/discovery/tracker_client.c b/src/discovery/tracker_client.c new file mode 100644 index 0000000..97d1fe5 --- /dev/null +++ b/src/discovery/tracker_client.c @@ -0,0 +1,257 @@ +/* tracker_client.c — HTTP/UDP tracker announce client. + * + * The wire codec (query building, bencode/UDP packet encode+decode) comes from + * the sibling `torrent-tracker` library; this file owns only the socket glue and + * the conversion between Naut's announce types and torrent-tracker's. */ +#include "naut/tracker.h" +#include "naut/log.h" + +#include "tracker.h" /* torrent-tracker public ABI */ + +#include <errno.h> +#include <netdb.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <unistd.h> +#include <sys/socket.h> +#include <sys/time.h> + +#define TRACKER_RESPONSE_MAX (16u << 20) + +void naut_tracker_response_free(naut_tracker_response *r) { + if (!r) return; + free(r->peers); + free(r->failure); + r->peers = NULL; + r->failure = NULL; + r->num_peers = 0; +} + +/* naut_announce_req -> torrent-tracker request (compact IPv4 announce). */ +static void to_tracker_request(const naut_announce_req *req, + tracker_announce_request *out) { + memset(out, 0, sizeof *out); + memcpy(out->info_hash, req->info_hash, 20); + memcpy(out->peer_id, req->peer_id, 20); + out->port = req->port; + out->uploaded = req->uploaded; + out->downloaded = req->downloaded; + out->left = req->left; + out->numwant = req->numwant; + out->key = req->key; + out->has_key = 1; + out->compact = 1; + out->event = (tracker_event)req->event; /* codes match BEP-15 */ +} + +/* Copy torrent-tracker IPv4 peers into a freshly malloc'd naut_peer_addr array. */ +static naut_err collect_peers(const tracker_peer *peers, size_t count, + const tracker_announce_response *resp, + naut_tracker_response *out) { + out->interval = (int32_t)resp->interval; + out->seeders = (int32_t)resp->complete; + out->leechers = (int32_t)resp->incomplete; + out->peers = NULL; + out->num_peers = 0; + if (count == 0) return NAUT_OK; + naut_peer_addr *v = malloc(count * sizeof *v); + if (!v) return NAUT_ERR_NOMEM; + size_t n = 0; + for (size_t i = 0; i < count; i++) { + if (peers[i].family != TRACKER_ADDR_IPV4) continue; /* IPv4 only */ + memcpy(v[n].ip, peers[i].addr, 4); + v[n].port = peers[i].port; + n++; + } + out->peers = v; + out->num_peers = n; + return NAUT_OK; +} + +size_t naut_tracker_http_url(const char *base, const naut_announce_req *req, + char *out, size_t outsz) { + tracker_announce_request treq; + to_tracker_request(req, &treq); + char query[2048]; + size_t qlen = 0; + if (tracker_http_write_announce_query(&treq, query, sizeof query, &qlen) != + TRACKER_OK) + return 0; + const char sep = strchr(base, '?') ? '&' : '?'; + int n = snprintf(out, outsz, "%s%c%.*s", base, sep, (int)qlen, query); + if (n < 0 || (size_t)n >= outsz) return 0; + return (size_t)n; +} + +/* --- HTTP --------------------------------------------------------------- */ + +static int dial(const char *host, const char *port, int socktype) { + struct addrinfo hints, *res = NULL, *ai; + memset(&hints, 0, sizeof hints); + hints.ai_family = AF_INET; /* IPv4 (compact peers are v4) */ + hints.ai_socktype = socktype; + if (getaddrinfo(host, port, &hints, &res) != 0) return -1; + int fd = -1; + for (ai = res; ai; ai = ai->ai_next) { + fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); + if (fd < 0) continue; + struct timeval tv = { .tv_sec = 10, .tv_usec = 0 }; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) break; + close(fd); fd = -1; + } + freeaddrinfo(res); + return fd; +} + +/* split "http://host[:port]/path" */ +static bool parse_http_url(const char *url, char *host, size_t hostsz, + char *port, size_t portsz, const char **path) { + if (strncmp(url, "http://", 7) != 0) return false; + const char *h = url + 7; + const char *slash = strchr(h, '/'); + const char *hostend = slash ? slash : h + strlen(h); + const char *colon = memchr(h, ':', (size_t)(hostend - h)); + size_t hlen = colon ? (size_t)(colon - h) : (size_t)(hostend - h); + if (hlen >= hostsz) return false; + memcpy(host, h, hlen); host[hlen] = 0; + if (colon) { + size_t plen = (size_t)(hostend - colon - 1); + if (plen >= portsz) return false; + memcpy(port, colon + 1, plen); port[plen] = 0; + } else { snprintf(port, portsz, "80"); } + *path = slash ? slash : "/"; + return true; +} + +static bool write_all(int fd, const void *data, size_t len) { + const uint8_t *p = data; + while (len) { + ssize_t n = write(fd, p, len); + if (n < 0) { + if (errno == EINTR) continue; + return false; + } + p += (size_t)n; + len -= (size_t)n; + } + return true; +} + +naut_err naut_tracker_announce_http(const char *url, naut_tracker_response *out) { + char host[256], port[16]; const char *path; + if (!parse_http_url(url, host, sizeof host, port, sizeof port, &path)) + return NAUT_ERR_INVAL; + int fd = dial(host, port, SOCK_STREAM); + if (fd < 0) { NAUT_WARN("tracker connect %s:%s failed", host, port); return NAUT_ERR_IO; } + + char req[4096]; + int rn = snprintf(req, sizeof req, + "GET %s HTTP/1.0\r\nHost: %s\r\nUser-Agent: Naut/0.1\r\nAccept: */*\r\n\r\n", + path, host); + if (rn < 0 || (size_t)rn >= sizeof req || + !write_all(fd, req, (size_t)rn)) { + close(fd); + return NAUT_ERR_IO; + } + + /* read whole response (server closes on HTTP/1.0) */ + size_t cap = 1 << 16, len = 0; + uint8_t *buf = malloc(cap); + if (!buf) { close(fd); return NAUT_ERR_NOMEM; } + naut_err read_error = NAUT_OK; + for (;;) { + if (len == cap) { + if (cap == TRACKER_RESPONSE_MAX) { read_error = NAUT_ERR_FULL; break; } + size_t next_cap = NAUT_MIN(cap * 2, (size_t)TRACKER_RESPONSE_MAX); + uint8_t *next = realloc(buf, next_cap); + if (!next) { read_error = NAUT_ERR_NOMEM; break; } + buf = next; + cap = next_cap; + } + ssize_t r = read(fd, buf + len, cap - len); + if (r < 0) { + if (errno == EINTR) continue; + read_error = NAUT_ERR_IO; + break; + } + if (r == 0) break; + len += (size_t)r; + } + close(fd); + if (read_error != NAUT_OK) { free(buf); return read_error; } + + /* find body after CRLFCRLF */ + uint8_t *body = NULL; size_t blen = 0; + for (size_t i = 0; i + 3 < len; i++) + if (buf[i]=='\r'&&buf[i+1]=='\n'&&buf[i+2]=='\r'&&buf[i+3]=='\n') { + body = buf + i + 4; blen = len - (i + 4); break; + } + bool ok = len >= 12 && memcmp(buf, "HTTP/", 5) == 0 && buf[9] == '2'; + if (!ok || !body) { free(buf); return NAUT_ERR_PROTO; } + + tracker_peer peers[TRACKER_MAX_PEERS]; + tracker_announce_response resp; + memset(&resp, 0, sizeof resp); + naut_err e = NAUT_ERR_PROTO; + if (tracker_http_parse_announce_response(body, blen, peers, + TRACKER_MAX_PEERS, &resp) == + TRACKER_OK) + e = collect_peers(resp.peers, resp.peer_count, &resp, out); + free(buf); + return e; +} + +/* --- UDP (BEP-15) ------------------------------------------------------- */ + +naut_err naut_tracker_announce_udp(const char *host, uint16_t port, + const naut_announce_req *req, + naut_tracker_response *out) { + char portstr[16]; snprintf(portstr, sizeof portstr, "%u", port); + int fd = dial(host, portstr, SOCK_DGRAM); + if (fd < 0) return NAUT_ERR_IO; + + srand((unsigned)time(NULL) ^ (unsigned)getpid()); + uint32_t txid = (uint32_t)rand(); + + uint8_t pkt[128], resp[2048]; + size_t written = 0; + if (tracker_udp_write_connect_request(txid, pkt, sizeof pkt, &written) != + TRACKER_OK || + !write_all(fd, pkt, written)) { + close(fd); return NAUT_ERR_IO; + } + ssize_t r = read(fd, resp, sizeof resp); + uint64_t cid = 0; + if (r < 0 || + tracker_udp_parse_connect_response(resp, (size_t)r, txid, &cid) != + TRACKER_OK) { + close(fd); return NAUT_ERR_IO; + } + + txid++; + tracker_announce_request treq; + to_tracker_request(req, &treq); + if (tracker_udp_write_announce_request(cid, txid, &treq, pkt, sizeof pkt, + &written) != TRACKER_OK || + !write_all(fd, pkt, written)) { + close(fd); return NAUT_ERR_IO; + } + r = read(fd, resp, sizeof resp); + naut_err e = NAUT_ERR_IO; + if (r >= 0) { + tracker_peer peers[TRACKER_MAX_PEERS]; + tracker_announce_response tresp; + memset(&tresp, 0, sizeof tresp); + e = NAUT_ERR_PROTO; + if (tracker_udp_parse_announce_response(resp, (size_t)r, txid, + TRACKER_ADDR_IPV4, peers, + TRACKER_MAX_PEERS, &tresp) == + TRACKER_OK) + e = collect_peers(tresp.peers, tresp.peer_count, &tresp, out); + } + close(fd); + return e; +} diff --git a/src/peer/mse.c b/src/peer/mse.c deleted file mode 100644 index 44df867..0000000 --- a/src/peer/mse.c +++ /dev/null @@ -1,466 +0,0 @@ -#include "naut/mse.h" -#include "naut/hash.h" - -#include <errno.h> -#include <openssl/bn.h> -#include <openssl/rand.h> -#include <stdlib.h> -#include <string.h> -#include <sys/socket.h> - -#define MSE_PAD_MAX 512 -#define MSE_CRYPTO_RC4 2u - -static const char DH_PRIME_HEX[] = - "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC" - "74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF2" - "5F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A63A3621000000" - "0000090563"; - -static void wr16(uint8_t *p, uint16_t value) { - p[0] = (uint8_t)(value >> 8); - p[1] = (uint8_t)value; -} - -static void wr32(uint8_t *p, uint32_t value) { - p[0] = (uint8_t)(value >> 24); - p[1] = (uint8_t)(value >> 16); - p[2] = (uint8_t)(value >> 8); - p[3] = (uint8_t)value; -} - -static uint16_t rd16(const uint8_t *p) { - return ((uint16_t)p[0] << 8) | p[1]; -} - -static uint32_t rd32(const uint8_t *p) { - return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | - ((uint32_t)p[2] << 8) | p[3]; -} - -static void hash_parts(const char label[4], - const uint8_t *first, size_t first_len, - const uint8_t *second, size_t second_len, - uint8_t out[20]) { - naut_sha1_ctx sha; - naut_sha1_init(&sha); - naut_sha1_update(&sha, label, 4); - naut_sha1_update(&sha, first, first_len); - if (second && second_len) naut_sha1_update(&sha, second, second_len); - naut_sha1_final(&sha, out); -} - -static void init_rc4(const uint8_t secret[NAUT_MSE_DH_LEN], - const uint8_t info_hash[20], - naut_mse_stream *stream) { - uint8_t key_a[20], key_b[20]; - hash_parts("keyA", secret, NAUT_MSE_DH_LEN, info_hash, 20, key_a); - hash_parts("keyB", secret, NAUT_MSE_DH_LEN, info_hash, 20, key_b); - naut_rc4_init(&stream->send, key_a, sizeof key_a, 1024); - naut_rc4_init(&stream->recv, key_b, sizeof key_b, 1024); - memset(key_a, 0, sizeof key_a); - memset(key_b, 0, sizeof key_b); -} - -/* ---- sans-IO handshake state machine ------------------------------------- */ - -enum { - PH_RECV_PUBKEY, /* waiting for the peer's 96-byte DH public key */ - PH_SYNC_VC, /* scanning past PadB for the encrypted VC */ - PH_RECV_SELECT, /* crypto_select + len(PadD) */ - PH_RECV_PAD, /* PadD bytes (discarded) */ - PH_RECV_HS, /* the peer's encrypted BitTorrent handshake */ -}; - -struct naut_mse_handshake { - int phase; - naut_err err; - bool done; - - uint8_t info_hash[20]; - uint8_t peer_id[NAUT_PEERID_LEN]; - uint64_t reserved; - - /* DH state retained until the shared secret is computed. */ - BN_CTX *ctx; - BIGNUM *prime; - BIGNUM *priv; - - naut_mse_stream stream; - uint8_t expected_vc[8]; - size_t vc_scanned; - size_t pad_remaining; - uint8_t remote_handshake[NAUT_HANDSHAKE_LEN]; - - uint8_t out[256]; - size_t out_len, out_off; - - uint8_t in[1024]; - size_t in_len; -}; - -static void dh_free(naut_mse_handshake *h) { - BN_CTX_free(h->ctx); h->ctx = NULL; - BN_free(h->prime); h->prime = NULL; - BN_clear_free(h->priv); h->priv = NULL; -} - -/* Generate our private key and public value, writing the 96-byte public key - * into the outgoing buffer. Retains prime/priv/ctx for dh_complete(). */ -static naut_err dh_begin(naut_mse_handshake *h) { - naut_err result = NAUT_ERR_IO; - BIGNUM *generator = BN_new(); - BIGNUM *local = BN_new(); - h->ctx = BN_CTX_new(); - h->priv = BN_new(); - if (!generator || !local || !h->ctx || !h->priv || - !BN_hex2bn(&h->prime, DH_PRIME_HEX) || !BN_set_word(generator, 2)) - goto done; - do { - if (!BN_rand_range(h->priv, h->prime)) goto done; - } while (BN_cmp(h->priv, generator) < 0); - if (!BN_mod_exp(local, generator, h->priv, h->prime, h->ctx) || - BN_bn2binpad(local, h->out, NAUT_MSE_DH_LEN) != NAUT_MSE_DH_LEN) - goto done; - h->out_len = NAUT_MSE_DH_LEN; - h->out_off = 0; - result = NAUT_OK; -done: - BN_free(generator); - BN_free(local); - if (result != NAUT_OK) dh_free(h); - return result; -} - -/* Validate the peer's public key and derive the shared secret. */ -static naut_err dh_complete(naut_mse_handshake *h, const uint8_t remote_bytes[96], - uint8_t secret[NAUT_MSE_DH_LEN]) { - naut_err result = NAUT_ERR_IO; - BIGNUM *remote = BN_new(); - BIGNUM *shared = BN_new(); - BIGNUM *limit = BN_new(); - BIGNUM *two = BN_new(); - if (!remote || !shared || !limit || !two || - !BN_bin2bn(remote_bytes, NAUT_MSE_DH_LEN, remote) || - !BN_set_word(two, 2) || !BN_copy(limit, h->prime) || - !BN_sub_word(limit, 1)) - goto done; - if (BN_cmp(remote, two) < 0 || BN_cmp(remote, limit) >= 0) { - result = NAUT_ERR_PROTO; - goto done; - } - if (!BN_mod_exp(shared, remote, h->priv, h->prime, h->ctx) || - BN_bn2binpad(shared, secret, NAUT_MSE_DH_LEN) != NAUT_MSE_DH_LEN) - goto done; - result = NAUT_OK; -done: - BN_free(remote); - BN_clear_free(shared); - BN_free(limit); - BN_free(two); - return result; -} - -naut_mse_handshake *naut_mse_handshake_begin( - const uint8_t info_hash[20], - const uint8_t peer_id[NAUT_PEERID_LEN], - uint64_t reserved) { - if (!info_hash || !peer_id) return NULL; - naut_mse_handshake *h = calloc(1, sizeof(*h)); - if (!h) return NULL; - memcpy(h->info_hash, info_hash, 20); - memcpy(h->peer_id, peer_id, NAUT_PEERID_LEN); - h->reserved = reserved; - h->phase = PH_RECV_PUBKEY; - if (dh_begin(h) != NAUT_OK) { - naut_mse_handshake_free(h); - return NULL; - } - return h; -} - -void naut_mse_handshake_free(naut_mse_handshake *h) { - if (!h) return; - dh_free(h); - /* keystream state is sensitive; scrub before release */ - memset(h, 0, sizeof(*h)); - free(h); -} - -static void consume(naut_mse_handshake *h, size_t n) { - memmove(h->in, h->in + n, h->in_len - n); - h->in_len -= n; -} - -/* Build req1/req2 + encrypted offer (VC, crypto_provide, PadC, IA) into out. */ -static void build_request(naut_mse_handshake *h, const uint8_t secret[96]) { - uint8_t req1[20], req2[20], req3[20]; - hash_parts("req1", secret, NAUT_MSE_DH_LEN, NULL, 0, req1); - hash_parts("req2", h->info_hash, 20, NULL, 0, req2); - hash_parts("req3", secret, NAUT_MSE_DH_LEN, NULL, 0, req3); - for (size_t i = 0; i < sizeof req2; i++) req2[i] ^= req3[i]; - - init_rc4(secret, h->info_hash, &h->stream); - - uint8_t *p = h->out; - memcpy(p, req1, 20); - memcpy(p + 20, req2, 20); - p += 40; - - uint8_t *offer = p; /* VC(8) crypto_provide(4) padlen(2) ialen(2) IA */ - memset(offer, 0, 8); - wr32(offer + 8, MSE_CRYPTO_RC4); - wr16(offer + 12, 0); - wr16(offer + 14, NAUT_HANDSHAKE_LEN); - naut_peer_handshake_build(offer + 16, h->info_hash, h->peer_id, h->reserved); - size_t offer_len = 16 + NAUT_HANDSHAKE_LEN; - naut_rc4_xor(&h->stream.send, offer, offer_len); - - h->out_len = 40 + offer_len; - h->out_off = 0; - - /* expected_vc = our recv keystream applied to 8 zero bytes at position 0, - * without advancing the real recv state (we resync on it). */ - naut_rc4 probe = h->stream.recv; - uint8_t vc[8] = {0}; - naut_rc4_xor(&probe, vc, sizeof vc); - memcpy(h->expected_vc, vc, sizeof vc); - h->vc_scanned = 0; -} - -static void advance(naut_mse_handshake *h) { - for (;;) { - switch (h->phase) { - case PH_RECV_PUBKEY: { - if (h->in_len < NAUT_MSE_DH_LEN) return; - uint8_t secret[NAUT_MSE_DH_LEN]; - naut_err e = dh_complete(h, h->in, secret); - if (e != NAUT_OK) { h->err = e; return; } - consume(h, NAUT_MSE_DH_LEN); - dh_free(h); /* DH no longer needed */ - build_request(h, secret); - memset(secret, 0, sizeof secret); - h->phase = PH_SYNC_VC; - return; /* out now holds req+offer: NEED_WRITE */ - } - case PH_SYNC_VC: { - while (h->in_len >= sizeof h->expected_vc) { - if (memcmp(h->in, h->expected_vc, sizeof h->expected_vc) == 0) { - uint8_t vc[8]; - memcpy(vc, h->in, sizeof vc); - naut_rc4_xor(&h->stream.recv, vc, sizeof vc); - static const uint8_t zero8[8] = {0}; - if (memcmp(vc, zero8, sizeof vc) != 0) { - h->err = NAUT_ERR_PROTO; - return; - } - consume(h, sizeof vc); - h->phase = PH_RECV_SELECT; - break; - } - consume(h, 1); - if (++h->vc_scanned > MSE_PAD_MAX) { - h->err = NAUT_ERR_PROTO; - return; - } - } - if (h->phase == PH_SYNC_VC) return; /* need more bytes */ - continue; - } - case PH_RECV_SELECT: { - if (h->in_len < 6) return; - uint8_t hdr[6]; - memcpy(hdr, h->in, sizeof hdr); - naut_rc4_xor(&h->stream.recv, hdr, sizeof hdr); - consume(h, sizeof hdr); - if (rd32(hdr) != MSE_CRYPTO_RC4) { h->err = NAUT_ERR_PROTO; return; } - h->pad_remaining = rd16(hdr + 4); - if (h->pad_remaining > MSE_PAD_MAX) { h->err = NAUT_ERR_PROTO; return; } - h->phase = PH_RECV_PAD; - continue; - } - case PH_RECV_PAD: { - if (h->pad_remaining > 0) { - size_t n = h->pad_remaining < h->in_len ? h->pad_remaining - : h->in_len; - if (n == 0) return; - naut_rc4_xor(&h->stream.recv, h->in, n); /* advance keystream */ - consume(h, n); - h->pad_remaining -= n; - if (h->pad_remaining > 0) return; - } - h->phase = PH_RECV_HS; - continue; - } - case PH_RECV_HS: { - if (h->in_len < NAUT_HANDSHAKE_LEN) return; - memcpy(h->remote_handshake, h->in, NAUT_HANDSHAKE_LEN); - naut_rc4_xor(&h->stream.recv, h->remote_handshake, NAUT_HANDSHAKE_LEN); - consume(h, NAUT_HANDSHAKE_LEN); - uint8_t remote_hash[20], remote_id[20]; - if (!naut_peer_handshake_parse(h->remote_handshake, remote_hash, - remote_id, NULL) || - memcmp(remote_hash, h->info_hash, 20) != 0) { - h->err = NAUT_ERR_PROTO; - return; - } - h->stream.active = true; - h->done = true; - return; - } - default: - h->err = NAUT_ERR_PROTO; - return; - } - } -} - -naut_mse_hs_status naut_mse_handshake_status(const naut_mse_handshake *h) { - if (!h || h->err != NAUT_OK) return NAUT_MSE_HS_ERROR; - if (h->done) return NAUT_MSE_HS_DONE; - if (h->out_off < h->out_len) return NAUT_MSE_HS_NEED_WRITE; - return NAUT_MSE_HS_NEED_READ; -} - -size_t naut_mse_handshake_pull(naut_mse_handshake *h, uint8_t *buf, size_t cap) { - if (!h || !buf) return 0; - size_t avail = h->out_len - h->out_off; - size_t n = avail < cap ? avail : cap; - if (n) { - memcpy(buf, h->out + h->out_off, n); - h->out_off += n; - if (h->out_off == h->out_len) h->out_len = h->out_off = 0; - } - return n; -} - -naut_mse_hs_status naut_mse_handshake_feed(naut_mse_handshake *h, - const uint8_t *data, size_t len, - size_t *consumed) { - if (consumed) *consumed = 0; - if (!h) return NAUT_MSE_HS_ERROR; - if (h->err == NAUT_OK && !h->done && data && len) { - size_t space = sizeof h->in - h->in_len; - size_t take = len < space ? len : space; - memcpy(h->in + h->in_len, data, take); - h->in_len += take; - if (consumed) *consumed = take; - advance(h); - } - return naut_mse_handshake_status(h); -} - -naut_err naut_mse_handshake_finish(naut_mse_handshake *h, - naut_mse_stream *stream, - uint8_t remote_handshake[NAUT_HANDSHAKE_LEN]) { - if (!h || !stream || !remote_handshake) return NAUT_ERR_INVAL; - if (h->err != NAUT_OK) return h->err; - if (!h->done) return NAUT_ERR_AGAIN; - *stream = h->stream; - memcpy(remote_handshake, h->remote_handshake, NAUT_HANDSHAKE_LEN); - return NAUT_OK; -} - -/* ---- blocking I/O helpers + convenience wrapper -------------------------- */ - -static bool raw_send_all(int fd, const void *data, size_t len) { - const uint8_t *p = data; - while (len) { - ssize_t n = send(fd, p, len, MSG_NOSIGNAL); - if (n < 0) { - if (errno == EINTR) continue; - return false; - } - if (n == 0) return false; - p += n; - len -= (size_t)n; - } - return true; -} - -static bool raw_recv_exact(int fd, void *data, size_t len) { - uint8_t *p = data; - while (len) { - ssize_t n = recv(fd, p, len, 0); - if (n < 0) { - if (errno == EINTR) continue; - return false; - } - if (n == 0) return false; - p += n; - len -= (size_t)n; - } - return true; -} - -naut_err naut_mse_client_handshake( - int fd, - const uint8_t info_hash[20], - const uint8_t peer_id[NAUT_PEERID_LEN], - uint64_t reserved, - naut_mse_stream *stream, - uint8_t remote_handshake[NAUT_HANDSHAKE_LEN]) { - if (fd < 0 || !info_hash || !peer_id || !stream || !remote_handshake) - return NAUT_ERR_INVAL; - memset(stream, 0, sizeof(*stream)); - - naut_mse_handshake *h = - naut_mse_handshake_begin(info_hash, peer_id, reserved); - if (!h) return NAUT_ERR_NOMEM; - - naut_err rc = NAUT_ERR_PROTO; - for (;;) { - naut_mse_hs_status st = naut_mse_handshake_status(h); - if (st == NAUT_MSE_HS_NEED_WRITE) { - uint8_t buf[256]; - size_t n; - bool ok = true; - while ((n = naut_mse_handshake_pull(h, buf, sizeof buf)) > 0) - if (!raw_send_all(fd, buf, n)) { ok = false; break; } - if (!ok) { rc = NAUT_ERR_IO; break; } - } else if (st == NAUT_MSE_HS_NEED_READ) { - /* One byte at a time: the handshake is tiny and one-shot, and this - * keeps the wrapper from over-reading into the payload stream. */ - uint8_t byte; - if (!raw_recv_exact(fd, &byte, 1)) { rc = NAUT_ERR_IO; break; } - naut_mse_handshake_feed(h, &byte, 1, NULL); - } else if (st == NAUT_MSE_HS_DONE) { - rc = naut_mse_handshake_finish(h, stream, remote_handshake); - break; - } else { - rc = h->err != NAUT_OK ? h->err : NAUT_ERR_PROTO; - break; - } - } - naut_mse_handshake_free(h); - return rc; -} - -/* ---- post-handshake stream I/O ------------------------------------------- */ - -bool naut_mse_send_all(int fd, naut_mse_stream *stream, - const void *data, size_t len) { - if (!stream || !stream->active) return raw_send_all(fd, data, len); - const uint8_t *p = data; - uint8_t block[16 * 1024]; - while (len) { - size_t n = len < sizeof block ? len : sizeof block; - memcpy(block, p, n); - naut_rc4_xor(&stream->send, block, n); - if (!raw_send_all(fd, block, n)) return false; - p += n; - len -= n; - } - return true; -} - -ssize_t naut_mse_recv(int fd, naut_mse_stream *stream, - void *data, size_t len) { - ssize_t n; - do { - n = recv(fd, data, len, 0); - } while (n < 0 && errno == EINTR); - if (n > 0 && stream && stream->active) - naut_rc4_xor(&stream->recv, data, (size_t)n); - return n; -} diff --git a/src/peer/pipeline.c b/src/peer/pipeline.c deleted file mode 100644 index 6b0989b..0000000 --- a/src/peer/pipeline.c +++ /dev/null @@ -1,64 +0,0 @@ -#include "naut/pipeline.h" - -#include <math.h> - -static uint32_t clamp_depth(const naut_pipeline *p, uint32_t depth) { - if (depth < p->min_depth) return p->min_depth; - if (depth > p->max_depth) return p->max_depth; - return depth; -} - -void naut_pipeline_init(naut_pipeline *p, uint32_t block_size, - uint32_t min_depth, uint32_t max_depth, - uint32_t initial_depth) { - if (!p) return; - if (block_size == 0) block_size = NAUT_BLOCK; - if (min_depth == 0) min_depth = 1; - if (max_depth < min_depth) max_depth = min_depth; - p->rtt_seconds = 0; - p->bytes_per_second = 0; - p->last_sample_at = 0; - p->min_depth = min_depth; - p->max_depth = max_depth; - p->block_size = block_size; - p->depth = clamp_depth(p, initial_depth); -} - -void naut_pipeline_on_block(naut_pipeline *p, uint32_t bytes, - double sent_at, double received_at) { - if (!p || bytes == 0 || sent_at <= 0 || received_at <= sent_at) return; - double rtt = received_at - sent_at; - if (rtt > 60.0) return; - - if (p->rtt_seconds == 0) p->rtt_seconds = rtt; - else p->rtt_seconds = p->rtt_seconds * 0.875 + rtt * 0.125; - - double interval = p->last_sample_at > 0 - ? received_at - p->last_sample_at : rtt; - if (interval <= 0) interval = rtt; - double rate = bytes / interval; - if (p->bytes_per_second == 0) p->bytes_per_second = rate; - else p->bytes_per_second = p->bytes_per_second * 0.8 + rate * 0.2; - p->last_sample_at = received_at; - - double blocks = (2.0 * p->bytes_per_second * p->rtt_seconds) / - p->block_size; - uint32_t target = blocks >= UINT32_MAX ? UINT32_MAX : - (uint32_t)ceil(blocks); - target = clamp_depth(p, target); - - /* Grow quickly enough to fill a fast path; shrink one eighth at a time so - * transient delayed samples do not collapse the pipe. */ - if (target > p->depth) { - uint32_t step = p->depth / 4 + 1; - p->depth = clamp_depth(p, NAUT_MIN(target, p->depth + step)); - } else if (target < p->depth) { - uint32_t step = p->depth / 8 + 1; - p->depth = clamp_depth(p, target > p->depth - step - ? target : p->depth - step); - } -} - -uint32_t naut_pipeline_depth(const naut_pipeline *p) { - return p ? p->depth : 0; -} diff --git a/src/piece/piece.c b/src/piece/piece.c index a13e478..1fc2f02 100644 --- a/src/piece/piece.c +++ b/src/piece/piece.c @@ -158,6 +158,48 @@ void naut_download_destroy(naut_download *d) { free(d); } +static void mark_piece_complete(naut_download *d, uint32_t p, + bool count_blocks, bool emit); + +naut_err naut_download_resume(naut_download *d) { + if (!d) return NAUT_ERR_INVAL; + uint64_t max_piece = d->piece_len; + uint64_t last_piece = piece_size(d, d->num_pieces - 1); + if (last_piece > max_piece) max_piece = last_piece; + if (max_piece > (uint64_t)SIZE_MAX) return NAUT_ERR_INVAL; + + uint8_t *buf = malloc((size_t)max_piece); + if (!buf) return NAUT_ERR_NOMEM; + + uint32_t resumed = 0; + uint8_t digest[NAUT_SHA1_LEN]; + for (uint32_t p = 0; p < d->num_pieces; p++) { + uint64_t ps = piece_size(d, p); + if (ps > (uint64_t)SIZE_MAX) { + free(buf); + return NAUT_ERR_INVAL; + } + naut_err e = naut_storage_read( + d->st, (int64_t)p * (int64_t)d->piece_len, buf, (size_t)ps); + if (e != NAUT_OK) { + free(buf); + return e; + } + naut_sha1(buf, ps, digest); + if (memcmp(digest, + d->mi->piece_hashes + (size_t)p * NAUT_SHA1_LEN, + NAUT_SHA1_LEN) != 0) + continue; + mark_piece_complete(d, p, true, false); + resumed++; + } + free(buf); + if (resumed) + NAUT_INFO("resume: verified %u/%u pieces from disk", + resumed, d->num_pieces); + return NAUT_OK; +} + void naut_download_set_worker_pool(naut_download *d, naut_worker_pool *pool) { if (d) d->workers = pool; } @@ -174,7 +216,7 @@ bool naut_download_file_complete(const naut_download *d, uint32_t f) { return f < d->num_files && d->file_done[f]; } -static void notify_files(naut_download *d, uint32_t p) { +static void notify_files(naut_download *d, uint32_t p, bool emit) { size_t lo = 0, hi = d->num_files; while (lo < hi) { size_t mid = (lo + hi) / 2; if (d->file_last[mid] < p) lo = mid + 1; else hi = mid; } @@ -182,11 +224,26 @@ static void notify_files(naut_download *d, uint32_t p) { if (d->file_done[f]) continue; if (--d->file_remain[f] == 0) { d->file_done[f] = true; - if (d->file_cb) d->file_cb(d->file_cb_ctx, (uint32_t)f, d->mi->files[f].path); + if (emit && d->file_cb) + d->file_cb(d->file_cb_ctx, (uint32_t)f, + d->mi->files[f].path); } } } +static void mark_piece_complete(naut_download *d, uint32_t p, + bool count_blocks, bool emit) { + if (naut_bitfield_test(&d->have, p)) return; + naut_bitfield_set(&d->have, p); + d->pieces_done++; + d->bytes_done += piece_size(d, p); + if (count_blocks) + d->recv_blocks += nblocks(d, p); + if (emit && d->piece_cb) + d->piece_cb(d->piece_cb_ctx, p); + notify_files(d, p, emit); +} + /* --- availability -------------------------------------------------------- */ void naut_download_inc_avail(naut_download *d, uint32_t p) { if (p < d->num_pieces) d->avail[p]++; @@ -323,13 +380,9 @@ static naut_err finish_verified(naut_download *d, uint32_t p, } naut_err e = naut_storage_write(d->st, (int64_t)p * (int64_t)d->piece_len, s->buf, ps); if (e != NAUT_OK) return e; - naut_bitfield_set(&d->have, p); - d->pieces_done++; - d->bytes_done += ps; + mark_piece_complete(d, p, false, true); free_ps(d, p); *done = true; - if (d->piece_cb) d->piece_cb(d->piece_cb_ctx, p); - notify_files(d, p); return NAUT_OK; } @@ -412,6 +465,40 @@ bool naut_download_in_endgame(const naut_download *d) { return d->endgame; } uint32_t naut_download_num_pieces(const naut_download *d) { return d->num_pieces; } uint32_t naut_download_pieces_done(const naut_download *d) { return d->pieces_done; } uint64_t naut_download_bytes_done(const naut_download *d) { return d->bytes_done; } +void naut_download_dump(const naut_download *d, FILE *out) { + if (!d || !out) return; + fprintf(out, "=== download dump: %u/%u pieces verified, %llu/%llu bytes ===\n", + d->pieces_done, d->num_pieces, + (unsigned long long)d->bytes_done, (unsigned long long)d->total); + fprintf(out, "blocks: %llu/%llu received, active_pieces=%u, endgame=%d\n", + (unsigned long long)d->recv_blocks, + (unsigned long long)d->total_blocks, + d->active_pieces, d->endgame); + + /* Per-piece assembly state for everything not yet verified. The pieces with + * blocks stuck in flight (or none requested at all) are the ones to chase. */ + uint32_t missing = 0, in_progress = 0; + for (uint32_t p = 0; p < d->num_pieces; p++) { + if (naut_bitfield_test(&d->have, p)) continue; + missing++; + pstate *s = d->ps[p]; + if (!s) continue; + in_progress++; + uint32_t requested = 0, idle = 0; + for (uint32_t b = 0; b < s->nblocks; b++) { + if (bget(s->recv_bits, b)) continue; + if (s->req_count[b]) requested++; + else idle++; + } + fprintf(out, + " piece %u: %u/%u blocks in, %u requested, %u not requested%s\n", + p, s->nrecv, s->nblocks, requested, idle, + s->verifying ? ", verifying" : ""); + } + fprintf(out, "incomplete pieces: %u (%u being assembled, %u untouched)\n", + missing, in_progress, missing - in_progress); +} + size_t naut_download_piece_states(const naut_download *d, uint8_t *out, size_t capacity) { if (!d || !out || capacity == 0) return 0; diff --git a/src/script/script.c b/src/script/script.c index 7988633..d72009e 100644 --- a/src/script/script.c +++ b/src/script/script.c @@ -29,8 +29,7 @@ struct naut_script { size_t head; size_t count; bool stopping; - naut_script_move_file_cb move_file; - void *move_context; + naut_script_host host; _Atomic uint64_t queued; _Atomic uint64_t handled; _Atomic uint64_t dropped; @@ -67,12 +66,12 @@ static int lua_move_file(lua_State *lua) { if (torrent_id < 0 || file_index < 0 || (uint64_t)file_index > UINT32_MAX) return luaL_error(lua, "move_file arguments out of range"); - if (!script->move_file) + if (!script->host.move_file) return luaL_error(lua, "move_file is unavailable"); - naut_err error = script->move_file(script->move_context, - (uint64_t)torrent_id, - (uint32_t)file_index, - destination); + naut_err error = script->host.move_file(script->host.context, + (uint64_t)torrent_id, + (uint32_t)file_index, + destination); if (error != NAUT_OK) return luaL_error(lua, "move_file failed: %d", error); atomic_fetch_add_explicit(&script->move_requests, 1, @@ -80,6 +79,98 @@ static int lua_move_file(lua_State *lua) { return 0; } +/* naut.get_labels(torrent_id) -> { "label", ... } (empty table if none). */ +static int lua_get_labels(lua_State *lua) { + naut_script *script = lua_script(lua); + lua_Integer torrent_id = luaL_checkinteger(lua, 1); + if (torrent_id < 0) + return luaL_error(lua, "get_labels: torrent id out of range"); + size_t count = 0; + char **labels = script->host.labels + ? script->host.labels(script->host.context, (uint64_t)torrent_id, &count) + : NULL; + lua_createtable(lua, (int)count, 0); + for (size_t i = 0; i < count; i++) { + lua_pushstring(lua, labels[i]); + lua_rawseti(lua, -2, (int)i + 1); + free(labels[i]); + } + free(labels); + return 1; +} + +/* naut.define_settings({ {key=,label=,type=,default=}, ... }) — declare the + * user-configurable variables this script reads, so the host can render a form + * and persist values. Re-declaring replaces the schema. */ +static int lua_define_settings(lua_State *lua) { + naut_script *script = lua_script(lua); + luaL_checktype(lua, 1, LUA_TTABLE); + if (!script->host.define_settings) return 0; + + size_t count = lua_rawlen(lua, 1); + naut_script_setting_def *defs = + count ? calloc(count, sizeof *defs) : NULL; + /* Stringified defaults need to outlive the per-entry stack churn. */ + char **owned = count ? calloc(count, sizeof *owned) : NULL; + if (count && (!defs || !owned)) { + free(defs); free(owned); + return luaL_error(lua, "define_settings: out of memory"); + } + + size_t n = 0; + for (size_t i = 0; i < count; i++) { + lua_rawgeti(lua, 1, (int)i + 1); /* entry table */ + if (!lua_istable(lua, -1)) { lua_pop(lua, 1); continue; } + lua_getfield(lua, -1, "key"); + const char *key = lua_tostring(lua, -1); + lua_getfield(lua, -2, "label"); + const char *label = lua_tostring(lua, -1); + lua_getfield(lua, -3, "type"); + const char *type = lua_tostring(lua, -1); + lua_getfield(lua, -4, "default"); + const char *defv; + if (lua_isboolean(lua, -1)) + defv = lua_toboolean(lua, -1) ? "true" : "false"; + else + defv = lua_tostring(lua, -1); /* nil -> NULL */ + + if (key) { + defs[n].key = key; /* table strings stay valid while the entry + * table is on the stack (popped after call) */ + defs[n].label = label ? label : key; + defs[n].type = type ? type : "string"; + owned[n] = defv ? strdup(defv) : NULL; + defs[n].default_value = owned[n]; + n++; + } + lua_pop(lua, 5); /* default,type,label,key,entry */ + } + script->host.define_settings(script->host.context, defs, n); + for (size_t i = 0; i < count; i++) free(owned[i]); + free(owned); + free(defs); + return 0; +} + +/* naut.get_setting(key) -> value (typed) or nil. */ +static int lua_get_setting(lua_State *lua) { + naut_script *script = lua_script(lua); + const char *key = luaL_checkstring(lua, 1); + if (!script->host.get_setting) { lua_pushnil(lua); return 1; } + naut_setting_type type = NAUT_SETTING_STRING; + char *value = script->host.get_setting(script->host.context, key, &type); + if (!value) { lua_pushnil(lua); return 1; } + if (type == NAUT_SETTING_BOOL) + lua_pushboolean(lua, strcmp(value, "true") == 0 || + strcmp(value, "1") == 0); + else if (type == NAUT_SETTING_NUMBER) + lua_pushnumber(lua, strtod(value, NULL)); + else + lua_pushstring(lua, value); + free(value); + return 1; +} + static void sandbox(lua_State *lua) { /* Remove every documented route to the filesystem, subprocesses, native * module loading, and raw chunk compilation. `load`/`loadstring` are @@ -103,6 +194,15 @@ static void install_api(naut_script *script) { lua_pushlightuserdata(lua, script); lua_pushcclosure(lua, lua_move_file, 1); lua_setfield(lua, -2, "move_file"); + lua_pushlightuserdata(lua, script); + lua_pushcclosure(lua, lua_get_labels, 1); + lua_setfield(lua, -2, "get_labels"); + lua_pushlightuserdata(lua, script); + lua_pushcclosure(lua, lua_define_settings, 1); + lua_setfield(lua, -2, "define_settings"); + lua_pushlightuserdata(lua, script); + lua_pushcclosure(lua, lua_get_setting, 1); + lua_setfield(lua, -2, "get_setting"); lua_setglobal(lua, "naut"); } @@ -206,8 +306,7 @@ static void queue_event(void *opaque, const naut_event *event) { naut_script *naut_script_create(naut_event_bus *events, const char *script_path, size_t queue_capacity, - naut_script_move_file_cb move_file, - void *move_context, + const naut_script_host *host, naut_err *error) { if (error) *error = NAUT_ERR_INVAL; if (!events || !script_path || !*script_path || queue_capacity == 0) @@ -219,8 +318,7 @@ naut_script *naut_script_create(naut_event_bus *events, } script->events = events; script->capacity = queue_capacity; - script->move_file = move_file; - script->move_context = move_context; + if (host) script->host = *host; script->queue = calloc(queue_capacity, sizeof(*script->queue)); if (!script->queue) { if (error) *error = NAUT_ERR_NOMEM; diff --git a/src/storage/storage.c b/src/storage/storage.c index 29a7118..44b1f72 100644 --- a/src/storage/storage.c +++ b/src/storage/storage.c @@ -62,11 +62,24 @@ naut_storage *naut_storage_open_opts(const naut_file *files, size_t nfiles, s->files[i].fd = -1; s->files[i].direct_fd = -1; char path[4096]; - int n = snprintf(path, sizeof path, "%s/%s", root, files[i].path); + const char *override = opts->overrides ? opts->overrides[i] : NULL; + int n = override + ? snprintf(path, sizeof path, "%s", override) + : snprintf(path, sizeof path, "%s/%s", root, files[i].path); if (n < 0 || n >= (int)sizeof path) goto fail_io; if (make_parents(path) != NAUT_OK) goto fail_io; int fd = open(path, O_RDWR | O_CREAT, 0666); + if (fd < 0 && override) { + /* The relocated copy is gone (e.g. external drive absent); fall back + * to the default location and let resume re-download it. */ + NAUT_WARN("open relocated %s: %s; falling back to %s root", + path, strerror(errno), files[i].path); + n = snprintf(path, sizeof path, "%s/%s", root, files[i].path); + if (n < 0 || n >= (int)sizeof path || make_parents(path) != NAUT_OK) + goto fail_io; + fd = open(path, O_RDWR | O_CREAT, 0666); + } if (fd < 0) { NAUT_ERROR("open %s: %s", path, strerror(errno)); goto fail_io; } if (ftruncate(fd, files[i].length) != 0) { NAUT_ERROR("ftruncate %s: %s", path, strerror(errno)); diff --git a/src/tracker/fetch.c b/src/tracker/fetch.c deleted file mode 100644 index ac52faa..0000000 --- a/src/tracker/fetch.c +++ /dev/null @@ -1,162 +0,0 @@ -#include "naut/tracker.h" -#include "naut/log.h" - -#include <errno.h> -#include <netdb.h> -#include <stdio.h> -#include <stdlib.h> -#include <string.h> -#include <time.h> -#include <unistd.h> -#include <sys/socket.h> -#include <sys/time.h> - -#define TRACKER_RESPONSE_MAX (16u << 20) - -static int dial(const char *host, const char *port, int socktype) { - struct addrinfo hints, *res = NULL, *ai; - memset(&hints, 0, sizeof hints); - hints.ai_family = AF_INET; /* IPv4 for now (compact peers are v4) */ - hints.ai_socktype = socktype; - if (getaddrinfo(host, port, &hints, &res) != 0) return -1; - int fd = -1; - for (ai = res; ai; ai = ai->ai_next) { - fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); - if (fd < 0) continue; - struct timeval tv = { .tv_sec = 10, .tv_usec = 0 }; - setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); - if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) break; - close(fd); fd = -1; - } - freeaddrinfo(res); - return fd; -} - -/* split "http://host[:port]/path" */ -static bool parse_http_url(const char *url, char *host, size_t hostsz, - char *port, size_t portsz, const char **path) { - if (strncmp(url, "http://", 7) != 0) return false; - const char *h = url + 7; - const char *slash = strchr(h, '/'); - const char *hostend = slash ? slash : h + strlen(h); - const char *colon = memchr(h, ':', (size_t)(hostend - h)); - size_t hlen = colon ? (size_t)(colon - h) : (size_t)(hostend - h); - if (hlen >= hostsz) return false; - memcpy(host, h, hlen); host[hlen] = 0; - if (colon) { - size_t plen = (size_t)(hostend - colon - 1); - if (plen >= portsz) return false; - memcpy(port, colon + 1, plen); port[plen] = 0; - } else { snprintf(port, portsz, "80"); } - *path = slash ? slash : "/"; - return true; -} - -static bool write_all(int fd, const void *data, size_t len) { - const uint8_t *p = data; - while (len) { - ssize_t n = write(fd, p, len); - if (n < 0) { - if (errno == EINTR) continue; - return false; - } - p += (size_t)n; - len -= (size_t)n; - } - return true; -} - -naut_err naut_tracker_announce_http(const char *url, naut_tracker_response *out) { - char host[256], port[16]; const char *path; - if (!parse_http_url(url, host, sizeof host, port, sizeof port, &path)) - return NAUT_ERR_INVAL; - int fd = dial(host, port, SOCK_STREAM); - if (fd < 0) { NAUT_WARN("tracker connect %s:%s failed", host, port); return NAUT_ERR_IO; } - - char req[2048]; - int rn = snprintf(req, sizeof req, - "GET %s HTTP/1.0\r\nHost: %s\r\nUser-Agent: Naut/0.1\r\nAccept: */*\r\n\r\n", - path, host); - if (rn < 0 || (size_t)rn >= sizeof req || - !write_all(fd, req, (size_t)rn)) { - close(fd); - return NAUT_ERR_IO; - } - - /* read whole response (server closes on HTTP/1.0) */ - size_t cap = 1 << 16, len = 0; - uint8_t *buf = malloc(cap); - if (!buf) { close(fd); return NAUT_ERR_NOMEM; } - naut_err read_error = NAUT_OK; - for (;;) { - if (len == cap) { - if (cap == TRACKER_RESPONSE_MAX) { - read_error = NAUT_ERR_FULL; - break; - } - size_t next_cap = NAUT_MIN(cap * 2, (size_t)TRACKER_RESPONSE_MAX); - uint8_t *next = realloc(buf, next_cap); - if (!next) { - read_error = NAUT_ERR_NOMEM; - break; - } - buf = next; - cap = next_cap; - } - ssize_t r = read(fd, buf + len, cap - len); - if (r < 0) { - if (errno == EINTR) continue; - read_error = NAUT_ERR_IO; - break; - } - if (r == 0) break; - len += (size_t)r; - } - close(fd); - if (read_error != NAUT_OK) { - free(buf); - return read_error; - } - - /* find body after CRLFCRLF */ - uint8_t *body = NULL; size_t blen = 0; - for (size_t i = 0; i + 3 < len; i++) - if (buf[i]=='\r'&&buf[i+1]=='\n'&&buf[i+2]=='\r'&&buf[i+3]=='\n') { - body = buf + i + 4; blen = len - (i + 4); break; - } - bool success = len >= 12 && memcmp(buf, "HTTP/", 5) == 0 && - buf[9] == '2'; - naut_err e = success && body - ? naut_tracker_parse_http(body, blen, out) - : NAUT_ERR_PROTO; - free(buf); - return e; -} - -naut_err naut_tracker_announce_udp(const char *host, uint16_t port, - const naut_announce_req *req, - naut_tracker_response *out) { - char portstr[16]; snprintf(portstr, sizeof portstr, "%u", port); - int fd = dial(host, portstr, SOCK_DGRAM); - if (fd < 0) return NAUT_ERR_IO; - - srand((unsigned)time(NULL) ^ (unsigned)getpid()); - uint32_t txid = (uint32_t)rand(); - - uint8_t pkt[98], resp[1500]; - naut_udp_build_connect(pkt, txid); - if (write(fd, pkt, 16) != 16) { close(fd); return NAUT_ERR_IO; } - ssize_t r = read(fd, resp, sizeof resp); - uint64_t cid; - if (r < 0 || naut_udp_parse_connect(resp, (size_t)r, txid, &cid) != NAUT_OK) { - close(fd); return NAUT_ERR_IO; - } - txid++; - naut_udp_build_announce(pkt, cid, txid, req); - if (write(fd, pkt, 98) != 98) { close(fd); return NAUT_ERR_IO; } - r = read(fd, resp, sizeof resp); - naut_err e = (r < 0) ? NAUT_ERR_IO - : naut_udp_parse_announce(resp, (size_t)r, txid, out); - close(fd); - return e; -} diff --git a/src/tracker/tracker.c b/src/tracker/tracker.c deleted file mode 100644 index cf6be74..0000000 --- a/src/tracker/tracker.c +++ /dev/null @@ -1,120 +0,0 @@ -#include "naut/tracker.h" -#include "naut/bencode.h" - -#include <stdio.h> -#include <stdlib.h> -#include <string.h> - -void naut_tracker_response_free(naut_tracker_response *r) { - free(r->peers); r->peers = NULL; r->num_peers = 0; - free(r->failure); r->failure = NULL; -} - -/* percent-encode raw bytes per RFC 3986 (unreserved chars pass through) */ -static size_t pct_encode(const uint8_t *in, size_t n, char *out, size_t outsz) { - static const char hx[] = "0123456789ABCDEF"; - size_t o = 0; - for (size_t i = 0; i < n; i++) { - uint8_t c = in[i]; - bool unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || - (c >= '0' && c <= '9') || c == '-' || c == '_' || - c == '.' || c == '~'; - if (unreserved) { - if (o + 1 >= outsz) return 0; - out[o++] = (char)c; - } else { - if (o + 3 >= outsz) return 0; - out[o++] = '%'; out[o++] = hx[c >> 4]; out[o++] = hx[c & 15]; - } - } - return o; -} - -size_t naut_tracker_http_url(const char *base, const naut_announce_req *req, - char *out, size_t outsz) { - static const char *ev[] = { "", "completed", "started", "stopped" }; - if (req->event < NAUT_TEV_NONE || req->event > NAUT_TEV_STOPPED) return 0; - char ih[61], pid[61]; /* 20*3 = 60 worst case + NUL */ - size_t ihn = pct_encode(req->info_hash, 20, ih, sizeof ih); - size_t pidn = pct_encode(req->peer_id, 20, pid, sizeof pid); - if (!ihn || !pidn) return 0; - ih[ihn] = 0; pid[pidn] = 0; - - const char *sep = strchr(base, '?') ? "&" : "?"; - int n = snprintf(out, outsz, - "%s%sinfo_hash=%s&peer_id=%s&port=%u&uploaded=%llu&downloaded=%llu" - "&left=%llu&compact=1&numwant=%d%s%s&key=%u", - base, sep, ih, pid, req->port, - (unsigned long long)req->uploaded, (unsigned long long)req->downloaded, - (unsigned long long)req->left, req->numwant < 0 ? 50 : req->numwant, - req->event ? "&event=" : "", ev[req->event], req->key); - if (n < 0 || (size_t)n >= outsz) return 0; - return (size_t)n; -} - -static naut_err parse_peers(const naut_bc *peers, naut_tracker_response *out) { - const uint8_t *p; size_t n; - if (naut_bc_get_str(peers, &p, &n)) { /* compact: 6 bytes each */ - if (n % 6 != 0) return NAUT_ERR_PROTO; - out->num_peers = n / 6; - out->peers = calloc(out->num_peers ? out->num_peers : 1, sizeof(naut_peer_addr)); - if (!out->peers) return NAUT_ERR_NOMEM; - for (size_t i = 0; i < out->num_peers; i++) { - memcpy(out->peers[i].ip, p + i*6, 4); - out->peers[i].port = ((uint16_t)p[i*6+4] << 8) | p[i*6+5]; - } - return NAUT_OK; - } - if (peers && peers->type == NAUT_BC_LIST) { /* dict form */ - out->peers = calloc(peers->v.list.count ? peers->v.list.count : 1, sizeof(naut_peer_addr)); - if (!out->peers) return NAUT_ERR_NOMEM; - for (size_t i = 0; i < peers->v.list.count; i++) { - const naut_bc *pe = naut_bc_list_at(peers, i); - const uint8_t *ips; size_t ipn; int64_t port; - if (!naut_bc_get_str(naut_bc_dict_get(pe, "ip"), &ips, &ipn)) continue; - if (!naut_bc_get_int(naut_bc_dict_get(pe, "port"), &port)) continue; - unsigned a, b, c, dd; - char tmp[64]; - if (ipn >= sizeof tmp) continue; - memcpy(tmp, ips, ipn); tmp[ipn] = 0; - if (sscanf(tmp, "%u.%u.%u.%u", &a, &b, &c, &dd) != 4) continue; - if (a > 255 || b > 255 || c > 255 || dd > 255 || - port <= 0 || port > UINT16_MAX) continue; - naut_peer_addr *pa = &out->peers[out->num_peers++]; - pa->ip[0]=(uint8_t)a; pa->ip[1]=(uint8_t)b; pa->ip[2]=(uint8_t)c; pa->ip[3]=(uint8_t)dd; - pa->port = (uint16_t)port; - } - return NAUT_OK; - } - return NAUT_ERR_PROTO; -} - -naut_err naut_tracker_parse_http(const uint8_t *body, size_t len, - naut_tracker_response *out) { - memset(out, 0, sizeof(*out)); - out->seeders = out->leechers = -1; - naut_bc_doc *doc = NULL; - naut_err e = naut_bc_parse(body, len, &doc); - if (e != NAUT_OK) return e; - const naut_bc *root = naut_bc_root(doc); - - const uint8_t *fp; size_t fn; - if (naut_bc_get_str(naut_bc_dict_get(root, "failure reason"), &fp, &fn)) { - out->failure = malloc(fn + 1); - if (out->failure) { memcpy(out->failure, fp, fn); out->failure[fn] = 0; } - naut_bc_free(doc); - return NAUT_ERR_PROTO; /* tracker reported failure */ - } - - int64_t iv = 0; - naut_bc_get_int(naut_bc_dict_get(root, "interval"), &iv); - out->interval = (int32_t)iv; - int64_t sc; - if (naut_bc_get_int(naut_bc_dict_get(root, "complete"), &sc)) out->seeders = (int32_t)sc; - if (naut_bc_get_int(naut_bc_dict_get(root, "incomplete"), &sc)) out->leechers = (int32_t)sc; - - e = parse_peers(naut_bc_dict_get(root, "peers"), out); - naut_bc_free(doc); - if (e != NAUT_OK) { naut_tracker_response_free(out); return e; } - return NAUT_OK; -} diff --git a/src/tracker/udp.c b/src/tracker/udp.c deleted file mode 100644 index adb7c26..0000000 --- a/src/tracker/udp.c +++ /dev/null @@ -1,81 +0,0 @@ -#include "naut/tracker.h" -#include <stdlib.h> -#include <string.h> - -#define UDP_PROTOCOL_ID 0x41727101980ULL /* BEP-15 magic */ -#define ACTION_CONNECT 0 -#define ACTION_ANNOUNCE 1 -#define ACTION_ERROR 3 - -static void wr16(uint8_t *p, uint16_t v) { p[0]=(uint8_t)(v>>8); p[1]=(uint8_t)v; } -static void wr32(uint8_t *p, uint32_t v) { - p[0]=(uint8_t)(v>>24); p[1]=(uint8_t)(v>>16); p[2]=(uint8_t)(v>>8); p[3]=(uint8_t)v; -} -static void wr64(uint8_t *p, uint64_t v) { wr32(p, (uint32_t)(v>>32)); wr32(p+4, (uint32_t)v); } -static uint32_t rd32(const uint8_t *p) { - return ((uint32_t)p[0]<<24)|((uint32_t)p[1]<<16)|((uint32_t)p[2]<<8)|p[3]; -} -static uint64_t rd64(const uint8_t *p) { return ((uint64_t)rd32(p)<<32) | rd32(p+4); } - -void naut_udp_build_connect(uint8_t out[16], uint32_t txid) { - wr64(out, UDP_PROTOCOL_ID); - wr32(out + 8, ACTION_CONNECT); - wr32(out + 12, txid); -} - -naut_err naut_udp_parse_connect(const uint8_t *in, size_t len, uint32_t txid, - uint64_t *connection_id) { - if (len < 16) return NAUT_ERR_PROTO; - if (rd32(in) != ACTION_CONNECT) return NAUT_ERR_PROTO; - if (rd32(in + 4) != txid) return NAUT_ERR_PROTO; - *connection_id = rd64(in + 8); - return NAUT_OK; -} - -void naut_udp_build_announce(uint8_t out[98], uint64_t connection_id, - uint32_t txid, const naut_announce_req *req) { - wr64(out + 0, connection_id); - wr32(out + 8, ACTION_ANNOUNCE); - wr32(out + 12, txid); - memcpy(out + 16, req->info_hash, 20); - memcpy(out + 36, req->peer_id, 20); - wr64(out + 56, req->downloaded); - wr64(out + 64, req->left); - wr64(out + 72, req->uploaded); - wr32(out + 80, (uint32_t)req->event); - wr32(out + 84, 0); /* IP: 0 = source */ - wr32(out + 88, req->key); - wr32(out + 92, (uint32_t)(req->numwant < 0 ? 50 : req->numwant)); - wr16(out + 96, req->port); -} - -naut_err naut_udp_parse_announce(const uint8_t *in, size_t len, uint32_t txid, - naut_tracker_response *out) { - memset(out, 0, sizeof(*out)); - out->seeders = out->leechers = -1; - if (len < 8) return NAUT_ERR_PROTO; - uint32_t action = rd32(in); - if (rd32(in + 4) != txid) return NAUT_ERR_PROTO; - if (action == ACTION_ERROR) { - size_t mn = len - 8; - out->failure = malloc(mn + 1); - if (out->failure) { memcpy(out->failure, in + 8, mn); out->failure[mn] = 0; } - return NAUT_ERR_PROTO; - } - if (action != ACTION_ANNOUNCE || len < 20 || (len - 20) % 6 != 0) - return NAUT_ERR_PROTO; - out->interval = (int32_t)rd32(in + 8); - out->leechers = (int32_t)rd32(in + 12); - out->seeders = (int32_t)rd32(in + 16); - - size_t avail = (len - 20) / 6; - out->peers = calloc(avail ? avail : 1, sizeof(naut_peer_addr)); - if (!out->peers) return NAUT_ERR_NOMEM; - for (size_t i = 0; i < avail; i++) { - const uint8_t *p = in + 20 + i*6; - memcpy(out->peers[i].ip, p, 4); - out->peers[i].port = ((uint16_t)p[4] << 8) | p[5]; - } - out->num_peers = avail; - return NAUT_OK; -} diff --git a/tests/fixtures/phase7.lua b/tests/fixtures/phase7.lua index 3f931a5..fddfea1 100644 --- a/tests/fixtures/phase7.lua +++ b/tests/fixtures/phase7.lua @@ -3,5 +3,8 @@ function on_torrent_finished(event) end function on_file_complete(event) - naut.move_file(event.torrent_id, event.index, event.path .. ".moved") + -- Labels surface to Lua as a plain array of strings; use the first to route. + local labels = naut.get_labels(event.torrent_id) + local suffix = labels[1] or "moved" + naut.move_file(event.torrent_id, event.index, event.path .. "." .. suffix) end diff --git a/tests/integration/run_phase7.sh b/tests/integration/run_phase7.sh index 0635639..994bee5 100644 --- a/tests/integration/run_phase7.sh +++ b/tests/integration/run_phase7.sh @@ -54,7 +54,7 @@ for _ in $(seq 1 100); do done [[ -n "$port" && "$port" != 0 ]] -"$daemon" --socket "$socket" --plugin "$plugin" \ +"$daemon" --socket "$socket" --plugin "$plugin" --state-dir "$tmp/state" \ >"$daemon_log" 2>&1 & daemon_pid=$! diff --git a/tests/unit/test_dht.c b/tests/unit/test_dht.c deleted file mode 100644 index c8370d1..0000000 --- a/tests/unit/test_dht.c +++ /dev/null @@ -1,98 +0,0 @@ -#include "naut/bencode.h" -#include "naut/dht.h" -#include "test.h" - -#include <stdlib.h> -#include <string.h> - -static void check_get_peers_query(void) { - uint8_t tx[2] = { 0x12, 0x34 }; - uint8_t id[20], hash[20]; - for (size_t i = 0; i < 20; i++) { - id[i] = (uint8_t)i; - hash[i] = (uint8_t)(0x80 + i); - } - - uint8_t *query = NULL; - size_t query_len = 0; - CHECK(naut_dht_build_get_peers(tx, sizeof tx, id, hash, - &query, &query_len) == NAUT_OK); - - naut_bc_doc *doc = NULL; - CHECK(naut_bc_parse(query, query_len, &doc) == NAUT_OK); - const naut_bc *root = naut_bc_root(doc); - CHECK(naut_bc_str_eq(naut_bc_dict_get(root, "y"), "q")); - CHECK(naut_bc_str_eq(naut_bc_dict_get(root, "q"), "get_peers")); - - const naut_bc *args = naut_bc_dict_get(root, "a"); - const uint8_t *p = NULL; - size_t n = 0; - CHECK(naut_bc_get_str(naut_bc_dict_get(args, "id"), &p, &n)); - CHECK(n == 20 && memcmp(p, id, 20) == 0); - CHECK(naut_bc_get_str(naut_bc_dict_get(args, "info_hash"), &p, &n)); - CHECK(n == 20 && memcmp(p, hash, 20) == 0); - - naut_bc_free(doc); - free(query); -} - -static void check_response(void) { - uint8_t packet[256]; - size_t len = 0; - const char *prefix = "d1:rd2:id20:"; - memcpy(packet + len, prefix, strlen(prefix)); - len += strlen(prefix); - for (size_t i = 0; i < 20; i++) packet[len++] = (uint8_t)(0x20 + i); - - const char *nodes = "5:nodes26:"; - memcpy(packet + len, nodes, strlen(nodes)); - len += strlen(nodes); - for (size_t i = 0; i < 20; i++) packet[len++] = (uint8_t)(0x40 + i); - packet[len++] = 192; packet[len++] = 0; packet[len++] = 2; packet[len++] = 9; - packet[len++] = 0x1a; packet[len++] = 0xe1; - - const char *suffix = "5:token3:abc6:valuesl6:"; - memcpy(packet + len, suffix, strlen(suffix)); - len += strlen(suffix); - packet[len++] = 203; packet[len++] = 0; packet[len++] = 113; packet[len++] = 7; - packet[len++] = 0xc8; packet[len++] = 0xd5; - const char *tail = "ee1:t2:aa1:y1:re"; - memcpy(packet + len, tail, strlen(tail)); - len += strlen(tail); - - naut_dht_response response; - CHECK(naut_dht_parse_response(packet, len, &response) == NAUT_OK); - CHECK(response.type == NAUT_DHT_RESPONSE); - CHECK(response.transaction_len == 2 && - memcmp(response.transaction, "aa", 2) == 0); - CHECK(response.has_id && response.id[0] == 0x20); - CHECK(response.token_len == 3 && - memcmp(response.token, "abc", 3) == 0); - CHECK(response.num_nodes == 1); - CHECK(response.nodes[0].ip[0] == 192 && - response.nodes[0].port == 6881); - CHECK(response.num_peers == 1); - CHECK(response.peers[0].ip[0] == 203 && - response.peers[0].port == 51413); - naut_dht_response_free(&response); -} - -int main(void) { - check_get_peers_query(); - check_response(); - - const char error[] = "d1:eli203e12:Server errore1:t2:zz1:y1:ee"; - naut_dht_response response; - CHECK(naut_dht_parse_response((const uint8_t *)error, sizeof error - 1, - &response) == NAUT_OK); - CHECK(response.type == NAUT_DHT_ERROR && response.error_code == 203); - naut_dht_response_free(&response); - - const char malformed[] = - "d1:rd2:id20:abcdefghijklmnopqrst5:nodes1:xe1:t1:a1:y1:re"; - CHECK(naut_dht_parse_response((const uint8_t *)malformed, - sizeof malformed - 1, - &response) == NAUT_ERR_PROTO); - - TEST_MAIN_END(); -} diff --git a/tests/unit/test_download.c b/tests/unit/test_download.c index 51ab06e..1868a30 100644 --- a/tests/unit/test_download.c +++ b/tests/unit/test_download.c @@ -69,6 +69,20 @@ int main(void) { CHECK(got && glen == olen && memcmp(got, orig, olen) == 0); free(got); + /* Existing verified data should be reflected in progress before any peer + * requests are made. */ + st = naut_storage_open(mi.files, mi.num_files, root, &err); + CHECK(st && err == NAUT_OK); + d = naut_download_create(&mi, st); + CHECK(d != NULL); + CHECK(naut_download_resume(d) == NAUT_OK); + CHECK(naut_download_complete(d)); + CHECK_EQ(naut_download_pieces_done(d), naut_download_num_pieces(d)); + CHECK_EQ((long long)naut_download_bytes_done(d), (long long)olen); + CHECK(!naut_download_next_request(d, &idx, &begin, &len)); + naut_download_destroy(d); + naut_storage_close(st); + /* The same path with SHA-1 verification offloaded to bounded workers. */ { char t2[] = "/tmp/naut_async_XXXXXX"; diff --git a/tests/unit/test_filemove.c b/tests/unit/test_filemove.c index 8b2b79b..7d3560f 100644 --- a/tests/unit/test_filemove.c +++ b/tests/unit/test_filemove.c @@ -108,6 +108,22 @@ int main(void) { memcmp(got1, global + mi.files[0].length, l1) == 0); free(got1); + /* Simulate a daemon restart: file 0 now lives at `dest`, not under `root`. + * Reopening with its saved location as an override must pick it up in place + * so resume verifies every piece -- if the override were ignored, file 0 + * would open as an empty placeholder under root and resume would fail. */ + const char *overrides[2] = { dest, NULL }; + naut_storage_opts ropts = { .preallocate = true, .overrides = overrides }; + naut_storage *st2 = + naut_storage_open_opts(mi.files, mi.num_files, root, &ropts, &err); + CHECK(st2 && err == NAUT_OK); + naut_download *d2 = naut_download_create(&mi, st2); + CHECK(d2 != NULL); + CHECK(naut_download_resume(d2) == NAUT_OK); + CHECK(naut_download_complete(d2)); /* both files verified from their homes */ + naut_download_destroy(d2); + naut_storage_close(st2); + naut_download_destroy(d); free(global); free(tor); naut_metainfo_free(&mi); char cmd[600]; snprintf(cmd, sizeof cmd, "rm -rf '%s' '%s'", root, destdir); diff --git a/tests/unit/test_mse.c b/tests/unit/test_mse.c deleted file mode 100644 index abb6849..0000000 --- a/tests/unit/test_mse.c +++ /dev/null @@ -1,56 +0,0 @@ -/* Drives the MSE handshake state machine without a socket. Full-handshake - * correctness is proven against libtorrent in interop_mse; this guards the - * sans-IO plumbing (state transitions, fragmented pull, DH validation) so it - * stays covered even where libtorrent is unavailable. */ -#include "naut/mse.h" -#include "test.h" -#include <string.h> - -int main(void) { - uint8_t info_hash[20], peer_id[NAUT_PEERID_LEN]; - memset(info_hash, 0xAB, sizeof info_hash); - memset(peer_id, 0xCD, sizeof peer_id); - - /* begin → must want to write its 96-byte public key first. */ - naut_mse_handshake *h = naut_mse_handshake_begin(info_hash, peer_id, 0); - CHECK(h != NULL); - CHECK_EQ(naut_mse_handshake_status(h), NAUT_MSE_HS_NEED_WRITE); - - /* Drain the public key one byte at a time; it must be exactly 96 bytes, - * after which the machine flips to waiting for the peer's key. */ - uint8_t pub[128]; - size_t total = 0, n; - while ((n = naut_mse_handshake_pull(h, pub + total, 1)) > 0) total += n; - CHECK_EQ((int)total, NAUT_MSE_DH_LEN); - CHECK_EQ(naut_mse_handshake_status(h), NAUT_MSE_HS_NEED_READ); - /* A real DH public key is never all-zero. */ - uint8_t zero[NAUT_MSE_DH_LEN] = {0}; - CHECK(memcmp(pub, zero, NAUT_MSE_DH_LEN) != 0); - - /* finish() before completion must refuse rather than hand out junk. */ - naut_mse_stream stream; - uint8_t remote_hs[NAUT_HANDSHAKE_LEN]; - CHECK_EQ(naut_mse_handshake_finish(h, &stream, remote_hs), NAUT_ERR_AGAIN); - - /* Feed an invalid (zero) peer public key fragmented across calls; the DH - * validation must reject it (0 < 2) and latch the error state. */ - size_t consumed_total = 0; - naut_mse_hs_status st = NAUT_MSE_HS_NEED_READ; - for (int i = 0; i < NAUT_MSE_DH_LEN; i++) { - size_t consumed = 0; - uint8_t b = 0; - st = naut_mse_handshake_feed(h, &b, 1, &consumed); - consumed_total += consumed; - if (st == NAUT_MSE_HS_ERROR) break; - } - CHECK_EQ(st, NAUT_MSE_HS_ERROR); - CHECK(consumed_total <= NAUT_MSE_DH_LEN); - CHECK_EQ(naut_mse_handshake_finish(h, &stream, remote_hs), NAUT_ERR_PROTO); - naut_mse_handshake_free(h); - - /* Bad arguments are rejected, not crashed on. */ - CHECK(naut_mse_handshake_begin(NULL, peer_id, 0) == NULL); - CHECK(naut_mse_handshake_begin(info_hash, NULL, 0) == NULL); - - TEST_MAIN_END(); -} diff --git a/tests/unit/test_pipeline.c b/tests/unit/test_pipeline.c deleted file mode 100644 index 220fcb8..0000000 --- a/tests/unit/test_pipeline.c +++ /dev/null @@ -1,30 +0,0 @@ -#include "naut/pipeline.h" -#include "test.h" - -int main(void) { - naut_pipeline pipeline; - naut_pipeline_init(&pipeline, NAUT_BLOCK, 4, 1024, 32); - CHECK_EQ(naut_pipeline_depth(&pipeline), 32); - - /* 16 KiB every 100 us with 20 ms RTT is about 164 MB/s and a 200-block - * BDP. Repeated samples should grow the window substantially. */ - double now = 1.0; - for (int i = 0; i < 100; i++) { - now += 0.0001; - naut_pipeline_on_block(&pipeline, NAUT_BLOCK, now - 0.020, now); - } - CHECK(naut_pipeline_depth(&pipeline) > 128); - CHECK(naut_pipeline_depth(&pipeline) <= 1024); - - uint32_t high = naut_pipeline_depth(&pipeline); - for (int i = 0; i < 100; i++) { - now += 0.050; - naut_pipeline_on_block(&pipeline, NAUT_BLOCK, now - 0.005, now); - } - CHECK(naut_pipeline_depth(&pipeline) < high); - CHECK(naut_pipeline_depth(&pipeline) >= 4); - - naut_pipeline_init(&pipeline, 0, 0, 0, 0); - CHECK_EQ(naut_pipeline_depth(&pipeline), 1); - TEST_MAIN_END(); -} diff --git a/tests/unit/test_script.c b/tests/unit/test_script.c index 4495b6d..a131248 100644 --- a/tests/unit/test_script.c +++ b/tests/unit/test_script.c @@ -3,6 +3,7 @@ #include <pthread.h> #include <stdatomic.h> +#include <stdlib.h> #include <string.h> #include <unistd.h> @@ -30,13 +31,29 @@ static naut_err capture_move(void *opaque, uint64_t torrent_id, return NAUT_OK; } +/* Hand the script a single label "anime" so the fixture can route on it. */ +static char **capture_labels(void *opaque, uint64_t torrent_id, size_t *count) { + (void)opaque; + (void)torrent_id; + char **labels = malloc(sizeof *labels); + if (!labels) { *count = 0; return NULL; } + labels[0] = strdup("anime"); + *count = labels[0] ? 1 : 0; + return labels; +} + int main(void) { pthread_t owner = pthread_self(); move_capture capture = {0}; naut_event_bus *events = naut_event_bus_create(); naut_err error; + naut_script_host host = { + .move_file = capture_move, + .labels = capture_labels, + .context = &capture, + }; naut_script *script = naut_script_create( - events, NAUT_PHASE7_SCRIPT, 8, capture_move, &capture, &error); + events, NAUT_PHASE7_SCRIPT, 8, &host, &error); CHECK(script && error == NAUT_OK); naut_event event = { @@ -65,7 +82,8 @@ int main(void) { CHECK(!pthread_equal(owner, capture.caller)); CHECK_EQ(capture.torrent_id, 42); CHECK_EQ(capture.file_index, 3); - CHECK(strcmp(capture.destination, "/tmp/completed-file.moved") == 0); + /* Proves naut.get_labels surfaced the label string into Lua. */ + CHECK(strcmp(capture.destination, "/tmp/completed-file.anime") == 0); naut_script_stats stats; naut_script_get_stats(script, &stats); diff --git a/tests/unit/test_tracker.c b/tests/unit/test_tracker.c deleted file mode 100644 index 0d6cf2c..0000000 --- a/tests/unit/test_tracker.c +++ /dev/null @@ -1,115 +0,0 @@ -#include "naut/tracker.h" -#include "naut/bencode.h" -#include "test.h" -#include <string.h> - -int main(void) { - naut_announce_req req; - memset(&req, 0, sizeof req); - for (int i = 0; i < 20; i++) { req.info_hash[i] = (uint8_t)i; req.peer_id[i] = (uint8_t)(0x80 + i); } - req.port = 6881; req.left = 1000; req.numwant = -1; req.key = 0xdeadbeef; - req.event = NAUT_TEV_STARTED; - - /* --- HTTP announce URL --- */ - char url[1024]; - size_t n = naut_tracker_http_url("http://t.example/announce", &req, url, sizeof url); - CHECK(n > 0); - CHECK(strstr(url, "info_hash=%00%01%02") != NULL); /* binary pct-encoded */ - CHECK(strstr(url, "port=6881") != NULL); - CHECK(strstr(url, "compact=1") != NULL); - CHECK(strstr(url, "event=started") != NULL); - /* base already having a query uses '&' */ - naut_tracker_http_url("http://t.example/announce?x=1", &req, url, sizeof url); - CHECK(strstr(url, "announce?x=1&info_hash=") != NULL); - req.event = (naut_tracker_event)99; - CHECK(naut_tracker_http_url("http://t.example/announce", &req, - url, sizeof url) == 0); - req.event = NAUT_TEV_STARTED; - - /* --- HTTP response parse: compact peers --- */ - { - /* d8:intervali1800e5:peers12:<two 6-byte peers>e */ - uint8_t body[128]; size_t b = 0; - const char *pre = "d8:intervali1800e8:completei5e10:incompletei2e5:peers12:"; - memcpy(body, pre, strlen(pre)); b = strlen(pre); - uint8_t peers[12] = { 1,2,3,4, 0x1a,0xe1, 10,0,0,1, 0x1a,0xe2 }; - memcpy(body + b, peers, 12); b += 12; - body[b++] = 'e'; - - naut_tracker_response r; - CHECK(naut_tracker_parse_http(body, b, &r) == NAUT_OK); - CHECK_EQ(r.interval, 1800); - CHECK_EQ(r.seeders, 5); - CHECK_EQ(r.leechers, 2); - CHECK_EQ(r.num_peers, 2); - CHECK(r.peers[0].ip[0]==1 && r.peers[0].ip[3]==4 && r.peers[0].port==0x1ae1); - CHECK(r.peers[1].ip[0]==10 && r.peers[1].port==0x1ae2); - naut_tracker_response_free(&r); - } - - /* --- failure reason --- */ - { - const char *body = "d14:failure reason17:torrent not founde"; - naut_tracker_response r; - CHECK(naut_tracker_parse_http((const uint8_t *)body, strlen(body), &r) == NAUT_ERR_PROTO); - CHECK(r.failure && strcmp(r.failure, "torrent not found") == 0); - naut_tracker_response_free(&r); - } - - /* --- UDP connect codec --- */ - { - uint8_t pkt[98]; - naut_udp_build_connect(pkt, 0x11223344); - /* protocol id 0x41727101980, action 0, txid */ - CHECK(pkt[0]==0 && pkt[1]==0 && pkt[2]==0x04 && pkt[3]==0x17 && - pkt[4]==0x27 && pkt[5]==0x10 && pkt[6]==0x19 && pkt[7]==0x80); - CHECK(pkt[8]==0 && pkt[11]==0); /* action connect */ - CHECK(pkt[12]==0x11 && pkt[15]==0x44); /* txid */ - - /* build a fake connect response and parse it */ - uint8_t resp[16] = {0}; - resp[3] = 0; /* action connect */ - resp[4]=0x11; resp[5]=0x22; resp[6]=0x33; resp[7]=0x44; /* txid */ - for (int i = 0; i < 8; i++) resp[8+i] = (uint8_t)(0xA0 + i); /* conn id */ - uint64_t cid = 0; - CHECK(naut_udp_parse_connect(resp, 16, 0x11223344, &cid) == NAUT_OK); - CHECK(cid == 0xA0A1A2A3A4A5A6A7ULL); - CHECK(naut_udp_parse_connect(resp, 16, 0x99999999, &cid) == NAUT_ERR_PROTO); /* wrong txid */ - } - - /* --- UDP announce codec round-trip --- */ - { - uint8_t pkt[98]; - naut_udp_build_announce(pkt, 0xA0A1A2A3A4A5A6A7ULL, 0x55667788, &req); - CHECK(pkt[11] == 1); /* action announce */ - CHECK(memcmp(pkt + 16, req.info_hash, 20) == 0); - CHECK(memcmp(pkt + 36, req.peer_id, 20) == 0); - CHECK(pkt[83] == NAUT_TEV_STARTED); /* event low byte */ - CHECK((pkt[96]<<8 | pkt[97]) == 6881); /* port */ - - /* fake announce response: action=1, txid, interval, leech, seed, 1 peer */ - uint8_t resp[26] = {0}; - resp[3] = 1; - resp[4]=0x55; resp[5]=0x66; resp[6]=0x77; resp[7]=0x88; - resp[11] = 0x84; /* interval 0x84 = 132 */ - resp[15] = 3; /* leechers */ - resp[19] = 7; /* seeders */ - resp[20]=192; resp[21]=168; resp[22]=0; resp[23]=5; resp[24]=0x1a; resp[25]=0xe1; - naut_tracker_response r; - CHECK(naut_udp_parse_announce(resp, 26, 0x55667788, &r) == NAUT_OK); - CHECK_EQ(r.interval, 132); - CHECK_EQ(r.leechers, 3); - CHECK_EQ(r.seeders, 7); - CHECK_EQ(r.num_peers, 1); - CHECK(r.peers[0].ip[0]==192 && r.peers[0].ip[3]==5 && r.peers[0].port==0x1ae1); - naut_tracker_response_free(&r); - - uint8_t malformed[27]; - memcpy(malformed, resp, sizeof resp); - malformed[26] = 0; - CHECK(naut_udp_parse_announce(malformed, sizeof malformed, - 0x55667788, &r) == NAUT_ERR_PROTO); - } - - TEST_MAIN_END(); -}