/* 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 "dm_media_priv.h" #include "dm_media_receipt.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 "../routing_layer/topo_group.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_file_probe; struct dm_media_state { struct UTUN_INSTANCE* inst; char root[768]; char pending[768]; struct dm_file_job* jobs; struct dm_file_probe* probes; 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]; uint8_t wrapped[DM_FILE_KEYS_SIZE]; /* Подписывается с metadata после подготовки. */ 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; uint64_t request_id; unsigned held; }; #define DM_SOURCE_MAX (DM_TARGET_MAX + 64) struct dm_probe_source { uint64_t node, group, sent, rtt; int used; }; struct dm_file_probe { struct dm_file_probe* next; uint8_t desc[DM_MEDIA_DESC_SIZE]; uint64_t token, deadline, author; char conv[64]; unsigned count; struct dm_probe_source sources[DM_SOURCE_MAX]; }; sqlite3_stmt* dm_media_prepare(struct UTUN_INSTANCE* inst, const char* sql) { sqlite3_stmt* st = NULL; int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, sql, -1, &st, NULL); if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: SQL prepare rc=%d error=%s statement=%s", rc, 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)); int final = sqlite3_finalize(st); if (rc == SQLITE_DONE && final != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: SQL finalize rc=%d step=%d error=%s", final, rc, sqlite3_errmsg(inst->topo_sqlite_db)); return rc == SQLITE_DONE && final == SQLITE_OK ? 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) { struct dm_message_view view; return !dm_message_parse(msg, len, &view) && view.size == len ? view.media : NULL; } int dm_media_metadata_decode(const uint8_t* data, size_t size, struct attachment_info* info, const uint8_t** keys) { if (!data || !info || !keys) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM metadata: invalid arguments"); return -1; } const uint8_t* wrapped = NULL; if (size >= 8 && !memcmp(data, "DMKEY001", 8)) { if (size <= DM_MEDIA_KEYS_HEADER_SIZE) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM metadata: truncated file keys"); return -1; } wrapped = data + 8; data += DM_MEDIA_KEYS_HEADER_SIZE; size -= DM_MEDIA_KEYS_HEADER_SIZE; } if (attachment_decode(data, size, info)) return -1; *keys = wrapped; return 0; } /* Получение ключа разрешено только действующему участнику закреплённой беседы. */ static int dm_file_key(struct UTUN_INSTANCE* inst, const uint8_t* body, size_t len, uint8_t out[32]) { struct dm_message_view view; if (dm_message_parse(body, len, &view) || !view.media || view.size != len) return -1; char conv[64]; snprintf(conv, sizeof(conv), "%llu", (unsigned long long)view.conv); struct dm_party local[2], parties[2]; uint8_t key[32], secret[32], plain[DM_DATA_MAX], nonce[DM_NONCE_SIZE]; if (dm_conversation_access(inst, conv, local, key)) return -1; size_t size = 0; const uint8_t* wrapped; struct attachment_info info; int result = -1; dm_build_nonce(view.conv, view.author, view.seq, nonce); if (media_ccm_decrypt(key, nonce, view.ciphertext, view.ciphertext_size, plain, &size) || dm_media_metadata_decode(plain, size, &info, &wrapped)) goto done; if (!wrapped) { if (view.mode != DM_MODE_DEVICE) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM file: GROUPS requires independent file keys"); goto done; } memcpy(out, key, 32); result = 0; goto done; } unsigned side = view.author == inst->node_id ? 0 : 1; if (view.mode == DM_MODE_GROUPS) { int member = dm_message_target(&view, inst->node_id, NULL, NULL); if (member < 0) goto done; side = (unsigned)member; memcpy(parties, view.parties, sizeof(parties)); } else { parties[side] = local[0]; parties[1 - side] = local[1]; } if (!dm_party_private(inst, parties + side, secret)) result = dm_file_keys_open(view.conv, view.author, view.media, parties, side, secret, wrapped, out); done: OPENSSL_cleanse(key, sizeof(key)); OPENSSL_cleanse(secret, sizeof(secret)); OPENSSL_cleanse(plain, sizeof(plain)); if (result) DEBUG_WARN(DEBUG_CATEGORY_DM, "DM file: key unavailable conv=%s author=%llu", conv, (unsigned long long)view.author); return result; } 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) { char path[1024]; if (!dm_file_path(inst->dm_media, 0, sqlite3_column_blob(st, 0), ".cipher", path, sizeof(path))) file_transfer_cancel_path(inst->file_transfer, path); } 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); } int dm_media_close_prepare(struct UTUN_INSTANCE* inst) { if (!inst->dm_media) return 0; return dm_media_exec(inst, "UPDATE dm_local_files SET generation=0 WHERE conv_id IN" " (SELECT conv_id FROM dm_conversations WHERE closed=1) AND NOT EXISTS" " (SELECT 1 FROM dm_files f WHERE f.conv_id=dm_local_files.conv_id AND f.ready=1" " AND (dm_local_files.path=f.local_path OR dm_local_files.path=substr(f.local_path,1,length(f.local_path)-5)||'.cipher'))"); } void dm_media_close_committed(struct UTUN_INSTANCE* inst) { struct dm_media_state* media = inst->dm_media; if (!media) return; for (struct dm_file_job* job = media->jobs, *next; job; job = next) { next = job->next; if (job->transfer && job->work == DM_FILE_RECEIVE && dm_conversation_access(inst, job->conv, NULL, NULL)) { DEBUG_INFO(DEBUG_CATEGORY_DM, "DM files: cancel after group exit conv=%s", job->conv); file_transfer_cancel(job->transfer); } } dm_cleanup_pump(media); } 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); } /* Транзакция 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; 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 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; } dir = author == inst->node_id; /* Здесь направление означает владение оригиналом, а не отображение своей реплики. */ recipient = dir ? (uint64_t)sqlite3_column_int64(st, 0) : inst->node_id; 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," "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_text(st, 10, path, -1, SQLITE_STATIC); sqlite3_bind_int(st, 11, dir ? 1 : 0); sqlite3_bind_int64(st, 12, (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 ? dm_media_targets_seed(inst, msg, len, recipient) : -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, node; memcpy(&author, r + 24, 8); memcpy(&node, r + 32, 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 f.body,t.received,t.taken FROM dm_files f LEFT JOIN dm_file_targets t" " ON t.id=f.id AND t.node=?2 WHERE f.id=?1 AND f.author=?3"); if (!st) return -1; sqlite3_bind_blob(st, 1, r + 40, 16, SQLITE_STATIC); sqlite3_bind_int64(st, 2, (sqlite3_int64)node); sqlite3_bind_int64(st, 3, (sqlite3_int64)author); 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; } int kind = dm_media_receipt_kind(r); int already = rc == SQLITE_ROW && sqlite3_column_bytes(st, kind + 1) == DM_MEDIA_RECEIPT_SIZE; if (rc != SQLITE_ROW || dm_media_receipt_save(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; } sqlite3_finalize(st); if (!kind) { 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 file fact: conv=%llu device=%llu fact=%s", (unsigned long long)conv, (unsigned long long)node, kind ? "opened" : "downloaded"); 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; } if (!dm_route_group(inst, peer, group, &group)) { DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM media: no admitted route peer=%llu cmd=%u", (unsigned long long)peer, cmd); return -1; } 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, uint64_t limit) { 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 > limit || 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, DM_MEDIA_MAX_SIZE); if (job->remove_source && dm_file_remove(job->input)) return; if (copied) return; } else if (job->input[0] && dm_file_copy(job->input, job->cipher, dm_media_cipher_size(job->size))) { 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); if (!job->error) { FILE* durable = fopen(job->cipher, "rb+"); if (!durable) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: received ciphertext sync open failed"); job->error = -1; } else { job->error = dm_file_sync(durable); if (fclose(durable)) job->error = -1; } } } 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]); /* Ответ относится к одному обращению S; свойства физической копии общие для всех обращений. */ static void dm_custody_status(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, const uint8_t assignment[16], const uint8_t id[16]) { sqlite3_stmt* st = dm_media_prepare(inst, "SELECT r.rejected,c.ready,c.expires,c.delete_on_open,d.id IS NOT NULL,d.receipt" " FROM dm_custody_requests r LEFT JOIN dm_custody c ON c.id=r.id LEFT JOIN dm_custody_done d ON d.id=r.id" " WHERE r.supernode=? AND r.assignment_id=? AND r.id=?"); if (!st) return; int rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)peer); if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 2, assignment, 16, SQLITE_STATIC); if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 3, id, 16, SQLITE_STATIC); if (rc == SQLITE_OK) rc = sqlite3_step(st); if (rc != SQLITE_ROW) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM custody: request status unavailable S=%llu rc=%d", (unsigned long long)peer, rc); sqlite3_finalize(st); return; } int rejected = sqlite3_column_int(st, 0), ready = sqlite3_column_type(st, 1) == SQLITE_NULL ? -1 : sqlite3_column_int(st, 1); uint64_t expires = (uint64_t)sqlite3_column_int64(st, 2); uint8_t policy = (uint8_t)sqlite3_column_int(st, 3); int retired = sqlite3_column_int(st, 4), delivered = sqlite3_column_bytes(st, 5) == DM_MEDIA_RECEIPT_SIZE; rc = sqlite3_finalize(st); if (rc != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM custody: status finalize failed rc=%d", rc); return; } uint8_t status[41]; memcpy(status, assignment, 16); memcpy(status + 16, id, 16); if (rejected) { status[32] = (uint8_t)rejected; dm_media_send(inst, group, peer, DM_MEDIA_REJECT, status, 33); } else if (retired) { /* Все факты нужны S при политике «получили все»; один последний receipt недостаточен. */ if (delivered) { st = dm_media_prepare(inst, "SELECT received FROM dm_file_targets WHERE id=?1 AND received IS NOT NULL" " UNION ALL SELECT taken FROM dm_file_targets WHERE id=?1 AND taken IS NOT NULL"); if (!st) return; rc = sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC); uint8_t facts[2 * DM_TARGET_MAX][DM_MEDIA_RECEIPT_SIZE]; unsigned count = 0; if (rc == SQLITE_OK) while ((rc = sqlite3_step(st)) == SQLITE_ROW && count < 2 * DM_TARGET_MAX) { if (sqlite3_column_bytes(st, 0) != DM_MEDIA_RECEIPT_SIZE) { rc = SQLITE_CORRUPT; break; } memcpy(facts[count++], sqlite3_column_blob(st, 0), DM_MEDIA_RECEIPT_SIZE); } int final = sqlite3_finalize(st); if (rc != SQLITE_DONE || final != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM custody: retired facts scan failed rc=%d final=%d", rc, final); return; } for (unsigned i = 0; i < count; i++) dm_media_send(inst, group, peer, DM_MEDIA_RECEIPT, facts[i], sizeof(facts[i])); } dm_media_send(inst, group, peer, delivered ? DM_MEDIA_DELETE_ACK : DM_MEDIA_EXPIRED, status, 32); } else if (ready >= 0 && expires > ntp_time_get_seconds(inst)) { memcpy(status + 32, &expires, 8); status[40] = policy; dm_media_send(inst, group, peer, ready ? DM_MEDIA_STORED : DM_MEDIA_RESERVED, status, sizeof(status)); } } /* Snapshot закрыт до send: локальный S=M может синхронно завершить копию из callback. */ static void dm_custody_notify(struct UTUN_INSTANCE* inst, const uint8_t id[16]) { struct custody_target { uint64_t peer; uint8_t assignment[16]; } *targets = NULL; size_t count = 0, capacity = 0; sqlite3_stmt* st = dm_media_prepare(inst, "SELECT supernode,assignment_id FROM dm_custody_requests WHERE id=? AND rejected=0"); if (!st) return; int rc = sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC); if (rc == SQLITE_OK) while ((rc = sqlite3_step(st)) == SQLITE_ROW) { if (sqlite3_column_bytes(st, 1) != 16) { rc = SQLITE_CORRUPT; break; } if (count == capacity) { size_t next = capacity ? capacity * 2 : 8; struct custody_target* grown = u_realloc(targets, next * sizeof(*targets)); if (!grown) { rc = SQLITE_NOMEM; break; } targets = grown; capacity = next; } targets[count].peer = (uint64_t)sqlite3_column_int64(st, 0); memcpy(targets[count++].assignment, sqlite3_column_blob(st, 1), 16); } int final = sqlite3_finalize(st); if (rc != SQLITE_DONE || final != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM custody: subscriber snapshot failed rc=%d final=%d count=%zu; S retries retained", rc, final, count); else for (size_t i = 0; i < count; i++) dm_custody_status(inst, 0, targets[i].peer, targets[i].assignment, id); u_free(targets); } /* Только 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) || dm_conversation_access(inst, job->conv, NULL, NULL))) { 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 + DM_MEDIA_KEYS_HEADER_SIZE + ATTACHMENT_WIRE_MAX]; memcpy(data, job->id, 16); memcpy(data + 16, &job->size, 8); memcpy(data + 24, job->hash, 32); memcpy(data + DM_MEDIA_DESC_SIZE, "DMKEY001", 8); memcpy(data + DM_MEDIA_DESC_SIZE + 8, job->wrapped, sizeof(job->wrapped)); int n = attachment_encode(&job->info, data + DM_MEDIA_DESC_SIZE + DM_MEDIA_KEYS_HEADER_SIZE, ATTACHMENT_WIRE_MAX); if (n < 0) goto fail; if (dm_send(inst, job->conv, "file", data, (uint32_t)(DM_MEDIA_DESC_SIZE + DM_MEDIA_KEYS_HEADER_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); chat_event_submitted(inst, job->request_id, 1); dm_job_free(job); return; } const char* sql = custody ? "SELECT body,author,ready,expires FROM dm_custody WHERE id=?" : "SELECT body,author,ready,0 FROM dm_files WHERE id=?"; 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; int ready = rc == SQLITE_ROW ? sqlite3_column_int(st, 2) : -1; uint64_t expires = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 3) : 0; if (len <= sizeof(body) && len) memcpy(body, sqlite3_column_blob(st, 0), len); sqlite3_finalize(st); /* EXPIRED завершает копию M; endpoint может принять проверенный оригинал в актуальном поколении. */ if (!len || len > sizeof(body) || (custody && (ready < 0 || 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; } if (!custody && job->input[0]) { st = dm_media_prepare(inst, "SELECT 1 FROM dm_custody WHERE id=? AND ready=1 AND expires>?"); if (!st) goto fail; sqlite3_bind_blob(st, 1, job->id, 16, SQLITE_STATIC); sqlite3_bind_int64(st, 2, ntp_time_get_seconds(inst)); int active = sqlite3_step(st) == SQLITE_ROW; sqlite3_finalize(st); if (!active) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM local download: custody expired during copy"); goto fail; } } uint8_t receipt[DM_MEDIA_RECEIPT_SIZE]; int acknowledge = !custody && job->author != inst->node_id; if (acknowledge && dm_media_receipt_make(inst, body, len, 0, receipt)) goto fail; if (dm_media_exec(inst, "BEGIN IMMEDIATE")) goto fail; st = dm_media_prepare(inst, custody ? "UPDATE dm_custody SET ready=1 WHERE id=? AND ready=0" : acknowledge ? "UPDATE dm_files SET ready=1,downloading=0,receipt=? WHERE id=? AND ready<=0" : "UPDATE dm_files SET ready=1,downloading=0 WHERE id=? AND ready<=0"); if (!st) { dm_media_exec(inst, "ROLLBACK"); goto fail; } int idx = 1; if (acknowledge) 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 || (acknowledge && dm_media_receipt_save(inst, receipt, body, len)) || dm_media_exec(inst, "COMMIT")) { dm_media_exec(inst, "ROLLBACK"); goto fail; } if (!custody && ready < 0) DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_media: recovered expired file conv=%s generation=%llu bytes=%llu", job->conv, (unsigned long long)job->generation, (unsigned long long)job->size); if (custody) { dm_custody_notify(inst, job->id); } else { if (acknowledge) { dm_media_send(inst, 0, 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_submitted(inst, job->request_id, 0); 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=?"); 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, custody ? ".download" : ".cipher", 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) { sqlite3_stmt* st = dm_media_prepare(media->inst, "SELECT conv_id,body FROM dm_files WHERE id=?"); if (!st) { dm_job_free(job); return; } sqlite3_bind_blob(st, 1, job->id, 16, SQLITE_STATIC); int rc = sqlite3_step(st); int found = rc == SQLITE_ROW; if (found) { snprintf(job->conv, sizeof(job->conv), "%s", sqlite3_column_text(st, 0)); found = !dm_file_key(media->inst, sqlite3_column_blob(st, 1), (size_t)sqlite3_column_bytes(st, 1), job->key); } sqlite3_finalize(st); if (!found) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: target key unavailable author=%llu", (unsigned long long)author); dm_job_free(job); return; } job->generation = dm_history_generation(media->inst, job->conv); if (dm_job_hold(job)) { dm_job_free(job); return; } } job->next = media->jobs; media->jobs = job; if (peer == media->inst->node_id) { if (dm_file_path(media, !custody, job->id, ".cipher", job->input, sizeof(job->input))) { dm_file_done(job, -1); return; } DEBUG_INFO(DEBUG_CATEGORY_DM, "DM local copy: custody=%d bytes=%llu", custody, (unsigned long long)job->size); media_async_submit(media->inst->media_async, media->inst->ua, dm_file_work, job, dm_file_done, job); return; } 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=?"); if (st) { sqlite3_bind_blob(st, 1, job->id, 16, SQLITE_STATIC); dm_media_step(media->inst, st); } } } /* Поставщиком может быть любая готовая реплика; UUID сам по себе права не даёт. */ 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 || (peer != inst->node_id && !topo_group_chat_allowed(inst, group, peer))) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM file lookup: stopped/denied group=%llu peer=%llu", (unsigned long long)group, (unsigned long long)peer); return -1; } for (int custody = 0; custody <= 1; custody++) { sqlite3_stmt* st = dm_media_prepare(inst, custody ? "SELECT author,recipient,plain_size,ready,expires,body,NULL FROM dm_custody WHERE id=?" : "SELECT author,recipient,plain_size,ready,0,body,conv_id FROM dm_files WHERE id=?"); 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; int allowed = 0; struct dm_message_view view; if (rc == SQLITE_ROW && !dm_message_parse(sqlite3_column_blob(st, 5), (size_t)sqlite3_column_bytes(st, 5), &view)) { if (view.mode == DM_MODE_DEVICE) allowed = peer == author || peer == recipient; else allowed = dm_party_member(inst, view.parties, peer) == 1 || dm_party_member(inst, view.parties + 1, peer) == 1; } if (!custody && rc == SQLITE_ROW && dm_conversation_access(inst, (const char*)sqlite3_column_text(st, 6), NULL, NULL)) ready = 0; sqlite3_finalize(st); if (!author || ready != 1 || size != dm_media_cipher_size(plain) || (custody && expiry <= ntp_time_get_seconds(inst))) continue; if (allowed || (!custody && dm_peer_role(inst, peer, "storage"))) 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; } /* Не менять активную передачу из-за позднего ответа; оставшиеся ответы — резерв. */ static struct dm_file_probe* dm_probe_find(struct dm_media_state* media, const uint8_t id[16]) { for (struct dm_file_probe* p = media->probes; p; p = p->next) if (!memcmp(p->desc, id, 16)) return p; return NULL; } static void dm_probe_add(struct dm_media_state* media, struct dm_file_probe* p, uint64_t node, uint64_t preferred) { for (unsigned i = 0; i < p->count; i++) if (p->sources[i].node == node) return; uint64_t group = preferred; if (node != media->inst->node_id && !dm_route_group(media->inst, node, preferred, &group)) return; if (p->count == DM_SOURCE_MAX) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM sources: candidate limit reached conv=%s", p->conv); return; } struct dm_probe_source* source = p->sources + p->count++; source->node = node; source->group = group; source->sent = get_time_tb(); uint8_t request[64]; uint64_t plain, size; memcpy(&plain, p->desc + 16, 8); size = dm_media_cipher_size(plain); memcpy(request, p->desc, 16); memcpy(request + 16, p->desc + 24, 32); memcpy(request + 48, &p->token, 8); memcpy(request + 56, &size, 8); if (dm_media_send(media->inst, group, node, DM_MEDIA_HAVE, request, sizeof(request))) source->used = 1; } /* Одна ограниченная параллельная проба по устройствам обеих сторон и известным M. */ static void dm_probe_start(struct dm_media_state* media, const uint8_t* body, size_t len, uint64_t preferred) { struct dm_message_view view; if (dm_message_parse(body, len, &view) || !view.media || dm_probe_find(media, view.media)) return; struct dm_file_probe* p = u_calloc(1, sizeof(*p)); if (!p) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM sources: allocation failed"); return; } memcpy(p->desc, view.media, sizeof(p->desc)); p->author = view.author; snprintf(p->conv, sizeof(p->conv), "%llu", (unsigned long long)view.conv); struct dm_party parties[2]; struct dm_target targets[DM_TARGET_MAX]; size_t count; if (dm_conversation_access(media->inst, p->conv, parties, NULL) || dm_identity_targets(media->inst, parties, targets, DM_TARGET_MAX, &count) || RAND_bytes((uint8_t*)&p->token, 8) != 1) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM sources: party/source snapshot unavailable conv=%s", p->conv); u_free(p); return; } p->deadline = get_time_tb() + 10000; p->next = media->probes; media->probes = p; for (size_t i = 0; i < count; i++) if (targets[i].node != media->inst->node_id) dm_probe_add(media, p, targets[i].node, preferred); sqlite3_stmt* st = dm_media_prepare(media->inst, "SELECT node FROM dm_file_sources WHERE id=? AND (expires=0 OR expires>?)"); if (st) { sqlite3_bind_blob(st, 1, p->desc, 16, SQLITE_STATIC); sqlite3_bind_int64(st, 2, ntp_time_get_seconds(media->inst)); uint64_t nodes[64]; unsigned n = 0; int rc; while ((rc = sqlite3_step(st)) == SQLITE_ROW && n < 64) { nodes[n++] = (uint64_t)sqlite3_column_int64(st, 0); } sqlite3_finalize(st); if (rc != SQLITE_DONE) DEBUG_WARN(DEBUG_CATEGORY_DM, "DM sources: holder scan incomplete rc=%d count=%u", rc, n); for (unsigned i = 0; i < n; i++) dm_probe_add(media, p, nodes[i], 0); } DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM sources: parallel HAVE conv=%s candidates=%u window_ms=1000", p->conv, p->count); } static void dm_probe_control(struct dm_media_state* media, uint64_t group, uint64_t peer, int reply, const uint8_t* request) { uint64_t token, size; memcpy(&token, request + 48, 8); memcpy(&size, request + 56, 8); if (reply) { struct dm_file_probe* p = dm_probe_find(media, request); if (!p || p->token != token || memcmp(request + 16, p->desc + 24, 32) || get_time_tb() > p->deadline) return; for (unsigned i = 0; i < p->count; i++) { struct dm_probe_source* s = p->sources + i; if (s->node == peer && !s->used && !s->rtt) { s->group = group; s->rtt = get_time_tb() - s->sent + 1; DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM sources: HAVE peer=%llu rtt_us=%llu", (unsigned long long)peer, (unsigned long long)(s->rtt * 100)); return; } } return; } sqlite3_stmt* st = dm_media_prepare(media->inst, "SELECT body FROM dm_files WHERE id=? UNION ALL SELECT body FROM dm_custody WHERE id=?"); if (!st) return; sqlite3_bind_blob(st, 1, request, 16, SQLITE_STATIC); sqlite3_bind_blob(st, 2, request, 16, SQLITE_STATIC); int found = 0, rc; while ((rc = sqlite3_step(st)) == SQLITE_ROW) { const uint8_t* desc = dm_message_media(sqlite3_column_blob(st, 0), (size_t)sqlite3_column_bytes(st, 0)); if (desc && !memcmp(desc + 24, request + 16, 32)) { found = 1; break; } } sqlite3_finalize(st); if (!found) return; char path[1024]; struct stat sb; if (!dm_file_lookup(media, group, peer, request, size, path, sizeof(path)) && !stat(path, &sb) && (uint64_t)sb.st_size == size) dm_media_send(media->inst, group, peer, DM_MEDIA_HAVE_ACK, request, 64); } /* После окна проб выбираем минимальный RTT. Ошибка transfer/проверки оставляет резервные источники. */ static void dm_probe_tick(struct dm_media_state* media) { struct dm_file_probe** link = &media->probes; while (*link) { struct dm_file_probe* p = *link; if (get_time_tb() < p->deadline || dm_job_find(media, p->desc, 0)) { link = &p->next; continue; } sqlite3_stmt* st = dm_media_prepare(media->inst, "SELECT f.ready,c.closed FROM dm_files f JOIN dm_conversations c USING(conv_id) WHERE f.id=?"); if (!st) return; sqlite3_bind_blob(st, 1, p->desc, 16, SQLITE_STATIC); int rc = sqlite3_step(st); int pending = rc == SQLITE_ROW && sqlite3_column_int(st, 0) != 1 && !sqlite3_column_int(st, 1); sqlite3_finalize(st); unsigned best = p->count; if (pending) for (unsigned i = 0; i < p->count; i++) if (!p->sources[i].used && p->sources[i].rtt && (best == p->count || p->sources[i].rtt < p->sources[best].rtt)) best = i; if (best == p->count) { *link = p->next; u_free(p); continue; } struct dm_probe_source* s = p->sources + best; s->used = 1; if (s->node != media->inst->node_id && !dm_route_group(media->inst, s->node, s->group, &s->group)) { DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM sources: candidate lost route peer=%llu", (unsigned long long)s->node); continue; } DEBUG_INFO(DEBUG_CATEGORY_DM, "DM sources: download conv=%s peer=%llu rtt_us=%llu", p->conv, (unsigned long long)s->node, (unsigned long long)(s->rtt * 100)); dm_file_pull(media, 0, s->group, s->node, p->desc, p->author); link = &p->next; } } /* Путь исходника копируется; caller может освободить строку после возврата. */ int dm_send_file(struct UTUN_INSTANCE* inst, const char* conv, const char* path, const struct attachment_info* info, int remove_source, uint64_t request_id) { 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); job->request_id = request_id; if (!dm_history_current(inst, conv, job->generation) || dm_conversation_access(inst, conv, NULL, NULL)) { 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); struct dm_party parties[2]; if (dm_conversation_access(inst, conv, parties, NULL) || RAND_bytes(job->id, sizeof(job->id)) != 1 || dm_file_keys_create(strtoull(conv, NULL, 10), inst->node_id, job->id, parties, job->wrapped, job->key) || 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, req->request_id)) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM files: send rejected conv=%s generation=%llu", req->conv, (unsigned long long)generation); chat_event_submitted(req->inst, req->request_id, 0); 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); } int dm_media_user_action(struct UTUN_INSTANCE* inst, const char* conv, uint64_t author, uint64_t seq, int opened) { if (!inst || !inst->dm_media || inst->dm_media->closing || !conv || (opened != 0 && opened != 1)) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM file action: invalid/stopped request"); return -1; } sqlite3_stmt* st = dm_media_prepare(inst, "SELECT body,ready,local_path,plain_size FROM dm_files WHERE conv_id=? AND author=? AND seq=?"); if (!st) return -1; sqlite3_bind_text(st, 1, conv, -1, SQLITE_STATIC); sqlite3_bind_int64(st, 2, (sqlite3_int64)author); sqlite3_bind_int64(st, 3, (sqlite3_int64)seq); int rc = sqlite3_step(st); uint8_t body[DM_BODY_MAX]; size_t len = rc == SQLITE_ROW ? (size_t)sqlite3_column_bytes(st, 0) : 0; int ready = rc == SQLITE_ROW ? sqlite3_column_int(st, 1) : 0; if (len && len <= sizeof(body)) memcpy(body, sqlite3_column_blob(st, 0), len); int available = 0; if (ready == 1) { struct stat sb; const char* path = (const char*)sqlite3_column_text(st, 2); available = path && !stat(path, &sb) && (uint64_t)sb.st_size == (uint64_t)sqlite3_column_int64(st, 3); } sqlite3_finalize(st); const uint8_t* desc = len <= sizeof(body) ? dm_message_media(body, len) : NULL; if (!desc || (opened && !available)) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM file action: file unavailable conv=%s rc=%d", conv, rc); return -1; } int active = !dm_conversation_access(inst, conv, NULL, NULL); if (opened) { struct dm_message_view view; if (dm_message_parse(body, len, &view)) return -1; if (!active || author == inst->node_id || (view.mode == DM_MODE_GROUPS && dm_message_target(&view, inst->node_id, NULL, NULL) == 0)) return 0; uint8_t r[DM_MEDIA_RECEIPT_SIZE]; if (dm_media_receipt_make(inst, body, len, 1, r) || dm_media_receipt_save(inst, r, body, len)) return -1; DEBUG_INFO(DEBUG_CATEGORY_DM, "DM file: user opened conv=%s author=%llu seq=%llu", conv, (unsigned long long)author, (unsigned long long)seq); } else if (!active) return -1; if (!opened && available) return 0; st = dm_media_prepare(inst, opened ? "UPDATE dm_files SET retry_at=0 WHERE id=?" : "UPDATE dm_files SET ready=0,manual=1,retry_at=0 WHERE id=?"); if (!st) return -1; sqlite3_bind_blob(st, 1, desc, 16, SQLITE_STATIC); if (dm_media_step(inst, st)) return -1; dm_media_notify(inst, strtoull(conv, NULL, 10)); return 0; } void dm_media_action_trampoline(void* arg) { struct dm_media_action_req* req = arg; if (!req) return; if (!dm_history_current(req->inst, req->conv, req->generation) || dm_media_user_action(req->inst, req->conv, req->author, req->seq, req->opened)) DEBUG_WARN(DEBUG_CATEGORY_DM, "DM file action: rejected conv=%s author=%llu seq=%llu opened=%d", req->conv, (unsigned long long)req->author, (unsigned long long)req->seq, req->opened); 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 < 24) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: short STORE"); return; } const uint8_t* assignment = p; static const uint8_t zero[16]; if (!memcmp(assignment, zero, 16)) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM custody: zero assignment ID"); return; } p += 16; len -= 16; 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); uint8_t hash[32]; SHA256(p, len, hash); sqlite3_stmt* st = dm_media_prepare(inst, "SELECT id,body_hash FROM dm_custody_requests WHERE supernode=? AND assignment_id=?"); if (!st) return; int rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)source); if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 2, assignment, 16, SQLITE_STATIC); if (rc == SQLITE_OK) rc = sqlite3_step(st); int exists = rc == SQLITE_ROW; int exact = exists && sqlite3_column_bytes(st, 0) == 16 && !memcmp(sqlite3_column_blob(st, 0), desc, 16) && sqlite3_column_bytes(st, 1) == 32 && !memcmp(sqlite3_column_blob(st, 1), hash, 32); int final = sqlite3_finalize(st); if ((rc != SQLITE_ROW && rc != SQLITE_DONE) || final != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM custody: assignment lookup failed rc=%d final=%d", rc, final); return; } if (exists) { if (!exact) DEBUG_WARN(DEBUG_CATEGORY_DM, "DM custody: assignment conflicts with file/body S=%llu", (unsigned long long)source); else dm_custody_status(inst, group, source, assignment, desc); return; } if (!dm_peer_role(inst, source, "supernode") || !dm_peer_role(inst, inst->node_id, "storage")) { DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: unauthorized STORE source=%llu group=%llu", (unsigned long long)source, (unsigned long long)group); return; } if (dm_media_exec(inst, "BEGIN IMMEDIATE")) return; st = dm_media_prepare(inst, "SELECT body_hash FROM dm_custody WHERE id=?1 UNION ALL SELECT body_hash FROM dm_custody_done WHERE id=?1"); if (!st) goto rollback; rc = sqlite3_bind_blob(st, 1, desc, 16, SQLITE_STATIC); if (rc == SQLITE_OK) rc = sqlite3_step(st); exists = rc == SQLITE_ROW; exact = !exists || (sqlite3_column_bytes(st, 0) == 32 && !memcmp(sqlite3_column_blob(st, 0), hash, 32)); if (rc == SQLITE_ROW) rc = sqlite3_step(st); final = sqlite3_finalize(st); if (rc != SQLITE_DONE || final != SQLITE_OK || !exact) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM custody: copy/tombstone conflict or lookup failure rc=%d final=%d exact=%d", rc, final, exact); goto rollback; } int rejected = 0; uint64_t cipher_size = dm_media_cipher_size(size); if (!exists) { st = dm_media_prepare(inst, "SELECT COALESCE(sum(cipher_size),0) FROM dm_custody"); if (!st) goto rollback; rc = sqlite3_step(st); uint64_t used = rc == SQLITE_ROW ? (uint64_t)sqlite3_column_int64(st, 0) : 0; final = sqlite3_finalize(st); if (rc != SQLITE_ROW || final != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM custody: quota read failed rc=%d final=%d; no REJECT", rc, final); goto rollback; } uint64_t quota = (uint64_t)chat_setting_get_int(inst, "dm_media_storage_mb", 1024) * 1024 * 1024; rejected = used > quota || cipher_size > quota - used; if (rejected) DEBUG_WARN(DEBUG_CATEGORY_DM, "DM custody: quota refused used=%llu requested=%llu quota=%llu", (unsigned long long)used, (unsigned long long)cipher_size, (unsigned long long)quota); } if (!exists && !rejected) { uint64_t expires = ntp_time_get_seconds(inst) + (uint64_t)chat_setting_get_int(inst, "dm_media_ttl_sec", 604800); int policy = chat_setting_get_int(inst, "dm_media_delete_on_open", 1); st = dm_media_prepare(inst, "INSERT INTO dm_custody(id,author,recipient,plain_size,cipher_size,body,body_hash," "expires,delete_on_open) VALUES(?,?,?,?,?,?,?,?,?)"); if (!st) goto rollback; rc = sqlite3_bind_blob(st, 1, desc, 16, SQLITE_STATIC); if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 2, (sqlite3_int64)author); if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 3, (sqlite3_int64)recipient); if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 4, (sqlite3_int64)size); if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 5, (sqlite3_int64)cipher_size); if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 6, p, (int)len, SQLITE_STATIC); if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 7, hash, 32, SQLITE_STATIC); if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 8, (sqlite3_int64)expires); if (rc == SQLITE_OK) rc = sqlite3_bind_int(st, 9, policy); if (rc != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM custody: reservation bind failed rc=%d", rc); sqlite3_finalize(st); goto rollback; } if (dm_media_step(inst, st) || dm_media_targets_seed(inst, p, len, recipient)) goto rollback; } st = dm_media_prepare(inst, "INSERT INTO dm_custody_requests(supernode,assignment_id,id,body_hash,rejected) VALUES(?,?,?,?,?)"); if (!st) goto rollback; rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)source); if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 2, assignment, 16, SQLITE_STATIC); if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 3, desc, 16, SQLITE_STATIC); if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 4, hash, 32, SQLITE_STATIC); if (rc == SQLITE_OK) rc = sqlite3_bind_int(st, 5, rejected); if (rc != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM custody: assignment bind failed rc=%d", rc); sqlite3_finalize(st); goto rollback; } if (dm_media_step(inst, st) || dm_media_exec(inst, "COMMIT")) goto rollback; uint64_t token[2], file[2]; memcpy(token, assignment, 16); memcpy(file, desc, 16); DEBUG_INFO(DEBUG_CATEGORY_DM, "DM custody: assignment=%016llx%016llx file=%016llx%016llx S=%llu existing_copy=%d rejected=%d", (unsigned long long)token[0], (unsigned long long)token[1], (unsigned long long)file[0], (unsigned long long)file[1], (unsigned long long)source, exists, rejected); dm_custody_status(inst, group, source, assignment, desc); return; rollback: DEBUG_WARN(DEBUG_CATEGORY_DM, "DM custody: STORE rolled back S=%llu; request can retry, no response", (unsigned long long)source); dm_media_exec(inst, "ROLLBACK"); } /* Удаление подтверждается только после 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,delete_on_open FROM dm_custody WHERE id=? AND ready=-1"); if (!st) return; int rc = sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC); if (rc != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: delete state bind failed rc=%d; retry retained", rc); sqlite3_finalize(st); return; } 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)); int policy = sqlite3_column_int(st, 2); int final = sqlite3_finalize(st); if (final != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: delete state finalize failed rc=%d; retry retained", final); return; } if (dm_media_exec(inst, "BEGIN IMMEDIATE")) return; st = dm_media_prepare(inst, "INSERT INTO dm_custody_done(id,body_hash,receipt,delete_on_open) VALUES(?,?,?,?)" " ON CONFLICT(id) DO UPDATE SET receipt=COALESCE(excluded.receipt,receipt)"); if (!st) goto rollback; rc = sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC); if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 2, hash, 32, SQLITE_STATIC); if (rc == SQLITE_OK) rc = delivered ? sqlite3_bind_blob(st, 3, receipt, sizeof(receipt), SQLITE_STATIC) : sqlite3_bind_null(st, 3); if (rc == SQLITE_OK) rc = sqlite3_bind_int(st, 4, policy); if (rc != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: tombstone bind failed rc=%d; custody retained", rc); sqlite3_finalize(st); goto rollback; } 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; rc = sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC); if (rc != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "dm_media: custody delete bind failed rc=%d; tombstone rolled back", rc); sqlite3_finalize(st); goto rollback; } if (dm_media_step(inst, st) || dm_media_exec(inst, "COMMIT")) goto rollback; dm_custody_notify(inst, id); DEBUG_INFO(DEBUG_CATEGORY_DM, "dm_media: custody copy removed reason=%s super=%llu", delivered ? "delivered" : "expired", (unsigned long long)super); return; rollback: DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: custody retirement rolled back; retry retained, no ACK"); dm_media_exec(inst, "ROLLBACK"); } /* Удаление больших файлов и fsync каталога выполняются worker, а не uasync. */ static void dm_custody_remove(struct dm_media_state* media, const uint8_t id[16]) { char cipher[1024], download[1024]; if (dm_file_path(media, 1, id, ".cipher", cipher, sizeof(cipher)) || dm_file_path(media, 1, id, ".download", download, sizeof(download))) return; file_transfer_cancel_path(media->inst->file_transfer, cipher); file_transfer_cancel_path(media->inst->file_transfer, download); 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); } /* Все факты уже проверены и сохранены. Терминальное состояние предшествует отмене I/O. */ static int dm_custody_complete(struct UTUN_INSTANCE* inst, const uint8_t id[16], int policy) { int done = dm_media_retention_done(inst, id, policy); if (done <= 0) return done; sqlite3_stmt* st = dm_media_prepare(inst, policy ? "UPDATE dm_custody SET ready=-1,retry_at=0,receipt=(SELECT taken FROM dm_file_targets" " WHERE id=? AND side=1 AND taken IS NOT NULL LIMIT 1) WHERE id=? AND ready>=0" : "UPDATE dm_custody SET ready=-1,retry_at=0,receipt=(SELECT received FROM dm_file_targets" " WHERE id=? AND side=1 AND received IS NOT NULL LIMIT 1) WHERE id=? AND ready>=0"); if (!st) return -1; sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC); sqlite3_bind_blob(st, 2, id, 16, SQLITE_STATIC); if (dm_media_step(inst, st)) return -1; if (sqlite3_changes(inst->topo_sqlite_db)) DEBUG_INFO(DEBUG_CATEGORY_DM, "DM custody: retention complete delete_on_open=%d", policy); return 1; } /* DELETE переносит факт; удаление определяет сохранённая политика M. */ static void dm_custody_delete(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t source, const uint8_t assignment[16], const uint8_t* r) { if (dm_media_receipt_kind(r) < 0) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM custody: unknown receipt domain"); return; } /* Для существующей копии точная подпись B разрешает удаление даже после отзыва роли M/S. */ sqlite3_stmt* st = dm_media_prepare(inst, "SELECT body,delete_on_open 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 policy = sqlite3_column_int(st, 1); int valid = !dm_media_receipt_save(inst, r, sqlite3_column_blob(st, 0), (size_t)sqlite3_column_bytes(st, 0)); sqlite3_finalize(st); if (!valid || dm_custody_complete(inst, r + 40, policy) != 1) return; 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_ROW && sqlite3_column_bytes(st, 0) == 32 && !memcmp(sqlite3_column_blob(st, 0), r + 56, 32); sqlite3_finalize(st); if (exact) { uint8_t ack[32]; memcpy(ack, assignment, 16); memcpy(ack + 16, r + 40, 16); dm_media_send(inst, group, source, DM_MEDIA_DELETE_ACK, ack, sizeof(ack)); } else if (rc == SQLITE_DONE) DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM custody: fact before STORE; S retains it for retry"); else DEBUG_WARN(DEBUG_CATEGORY_DM, "dm_media: delete conflicts with retired artifact rc=%d", rc); } /* 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 (!holder || (source == author && (holder != author || expiry)) || (source != author && !dm_peer_role(inst, source, "supernode"))) { 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, "INSERT INTO dm_file_sources(id,node,expires) VALUES(?,?,?) ON CONFLICT(id,node) DO NOTHING"); if (!st) return; sqlite3_bind_blob(st, 1, desc, 16, SQLITE_STATIC); sqlite3_bind_int64(st, 2, (sqlite3_int64)holder); sqlite3_bind_int64(st, 3, (sqlite3_int64)expiry); if (dm_media_step(inst, st)) return; st = dm_media_prepare(inst, "SELECT expires FROM dm_file_sources WHERE id=? AND node=?"); if (!st) return; sqlite3_bind_blob(st, 1, desc, 16, SQLITE_STATIC); sqlite3_bind_int64(st, 2, (sqlite3_int64)holder); int rc = sqlite3_step(st); int current = rc == SQLITE_ROW && sqlite3_column_int64(st, 0) >= 0 && (uint64_t)sqlite3_column_int64(st, 0) == expiry; int final = sqlite3_finalize(st); if (!current || final != SQLITE_OK) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM source: stale/conflicting OFFER holder=%llu expiry=%llu rc=%d final=%d", (unsigned long long)holder, (unsigned long long)expiry, rc, final); return; } st = dm_media_prepare(inst, "UPDATE dm_files SET holder=?,expires=?,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_blob(st, 3, desc, 16, SQLITE_STATIC); dm_media_step(inst, st); } /* Исчезновение одной копии не отменяет файл, другого поставщика или готовую локальную историю. */ static void dm_source_gone(struct UTUN_INSTANCE* inst, uint64_t source, const uint8_t* p) { uint64_t holder; memcpy(&holder, p + 48, 8); if (!holder || (source != holder && !dm_peer_role(inst, source, "supernode"))) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM source: unauthorized removal source=%llu holder=%llu", (unsigned long long)source, (unsigned long long)holder); return; } sqlite3_stmt* st = dm_media_prepare(inst, "SELECT body FROM dm_files WHERE id=?"); if (!st) return; int rc = sqlite3_bind_blob(st, 1, p, 16, SQLITE_STATIC); if (rc == SQLITE_OK) rc = sqlite3_step(st); uint8_t hash[32]; if (rc == SQLITE_ROW) SHA256(sqlite3_column_blob(st, 0), (size_t)sqlite3_column_bytes(st, 0), hash); int exact = rc == SQLITE_ROW && !memcmp(hash, p + 16, 32), final = sqlite3_finalize(st); if (!exact || final != SQLITE_OK) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM source: removal for unknown/conflicting file rc=%d final=%d", rc, final); return; } if (dm_media_exec(inst, "BEGIN IMMEDIATE")) return; st = dm_media_prepare(inst, "INSERT INTO dm_file_sources(id,node,expires) VALUES(?,?,-1)" " ON CONFLICT(id,node) DO UPDATE SET expires=-1"); if (!st) goto rollback; rc = sqlite3_bind_blob(st, 1, p, 16, SQLITE_STATIC); if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 2, (sqlite3_int64)holder); if (rc != SQLITE_OK) { sqlite3_finalize(st); goto rollback; } if (dm_media_step(inst, st)) goto rollback; st = dm_media_prepare(inst, "UPDATE dm_files SET holder=author,expires=0,retry_at=0 WHERE id=? AND holder=? AND ready<=0"); if (!st) goto rollback; rc = sqlite3_bind_blob(st, 1, p, 16, SQLITE_STATIC); if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 2, (sqlite3_int64)holder); if (rc != SQLITE_OK) { sqlite3_finalize(st); goto rollback; } if (dm_media_step(inst, st) || dm_media_exec(inst, "COMMIT")) goto rollback; struct dm_file_probe* probe = dm_probe_find(inst->dm_media, p); if (probe) for (unsigned i = 0; i < probe->count; i++) if (probe->sources[i].node == holder) probe->sources[i].used = 1; DEBUG_INFO(DEBUG_CATEGORY_DM, "DM source: retired holder=%llu; other sources and local file retained", (unsigned long long)holder); return; rollback: DEBUG_WARN(DEBUG_CATEGORY_DM, "DM source: removal rolled back holder=%llu rc=%d", (unsigned long long)holder, rc); dm_media_exec(inst, "ROLLBACK"); } /* Строгие границы 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_HAVE || cmd == DM_MEDIA_HAVE_ACK) && len == 64) dm_probe_control(inst->dm_media, group, source, cmd == DM_MEDIA_HAVE_ACK, p); else if (cmd == DM_MEDIA_STORE) dm_custody_store(inst, group, source, p, len); else if (cmd == DM_MEDIA_DELETE && len == 16 + DM_MEDIA_RECEIPT_SIZE) dm_custody_delete(inst, group, source, p, p + 16); else if (cmd == DM_MEDIA_SOURCE_GONE && len == 56) dm_source_gone(inst, 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 || cmd == DM_MEDIA_RESERVED) && len == 41) || (cmd == DM_MEDIA_DELETE_ACK && len == 32) || (cmd == DM_MEDIA_EXPIRED && len == 32) || (cmd == DM_MEDIA_REJECT && len == 33)) { dm_mailbox_media_recv(dm_mailbox_get_media(inst), group, source, cmd, p, len); } 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; } if (!topo_group_chat_allowed(conn->instance, group, source)) { DEBUG_WARN(DEBUG_CATEGORY_DM, "DM media receive denied group=%llu peer=%llu", (unsigned long long)group, (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); } } /* Автор повторяет все полученные факты к S, остальные устройства — только свои. */ static void dm_file_forward_facts(struct dm_media_state* media, const uint8_t id[16], uint64_t author) { struct UTUN_INSTANCE* inst = media->inst; sqlite3_stmt* st = dm_media_prepare(inst, "SELECT received FROM dm_file_targets WHERE id=?1 AND received IS NOT NULL AND (?2=0 OR node=?2)" " UNION ALL SELECT taken FROM dm_file_targets WHERE id=?1 AND taken IS NOT NULL AND (?2=0 OR node=?2)"); if (!st) return; sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC); sqlite3_bind_int64(st, 2, author == inst->node_id ? 0 : (sqlite3_int64)inst->node_id); uint8_t facts[2 * DM_TARGET_MAX][DM_MEDIA_RECEIPT_SIZE]; unsigned n = 0; int rc; while ((rc = sqlite3_step(st)) == SQLITE_ROW && n < 2 * DM_TARGET_MAX) { if (sqlite3_column_bytes(st, 0) != DM_MEDIA_RECEIPT_SIZE) { rc = SQLITE_CORRUPT; break; } memcpy(facts[n++], sqlite3_column_blob(st, 0), DM_MEDIA_RECEIPT_SIZE); } sqlite3_finalize(st); if (rc != SQLITE_DONE) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM facts: scan failed rc=%d", rc); return; } uint64_t gid; int route = author != inst->node_id && dm_route_group(inst, author, 0, &gid); for (unsigned i = 0; i < n; i++) { if (route) dm_media_send(inst, gid, author, DM_MEDIA_RECEIPT, facts[i], DM_MEDIA_RECEIPT_SIZE); dm_mailbox_media_receipt(inst, facts[i]); } } /* Текст и файл имеют независимые 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,body,ready,manual,first_attempt,plain_size" " FROM dm_files WHERE retry_at<=? AND conv_id IN" " (SELECT conv_id FROM dm_conversations WHERE closed=0) 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]; memcpy(id, sqlite3_column_blob(st, 0), 16); int dir = sqlite3_column_int(st, 1), ready = sqlite3_column_int(st, 4); uint64_t author = (uint64_t)sqlite3_column_int64(st, 2); int manual = sqlite3_column_int(st, 5); uint64_t first = (uint64_t)sqlite3_column_int64(st, 6), size = (uint64_t)sqlite3_column_int64(st, 7); size_t len = (size_t)sqlite3_column_bytes(st, 3); if (len <= sizeof(body)) memcpy(body, sqlite3_column_blob(st, 3), len); sqlite3_finalize(st); st = dm_media_prepare(inst, "UPDATE dm_files SET retry_at=?1,first_attempt=CASE WHEN first_attempt=0 THEN ?3 ELSE first_attempt END WHERE id=?2"); if (!st) return; sqlite3_bind_int64(st, 1, (sqlite3_int64)(now + 5)); sqlite3_bind_int64(st, 3, (sqlite3_int64)now); 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 && ready == 1) { st = dm_media_prepare(inst, "SELECT node FROM dm_file_targets WHERE id=? AND received IS NULL ORDER BY side DESC,node"); if (!st) return; sqlite3_bind_blob(st, 1, id, 16, SQLITE_STATIC); uint64_t nodes[DM_TARGET_MAX]; unsigned count = 0; while ((rc = sqlite3_step(st)) == SQLITE_ROW && count < DM_TARGET_MAX) nodes[count++] = (uint64_t)sqlite3_column_int64(st, 0); sqlite3_finalize(st); if (rc != SQLITE_DONE) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM files: target scan failed rc=%d", rc); continue; } for (unsigned j = 0; j < count; j++) { uint64_t gid; if (!dm_route_group(inst, nodes[j], 0, &gid)) continue; uint8_t offer[24 + DM_MEDIA_BODY_MAX]; memcpy(offer, nodes + j, 8); memcpy(offer + 8, &author, 8); memset(offer + 16, 0, 8); memcpy(offer + 24, body, len); dm_media_send(inst, gid, nodes[j], DM_MEDIA_OFFER, offer, len + 24); } if (count && first && now >= first + 5) dm_mailbox_store_file(inst, nodes[0], body, len); } else if (ready <= 0 && !dm_job_find(media, id, 0)) { uint64_t limit = inst->config->global.chatserver_storage_unit_size; if (manual || (chat_setting_get_int(inst, "storage_autoload", 1) && limit && size <= limit)) dm_probe_start(media, body, len, 0); else DEBUG_DEBUG(DEBUG_CATEGORY_DM, "DM files: waiting for manual download bytes=%llu autoload=%d limit=%llu", (unsigned long long)size, chat_setting_get_int(inst, "storage_autoload", 1), (unsigned long long)limit); } dm_file_forward_facts(media, id, 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,expires,ready,delete_on_open," "EXISTS(SELECT 1 FROM dm_files f WHERE f.id=dm_custody.id AND f.ready=1) 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), expires = (uint64_t)sqlite3_column_int64(st, 3); int ready = sqlite3_column_int(st, 4); int policy = sqlite3_column_int(st, 5); int local = sqlite3_column_int(st, 6); sqlite3_finalize(st); if (ready >= 0 && dm_custody_complete(inst, id, policy) == 1) ready = -1; 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) { dm_custody_remove(media, id); } else if (!ready) { const uint8_t* desc = len <= sizeof(body) ? dm_message_media(body, len) : NULL; uint64_t gid = 0; if (desc && local) dm_file_pull(media, 1, 0, inst->node_id, desc, author); else if (desc && dm_route_group(inst, author, 0, &gid)) dm_file_pull(media, 1, gid, author, desc, author); } } } 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_probe_tick(media); 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, "UPDATE dm_files SET downloading=0,retry_at=0") || dm_media_exec(inst, "UPDATE dm_custody SET retry_at=0") || dm_media_targets_restore(inst)) 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; if (etcp_router_require_crypto(inst, ETCP_RT_ID_DM_MEDIA, ROUTER_FLAG_SIGNED) || etcp_router_set_authorize(inst, ETCP_RT_ID_DM_MEDIA, topo_group_chat_allowed)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM media router policy setup failed"); 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); while (media->probes) { struct dm_file_probe* p = media->probes; media->probes = p->next; u_free(p); } 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); }