/* dm_media — E2E файлы бесед и отдельное ограниченное хранилище M. */ #include #include #include #include #include #include #include #include "../../lib/mem.h" #include "../../lib/platform_compat.h" #include "../../lib/debug_config.h" #include "../../lib/json_flat.h" #include "dm_media_priv.h" #include "dm_history.h" #include "dm_crypto.h" #include "dm_mailbox.h" #include "dm_mailbox_media.h" #include "../utun_instance.h" #include "../ntp_time.h" #include "../chat/chat_event.h" #include "../chat/chat_setting.h" #include "../routing_layer/etcp_router.h" #include "../routing_layer/topo_node_sqlite.h" #include "../transport_layer/secure_channel.h" #include "../transport_layer/etcp.h" #include "../media_delivery/file_transfer.h" #include "../media_async/encrypted_file.h" enum dm_file_work { DM_FILE_PREPARE, DM_FILE_RECEIVE, DM_FILE_CUSTODY, DM_FILE_DELETE }; struct dm_file_job; struct dm_media_state { struct UTUN_INSTANCE* inst; char root[768]; char pending[768]; struct dm_file_job* jobs; void* timer; int closing; int cleanup_active; }; struct dm_file_job { struct dm_file_job* next; struct dm_media_state* media; enum dm_file_work work; struct file_transfer_op* transfer; uint8_t id[16], key[32], hash[32]; uint64_t author, size; char conv[64]; struct attachment_info info; int remove_source; char input[1024], cipher[1024], plain[1024], temp[1024], output[1024]; int error; uint64_t generation; unsigned held; }; sqlite3_stmt* dm_media_prepare(struct UTUN_INSTANCE* inst, const char* sql) { sqlite3_stmt* st = NULL; if (sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL) != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: SQL prepare failed: %s statement=%s", sqlite3_errmsg(inst->topo_sqlite_db), sql); return st; } /* Шаг записи всегда завершает statement; SQL-ошибки нельзя скрывать ACK. */ int dm_media_step(struct UTUN_INSTANCE* inst, sqlite3_stmt* st) { if (!st) return -1; int rc = sqlite3_step(st); if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: SQL step rc=%d error=%s statement=%s", rc, sqlite3_errmsg(inst->topo_sqlite_db), sqlite3_sql(st)); sqlite3_finalize(st); return rc == SQLITE_DONE ? 0 : -1; } int dm_media_exec(struct UTUN_INSTANCE* inst, const char* sql) { int rc = sqlite3_exec(inst->topo_sqlite_db, sql, NULL, NULL, NULL); if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: SQL rc=%d error=%s statement=%s", rc, sqlite3_errmsg(inst->topo_sqlite_db), sql); return rc == SQLITE_OK ? 0 : -1; } const uint8_t* dm_message_media(const uint8_t* msg, size_t len) { size_t actual; if (!dm_msg_len(msg, len, &actual) || actual != len || msg[32] != 4 || memcmp(msg + 33, "file", 4)) return NULL; return msg + len - DM_MSG_SIG_SIZE - DM_MEDIA_DESC_SIZE; } uint64_t dm_media_cipher_size(uint64_t size) { return size + (size ? (size + DM_MEDIA_PLAIN_SIZE - 1) / DM_MEDIA_PLAIN_SIZE : 1) * DM_TAG_SIZE; } /* Пути зависят только от UUID, имя пользователя не попадает в файловый путь. */ static int dm_file_path(struct dm_media_state* media, int custody, const uint8_t id[16], const char* suffix, char* path, size_t cap) { char hex[33]; for (int i = 0; i < 16; i++) snprintf(hex + 2 * i, 3, "%02x", id[i]); int n = snprintf(path, cap, "%s/%s%s", custody ? media->pending : media->root, hex, suffix); if (n < 0 || (size_t)n >= cap) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: file path too long"); return -1; } return 0; } /* fflush не гарантирует сохранение: fsync/_commit нужен до публикации и ACK. */ static int dm_file_sync(FILE* file) { if (fflush(file) != 0) goto fail; #ifdef _WIN32 if (_commit(_fileno(file)) != 0) goto fail; #else if (fsync(fileno(file)) != 0) goto fail; #endif return 0; fail: DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: file sync failed: %s", strerror(errno)); return -1; } static int dm_file_remove(const char* path) { if (remove(path) != 0 && errno != ENOENT) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: remove failed path=%s: %s", path, strerror(errno)); return -1; } return 0; } /* Манифест хранит только пути приложения. Поколение делает очередь удаления durable. */ int dm_media_file_hold(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation, const char* path) { if (!path || !path[0] || !dm_history_current(inst, conv, generation)) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM files: hold rejected conv=%s path=%s", conv ? conv : "", path ? path : ""); return -1; } sqlite3_stmt* st = dm_media_prepare(inst, "INSERT INTO dm_local_files(path,conv_id,generation,busy) VALUES(?,?,?,1) ON CONFLICT(path) DO UPDATE SET busy=busy+1" " WHERE conv_id=excluded.conv_id AND generation=excluded.generation AND busy>=0"); if (!st) return -1; sqlite3_bind_text(st, 1, path, -1, SQLITE_STATIC); sqlite3_bind_text(st, 2, conv, -1, SQLITE_STATIC); sqlite3_bind_int64(st, 3, (sqlite3_int64)generation); if (dm_media_step(inst, st)) return -1; if (sqlite3_changes(inst->topo_sqlite_db) != 1) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: path belongs to another task/generation path=%s", path); return -1; } DEBUG_TRACE(DEBUG_CATEGORY_DM, "DM files: hold conv=%s generation=%llu path=%s", conv, (unsigned long long)generation, path); return 0; } void dm_media_file_release(struct UTUN_INSTANCE* inst, const char* path) { sqlite3_stmt* st = dm_media_prepare(inst, "UPDATE dm_local_files SET busy=busy-1 WHERE path=? AND busy>0"); if (!st) return; sqlite3_bind_text(st, 1, path, -1, SQLITE_STATIC); if (!dm_media_step(inst, st) && sqlite3_changes(inst->topo_sqlite_db) != 1) DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: release without owner path=%s", path); } struct dm_cleanup_job { struct dm_media_state* media; char path[1024]; int error; }; static void dm_cleanup_pump(struct dm_media_state* media); static void dm_cleanup_work(void* arg) { struct dm_cleanup_job* job = arg; job->error = dm_file_remove(job->path); if (!job->error) job->error = file_io_sync_directory(job->path); } static void dm_cleanup_done(void* arg, int error) { struct dm_cleanup_job* job = arg; struct dm_media_state* media = job->media; media->cleanup_active = 0; int success = !error && !job->error; sqlite3_stmt* st = dm_media_prepare(media->inst, success ? "DELETE FROM dm_local_files WHERE path=? AND busy=-1" : "UPDATE dm_local_files SET busy=0,retry_at=?2 WHERE path=?1 AND busy=-1"); if (st && !success) sqlite3_bind_int64(st, 2, (sqlite3_int64)(ntp_time_get_seconds(media->inst) + 30)); if (st) { sqlite3_bind_text(st, 1, job->path, -1, SQLITE_STATIC); if (dm_media_step(media->inst, st)) success = 0; } else success = 0; if (success) DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM files: removed deleted history path=%s", job->path); else DEBUG_WARN(DEBUG_CATEGORY_DM, "DM files: cleanup retained for retry path=%s callback=%d worker=%d", job->path, error, job->error); u_free(job); if (success && !media->closing) dm_cleanup_pump(media); } static void dm_cleanup_pump(struct dm_media_state* media) { if (media->closing || media->cleanup_active) return; sqlite3_stmt* st = dm_media_prepare(media->inst, "SELECT f.path FROM dm_local_files f JOIN dm_history h USING(conv_id)" " WHERE f.generationinst)); int rc = sqlite3_step(st); if (rc != SQLITE_ROW) { if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: cleanup scan failed rc=%d", rc); sqlite3_finalize(st); return; } struct dm_cleanup_job* job = u_calloc(1, sizeof(*job)); if (!job) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: cleanup allocation failed"); sqlite3_finalize(st); return; } const char* path = (const char*)sqlite3_column_text(st, 0); if (!path || strlen(path) >= sizeof(job->path)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: invalid cleanup path"); sqlite3_finalize(st); u_free(job); return; } job->media = media; snprintf(job->path, sizeof(job->path), "%s", path); sqlite3_finalize(st); st = dm_media_prepare(media->inst, "UPDATE dm_local_files SET busy=-1 WHERE path=? AND busy<=0"); if (!st) { u_free(job); return; } sqlite3_bind_text(st, 1, job->path, -1, SQLITE_STATIC); if (dm_media_step(media->inst, st)) { u_free(job); return; } media->cleanup_active = 1; media_async_submit(media->inst->media_async, media->inst->ua, dm_cleanup_work, job, dm_cleanup_done, job); } int dm_media_forget_prepare(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation) { struct dm_media_state* media = inst->dm_media; sqlite3_stmt* rows = dm_media_prepare(inst, "SELECT id FROM dm_files WHERE conv_id=?"); if (!rows) return -1; sqlite3_bind_text(rows, 1, conv, -1, SQLITE_STATIC); int rc, result = 0; while ((rc = sqlite3_step(rows)) == SQLITE_ROW) { if (sqlite3_column_bytes(rows, 0) != 16) { result = -1; break; } const char* suffixes[] = {".file", ".cipher", ".encrypt", ".download", ".decrypt"}; for (unsigned i = 0; i < sizeof(suffixes) / sizeof(suffixes[0]); i++) { char path[1024]; if (dm_file_path(media, 0, sqlite3_column_blob(rows, 0), suffixes[i], path, sizeof(path))) { result = -1; break; } sqlite3_stmt* st = dm_media_prepare(inst, "INSERT INTO dm_local_files(path,conv_id,generation) VALUES(?,?,?) ON CONFLICT(path) DO NOTHING"); if (!st) { result = -1; break; } sqlite3_bind_text(st, 1, path, -1, SQLITE_STATIC); sqlite3_bind_text(st, 2, conv, -1, SQLITE_STATIC); sqlite3_bind_int64(st, 3, (sqlite3_int64)generation); if (dm_media_step(inst, st)) { result = -1; break; } } if (result) break; } sqlite3_finalize(rows); if (result || rc != SQLITE_DONE) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: delete manifest failed conv=%s rc=%d", conv, rc); return -1; } sqlite3_stmt* st = dm_media_prepare(inst, "DELETE FROM dm_files WHERE conv_id=?"); if (!st) return -1; sqlite3_bind_text(st, 1, conv, -1, SQLITE_STATIC); return dm_media_step(inst, st); } void dm_media_forget_committed(struct UTUN_INSTANCE* inst, const char* conv) { sqlite3_stmt* st = dm_media_prepare(inst, "SELECT media_id FROM dm_deleted_messages WHERE conv_id=? AND media_id IS NOT NULL"); if (st) { sqlite3_bind_text(st, 1, conv, -1, SQLITE_STATIC); int rc; while ((rc = sqlite3_step(st)) == SQLITE_ROW) { if (sqlite3_column_bytes(st, 0) == 16) file_transfer_cancel_object(inst->file_transfer, sqlite3_column_blob(st, 0)); else DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: damaged deleted media ID conv=%s", conv); } if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: cancellation scan failed conv=%s rc=%d", conv, rc); sqlite3_finalize(st); } dm_cleanup_pump(inst->dm_media); } /* Подписанная роль и живые мемберы в одной области; тип NAT не даёт полномочий. */ int dm_media_scope(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t node, const char* role, uint64_t author, uint64_t recipient) { if (!group) return 0; char sql[256]; snprintf(sql, sizeof(sql), "SELECT node_id,adm_tags FROM \"peers_%llu\" WHERE deleted=0 AND node_id IN (?,?,?)", (unsigned long long)group); sqlite3_stmt* st = dm_media_prepare(inst, sql); if (!st) return 0; sqlite3_bind_int64(st, 1, (sqlite3_int64)node); sqlite3_bind_int64(st, 2, (sqlite3_int64)author); sqlite3_bind_int64(st, 3, (sqlite3_int64)recipient); int has_a = 0, has_b = 0, allowed = 0, rc; while ((rc = sqlite3_step(st)) == SQLITE_ROW) { uint64_t id = (uint64_t)sqlite3_column_int64(st, 0); if (id == author) has_a = 1; if (id == recipient) has_b = 1; if (id == node) { const char* tags = (const char*)sqlite3_column_text(st, 1); char value[16]; allowed = !role || (tags && json_flat_get(tags, role, value, sizeof(value)) == 0 && !strcmp(value, "yes")); } } if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: role scan failed rc=%d", rc); sqlite3_finalize(st); return rc == SQLITE_DONE && allowed && has_a && has_b; } void dm_media_notify(struct UTUN_INSTANCE* inst, uint64_t conv) { char id[64]; int n = snprintf(id, sizeof(id), "%llu", (unsigned long long)conv); uint8_t evt[1 + 64 + 8]; evt[0] = (uint8_t)n; memcpy(evt + 1, id, (size_t)n); memcpy(evt + 1 + n, &inst->node_id, 8); chat_event_post(inst, CHAT_EVT_DM_MSG_RECEIVED, evt, n + 9); } /* Квитанция подписывает UUID и хеш всего сообщения с descriptor. */ int dm_media_verify_receipt(struct UTUN_INSTANCE* inst, const uint8_t* r, const uint8_t* msg, size_t len) { uint64_t conv, author, recipient, seq; if (!inst || !r || memcmp(r, "DMFILE01", 8)) goto invalid; memcpy(&conv, r + 8, 8); memcpy(&seq, r + 16, 8); memcpy(&author, r + 24, 8); memcpy(&recipient, r + 32, 8); if (!author || !recipient || author == recipient || !seq || seq > INT64_MAX || conv != dm_derive_conv_id(author, recipient)) goto invalid; sqlite3_stmt* st = dm_media_prepare(inst, "SELECT ed25519_pubkey FROM nodes WHERE node_id=?"); if (!st) return -1; sqlite3_bind_int64(st, 1, (sqlite3_int64)recipient); uint8_t key[32]; int found = sqlite3_step(st) == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 32; if (found) memcpy(key, sqlite3_column_blob(st, 0), sizeof(key)); sqlite3_finalize(st); if (!found || sc_ed25519_verify(key, r, 88, r + 88) != SC_OK) goto invalid; if (msg) { const uint8_t* desc = dm_message_media(msg, len); uint8_t hash[32]; if (!desc || memcmp(r + 8, msg, 16) || memcmp(r + 24, msg + 24, 8) || memcmp(r + 40, desc, 16)) goto invalid; SHA256(msg, len, hash); if (memcmp(hash, r + 56, 32)) goto invalid; } return 0; invalid: DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: rejected media receipt body_bytes=%zu", len); return -1; } /* Транзакция dm_core связывает историю, очередь и состояние файла. */ int dm_media_track(struct UTUN_INSTANCE* inst, int dir, const uint8_t* msg, size_t len) { const uint8_t* desc = dm_message_media(msg, len); if (!desc) return 0; struct dm_media_state* media = inst->dm_media; if (!media || media->closing) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: file received while owner stopped"); return -1; } uint64_t conv, seq, author, size, recipient, group; memcpy(&conv, msg, 8); memcpy(&seq, msg + 8, 8); memcpy(&author, msg + 24, 8); memcpy(&size, desc + 16, 8); char id[64], path[1024]; snprintf(id, sizeof(id), "%llu", (unsigned long long)conv); sqlite3_stmt* st = dm_media_prepare(inst, "SELECT peer_node_id,group_id FROM dm_conversations WHERE conv_id=?"); if (!st) return -1; sqlite3_bind_text(st, 1, id, -1, SQLITE_STATIC); int rc = sqlite3_step(st); if (rc != SQLITE_ROW) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: conversation absent conv=%s rc=%d", id, rc); sqlite3_finalize(st); return -1; } recipient = dir ? (uint64_t)sqlite3_column_int64(st, 0) : inst->node_id; group = (uint64_t)sqlite3_column_int64(st, 1); sqlite3_finalize(st); if (dm_file_path(media, 0, desc, ".file", path, sizeof(path))) return -1; if (dir) { struct stat sb; if (stat(path, &sb) || (uint64_t)sb.st_size != size) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: outgoing file not prepared path=%s size=%llu", path, (unsigned long long)size); return -1; } } st = dm_media_prepare(inst, "INSERT INTO dm_files(id,conv_id,dir,seq,author,recipient,plain_size,cipher_hash,body,group_id," "local_path,ready,holder) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING"); if (!st) return -1; sqlite3_bind_blob(st, 1, desc, 16, SQLITE_STATIC); sqlite3_bind_text(st, 2, id, -1, SQLITE_STATIC); sqlite3_bind_int(st, 3, dir); sqlite3_bind_int64(st, 4, (sqlite3_int64)seq); sqlite3_bind_int64(st, 5, (sqlite3_int64)author); sqlite3_bind_int64(st, 6, (sqlite3_int64)recipient); sqlite3_bind_int64(st, 7, (sqlite3_int64)size); sqlite3_bind_blob(st, 8, desc + 24, 32, SQLITE_STATIC); sqlite3_bind_blob(st, 9, msg, (int)len, SQLITE_STATIC); sqlite3_bind_int64(st, 10, (sqlite3_int64)group); sqlite3_bind_text(st, 11, path, -1, SQLITE_STATIC); sqlite3_bind_int(st, 12, dir ? 1 : 0); sqlite3_bind_int64(st, 13, (sqlite3_int64)author); if (dm_media_step(inst, st)) return -1; st = dm_media_prepare(inst, "SELECT body FROM dm_files WHERE id=?"); if (!st) return -1; sqlite3_bind_blob(st, 1, desc, 16, SQLITE_STATIC); rc = sqlite3_step(st); int exact = rc == SQLITE_ROW && sqlite3_column_bytes(st, 0) == (int)len && !memcmp(sqlite3_column_blob(st, 0), msg, len); sqlite3_finalize(st); if (!exact) DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: conflicting UUID/body conv=%s seq=%llu", id, (unsigned long long)seq); return exact ? 0 : -1; } /* Квитанция endpoint удаляет только media pending, история/собственный файл остаются. */ int dm_media_accept_receipt(struct UTUN_INSTANCE* inst, const uint8_t* r) { if (dm_media_verify_receipt(inst, r, NULL, 0)) return -1; uint64_t author; memcpy(&author, r + 24, 8); if (author != inst->node_id) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: receipt for another author"); return -1; } sqlite3_stmt* st = dm_media_prepare(inst, "SELECT body,receipt FROM dm_files WHERE id=? AND dir=1"); if (!st) return -1; sqlite3_bind_blob(st, 1, r + 40, 16, SQLITE_STATIC); int rc = sqlite3_step(st); if (rc == SQLITE_DONE) { sqlite3_finalize(st); st = dm_media_prepare(inst, "SELECT body_hash FROM dm_deleted_messages WHERE conv_id=? AND dir=1 AND seq=? AND media_id=?"); if (!st) return -1; uint64_t conv, seq; char id[64]; memcpy(&conv, r + 8, 8); memcpy(&seq, r + 16, 8); snprintf(id, sizeof(id), "%llu", (unsigned long long)conv); sqlite3_bind_text(st, 1, id, -1, SQLITE_STATIC); sqlite3_bind_int64(st, 2, (sqlite3_int64)seq); sqlite3_bind_blob(st, 3, r + 40, 16, SQLITE_STATIC); rc = sqlite3_step(st); int exact = rc == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 32 && !memcmp(sqlite3_column_blob(st, 0), r + 56, 32); sqlite3_finalize(st); if (exact) { DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM files: late receipt for deleted file conv=%s", id); return 0; } DEBUG_WARN(DEBUG_CATEGORY_DM, "DM files: receipt for unknown/conflicting deleted file conv=%s", id); return -1; } if (rc != SQLITE_ROW || dm_media_verify_receipt(inst, r, sqlite3_column_blob(st, 0), (size_t)sqlite3_column_bytes(st, 0))) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: receipt does not match outgoing artifact rc=%d", rc); sqlite3_finalize(st); return -1; } int already = sqlite3_column_bytes(st, 1) == DM_MEDIA_RECEIPT_SIZE; sqlite3_finalize(st); st = dm_media_prepare(inst, "UPDATE dm_files SET receipt=? WHERE id=? AND dir=1"); if (!st) return -1; sqlite3_bind_blob(st, 1, r, DM_MEDIA_RECEIPT_SIZE, SQLITE_STATIC); sqlite3_bind_blob(st, 2, r + 40, 16, SQLITE_STATIC); if (dm_media_step(inst, st)) return -1; if (!already) { uint64_t conv; memcpy(&conv, r + 8, 8); DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_media: recipient confirmed file conv=%llu", (unsigned long long)conv); dm_media_notify(inst, conv); } return 0; } static void dm_media_control(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t source, uint8_t cmd, const uint8_t* body, size_t len); /* Local S/M тоже используют тот же проверяемый обработчик, без искусственного CM к себе. */ int dm_media_send(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, uint8_t cmd, const uint8_t* body, size_t len) { if (len > DM_MEDIA_BODY_MAX + 24 || !inst->dm_media || inst->dm_media->closing) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: invalid/stopped control send bytes=%zu", len); return -1; } if (peer == inst->node_id) { dm_media_control(inst, group, peer, cmd, body, len); return 0; } struct ETCP_ROUTER_CONN* rc = etcp_router_conn_get(inst, group, peer, ETCP_RT_ID_DM_MEDIA); if (!rc) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: router channel allocation failed"); return -1; } if (!etcp_router_send_q_has_room(inst, group, peer, ETCP_RT_ID_DM_MEDIA)) return -1; uint8_t packet[1 + DM_MEDIA_BODY_MAX + 24]; packet[0] = cmd; memcpy(packet + 1, body, len); int error = etcp_router_conn_send(rc, packet, len + 1, ROUTER_FLAG_SIGNED); if (error) DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: control send failed peer=%llu cmd=%u rc=%d", (unsigned long long)peer, cmd, error); return error; } static struct dm_file_job* dm_job_find(struct dm_media_state* media, const uint8_t id[16], int custody) { for (struct dm_file_job* job = media->jobs; job; job = job->next) if ((job->work == DM_FILE_CUSTODY || job->work == DM_FILE_DELETE) == custody && !memcmp(job->id, id, 16)) return job; return NULL; } static int dm_job_paths(struct dm_file_job* job, const char** paths) { if (job->work != DM_FILE_PREPARE && job->work != DM_FILE_RECEIVE) return 0; paths[0] = job->cipher; paths[1] = job->temp; paths[2] = job->work == DM_FILE_PREPARE ? job->plain : job->output; if (job->work == DM_FILE_PREPARE && job->remove_source) { paths[3] = job->input; return 4; } return 3; } static int dm_job_hold(struct dm_file_job* job) { const char* paths[4]; int count = dm_job_paths(job, paths); for (int i = 0; i < count; i++) { if (dm_media_file_hold(job->media->inst, job->conv, job->generation, paths[i])) return -1; job->held++; } return 0; } static void dm_job_free(struct dm_file_job* job) { struct dm_file_job** p = &job->media->jobs; while (*p && *p != job) p = &(*p)->next; if (*p) *p = job->next; const char* paths[4]; dm_job_paths(job, paths); for (unsigned i = 0; i < job->held; i++) dm_media_file_release(job->media->inst, paths[i]); OPENSSL_cleanse(job->key, sizeof(job->key)); u_free(job); } /* Копирование своего source фиксирует один снимок до шифрования. */ static int dm_file_copy(const char* source, const char* dest) { FILE* in = fopen(source, "rb"); FILE* out = in ? fopen(dest, "wb") : NULL; int error = -1; if (!in || !out) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: copy open failed %s -> %s: %s", source, dest, strerror(errno)); goto done; } uint8_t buf[32768]; uint64_t size = 0; size_t n; while ((n = fread(buf, 1, sizeof(buf), in))) { size += n; if (size > DM_MEDIA_MAX_SIZE || fwrite(buf, 1, n, out) != n) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: copy size/write failed bytes=%llu", (unsigned long long)size); goto done; } } if (ferror(in)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: source read failed: %s", strerror(errno)); goto done; } error = dm_file_sync(out); done: if (in && fclose(in)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: source close failed"); error = -1; } if (out && fclose(out)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: copy close failed"); error = -1; } return error; } /* Worker не обращается к БД/сети/контексту instance. */ static void dm_file_work(void* arg) { struct dm_file_job* job = arg; job->error = -1; if (job->work == DM_FILE_DELETE) { if (!dm_file_remove(job->cipher) && !dm_file_remove(job->temp)) job->error = file_io_sync_directory(job->cipher); return; } if (job->work == DM_FILE_PREPARE) { int copied = dm_file_copy(job->input, job->plain); if (job->remove_source && dm_file_remove(job->input)) return; if (copied) return; } FILE* in = fopen(job->work == DM_FILE_PREPARE ? job->plain : job->cipher, "rb"); FILE* out = job->work == DM_FILE_CUSTODY ? NULL : fopen(job->temp, "wb"); if (!in || (job->work != DM_FILE_CUSTODY && !out)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: worker open failed: %s", strerror(errno)); goto done; } if (job->work == DM_FILE_PREPARE) { job->error = media_encrypt_stream(job->key, job->author, job->id, in, out, &job->size, job->hash); } else if (job->work == DM_FILE_RECEIVE) { job->error = media_decrypt_stream(job->key, job->author, job->id, job->size, job->hash, in, out); } else { SHA256_CTX hash; SHA256_Init(&hash); uint8_t buf[32768], digest[32]; uint64_t size = 0; size_t n; while ((n = fread(buf, 1, sizeof(buf), in))) { size += n; SHA256_Update(&hash, buf, n); } SHA256_Final(digest, &hash); if (ferror(in) || size != dm_media_cipher_size(job->size) || memcmp(digest, job->hash, 32)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: custody hash/size/read failed bytes=%llu expected=%llu", (unsigned long long)size, (unsigned long long)dm_media_cipher_size(job->size)); } else job->error = 0; if (!job->error) { FILE* durable = fopen(job->cipher, "rb+"); if (!durable) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: custody sync open failed"); job->error = -1; } else { job->error = dm_file_sync(durable); if (fclose(durable)) job->error = -1; } } } if (!job->error && out) job->error = dm_file_sync(out); done: if (in && fclose(in)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: worker input close failed"); job->error = -1; } if (out && fclose(out)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: worker output close failed"); job->error = -1; } if (!job->error) { if (job->work == DM_FILE_PREPARE) { job->error = file_io_publish(job->temp, job->cipher); if (!job->error) job->error = file_io_sync_directory(job->plain); } else job->error = file_io_publish(job->work == DM_FILE_CUSTODY ? job->cipher : job->temp, job->output); } } static void dm_custody_finalize(struct dm_media_state* media, const uint8_t id[16]); /* Только uasync публикует проверенный файл и изменяет durable состояние. */ static void dm_file_done(void* arg, int error) { struct dm_file_job* job = arg; struct dm_media_state* media = job->media; struct UTUN_INSTANCE* inst = media->inst; if (job->work == DM_FILE_DELETE) { if (!error && !job->error && !media->closing) dm_custody_finalize(media, job->id); else DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: custody deletion worker failed/cancelled error=%d worker=%d", error, job->error); dm_job_free(job); return; } int custody = job->work == DM_FILE_CUSTODY; if (error || job->error || media->closing) goto fail; if (!custody && !dm_history_current(inst, job->conv, job->generation)) { DEBUG_INFO(DEBUG_CATEGORY_DM, "DM files: discard deleted preparation conv=%s generation=%llu work=%d", job->conv, (unsigned long long)job->generation, job->work); goto fail; } if (job->work == DM_FILE_PREPARE) { uint8_t data[DM_MEDIA_DESC_SIZE + ATTACHMENT_WIRE_MAX]; memcpy(data, job->id, 16); memcpy(data + 16, &job->size, 8); memcpy(data + 24, job->hash, 32); int n = attachment_encode(&job->info, data + DM_MEDIA_DESC_SIZE, ATTACHMENT_WIRE_MAX); if (n < 0) goto fail; if (dm_send(inst, job->conv, "file", data, (uint32_t)(DM_MEDIA_DESC_SIZE + n))) goto fail; DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_media: prepared conv=%s bytes=%llu name=%s", job->conv, (unsigned long long)job->size, job->info.name); chat_event_attachment(inst, 1, job->conv, job->id, 1); dm_job_free(job); return; } const char* sql = custody ? "SELECT body,supernode,group_id,ready,expires FROM dm_custody WHERE id=?" : "SELECT body,author,group_id,ready,0 FROM dm_files WHERE id=? AND dir=0"; sqlite3_stmt* st = dm_media_prepare(inst, sql); if (!st) goto fail; sqlite3_bind_blob(st, 1, job->id, 16, SQLITE_STATIC); int rc = sqlite3_step(st); uint8_t body[DM_MEDIA_BODY_MAX]; size_t len = rc == SQLITE_ROW ? (size_t)sqlite3_column_bytes(st, 0) : 0; uint64_t peer = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 1) : 0; uint64_t group = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 2) : 0; int ready = rc == SQLITE_ROW ? sqlite3_column_int(st, 3) : -1; uint64_t expires = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 4) : 0; if (len <= sizeof(body) && len) memcpy(body, sqlite3_column_blob(st, 0), len); sqlite3_finalize(st); if (ready < 0 || !len || len > sizeof(body) || (custody && expires <= ntp_time_get_seconds(inst))) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: completed task no longer admissible custody=%d ready=%d expiry=%llu", custody, ready, (unsigned long long)expires); goto fail; } uint8_t receipt[DM_MEDIA_RECEIPT_SIZE]; if (!custody) { memcpy(receipt, "DMFILE01", 8); memcpy(receipt + 8, body, 16); memcpy(receipt + 24, body + 24, 8); memcpy(receipt + 32, &inst->node_id, 8); memcpy(receipt + 40, job->id, 16); SHA256(body, len, receipt + 56); if (sc_ed25519_sign(inst->my_ed25519_privkey, receipt, 88, receipt + 88) != SC_OK) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: media receipt signing failed"); goto fail; } } st = dm_media_prepare(inst, custody ? "UPDATE dm_custody SET ready=1 WHERE id=? AND ready=0" : "UPDATE dm_files SET ready=1,downloading=0,receipt=? WHERE id=? AND ready<=0"); if (!st) goto fail; int idx = 1; if (!custody) sqlite3_bind_blob(st, idx++, receipt, sizeof(receipt), SQLITE_STATIC); sqlite3_bind_blob(st, idx, job->id, 16, SQLITE_STATIC); if (dm_media_step(inst, st) || sqlite3_changes(inst->topo_sqlite_db) != 1) goto fail; if (custody) { uint8_t stored[24]; memcpy(stored, job->id, 16); memcpy(stored + 16, &expires, 8); dm_media_send(inst, group, peer, DM_MEDIA_STORED, stored, sizeof(stored)); } else { dm_file_remove(job->cipher); dm_media_send(inst, group, peer, DM_MEDIA_RECEIPT, receipt, sizeof(receipt)); dm_mailbox_media_receipt(inst, receipt); uint64_t conv; memcpy(&conv, body, 8); dm_media_notify(inst, conv); } DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_media: file committed custody=%d peer=%llu bytes=%llu expires=%llu", custody, (unsigned long long)peer, (unsigned long long)job->size, (unsigned long long)expires); dm_job_free(job); return; fail: DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: file task failed/cancelled work=%d error=%d worker=%d closing=%d", job->work, error, job->error, media->closing); if (job->work == DM_FILE_PREPARE) { chat_event_attachment(inst, 1, job->conv, job->id, 2); dm_file_remove(job->plain); dm_file_remove(job->cipher); if (job->remove_source) dm_file_remove(job->input); } else if (!custody) { sqlite3_stmt* st = dm_media_prepare(inst, "UPDATE dm_files SET downloading=0 WHERE id=? AND dir=0"); if (st) { sqlite3_bind_blob(st, 1, job->id, 16, SQLITE_STATIC); dm_media_step(inst, st); } } dm_file_remove(job->temp); if (job->output[0]) dm_file_remove(job->output); if (job->work != DM_FILE_PREPARE) dm_file_remove(job->cipher); dm_job_free(job); } static void dm_file_received(void* arg, int error) { struct dm_file_job* job = arg; job->transfer = NULL; if (error || job->media->closing) { dm_file_done(job, error ? error : -2); return; } media_async_submit(job->media->inst->media_async, job->media->inst->ua, dm_file_work, job, dm_file_done, job); } /* Получать файл может endpoint или M; обе операции владеют одним CM handle в transfer. */ static void dm_file_pull(struct dm_media_state* media, int custody, uint64_t group, uint64_t peer, const uint8_t* desc, uint64_t author) { if (dm_job_find(media, desc, custody)) return; struct dm_file_job* job = u_calloc(1, sizeof(*job)); if (!job) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: download allocation failed"); return; } job->media = media; job->work = custody ? DM_FILE_CUSTODY : DM_FILE_RECEIVE; job->author = author; memcpy(job->id, desc, 16); memcpy(&job->size, desc + 16, 8); memcpy(job->hash, desc + 24, 32); if (dm_file_path(media, custody, job->id, ".download", job->cipher, sizeof(job->cipher)) || dm_file_path(media, custody, job->id, ".decrypt", job->temp, sizeof(job->temp)) || dm_file_path(media, custody, job->id, custody ? ".cipher" : ".file", job->output, sizeof(job->output))) { u_free(job); return; } if (!custody) { snprintf(job->conv, sizeof(job->conv), "%llu", (unsigned long long)dm_derive_conv_id(media->inst->node_id, author)); job->generation = dm_history_generation(media->inst, job->conv); if (dm_job_hold(job)) { dm_job_free(job); return; } sqlite3_stmt* st = dm_media_prepare(media->inst, "SELECT x25519_pubkey FROM nodes WHERE node_id=?"); if (!st) { dm_job_free(job); return; } sqlite3_bind_int64(st, 1, (sqlite3_int64)author); int rc = sqlite3_step(st); uint8_t pub[32]; int found = rc == SQLITE_ROW && sqlite3_column_bytes(st, 0) == sizeof(pub); if (found) memcpy(pub, sqlite3_column_blob(st, 0), sizeof(pub)); sqlite3_finalize(st); if (!found || dm_derive_content_key(media->inst->my_keys.private_key, pub, job->key)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: target key unavailable author=%llu", (unsigned long long)author); dm_job_free(job); return; } } job->next = media->jobs; media->jobs = job; job->transfer = file_transfer_pull(media->inst->file_transfer, group, peer, job->id, dm_media_cipher_size(job->size), job->cipher, dm_file_received, job); if (!job->transfer) { dm_file_done(job, -1); return; } if (!custody) { sqlite3_stmt* st = dm_media_prepare(media->inst, "UPDATE dm_files SET downloading=1 WHERE id=? AND dir=0"); if (st) { sqlite3_bind_blob(st, 1, job->id, 16, SQLITE_STATIC); dm_media_step(media->inst, st); } } } /* Общий авторизатор сервера transfer: A отдаёт B/storage, M — только B по custody. */ static int dm_file_lookup(void* arg, uint64_t group, uint64_t peer, const uint8_t id[16], uint64_t size, char* path, size_t cap) { struct dm_media_state* media = arg; struct UTUN_INSTANCE* inst = media->inst; if (media->closing) return -1; for (int custody = 0; custody <= 1; custody++) { sqlite3_stmt* st = dm_media_prepare(inst, custody ? "SELECT author,recipient,plain_size,ready,expires FROM dm_custody WHERE id=?" : "SELECT author,recipient,plain_size,ready,0 FROM dm_files WHERE id=? AND dir=1"); if (!st) return -1; sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC); int rc = sqlite3_step(st); uint64_t author = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 0) : 0; uint64_t recipient = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 1) : 0; uint64_t plain = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 2) : 0; int ready = rc == SQLITE_ROW ? sqlite3_column_int(st, 3) : 0; uint64_t expiry = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 4) : 0; sqlite3_finalize(st); if (!author || ready != 1 || size != dm_media_cipher_size(plain) || (custody && expiry <= ntp_time_get_seconds(inst))) continue; if ((peer == recipient && dm_media_scope(inst, group, peer, NULL, author, recipient)) || (!custody && dm_media_scope(inst, group, peer, "storage", author, recipient))) return dm_file_path(media, custody, id, ".cipher", path, cap); } DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: file serve denied peer=%llu bytes=%llu", (unsigned long long)peer, (unsigned long long)size); return -1; } /* Путь исходника копируется; caller может освободить строку после возврата. */ int dm_send_file(struct UTUN_INSTANCE* inst, const char* conv, const char* path, const struct attachment_info* info, int remove_source) { struct dm_media_state* media = inst ? inst->dm_media : NULL; if (!media || media->closing || !inst->media_async || !conv || !path || attachment_validate(info) || strlen(path) >= 1024) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: invalid file send arguments/state"); return -1; } unsigned preparing = 0; for (struct dm_file_job* job = media->jobs; job; job = job->next) if (job->work == DM_FILE_PREPARE) preparing++; if (preparing >= 4) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: preparation limit reached count=%u", preparing); return -1; } struct dm_file_job* job = u_calloc(1, sizeof(*job)); if (!job) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: preparation allocation failed"); return -1; } job->media = media; job->work = DM_FILE_PREPARE; job->author = inst->node_id; job->generation = dm_history_generation(inst, conv); if (!dm_history_current(inst, conv, job->generation)) { u_free(job); return -1; } snprintf(job->conv, sizeof(job->conv), "%s", conv); job->info = *info; job->remove_source = remove_source; snprintf(job->input, sizeof(job->input), "%s", path); sqlite3_stmt* st = dm_media_prepare(inst, "SELECT peer_x25519 FROM dm_conversations WHERE conv_id=?"); if (!st) { u_free(job); return -1; } sqlite3_bind_text(st, 1, conv, -1, SQLITE_STATIC); uint8_t pub[32]; int rc = sqlite3_step(st), found = rc == SQLITE_ROW && sqlite3_column_bytes(st, 0) == sizeof(pub); if (found) memcpy(pub, sqlite3_column_blob(st, 0), sizeof(pub)); sqlite3_finalize(st); if (!found || dm_derive_content_key(inst->my_keys.private_key, pub, job->key) || RAND_bytes(job->id, sizeof(job->id)) != 1 || dm_file_path(media, 0, job->id, ".file", job->plain, sizeof(job->plain)) || dm_file_path(media, 0, job->id, ".cipher", job->cipher, sizeof(job->cipher)) || dm_file_path(media, 0, job->id, ".encrypt", job->temp, sizeof(job->temp))) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: preparation keys/paths failed conv=%s", conv); OPENSSL_cleanse(job->key, sizeof(job->key)); u_free(job); return -1; } if (dm_job_hold(job)) { dm_job_free(job); return -1; } job->next = media->jobs; media->jobs = job; DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_media: preparing conv=%s kind=%d name=%s", conv, info->kind, info->name); chat_event_attachment(inst, 1, conv, job->id, 0); media_async_submit(inst->media_async, inst->ua, dm_file_work, job, dm_file_done, job); return 0; } /* Удаление непринятого временного файла тоже выполняется вне uasync. */ static void dm_source_cleanup(void* arg) { struct dm_file_req* req = arg; dm_file_remove(req->path); } static void dm_source_cleaned(void* arg, int error) { if (error) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: source cleanup cancelled error=%d", error); dm_source_cleanup(arg); } u_free(arg); } void dm_send_file_trampoline(void* arg) { struct dm_file_req* req = arg; if (!req) return; uint64_t generation = req->generation ? req->generation : dm_history_generation(req->inst, req->conv); int current = dm_history_current(req->inst, req->conv, generation); if (!current || dm_send_file(req->inst, req->conv, req->path, &req->info, req->remove_source)) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM files: send rejected conv=%s generation=%llu", req->conv, (unsigned long long)generation); const uint8_t rejected[16] = {0}; if (current) chat_event_attachment(req->inst, 1, req->conv, rejected, 2); if (req->remove_source) { media_async_submit(req->inst ? req->inst->media_async : NULL, req->inst ? req->inst->ua : NULL, dm_source_cleanup, req, dm_source_cleaned, req); return; } } u_free(req); } /* STORE сначала резервирует место и фиксирует expiry; повтор не продлевает TTL. */ static void dm_custody_store(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t source, const uint8_t* p, size_t len) { if (len < 8) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: short STORE"); return; } uint64_t recipient, author, size; memcpy(&recipient, p, 8); p += 8; len -= 8; const uint8_t* desc = dm_message_media(p, len); if (!desc || dm_verify_message(inst, recipient, p, len)) return; memcpy(&author, p + 24, 8); memcpy(&size, desc + 16, 8); if (!dm_media_scope(inst, group, source, "supernode", author, recipient) || !dm_media_scope(inst, group, inst->node_id, "storage", author, recipient)) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: unauthorized STORE source=%llu group=%llu", (unsigned long long)source, (unsigned long long)group); return; } uint8_t hash[32]; SHA256(p, len, hash); sqlite3_stmt* st = dm_media_prepare(inst, "SELECT body_hash,receipt FROM dm_custody_done WHERE id=?"); if (!st) return; sqlite3_bind_blob(st, 1, desc, 16, SQLITE_STATIC); int rc = sqlite3_step(st); if (rc == SQLITE_ROW) { int exact = sqlite3_column_bytes(st, 0) == 32 && !memcmp(sqlite3_column_blob(st, 0), hash, 32); uint8_t receipt[DM_MEDIA_RECEIPT_SIZE]; int delivered = sqlite3_column_bytes(st, 1) == sizeof(receipt); if (delivered) memcpy(receipt, sqlite3_column_blob(st, 1), sizeof(receipt)); sqlite3_finalize(st); if (!exact) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: STORE conflicts with retired UUID"); return; } dm_media_send(inst, group, source, delivered ? DM_MEDIA_RECEIPT : DM_MEDIA_EXPIRED, delivered ? receipt : desc, delivered ? sizeof(receipt) : 16); return; } sqlite3_finalize(st); if (rc != SQLITE_DONE) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: tombstone lookup failed rc=%d", rc); return; } st = dm_media_prepare(inst, "SELECT body,ready,expires FROM dm_custody WHERE id=?"); if (!st) return; sqlite3_bind_blob(st, 1, desc, 16, SQLITE_STATIC); rc = sqlite3_step(st); if (rc == SQLITE_ROW) { int exact = sqlite3_column_bytes(st, 0) == (int)len && !memcmp(sqlite3_column_blob(st, 0), p, len); int ready = sqlite3_column_int(st, 1); uint64_t expiry = (uint64_t)sqlite3_column_int64(st, 2); sqlite3_finalize(st); if (!exact) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: conflicting custody body"); return; } if (ready == 1 && expiry > ntp_time_get_seconds(inst)) { uint8_t stored[24]; memcpy(stored, desc, 16); memcpy(stored + 16, &expiry, 8); dm_media_send(inst, group, source, DM_MEDIA_STORED, stored, sizeof(stored)); } return; } sqlite3_finalize(st); if (rc != SQLITE_DONE) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: custody lookup failed rc=%d", rc); return; } st = dm_media_prepare(inst, "SELECT COALESCE(sum(cipher_size),0) FROM dm_custody"); if (!st) return; rc = sqlite3_step(st); uint64_t used = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 0) : UINT64_MAX; sqlite3_finalize(st); uint64_t quota = (uint64_t)chat_setting_get_int(inst, "dm_media_storage_mb", 1024) * 1024 * 1024; uint64_t cipher_size = dm_media_cipher_size(size); if (used > quota || cipher_size > quota - used) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: custody quota refused used=%llu requested=%llu quota=%llu", (unsigned long long)used, (unsigned long long)cipher_size, (unsigned long long)quota); uint8_t reject[17]; memcpy(reject, desc, 16); reject[16] = 1; dm_media_send(inst, group, source, DM_MEDIA_REJECT, reject, sizeof(reject)); return; } uint64_t expires = ntp_time_get_seconds(inst) + (uint64_t)chat_setting_get_int(inst, "dm_media_ttl_sec", 604800); st = dm_media_prepare(inst, "INSERT INTO dm_custody(id,author,recipient,plain_size,cipher_size,body,body_hash," "group_id,supernode,expires) VALUES(?,?,?,?,?,?,?,?,?,?)"); if (!st) return; sqlite3_bind_blob(st, 1, desc, 16, SQLITE_STATIC); sqlite3_bind_int64(st, 2, (sqlite3_int64)author); sqlite3_bind_int64(st, 3, (sqlite3_int64)recipient); sqlite3_bind_int64(st, 4, (sqlite3_int64)size); sqlite3_bind_int64(st, 5, (sqlite3_int64)cipher_size); sqlite3_bind_blob(st, 6, p, (int)len, SQLITE_STATIC); sqlite3_bind_blob(st, 7, hash, 32, SQLITE_STATIC); sqlite3_bind_int64(st, 8, (sqlite3_int64)group); sqlite3_bind_int64(st, 9, (sqlite3_int64)source); sqlite3_bind_int64(st, 10, (sqlite3_int64)expires); if (!dm_media_step(inst, st)) DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_media: custody reserved author=%llu recipient=%llu bytes=%llu expires=%llu", (unsigned long long)author, (unsigned long long)recipient, (unsigned long long)cipher_size, (unsigned long long)expires); } /* Удаление подтверждается только после durable удаления файла и копии из БД. */ static void dm_custody_finalize(struct dm_media_state* media, const uint8_t id[16]) { struct UTUN_INSTANCE* inst = media->inst; sqlite3_stmt* st = dm_media_prepare(inst, "SELECT body_hash,receipt,supernode,group_id FROM dm_custody WHERE id=? AND ready=-1"); if (!st) return; sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC); int rc = sqlite3_step(st); uint8_t hash[32], receipt[DM_MEDIA_RECEIPT_SIZE]; if (rc != SQLITE_ROW || sqlite3_column_bytes(st, 0) != sizeof(hash)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: delete state missing/corrupt rc=%d", rc); sqlite3_finalize(st); return; } memcpy(hash, sqlite3_column_blob(st, 0), sizeof(hash)); int delivered = sqlite3_column_bytes(st, 1) == sizeof(receipt); if (delivered) memcpy(receipt, sqlite3_column_blob(st, 1), sizeof(receipt)); uint64_t super = (uint64_t)sqlite3_column_int64(st, 2), group = (uint64_t)sqlite3_column_int64(st, 3); sqlite3_finalize(st); if (dm_media_exec(inst, "BEGIN IMMEDIATE")) return; st = dm_media_prepare(inst, "INSERT INTO dm_custody_done(id,body_hash,receipt) VALUES(?,?,?)" " ON CONFLICT(id) DO UPDATE SET receipt=COALESCE(excluded.receipt,receipt)"); if (!st) goto rollback; sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC); sqlite3_bind_blob(st, 2, hash, 32, SQLITE_STATIC); if (delivered) sqlite3_bind_blob(st, 3, receipt, sizeof(receipt), SQLITE_STATIC); else sqlite3_bind_null(st, 3); if (dm_media_step(inst, st)) goto rollback; st = dm_media_prepare(inst, "DELETE FROM dm_custody WHERE id=? AND ready=-1"); if (!st) goto rollback; sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC); if (dm_media_step(inst, st) || dm_media_exec(inst, "COMMIT")) goto rollback; dm_media_send(inst, group, super, delivered ? DM_MEDIA_DELETE_ACK : DM_MEDIA_EXPIRED, id, 16); DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_media: custody copy removed reason=%s super=%llu", delivered ? "delivered" : "expired", (unsigned long long)super); return; rollback: dm_media_exec(inst, "ROLLBACK"); } /* Удаление больших файлов и fsync каталога выполняются worker, а не uasync. */ static void dm_custody_remove(struct dm_media_state* media, const uint8_t id[16]) { if (dm_job_find(media, id, 1)) return; struct dm_file_job* job = u_calloc(1, sizeof(*job)); if (!job) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: delete worker allocation failed"); return; } job->media = media; job->work = DM_FILE_DELETE; memcpy(job->id, id, 16); if (dm_file_path(media, 1, id, ".cipher", job->cipher, sizeof(job->cipher)) || dm_file_path(media, 1, id, ".download", job->temp, sizeof(job->temp))) { u_free(job); return; } job->next = media->jobs; media->jobs = job; media_async_submit(media->inst->media_async, media->inst->ua, dm_file_work, job, dm_file_done, job); } /* Подпись B позволяет любому авторизованному S завершить удаление точного артефакта. */ static void dm_custody_delete(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t source, const uint8_t* r) { if (dm_media_verify_receipt(inst, r, NULL, 0)) return; uint64_t author, recipient; memcpy(&author, r + 24, 8); memcpy(&recipient, r + 32, 8); /* Для существующей копии точная подпись B разрешает удаление даже после отзыва роли M/S. */ sqlite3_stmt* st = dm_media_prepare(inst, "SELECT body FROM dm_custody WHERE id=?"); if (!st) return; sqlite3_bind_blob(st, 1, r + 40, 16, SQLITE_STATIC); int rc = sqlite3_step(st); if (rc == SQLITE_ROW) { int valid = !dm_media_verify_receipt(inst, r, sqlite3_column_blob(st, 0), (size_t)sqlite3_column_bytes(st, 0)); sqlite3_finalize(st); if (!valid) return; st = dm_media_prepare(inst, "UPDATE dm_custody SET ready=-1,receipt=?,retry_at=0 WHERE id=?"); if (!st) return; sqlite3_bind_blob(st, 1, r, DM_MEDIA_RECEIPT_SIZE, SQLITE_STATIC); sqlite3_bind_blob(st, 2, r + 40, 16, SQLITE_STATIC); if (dm_media_step(inst, st)) return; file_transfer_cancel_object(inst->file_transfer, r + 40); dm_custody_remove(inst->dm_media, r + 40); return; } sqlite3_finalize(st); if (rc != SQLITE_DONE) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: delete lookup failed rc=%d", rc); return; } st = dm_media_prepare(inst, "SELECT body_hash FROM dm_custody_done WHERE id=?"); if (!st) return; sqlite3_bind_blob(st, 1, r + 40, 16, SQLITE_STATIC); rc = sqlite3_step(st); int exact = rc == SQLITE_DONE || (rc == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 32 && !memcmp(sqlite3_column_blob(st, 0), r + 56, 32)); sqlite3_finalize(st); if (!exact) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: delete conflicts with retired artifact"); return; } if (rc == SQLITE_DONE && (!dm_media_scope(inst, group, source, "supernode", author, recipient) || !dm_media_scope(inst, group, inst->node_id, "storage", author, recipient))) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: unknown DELETE without custody grant source=%llu", (unsigned long long)source); return; } st = dm_media_prepare(inst, "INSERT INTO dm_custody_done(id,body_hash,receipt) VALUES(?,?,?)" " ON CONFLICT(id) DO UPDATE SET receipt=excluded.receipt"); if (!st) return; sqlite3_bind_blob(st, 1, r + 40, 16, SQLITE_STATIC); sqlite3_bind_blob(st, 2, r + 56, 32, SQLITE_STATIC); sqlite3_bind_blob(st, 3, r, DM_MEDIA_RECEIPT_SIZE, SQLITE_STATIC); if (!dm_media_step(inst, st)) dm_media_send(inst, group, source, DM_MEDIA_DELETE_ACK, r + 40, 16); } /* OFFER не меняет идентичность беседы. Чужой holder допустим только от S. */ static void dm_media_offer(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t source, const uint8_t* p, size_t len) { if (len <= 24) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: short OFFER"); return; } uint64_t recipient, holder, expiry, author; memcpy(&recipient, p, 8); memcpy(&holder, p + 8, 8); memcpy(&expiry, p + 16, 8); p += 24; len -= 24; const uint8_t* desc = dm_message_media(p, len); if (recipient != inst->node_id || !desc || dm_verify_message(inst, recipient, p, len)) return; memcpy(&author, p + 24, 8); if ((source == author && (holder != author || expiry)) || (source != author && !dm_media_scope(inst, group, source, "supernode", author, recipient)) || (holder != author && !dm_media_scope(inst, group, holder, "storage", author, recipient))) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: unauthorized OFFER source=%llu holder=%llu", (unsigned long long)source, (unsigned long long)holder); return; } uint8_t receipt[DM_RECEIPT_SIZE]; if (dm_accept_message(inst, p, len, receipt)) return; dm_mailbox_receipt(inst, receipt); uint8_t file_receipt[DM_MEDIA_RECEIPT_SIZE]; int deleted = dm_history_deleted(inst, p, len, 0, file_receipt); if (deleted) { if (deleted > 0 && !memcmp(file_receipt, "DMFILE01", 8)) { dm_media_send(inst, group, author, DM_MEDIA_RECEIPT, file_receipt, sizeof(file_receipt)); dm_mailbox_media_receipt(inst, file_receipt); } return; } sqlite3_stmt* st = dm_media_prepare(inst, "UPDATE dm_files SET holder=?,expires=?,group_id=?,retry_at=0 WHERE id=? AND ready<=0"); if (!st) return; sqlite3_bind_int64(st, 1, (sqlite3_int64)holder); sqlite3_bind_int64(st, 2, (sqlite3_int64)expiry); sqlite3_bind_int64(st, 3, (sqlite3_int64)group); sqlite3_bind_blob(st, 4, desc, 16, SQLITE_STATIC); dm_media_step(inst, st); } /* Строгие границы control, независимые подписи DM и квитанций. */ static void dm_media_control(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t source, uint8_t cmd, const uint8_t* p, size_t len) { if (!inst->dm_media || inst->dm_media->closing) return; if (cmd == DM_MEDIA_OFFER) dm_media_offer(inst, group, source, p, len); else if (cmd == DM_MEDIA_STORE) dm_custody_store(inst, group, source, p, len); else if (cmd == DM_MEDIA_DELETE && len == DM_MEDIA_RECEIPT_SIZE) dm_custody_delete(inst, group, source, p); else if (cmd == DM_MEDIA_RECEIPT && len == DM_MEDIA_RECEIPT_SIZE) { uint64_t author; memcpy(&author, p + 24, 8); if (author == inst->node_id) { dm_media_accept_receipt(inst, p); } else dm_mailbox_media_recv(dm_mailbox_get_media(inst), group, source, cmd, p, len); } else if ((cmd == DM_MEDIA_STORED && len == 24) || (cmd == DM_MEDIA_DELETE_ACK && len == 16) || (cmd == DM_MEDIA_EXPIRED && len == 16) || (cmd == DM_MEDIA_REJECT && len == 17)) { dm_mailbox_media_recv(dm_mailbox_get_media(inst), group, source, cmd, p, len); if (cmd == DM_MEDIA_EXPIRED) { sqlite3_stmt* st = dm_media_prepare(inst, "SELECT author,recipient FROM dm_files WHERE id=? AND dir=0 AND ready<=0"); if (!st) return; sqlite3_bind_blob(st, 1, p, 16, SQLITE_STATIC); int rc = sqlite3_step(st); uint64_t author = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 0) : 0; uint64_t recipient = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 1) : 0; sqlite3_finalize(st); if (author && dm_media_scope(inst, group, source, "supernode", author, recipient)) { st = dm_media_prepare(inst, "UPDATE dm_files SET ready=-1,expires=0 WHERE id=? AND ready<=0"); if (st) { sqlite3_bind_blob(st, 1, p, 16, SQLITE_STATIC); dm_media_step(inst, st); } dm_media_notify(inst, dm_derive_conv_id(author, recipient)); } } } else DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: invalid control cmd=%u bytes=%zu", cmd, len); } static void dm_media_recv(struct ETCP_CONN* conn, struct ll_entry* e) { if (!conn || !e || !e->dgram || e->len <= ROUTER_SVC_PAYLOAD_OFF) goto done; uint64_t group, source; memcpy(&group, e->dgram + ROUTER_SVC_GROUP_OFF, 8); memcpy(&source, e->dgram + ROUTER_SVC_SRC_OFF, 8); if (!(e->dgram[ROUTER_SVC_FLAGS_OFF] & ROUTER_FLAG_SIGNED)) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: unsigned control source=%llu", (unsigned long long)source); goto done; } const uint8_t* p = e->dgram + ROUTER_SVC_PAYLOAD_OFF; dm_media_control(conn->instance, group, source, p[0], p + 1, e->len - ROUTER_SVC_PAYLOAD_OFF - 1); done: if (e) { queue_dgram_free(e); queue_entry_free(e); } } /* Текст и файл имеют независимые durable retries; наличие text ACK не останавливает медиа. */ static void dm_files_tick(struct dm_media_state* media, uint64_t now) { struct UTUN_INSTANCE* inst = media->inst; for (int i = 0; i < 8; i++) { sqlite3_stmt* st = dm_media_prepare(inst, "SELECT id,dir,author,recipient,group_id,body,ready,holder,expires,receipt" " FROM dm_files WHERE retry_at<=? ORDER BY retry_at,seq LIMIT 1"); if (!st) return; sqlite3_bind_int64(st, 1, (sqlite3_int64)now); int rc = sqlite3_step(st); if (rc == SQLITE_DONE) { sqlite3_finalize(st); break; } if (rc != SQLITE_ROW || sqlite3_column_bytes(st, 0) != 16) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: local files scan failed rc=%d", rc); sqlite3_finalize(st); return; } uint8_t id[16], body[DM_MEDIA_BODY_MAX], receipt[DM_MEDIA_RECEIPT_SIZE]; memcpy(id, sqlite3_column_blob(st, 0), 16); int dir = sqlite3_column_int(st, 1), ready = sqlite3_column_int(st, 6); uint64_t author = (uint64_t)sqlite3_column_int64(st, 2), recipient = (uint64_t)sqlite3_column_int64(st, 3); uint64_t group = (uint64_t)sqlite3_column_int64(st, 4), holder = (uint64_t)sqlite3_column_int64(st, 7); uint64_t expiry = (uint64_t)sqlite3_column_int64(st, 8); size_t len = (size_t)sqlite3_column_bytes(st, 5); int delivered = sqlite3_column_bytes(st, 9) == sizeof(receipt); if (len <= sizeof(body)) memcpy(body, sqlite3_column_blob(st, 5), len); if (delivered) memcpy(receipt, sqlite3_column_blob(st, 9), sizeof(receipt)); sqlite3_finalize(st); st = dm_media_prepare(inst, "UPDATE dm_files SET retry_at=? WHERE id=?"); if (!st) return; sqlite3_bind_int64(st, 1, (sqlite3_int64)(now + (dir && delivered ? 3600 : 5))); sqlite3_bind_blob(st, 2, id, 16, SQLITE_STATIC); if (dm_media_step(inst, st)) return; const uint8_t* desc = len <= sizeof(body) ? dm_message_media(body, len) : NULL; if (!desc) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: corrupt saved file descriptor"); continue; } if (dir && !delivered) { uint64_t gid = group; if (dm_route_group(inst, recipient, group, &gid)) { uint8_t offer[24 + DM_MEDIA_BODY_MAX]; memcpy(offer, &recipient, 8); memcpy(offer + 8, &author, 8); memset(offer + 16, 0, 8); memcpy(offer + 24, body, len); dm_media_send(inst, gid, recipient, DM_MEDIA_OFFER, offer, len + 24); } else dm_mailbox_put(inst, recipient, author, body, len); } else if (!dir && ready == 1 && delivered) { uint64_t gid = group; if (dm_route_group(inst, author, group, &gid)) dm_media_send(inst, gid, author, DM_MEDIA_RECEIPT, receipt, sizeof(receipt)); dm_mailbox_media_receipt(inst, receipt); } else if (!dir && ready <= 0 && !dm_job_find(media, id, 0)) { uint64_t peer = author, gid = group; if (!dm_route_group(inst, author, group, &gid)) { peer = holder; if (holder == author || (expiry && expiry <= now) || !dm_route_group(inst, holder, group, &gid)) continue; } dm_file_pull(media, 0, gid, peer, desc, author); } } } /* Периодическая уборка TTL не вытесняет подтверждённые файлы раньше их expiry. */ static void dm_custody_tick(struct dm_media_state* media, uint64_t now) { struct UTUN_INSTANCE* inst = media->inst; for (int i = 0; i < 8; i++) { sqlite3_stmt* st = dm_media_prepare(inst, "SELECT id,body,author,supernode,group_id,expires,ready FROM dm_custody" " WHERE retry_at<=? ORDER BY retry_at LIMIT 1"); if (!st) return; sqlite3_bind_int64(st, 1, (sqlite3_int64)now); int rc = sqlite3_step(st); if (rc == SQLITE_DONE) { sqlite3_finalize(st); break; } if (rc != SQLITE_ROW || sqlite3_column_bytes(st, 0) != 16) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: custody scan failed rc=%d", rc); sqlite3_finalize(st); return; } uint8_t id[16], body[DM_MEDIA_BODY_MAX]; memcpy(id, sqlite3_column_blob(st, 0), 16); size_t len = (size_t)sqlite3_column_bytes(st, 1); if (len <= sizeof(body)) memcpy(body, sqlite3_column_blob(st, 1), len); uint64_t author = (uint64_t)sqlite3_column_int64(st, 2), super = (uint64_t)sqlite3_column_int64(st, 3); uint64_t group = (uint64_t)sqlite3_column_int64(st, 4), expires = (uint64_t)sqlite3_column_int64(st, 5); int ready = sqlite3_column_int(st, 6); sqlite3_finalize(st); st = dm_media_prepare(inst, "UPDATE dm_custody SET retry_at=?,ready=CASE WHEN expires<=? THEN -1 ELSE ready END WHERE id=?"); if (!st) return; sqlite3_bind_int64(st, 1, (sqlite3_int64)(now + 5)); sqlite3_bind_int64(st, 2, (sqlite3_int64)now); sqlite3_bind_blob(st, 3, id, 16, SQLITE_STATIC); if (dm_media_step(inst, st)) return; if (expires <= now || ready < 0) { file_transfer_cancel_object(inst->file_transfer, id); dm_custody_remove(media, id); } else if (!ready) { const uint8_t* desc = len <= sizeof(body) ? dm_message_media(body, len) : NULL; uint64_t gid = group; if (desc && dm_route_group(inst, author, group, &gid)) dm_file_pull(media, 1, gid, author, desc, author); } else { uint8_t stored[24]; memcpy(stored, id, 16); memcpy(stored + 16, &expires, 8); dm_media_send(inst, group, super, DM_MEDIA_STORED, stored, sizeof(stored)); } } } static void dm_media_tick(void* arg) { struct dm_media_state* media = arg; media->timer = NULL; uint64_t now = ntp_time_get_seconds(media->inst); dm_files_tick(media, now); dm_custody_tick(media, now); dm_cleanup_pump(media); media->timer = uasync_set_timeout(media->inst->ua, 10000, media, dm_media_tick, "dm_media"); if (!media->timer) DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: retry timer allocation failed"); } int dm_media_init(struct UTUN_INSTANCE* inst) { if (!inst || !inst->dm || !inst->media_async || inst->dm_media) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: invalid initialization state"); return -1; } struct dm_media_state* media = u_calloc(1, sizeof(*media)); if (!media) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: owner allocation failed"); return -1; } media->inst = inst; const char* base = inst->config->global.db_path; if (!base[0]) base = "."; int n = snprintf(media->root, sizeof(media->root), "%s/dm_media", base); int m = snprintf(media->pending, sizeof(media->pending), "%s/dm_pending", base); if (n < 0 || (size_t)n >= sizeof(media->root) || m < 0 || (size_t)m >= sizeof(media->pending) || (utun_mkdir(media->root, 0700) && errno != EEXIST) || (utun_mkdir(media->pending, 0700) && errno != EEXIST)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: cannot create file roots: %s", strerror(errno)); goto fail; } if (dm_media_exec(inst, "CREATE TABLE IF NOT EXISTS dm_files(id BLOB PRIMARY KEY,conv_id TEXT NOT NULL,dir INTEGER NOT NULL," "seq INTEGER NOT NULL,author INTEGER NOT NULL,recipient INTEGER NOT NULL,plain_size INTEGER NOT NULL," "cipher_hash BLOB NOT NULL,body BLOB NOT NULL,group_id INTEGER NOT NULL,local_path TEXT NOT NULL," "ready INTEGER NOT NULL DEFAULT 0,holder INTEGER NOT NULL,expires INTEGER NOT NULL DEFAULT 0," "receipt BLOB,downloading INTEGER NOT NULL DEFAULT 0,retry_at INTEGER NOT NULL DEFAULT 0," "UNIQUE(conv_id,dir,seq))") || dm_media_exec(inst, "CREATE TABLE IF NOT EXISTS dm_custody(id BLOB PRIMARY KEY,author INTEGER NOT NULL,recipient INTEGER NOT NULL," "plain_size INTEGER NOT NULL,cipher_size INTEGER NOT NULL,body BLOB NOT NULL,body_hash BLOB NOT NULL," "group_id INTEGER NOT NULL,supernode INTEGER NOT NULL,expires INTEGER NOT NULL," "ready INTEGER NOT NULL DEFAULT 0,receipt BLOB,retry_at INTEGER NOT NULL DEFAULT 0)") || dm_media_exec(inst, "CREATE TABLE IF NOT EXISTS dm_custody_done(id BLOB PRIMARY KEY,body_hash BLOB NOT NULL,receipt BLOB)") || dm_media_exec(inst, "UPDATE dm_files SET downloading=0,retry_at=0") || dm_media_exec(inst, "UPDATE dm_custody SET retry_at=0")) goto fail; inst->dm_media = media; if (!file_transfer_create(inst, dm_file_lookup, media) || etcp_router_bind(inst, ETCP_RT_ID_DM_MEDIA, dm_media_recv)) goto fail; media->timer = uasync_set_timeout(inst->ua, 10000, media, dm_media_tick, "dm_media"); if (!media->timer) goto fail; DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_media: initialized root=%s custody=%s ttl=%ds quota=%dMiB", media->root, media->pending, chat_setting_get_int(inst, "dm_media_ttl_sec", 604800), chat_setting_get_int(inst, "dm_media_storage_mb", 1024)); return 0; fail: DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: initialization failed"); if (inst->dm_media) dm_media_destroy(inst); else u_free(media); return -1; } /* Снять producers до ожидания workers; callbacks отмены ещё имеют живого owner. */ void dm_media_quiesce(struct UTUN_INSTANCE* inst) { struct dm_media_state* media = inst ? inst->dm_media : NULL; if (!media || media->closing) return; media->closing = 1; if (media->timer) uasync_cancel_timeout(inst->ua, media->timer); media->timer = NULL; etcp_router_unbind(inst, ETCP_RT_ID_DM_MEDIA); file_transfer_destroy(inst->file_transfer); DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_media: quiesced, waiting for workers"); } void dm_media_destroy(struct UTUN_INSTANCE* inst) { if (!inst || !inst->dm_media) return; dm_media_quiesce(inst); struct dm_media_state* media = inst->dm_media; if (media->jobs || media->cleanup_active) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: destroy before media_async workers drained"); return; } inst->dm_media = NULL; DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_media: destroyed, durable files/jobs retained"); u_free(media); }