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 <noreply@anthropic.com>
This commit is contained in:
ookami125 2026-06-22 01:01:50 -04:00
parent 187e8f2db2
commit 7bbc1ee22c
5 changed files with 149 additions and 8 deletions

View file

@ -7,6 +7,6 @@
- ✅ I need to be able to add Tags on adding a torrent. - ✅ I need to be able to add Tags on adding a torrent.
- ✅ Categories aren't saved across restart. - ✅ Categories aren't saved across restart.
- ✅ Pausing a torrent will go back into Downloading and Seeding. - ✅ 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. - ⬛ A paused torrent should still do a full piece check.
- ✅ Something appears to have broken the peers info tab, nothing shows up. - ✅ Something appears to have broken the peers info tab, nothing shows up.

View file

@ -1,5 +1,6 @@
#include "naut/event.h" #include "naut/event.h"
#include "naut/log.h" #include "naut/log.h"
#include "naut/metainfo.h"
#include "naut/plugin.h" #include "naut/plugin.h"
#include "naut/rpc.h" #include "naut/rpc.h"
#include "naut/script.h" #include "naut/script.h"
@ -89,6 +90,8 @@ typedef struct {
char *category; /* single category (qBittorrent-style), may be ""*/ char *category; /* single category (qBittorrent-style), may be ""*/
char **tags; /* user tags (multiple) */ char **tags; /* user tags (multiple) */
size_t num_tags; /* category + tags are the flat labels Lua sees */ 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_seq; /* bumped by a dump RPC; > dump_done_seq => pending */
uint64_t dump_done_seq; /* highest dump_seq the worker has rendered */ uint64_t dump_done_seq; /* highest dump_seq the worker has rendered */
char *dump_text; /* latest rendered dump (owner: task) */ 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(); 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 /* 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 * 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 * 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, static torrent_task *spawn_torrent(daemon_state *state, const char *source,
bool source_managed, bool source_is_temp, bool source_managed, bool source_is_temp,
const char *output, const json_t *peers_json, 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 *meta_json,
const json_t *id_opt, const char *name, const json_t *id_opt, const char *name,
bool start_paused, bool force_start, 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)); torrent_task *task = calloc(1, sizeof(*task));
if (!task) { *error = NAUT_ERR_NOMEM; return NULL; } if (!task) { *error = NAUT_ERR_NOMEM; return NULL; }
task->daemon = state; 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); pthread_mutex_lock(&state->torrent_lock);
if (state->torrent_count == MAX_TORRENTS) { if (state->torrent_count == MAX_TORRENTS) {
pthread_mutex_unlock(&state->torrent_lock); pthread_mutex_unlock(&state->torrent_lock);
*error = NAUT_ERR_FULL; *error = NAUT_ERR_FULL;
goto fail_task; 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 (id_opt) {
if (!json_is_integer(id_opt) || json_integer_value(id_opt) < 0) { if (!json_is_integer(id_opt) || json_integer_value(id_opt) < 0) {
pthread_mutex_unlock(&state->torrent_lock); pthread_mutex_unlock(&state->torrent_lock);
@ -1510,6 +1630,8 @@ fail_task:
free(task->locations); free(task->locations);
for (size_t i = 0; i < task->num_tags; i++) free(task->tags[i]); for (size_t i = 0; i < task->num_tags; i++) free(task->tags[i]);
free(task->tags); 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->category);
free(task->pending_save_path); free(task->pending_save_path);
pthread_mutex_destroy(&task->lock); 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, state, source, managed, is_temp, output, peers_json,
/*locations_json=*/NULL, /*meta_json=*/params, /*locations_json=*/NULL, /*meta_json=*/params,
json_object_get(params, "torrent_id"), name, 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 (!task) {
if (managed || is_temp) unlink(source); if (managed || is_temp) unlink(source);
return NULL; return NULL;
@ -2357,6 +2480,8 @@ static void destroy_torrent(torrent_task *task) {
free(task->locations); free(task->locations);
for (size_t i = 0; i < task->num_tags; i++) free(task->tags[i]); for (size_t i = 0; i < task->num_tags; i++) free(task->tags[i]);
free(task->tags); 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->category);
free(task->pending_save_path); free(task->pending_save_path);
free(task->name); free(task->name);
@ -2498,7 +2623,8 @@ static void restore_torrents(daemon_state *state) {
/*meta_json=*/rec, /*meta_json=*/rec,
json_object_get(rec, "torrent_id"), json_object_get(rec, "torrent_id"),
json_string_value(json_object_get(rec, "name")), json_string_value(json_object_get(rec, "name")),
paused, force_start, queue_pos, &error)) paused, force_start, queue_pos,
/*check_overlap=*/false, &error))
restored++; restored++;
else { else {
NAUT_WARN("restore: %s failed: %s", source, naut_strerror(error)); NAUT_WARN("restore: %s failed: %s", source, naut_strerror(error));

View file

@ -52,6 +52,7 @@ enum {
NAUT_ERR_FULL = -8, NAUT_ERR_FULL = -8,
NAUT_ERR_EMPTY = -9, NAUT_ERR_EMPTY = -9,
NAUT_ERR_NOTFOUND = -10, NAUT_ERR_NOTFOUND = -10,
NAUT_ERR_EXIST = -11, /* already exists / data would overlap */
}; };
const char *naut_strerror(naut_err e); const char *naut_strerror(naut_err e);

View file

@ -410,7 +410,9 @@ static bool serve_file(int fd, const char *request_path) {
return true; 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; if (!g_webui.host.call_rpc) return NULL;
char *request = json_dumps(params ? params : json_null(), char *request = json_dumps(params ? params : json_null(),
JSON_COMPACT | JSON_ENCODE_ANY); 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, naut_err error = g_webui.host.call_rpc(g_webui.host.host_context,
method, request, &response); method, request, &response);
free(request); free(request);
if (out_err) *out_err = error;
if (error != NAUT_OK || !response) { if (error != NAUT_OK || !response) {
free(response); free(response);
return NULL; return NULL;
@ -430,6 +433,10 @@ static json_t *rpc_call_json(const char *method, json_t *params) {
return json; 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, static const char *json_string_or(const json_t *obj, const char *key,
const char *fallback) { const char *fallback) {
const char *value = json_string_value(json_object_get(obj, key)); const char *value = json_string_value(json_object_get(obj, key));
@ -1402,11 +1409,17 @@ static void api_add(int fd, const char *body, size_t len) {
json_object_set_new(params, "category", json_string(category_name)); json_object_set_new(params, "category", json_string(category_name));
if (tags && json_array_size(tags) > 0) if (tags && json_array_size(tags) > 0)
json_object_set_new(params, "tags", json_deep_copy(tags)); 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(params);
json_decref(req); json_decref(req);
if (!result) { if (!result) {
json_decref(tags); json_decref(tags);
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"); http_text(fd, 502, "Bad Gateway", "add_torrent failed");
return; return;
} }

View file

@ -13,6 +13,7 @@ const char *naut_strerror(naut_err e) {
case NAUT_ERR_FULL: return "full"; case NAUT_ERR_FULL: return "full";
case NAUT_ERR_EMPTY: return "empty"; case NAUT_ERR_EMPTY: return "empty";
case NAUT_ERR_NOTFOUND: return "not found"; case NAUT_ERR_NOTFOUND: return "not found";
case NAUT_ERR_EXIST: return "already exists / data would overlap";
default: return "unknown error"; default: return "unknown error";
} }
} }