From 7bbc1ee22cfc376b4e2fcda969b32ae15100b869 Mon Sep 17 00:00:00 2001 From: ookami125 Date: Mon, 22 Jun 2026 01:01:50 -0400 Subject: [PATCH] nautd: block adding a torrent that would overlap existing data spawn_torrent parses each torrent's file list (at add and restore) and refuses an add whose files would write where a registered torrent's data lives (NAUT_ERR_EXIST). Magnets are checked once metadata is known is out of scope; restore skips the check. webui surfaces it as HTTP 409. Mark #10 done. Co-Authored-By: Claude Opus 4.8 --- ISSUES.md | 2 +- apps/nautd/main.c | 134 ++++++++++++++++++++++++++++++++++++++++-- include/naut/common.h | 1 + plugins/webui/webui.c | 19 +++++- src/core/common.c | 1 + 5 files changed, 149 insertions(+), 8 deletions(-) diff --git a/ISSUES.md b/ISSUES.md index 8be8efb..efac3a8 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -7,6 +7,6 @@ - ✅ 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 +- ✅ A torrents data could overlap with another existing torrent. This should be blocked to avoid - ⬛ A paused torrent should still do a full piece check. - ✅ Something appears to have broken the peers info tab, nothing shows up. \ No newline at end of file diff --git a/apps/nautd/main.c b/apps/nautd/main.c index 464aaad..5b4c3c5 100644 --- a/apps/nautd/main.c +++ b/apps/nautd/main.c @@ -1,5 +1,6 @@ #include "naut/event.h" #include "naut/log.h" +#include "naut/metainfo.h" #include "naut/plugin.h" #include "naut/rpc.h" #include "naut/script.h" @@ -89,6 +90,8 @@ typedef struct { 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 */ + char **files; /* torrent's file relpaths (for overlap checks) */ + size_t num_files; /* parsed at add; empty for magnets pre-metadata */ 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) */ @@ -1381,10 +1384,100 @@ static json_t *rpc_set_label_taxonomy(void *opaque, const json_t *params, return json_object(); } +/* --- data-overlap guard (block torrents that would write the same files) --- */ + +static uint8_t *slurp_file(const char *path, size_t *len) { + FILE *f = fopen(path, "rb"); + if (!f) return NULL; + if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return NULL; } + long n = ftell(f); + if (n < 0) { fclose(f); return NULL; } + rewind(f); + uint8_t *buf = malloc((size_t)n + 1); + if (!buf) { fclose(f); return NULL; } + size_t got = fread(buf, 1, (size_t)n, f); + fclose(f); + if (got != (size_t)n) { free(buf); return NULL; } + if (len) *len = got; + return buf; +} + +/* Parse a .torrent file's file list into relative paths. Returns the count (0 + * for magnets, which have no metadata yet, or on failure). Caller frees. */ +static size_t parse_torrent_files(const char *source, char ***out) { + *out = NULL; + if (!source || strncmp(source, "magnet:", 7) == 0) return 0; + size_t len = 0; + uint8_t *bytes = slurp_file(source, &len); + if (!bytes) return 0; + naut_metainfo mi; + memset(&mi, 0, sizeof mi); + if (naut_metainfo_parse(bytes, len, &mi) != NAUT_OK) { free(bytes); return 0; } + free(bytes); + char **files = mi.num_files ? calloc(mi.num_files, sizeof *files) : NULL; + size_t c = 0; + if (files) + for (size_t i = 0; i < mi.num_files; i++) + if (mi.files[i].path) { + char *p = strdup(mi.files[i].path); + if (p) files[c++] = p; + } + naut_metainfo_free(&mi); + *out = files; + return c; +} + +/* Two absolute paths conflict if equal, or one is a directory-prefix of the + * other (a file vs a folder that would contain it). */ +static bool paths_conflict(const char *a, const char *b) { + size_t la = strlen(a), lb = strlen(b); + if (la == lb) return strcmp(a, b) == 0; + const char *shorter = la < lb ? a : b, *longer = la < lb ? b : a; + size_t sl = la < lb ? la : lb; + return strncmp(shorter, longer, sl) == 0 && longer[sl] == '/'; +} + +/* Current absolute path of file `fi` (relpath `rel`) for `task`: its override if + * individually moved, else output_dir/rel. Caller holds task->lock. */ +static void task_file_abs(const torrent_task *task, size_t fi, const char *rel, + char *out, size_t outsz) { + for (size_t i = 0; i < task->num_locations; i++) + if (task->locations[i].file_index == fi) { + snprintf(out, outsz, "%s", task->locations[i].path); + return; + } + snprintf(out, outsz, "%s/%s", task->output_dir ? task->output_dir : "", rel); +} + +/* True if any of `news` (absolute paths) would write where an already-registered + * torrent's file lives. Caller holds state->torrent_lock; locks each task. */ +static bool data_overlaps_locked(daemon_state *state, char *const *news, + size_t nnew, char *conflict, size_t csz) { + for (size_t t = 0; t < state->torrent_count; t++) { + torrent_task *o = state->torrents[t]; + pthread_mutex_lock(&o->lock); + bool removed = o->remove_requested; + for (size_t fi = 0; !removed && fi < o->num_files; fi++) { + char have[PATH_MAX]; + task_file_abs(o, fi, o->files[fi], have, sizeof have); + for (size_t k = 0; k < nnew; k++) + if (paths_conflict(have, news[k])) { + snprintf(conflict, csz, "%s", have); + pthread_mutex_unlock(&o->lock); + return true; + } + } + pthread_mutex_unlock(&o->lock); + } + return false; +} + /* 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). */ + * that decision so a failed restore does not delete a durable upload). When + * `check_overlap` is set, an add whose files would land on an existing torrent's + * data is refused with NAUT_ERR_EXIST. */ 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, @@ -1392,7 +1485,8 @@ static torrent_task *spawn_torrent(daemon_state *state, const char *source, 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) { + int queue_pos, bool check_overlap, + naut_err *error) { torrent_task *task = calloc(1, sizeof(*task)); if (!task) { *error = NAUT_ERR_NOMEM; return NULL; } task->daemon = state; @@ -1453,12 +1547,38 @@ static torrent_task *spawn_torrent(daemon_state *state, const char *source, } } + /* File list (for overlap detection); empty for magnets until metadata. */ + task->num_files = parse_torrent_files(source, &task->files); + 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 (check_overlap && task->num_files) { + char **news = calloc(task->num_files, sizeof *news); + bool ok = news != NULL; + for (size_t i = 0; ok && i < task->num_files; i++) { + char tmp[PATH_MAX]; + snprintf(tmp, sizeof tmp, "%s/%s", output, task->files[i]); + news[i] = strdup(tmp); + if (!news[i]) ok = false; + } + char conflict[PATH_MAX] = {0}; + bool overlap = ok && data_overlaps_locked(state, news, task->num_files, + conflict, sizeof conflict); + for (size_t i = 0; news && i < task->num_files; i++) free(news[i]); + free(news); + if (!ok || overlap) { + pthread_mutex_unlock(&state->torrent_lock); + if (overlap) + NAUT_WARN("add blocked: data overlaps existing torrent at %s", + conflict); + *error = overlap ? NAUT_ERR_EXIST : NAUT_ERR_NOMEM; + goto fail_task; + } + } if (id_opt) { if (!json_is_integer(id_opt) || json_integer_value(id_opt) < 0) { pthread_mutex_unlock(&state->torrent_lock); @@ -1510,6 +1630,8 @@ fail_task: free(task->locations); for (size_t i = 0; i < task->num_tags; i++) free(task->tags[i]); free(task->tags); + for (size_t i = 0; i < task->num_files; i++) free(task->files[i]); + free(task->files); free(task->category); free(task->pending_save_path); pthread_mutex_destroy(&task->lock); @@ -1565,7 +1687,8 @@ static json_t *rpc_add_torrent(void *opaque, const json_t *params, 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); + start_paused, /*force_start=*/false, /*queue_pos=*/-1, + /*check_overlap=*/true, error); if (!task) { if (managed || is_temp) unlink(source); return NULL; @@ -2357,6 +2480,8 @@ static void destroy_torrent(torrent_task *task) { free(task->locations); for (size_t i = 0; i < task->num_tags; i++) free(task->tags[i]); free(task->tags); + for (size_t i = 0; i < task->num_files; i++) free(task->files[i]); + free(task->files); free(task->category); free(task->pending_save_path); free(task->name); @@ -2498,7 +2623,8 @@ static void restore_torrents(daemon_state *state) { /*meta_json=*/rec, json_object_get(rec, "torrent_id"), json_string_value(json_object_get(rec, "name")), - paused, force_start, queue_pos, &error)) + paused, force_start, queue_pos, + /*check_overlap=*/false, &error)) restored++; else { NAUT_WARN("restore: %s failed: %s", source, naut_strerror(error)); diff --git a/include/naut/common.h b/include/naut/common.h index 2884041..f4842c7 100644 --- a/include/naut/common.h +++ b/include/naut/common.h @@ -52,6 +52,7 @@ enum { NAUT_ERR_FULL = -8, NAUT_ERR_EMPTY = -9, NAUT_ERR_NOTFOUND = -10, + NAUT_ERR_EXIST = -11, /* already exists / data would overlap */ }; const char *naut_strerror(naut_err e); diff --git a/plugins/webui/webui.c b/plugins/webui/webui.c index 4750f4b..1393068 100644 --- a/plugins/webui/webui.c +++ b/plugins/webui/webui.c @@ -410,7 +410,9 @@ static bool serve_file(int fd, const char *request_path) { return true; } -static json_t *rpc_call_json(const char *method, json_t *params) { +static json_t *rpc_call_json_err(const char *method, json_t *params, + naut_err *out_err) { + if (out_err) *out_err = NAUT_ERR_INVAL; if (!g_webui.host.call_rpc) return NULL; char *request = json_dumps(params ? params : json_null(), JSON_COMPACT | JSON_ENCODE_ANY); @@ -419,6 +421,7 @@ static json_t *rpc_call_json(const char *method, json_t *params) { naut_err error = g_webui.host.call_rpc(g_webui.host.host_context, method, request, &response); free(request); + if (out_err) *out_err = error; if (error != NAUT_OK || !response) { free(response); return NULL; @@ -430,6 +433,10 @@ static json_t *rpc_call_json(const char *method, json_t *params) { return json; } +static json_t *rpc_call_json(const char *method, json_t *params) { + return rpc_call_json_err(method, params, NULL); +} + static const char *json_string_or(const json_t *obj, const char *key, const char *fallback) { const char *value = json_string_value(json_object_get(obj, key)); @@ -1402,12 +1409,18 @@ static void api_add(int fd, const char *body, size_t len) { json_object_set_new(params, "category", json_string(category_name)); if (tags && json_array_size(tags) > 0) json_object_set_new(params, "tags", json_deep_copy(tags)); - json_t *result = rpc_call_json("add_torrent", params); + naut_err add_err = NAUT_OK; + json_t *result = rpc_call_json_err("add_torrent", params, &add_err); json_decref(params); json_decref(req); if (!result) { json_decref(tags); - http_text(fd, 502, "Bad Gateway", "add_torrent failed"); + if (add_err == NAUT_ERR_EXIST) + http_text(fd, 409, "Conflict", + "this torrent's data would overlap an existing torrent; " + "choose a different save path"); + else + http_text(fd, 502, "Bad Gateway", "add_torrent failed"); return; } uint64_t new_id = json_u64(result, "torrent_id"); diff --git a/src/core/common.c b/src/core/common.c index af93026..0baed49 100644 --- a/src/core/common.c +++ b/src/core/common.c @@ -13,6 +13,7 @@ const char *naut_strerror(naut_err e) { case NAUT_ERR_FULL: return "full"; case NAUT_ERR_EMPTY: return "empty"; case NAUT_ERR_NOTFOUND: return "not found"; + case NAUT_ERR_EXIST: return "already exists / data would overlap"; default: return "unknown error"; } }