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:
parent
187e8f2db2
commit
7bbc1ee22c
5 changed files with 149 additions and 8 deletions
|
|
@ -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.
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,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));
|
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);
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
uint64_t new_id = json_u64(result, "torrent_id");
|
uint64_t new_id = json_u64(result, "torrent_id");
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue