From 376d42cd799662c69b3d76ff3a43432548a4fc6af2ad420949038ed7c7428851 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Thu, 15 Jan 2026 18:55:03 +0300 Subject: [PATCH] 15 01 25 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Доделал типы сообщений посты в линии и едиты на них.ответы на них И ответы в другие блокчейны (Все тесты тесты проходят) --- .../src/main/java/blockchain/MsgSubType.java | 59 +- .../java/blockchain/body/BodyHasLine.java | 13 +- .../blockchain/body/BodyRecordParser.java | 18 +- .../blockchain/body/CreateChannelBody.java | 154 ++++++ .../main/java/blockchain/body/TextBody.java | 512 ++++++++++++++---- .../test/it/blockchain/AddBlockSender.java | 18 +- .../java/test/it/blockchain/ChainState.java | 226 +++++--- .../test/it/cases/IT_03_AddBlock_NoAuth.java | 225 +++----- 8 files changed, 863 insertions(+), 362 deletions(-) create mode 100644 shine-server-blockchain/src/main/java/blockchain/body/CreateChannelBody.java diff --git a/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java b/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java index 07af644..c93f1e5 100644 --- a/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java +++ b/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java @@ -9,6 +9,22 @@ package blockchain; * * Важно: * - Значения менять после релиза нельзя (иначе сломается совместимость). + * + * ========================================================================= + * Про EDIT-типы (важные правила, чтобы не было “двойных правок”): + * + * 1) EDIT разрешён ТОЛЬКО автору (в своём блокчейне). + * Никаких “я отредачу чужое” — нельзя. + * + * 2) EDIT всегда ссылается ТОЛЬКО на ОРИГИНАЛ: + * - EDIT_POST -> на исходный POST + * - EDIT_REPLY -> на исходный REPLY + * НЕЛЬЗЯ ссылаться на предыдущий EDIT (цепочка edit-ов запрещена). + * + * 3) REPLY может ссылаться на блоки в чужих линиях / чужих каналах, + * и существование цели на уровне check() не проверяется + * (check() БД не видит). Если цели нет — “никто не увидит” и ок. + * ========================================================================= */ public final class MsgSubType { @@ -18,20 +34,35 @@ public final class MsgSubType { /** HeaderBody: subType всегда 0 (compat). */ public static final short HEADER_COMPAT = 0; + public static final short TECH_CREATE_CHANNEL = 1; /* ===================== TEXT (msg_type=1) ===================== */ - /** Новая публикация. */ - public static final short TEXT_NEW = 1; + /** + * POST — обычный пост в канале (в линии канала). + * Имеет hasLine (prevLineNumber/prevLineHash32/thisLineNumber). + */ + public static final short TEXT_POST = 10; - /** Ответ (reply). */ - public static final short TEXT_REPLY = 2; + /** + * EDIT_POST — редактирование ПОСТА. + * Имеет hasLine (принадлежит линии канала) + * И имеет target на ОРИГИНАЛЬНЫЙ POST (без toBlockchainName). + */ + public static final short TEXT_EDIT_POST = 11; - /** Репост (repost). */ - public static final short TEXT_REPOST = 3; + /** + * REPLY — ответ на сообщение. + * НЕ в линии. Имеет target (toBlockchainName + blockNumber + hash32). + * Может указывать на чужой блокчейн/чужую линию/чужой канал. + */ + public static final short TEXT_REPLY = 20; - /** Редактирование (edit). */ - public static final short TEXT_EDIT = 10; + /** + * EDIT_REPLY — редактирование ОТВЕТА. + * НЕ в линии. Имеет target на ОРИГИНАЛЬНЫЙ REPLY (без toBlockchainName). + */ + public static final short TEXT_EDIT_REPLY = 21; /* ===================== REACTION (msg_type=2) ===================== */ @@ -39,27 +70,19 @@ public final class MsgSubType { public static final short REACTION_LIKE = 1; /* ===================== CONNECTION (msg_type=3) ===================== */ - /** - * Совпадает с ConnectionBody: - * SET: FRIEND=10, CONTACT=20, FOLLOW=30 - * UNSET: UNFRIEND=11, UNCONTACT=21, UNFOLLOW=31 - */ /** Добавить в друзья. */ public static final short CONNECTION_FRIEND = 10; - /** Удалить из друзей. */ public static final short CONNECTION_UNFRIEND = 11; /** Добавить в контакты. */ public static final short CONNECTION_CONTACT = 20; - /** Удалить из контактов. */ public static final short CONNECTION_UNCONTACT = 21; /** Подписаться (follow). */ public static final short CONNECTION_FOLLOW = 30; - /** Отписаться (unfollow). */ public static final short CONNECTION_UNFOLLOW = 31; @@ -67,6 +90,4 @@ public final class MsgSubType { /** Параметр профиля key/value (обе строки). */ public static final short USER_PARAM_TEXT_TEXT = 1; - - -} +} \ No newline at end of file diff --git a/shine-server-blockchain/src/main/java/blockchain/body/BodyHasLine.java b/shine-server-blockchain/src/main/java/blockchain/body/BodyHasLine.java index ebbe72b..d5655af 100644 --- a/shine-server-blockchain/src/main/java/blockchain/body/BodyHasLine.java +++ b/shine-server-blockchain/src/main/java/blockchain/body/BodyHasLine.java @@ -1,13 +1,22 @@ package blockchain.body; /** - * BodyHasLine — для типов, которые имеют линейные поля в body: - * TEXT / CONNECTION / USER_PARAM + * BodyHasLine — для типов, которые имеют линейные поля в body. + * + * В проекте hasLine встречается, например, у: + * - TECH: CREATE_CHANNEL (type=0, subType=1) — идёт по тех-линии + * - TEXT: POST / EDIT_POST (type=1, subType=10/11) — линия канала + * - CONNECTION (type=3) + * - USER_PARAM (type=4) * * Формат линейных полей (BigEndian) в НАЧАЛЕ bodyBytes: * [4] prevLineNumber * [32] prevLineHash32 * [4] thisLineNumber + * + * Важно: + * - Правильность prevLineNumber/hash и согласование thisLineNumber + * проверяется на сервере/в БД при вставке (а не в body.check()). */ public interface BodyHasLine { diff --git a/shine-server-blockchain/src/main/java/blockchain/body/BodyRecordParser.java b/shine-server-blockchain/src/main/java/blockchain/body/BodyRecordParser.java index 355cae3..121a199 100644 --- a/shine-server-blockchain/src/main/java/blockchain/body/BodyRecordParser.java +++ b/shine-server-blockchain/src/main/java/blockchain/body/BodyRecordParser.java @@ -1,7 +1,7 @@ package blockchain.body; /** - * Парсер body теперь выбирает класс по header: type/subType/version, + * Парсер body выбирает класс по header: type/subType/version, * потому что bodyBytes больше НЕ содержат type/subType/version. */ public final class BodyRecordParser { @@ -14,23 +14,31 @@ public final class BodyRecordParser { int t = type & 0xFFFF; int v = version & 0xFFFF; - // ключ = (type<<16)|version (как раньше по смыслу), но берём из HEADER int key = (t << 16) | v; BodyRecord r = switch (key) { - case HeaderBody.KEY -> new HeaderBody(subType, version, bodyBytes); + case HeaderBody.KEY -> { + int st = subType & 0xFFFF; + if (st == (HeaderBody.SUBTYPE_COMPAT & 0xFFFF)) { + yield new HeaderBody(subType, version, bodyBytes); + } + if (st == (CreateChannelBody.SUBTYPE & 0xFFFF)) { + yield new CreateChannelBody(subType, version, bodyBytes); + } + throw new IllegalArgumentException("Unknown TECH subType for type=0 ver=1: subType=" + st); + } + case TextBody.KEY -> new TextBody(subType, version, bodyBytes); case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes); case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes); case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes); + default -> throw new IllegalArgumentException(String.format( "Unknown body type/version from header: type=%d ver=%d subType=%d", t, v, (subType & 0xFFFF) )); }; - // 1) “построили” объект - // 2) ОБЯЗАТЕЛЬНО прогнали валидацию return r.check(); } } \ No newline at end of file diff --git a/shine-server-blockchain/src/main/java/blockchain/body/CreateChannelBody.java b/shine-server-blockchain/src/main/java/blockchain/body/CreateChannelBody.java new file mode 100644 index 0000000..2c9c6c5 --- /dev/null +++ b/shine-server-blockchain/src/main/java/blockchain/body/CreateChannelBody.java @@ -0,0 +1,154 @@ +package blockchain.body; + +import blockchain.MsgSubType; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; + +/** + * CreateChannelBody — TECH сообщение создания канала. + * + * type=0, ver=1 (в заголовке блока) + * subType=MsgSubType.TECH_CREATE_CHANNEL (=1) + * + * Это сообщение идёт по ТЕХ-ЛИНИИ (hasLine): + * - prevLineNumber/hash указывают на предыдущее TECH-сообщение (HEADER или прошлый CREATE_CHANNEL) + * - thisLineNumber: 1,2,3... (тех-нумерация) + * + * bodyBytes (BigEndian): + * [4] prevLineNumber + * [32] prevLineHash32 + * [4] thisLineNumber + * [1] channelNameLen (uint8) + * [N] channelName UTF-8 (^[A-Za-z0-9_]+$) + * + * Важно: + * - канал "0" зарезервирован (создаётся по умолчанию от HEADER), создавать его нельзя. + */ +public final class CreateChannelBody implements BodyRecord, BodyHasLine { + + public static final short TYPE = 0; + public static final short VER = 1; + + public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); + + public static final short SUBTYPE = MsgSubType.TECH_CREATE_CHANNEL; + + private static final byte[] ZERO32 = new byte[32]; + + public final short subType; // из header + public final short version; // из header + + // line + public final int prevLineNumber; + public final byte[] prevLineHash32; // 32 + public final int thisLineNumber; + + // payload + public final String channelName; + + public CreateChannelBody(short subType, short version, byte[] bodyBytes) { + Objects.requireNonNull(bodyBytes, "bodyBytes == null"); + + this.subType = subType; + this.version = version; + + if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { + throw new IllegalArgumentException("CreateChannelBody version must be 1, got=" + (this.version & 0xFFFF)); + } + if ((this.subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) { + throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1), got=" + (this.subType & 0xFFFF)); + } + + if (bodyBytes.length < (4 + 32 + 4) + 1 + 1) { + throw new IllegalArgumentException("CreateChannelBody too short"); + } + + ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); + + this.prevLineNumber = bb.getInt(); + + this.prevLineHash32 = new byte[32]; + bb.get(this.prevLineHash32); + + this.thisLineNumber = bb.getInt(); + + int nameLen = Byte.toUnsignedInt(bb.get()); + if (nameLen <= 0) throw new IllegalArgumentException("channelNameLen is 0"); + if (bb.remaining() != nameLen) { + throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " nameLen=" + nameLen); + } + + byte[] nameBytes = new byte[nameLen]; + bb.get(nameBytes); + + this.channelName = new String(nameBytes, StandardCharsets.UTF_8); + + if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); + } + + public CreateChannelBody(int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, String channelName) { + Objects.requireNonNull(channelName, "channelName == null"); + + this.subType = SUBTYPE; + this.version = VER; + + this.prevLineNumber = prevLineNumber; + this.prevLineHash32 = (prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32)); + this.thisLineNumber = thisLineNumber; + + this.channelName = channelName; + } + + @Override + public CreateChannelBody check() { + if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) + throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1)"); + + if (channelName == null || channelName.isBlank()) + throw new IllegalArgumentException("channelName is blank"); + + if (!channelName.matches("^[A-Za-z0-9_]+$")) + throw new IllegalArgumentException("channelName must match ^[A-Za-z0-9_]+$"); + + if ("0".equals(channelName)) + throw new IllegalArgumentException("channelName \"0\" is reserved"); + + // tech-line: prev обязателен (минимум HEADER=0) + if (prevLineNumber < 0) + throw new IllegalArgumentException("prevLineNumber must be >=0 for CreateChannelBody"); + if (prevLineHash32 == null || prevLineHash32.length != 32) + throw new IllegalArgumentException("prevLineHash32 invalid"); + if (thisLineNumber <= 0) + throw new IllegalArgumentException("thisLineNumber must be >=1 for CreateChannelBody"); + + return this; + } + + @Override + public byte[] toBytes() { + byte[] nameUtf8 = channelName.getBytes(StandardCharsets.UTF_8); + if (nameUtf8.length == 0 || nameUtf8.length > 255) + throw new IllegalArgumentException("channelName utf8 len must be 1..255"); + + int cap = (4 + 32 + 4) + 1 + nameUtf8.length; + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + + bb.putInt(prevLineNumber); + bb.put(prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32)); + bb.putInt(thisLineNumber); + + bb.put((byte) nameUtf8.length); + bb.put(nameUtf8); + + return bb.array(); + } + + /* ====================== BodyHasLine ====================== */ + @Override public int prevLineNumber() { return prevLineNumber; } + @Override public byte[] prevLineHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); } + @Override public int thisLineNumber() { return thisLineNumber; } +} \ No newline at end of file diff --git a/shine-server-blockchain/src/main/java/blockchain/body/TextBody.java b/shine-server-blockchain/src/main/java/blockchain/body/TextBody.java index 341ee41..5f5c51b 100644 --- a/shine-server-blockchain/src/main/java/blockchain/body/TextBody.java +++ b/shine-server-blockchain/src/main/java/blockchain/body/TextBody.java @@ -14,24 +14,74 @@ import java.util.Objects; * TextBody — type=1, ver=1 (в заголовке блока). * * subType (в заголовке блока): - * 1 = NEW - * 2 = REPLY - * 3 = REPOST - * 10 = EDIT + * 10 = POST + * 11 = EDIT_POST + * 20 = REPLY + * 21 = EDIT_REPLY * - * bodyBytes (BigEndian), новый формат: + * ========================================================================= + * КОНЦЕПЦИЯ ЛИНИЙ ДЛЯ ТЕКСТОВЫХ СООБЩЕНИЙ: + * + * POST и EDIT_POST принадлежат ЛИНИИ КАНАЛА и имеют hasLine: * [4] prevLineNumber * [32] prevLineHash32 * [4] thisLineNumber * - * [2] textLenBytes (uint16) - * [N] text UTF-8 + * Канал в POST/EDIT_POST НЕ хранится (channelName не лежит в bodyBytes). + * Канал определяется логически через lineRootBlockNumber: + * - канал "0": lineRootBlockNumber = blockNumber заголовка (HEADER) + * - канал "X": lineRootBlockNumber = blockNumber тех-сообщения CREATE_CHANNEL("X") * - * Далее ТОЛЬКО если subType == REPLY/REPOST/EDIT: - * [1] toBlockchainNameLen (uint8) - * [N] toBlockchainName UTF-8 - * [4] toBlockGlobalNumber (int32) - * [32] toBlockHash32 (raw 32 bytes) + * REPLY и EDIT_REPLY НЕ имеют линии (нет hasLine). + * + * ========================================================================= + * ФОРМАТЫ bodyBytes (BigEndian): + * + * 1) POST (subType=10): + * [4] prevLineNumber + * [32] prevLineHash32 + * [4] thisLineNumber // 0,1,2... + * [2] textLenBytes (uint16) + * [N] text UTF-8 + * + * 2) EDIT_POST (subType=11): + * [4] prevLineNumber + * [32] prevLineHash32 + * [4] thisLineNumber // равен thisLineNumber предыдущего сообщения линии + * + * hasTarget (на ОРИГИНАЛЬНЫЙ POST, toBchName НЕ хранить): + * [4] toBlockGlobalNumber + * [32] toBlockHash32 + * + * [2] textLenBytes (uint16) + * [N] text UTF-8 + * + * 3) REPLY (subType=20) — НЕ в линии: + * hasTarget (может быть на чужой блокчейн; существование НЕ проверяем): + * [1] toBlockchainNameLen (uint8) + * [N] toBlockchainName UTF-8 + * [4] toBlockGlobalNumber + * [32] toBlockHash32 + * + * [2] textLenBytes (uint16) + * [M] text UTF-8 + * + * 4) EDIT_REPLY (subType=21) — НЕ в линии: + * hasTarget (на ОРИГИНАЛЬНЫЙ REPLY, toBchName НЕ хранить): + * [4] toBlockGlobalNumber + * [32] toBlockHash32 + * + * [2] textLenBytes (uint16) + * [N] text UTF-8 + * + * ========================================================================= + * ВАЖНО: + * - Body.check() НЕ имеет доступа к БД, поэтому: + * - не проверяет существование prevLineNumber/hash + * - не проверяет согласование thisLineNumber относительно prev + * - не проверяет существование target для REPLY + * + * Эти проверки выполняются на сервере/в БД при вставке. */ public final class TextBody implements BodyRecord, BodyHasTarget, BodyHasLine { @@ -43,18 +93,25 @@ public final class TextBody implements BodyRecord, BodyHasTarget, BodyHasLine { public final short subType; // из header public final short version; // из header - // линейные поля + // ===== line fields (только для POST/EDIT_POST) ===== + // Для REPLY/EDIT_REPLY эти поля НЕ сериализуются; значения держим как "пустые". public final int prevLineNumber; - public final byte[] prevLineHash32; // 32 + public final byte[] prevLineHash32; // 32 or null public final int thisLineNumber; - // payload + // ===== message text ===== public final String message; - // target (только для reply/repost/edit) - public final String toBlockchainName; - public final int toBlockGlobalNumber; - public final byte[] toBlockHash32; + // ===== target fields ===== + // REPLY: toBlockchainName + globalNumber + hash32 + // EDIT_POST / EDIT_REPLY: только globalNumber + hash32 (без toBlockchainName) + public final String toBlockchainName; // nullable + public final Integer toBlockGlobalNumber; // nullable + public final byte[] toBlockHash32; // nullable(но если target есть -> 32) + + /* ===================================================================== */ + /* ====================== Конструктор из байт ========================== */ + /* ===================================================================== */ public TextBody(short subType, short version, byte[] bodyBytes) { Objects.requireNonNull(bodyBytes, "bodyBytes == null"); @@ -65,52 +122,59 @@ public final class TextBody implements BodyRecord, BodyHasTarget, BodyHasLine { if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { throw new IllegalArgumentException("TextBody version must be 1, got=" + (this.version & 0xFFFF)); } - if (!isValidSubType(this.subType)) { throw new IllegalArgumentException("Bad Text subType: " + (this.subType & 0xFFFF)); } - // минимум: line(4+32+4) + textLen(2) - if (bodyBytes.length < 4 + 32 + 4 + 2) { - throw new IllegalArgumentException("TextBody too short"); - } - ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); - this.prevLineNumber = bb.getInt(); + int st = this.subType & 0xFFFF; - this.prevLineHash32 = new byte[32]; - bb.get(this.prevLineHash32); + if (st == (MsgSubType.TEXT_POST & 0xFFFF)) { + // POST: hasLine + text + ensureMin(bb, (4 + 32 + 4) + 2, "POST too short"); - this.thisLineNumber = bb.getInt(); + this.prevLineNumber = bb.getInt(); + this.prevLineHash32 = new byte[32]; + bb.get(this.prevLineHash32); + this.thisLineNumber = bb.getInt(); - int textLen = Short.toUnsignedInt(bb.getShort()); - if (textLen <= 0) throw new IllegalArgumentException("Text payload is empty"); - if (bb.remaining() < textLen) throw new IllegalArgumentException("Text payload too short (len=" + textLen + ")"); + this.message = readStrictUtf8Len16(bb, "POST text"); - byte[] textBytes = new byte[textLen]; - bb.get(textBytes); + this.toBlockchainName = null; + this.toBlockGlobalNumber = null; + this.toBlockHash32 = null; - var decoder = StandardCharsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT); + ensureNoTail(bb, "POST"); - try { - this.message = decoder.decode(ByteBuffer.wrap(textBytes)).toString(); - } catch (CharacterCodingException e) { - throw new IllegalArgumentException("Text payload is not valid UTF-8", e); - } + } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + // EDIT_POST: hasLine + target(no bch) + text + ensureMin(bb, (4 + 32 + 4) + (4 + 32) + 2, "EDIT_POST too short"); - if (this.message.isBlank()) throw new IllegalArgumentException("Text message is blank"); + this.prevLineNumber = bb.getInt(); + this.prevLineHash32 = new byte[32]; + bb.get(this.prevLineHash32); + this.thisLineNumber = bb.getInt(); - // target only for reply/repost/edit - if (isHasTargetSubType(this.subType)) { - if (bb.remaining() < 1) throw new IllegalArgumentException("Missing toBlockchainNameLen"); + int tgtNum = bb.getInt(); + byte[] tgtHash = new byte[32]; + bb.get(tgtHash); + + this.toBlockchainName = null; + this.toBlockGlobalNumber = tgtNum; + this.toBlockHash32 = tgtHash; + + this.message = readStrictUtf8Len16(bb, "EDIT_POST text"); + + ensureNoTail(bb, "EDIT_POST"); + + } else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { + // REPLY: target(with bch) + text + ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short"); int nameLen = Byte.toUnsignedInt(bb.get()); - if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0"); - if (bb.remaining() < nameLen + 4 + 32) - throw new IllegalArgumentException("Reply/Repost/Edit payload too short"); + if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0"); + ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short"); byte[] nameBytes = new byte[nameLen]; bb.get(nameBytes); @@ -121,40 +185,121 @@ public final class TextBody implements BodyRecord, BodyHasTarget, BodyHasLine { this.toBlockHash32 = new byte[32]; bb.get(this.toBlockHash32); - if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); + this.message = readStrictUtf8Len16(bb, "REPLY text"); + + // line fields отсутствуют в байтах + this.prevLineNumber = -1; + this.prevLineHash32 = null; + this.thisLineNumber = -1; + + ensureNoTail(bb, "REPLY"); + + } else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { + // EDIT_REPLY: target(no bch) + text + ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short"); + + int tgtNum = bb.getInt(); + byte[] tgtHash = new byte[32]; + bb.get(tgtHash); + + this.toBlockchainName = null; + this.toBlockGlobalNumber = tgtNum; + this.toBlockHash32 = tgtHash; + + this.message = readStrictUtf8Len16(bb, "EDIT_REPLY text"); + + // line fields отсутствуют в байтах + this.prevLineNumber = -1; + this.prevLineHash32 = null; + this.thisLineNumber = -1; + + ensureNoTail(bb, "EDIT_REPLY"); } else { - this.toBlockchainName = null; - this.toBlockGlobalNumber = 0; - this.toBlockHash32 = null; - - if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail for subType=NEW, remaining=" + bb.remaining()); + // недостижимо из-за isValidSubType, но пусть будет + throw new IllegalArgumentException("Unsupported Text subType: " + st); } } - public TextBody(int prevLineNumber, + /* ===================================================================== */ + /* ====================== Фабрики (удобно) ============================= */ + /* ===================================================================== */ + + public static TextBody newPost(int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, String message) { + return new TextBody(MsgSubType.TEXT_POST, prevLineNumber, prevLineHash32, thisLineNumber, + message, null, null, null); + } + + public static TextBody newEditPost(int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, + int targetBlockNumber, byte[] targetHash32, + String message) { + return new TextBody(MsgSubType.TEXT_EDIT_POST, prevLineNumber, prevLineHash32, thisLineNumber, + message, null, targetBlockNumber, targetHash32); + } + + public static TextBody newReply(String toBlockchainName, int targetBlockNumber, byte[] targetHash32, String message) { + return new TextBody(MsgSubType.TEXT_REPLY, -1, null, -1, + message, toBlockchainName, targetBlockNumber, targetHash32); + } + + public static TextBody newEditReply(int targetBlockNumber, byte[] targetHash32, String message) { + return new TextBody(MsgSubType.TEXT_EDIT_REPLY, -1, null, -1, + message, null, targetBlockNumber, targetHash32); + } + + /** + * Универсальный конструктор “вручную”. + * Для REPLY/EDIT_REPLY line поля игнорируются при сериализации (их в формате нет). + */ + public TextBody(short subType, + int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, - short subType, String message, String toBlockchainName, Integer toBlockGlobalNumber, byte[] toBlockHash32) { Objects.requireNonNull(message, "message == null"); + if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad Text subType: " + (subType & 0xFFFF)); if (message.isBlank()) throw new IllegalArgumentException("message is blank"); - this.prevLineNumber = prevLineNumber; - this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); - this.thisLineNumber = thisLineNumber; - this.subType = subType; this.version = VER; + int st = subType & 0xFFFF; + + // line применима только к POST/EDIT_POST + if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + this.prevLineNumber = prevLineNumber; + this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); + this.thisLineNumber = thisLineNumber; + } else { + this.prevLineNumber = -1; + this.prevLineHash32 = null; + this.thisLineNumber = -1; + } + this.message = message; - if (isHasTargetSubType(subType)) { + // target правила + if (st == (MsgSubType.TEXT_POST & 0xFFFF)) { + this.toBlockchainName = null; + this.toBlockGlobalNumber = null; + this.toBlockHash32 = null; + + } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null"); + Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); + if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); + + this.toBlockchainName = null; // по ТЗ: не хранить + this.toBlockGlobalNumber = toBlockGlobalNumber; + this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); + + } else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); @@ -165,47 +310,81 @@ public final class TextBody implements BodyRecord, BodyHasTarget, BodyHasLine { this.toBlockchainName = toBlockchainName; this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); + + } else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { + Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null"); + Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); + if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); + + this.toBlockchainName = null; // по ТЗ: не хранить + this.toBlockGlobalNumber = toBlockGlobalNumber; + this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); + } else { + // недостижимо this.toBlockchainName = null; - this.toBlockGlobalNumber = 0; + this.toBlockGlobalNumber = null; this.toBlockHash32 = null; } } private static boolean isValidSubType(short st) { int v = st & 0xFFFF; - return v == (MsgSubType.TEXT_NEW & 0xFFFF) + return v == (MsgSubType.TEXT_POST & 0xFFFF) + || v == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || v == (MsgSubType.TEXT_REPLY & 0xFFFF) - || v == (MsgSubType.TEXT_REPOST & 0xFFFF) - || v == (MsgSubType.TEXT_EDIT & 0xFFFF); - } - - private static boolean isHasTargetSubType(short st) { - int v = st & 0xFFFF; - return v == (MsgSubType.TEXT_REPLY & 0xFFFF) - || v == (MsgSubType.TEXT_REPOST & 0xFFFF) - || v == (MsgSubType.TEXT_EDIT & 0xFFFF); + || v == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF); } @Override public TextBody check() { - if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad Text subType: " + (subType & 0xFFFF)); - if (message == null || message.isBlank()) throw new IllegalArgumentException("Text message is blank"); + if (!isValidSubType(subType)) + throw new IllegalArgumentException("Bad Text subType: " + (subType & 0xFFFF)); - // line fields rule: - if (prevLineNumber == -1) { - if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1"); - if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1"); + if (message == null || message.isBlank()) + throw new IllegalArgumentException("Text message is blank"); + + int st = subType & 0xFFFF; + + // локальные проверки line (БД не трогаем) + if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + if (prevLineHash32 == null || prevLineHash32.length != 32) + throw new IllegalArgumentException("prevLineHash32 invalid"); } else { - if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid"); + // reply/edit_reply: line отсутствует + if (prevLineHash32 != null) + throw new IllegalArgumentException("REPLY/EDIT_REPLY must not contain line hash"); } - if (isHasTargetSubType(subType)) { - if (toBlockchainName == null || toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); - if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); - if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid"); - } else { - if (toBlockchainName != null || toBlockHash32 != null) throw new IllegalArgumentException("SUB_NEW must not contain target fields"); + // target rules + if (st == (MsgSubType.TEXT_POST & 0xFFFF)) { + if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null) + throw new IllegalArgumentException("POST must not contain target fields"); + + } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + if (toBlockchainName != null) + throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName in target"); + if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0) + throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid"); + if (toBlockHash32 == null || toBlockHash32.length != 32) + throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid"); + + } else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { + if (toBlockchainName == null || toBlockchainName.isBlank()) + throw new IllegalArgumentException("REPLY toBlockchainName is blank"); + if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0) + throw new IllegalArgumentException("REPLY toBlockGlobalNumber invalid"); + if (toBlockHash32 == null || toBlockHash32.length != 32) + throw new IllegalArgumentException("REPLY toBlockHash32 invalid"); + + } else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { + if (toBlockchainName != null) + throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName in target"); + if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0) + throw new IllegalArgumentException("EDIT_REPLY toBlockGlobalNumber invalid"); + if (toBlockHash32 == null || toBlockHash32.length != 32) + throw new IllegalArgumentException("EDIT_REPLY toBlockHash32 invalid"); } return this; @@ -217,53 +396,152 @@ public final class TextBody implements BodyRecord, BodyHasTarget, BodyHasLine { if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty"); if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); - int cap = 4 + 32 + 4 - + 2 + msgUtf8.length; + int st = subType & 0xFFFF; - byte[] nameBytes = null; + if (st == (MsgSubType.TEXT_POST & 0xFFFF)) { + // hasLine + text + int cap = (4 + 32 + 4) + 2 + msgUtf8.length; - if (isHasTargetSubType(subType)) { - nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8); - if (nameBytes.length == 0 || nameBytes.length > 255) - throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255"); - if (toBlockHash32 == null || toBlockHash32.length != 32) - throw new IllegalArgumentException("toBlockHash32 != 32"); + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + bb.putInt(prevLineNumber); + bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); + bb.putInt(thisLineNumber); + bb.putShort((short) msgUtf8.length); + bb.put(msgUtf8); + return bb.array(); - cap += 1 + nameBytes.length + 4 + 32; - } + } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + // hasLine + target(no bch) + text + if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber"); + if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32"); - ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + int cap = (4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length; - bb.putInt(prevLineNumber); - bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); - bb.putInt(thisLineNumber); + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + bb.putInt(prevLineNumber); + bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); + bb.putInt(thisLineNumber); - bb.putShort((short) msgUtf8.length); - bb.put(msgUtf8); - - if (isHasTargetSubType(subType)) { - bb.put((byte) nameBytes.length); - bb.put(nameBytes); bb.putInt(toBlockGlobalNumber); bb.put(toBlockHash32); - } - return bb.array(); + bb.putShort((short) msgUtf8.length); + bb.put(msgUtf8); + return bb.array(); + + } else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { + // target(with bch) + text + if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName"); + if (toBlockGlobalNumber == null) throw new IllegalArgumentException("REPLY missing toBlockGlobalNumber"); + if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPLY toBlockHash32 != 32"); + + byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); + if (nameUtf8.length == 0 || nameUtf8.length > 255) + throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255"); + + int cap = 1 + nameUtf8.length + 4 + 32 + + 2 + msgUtf8.length; + + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + bb.put((byte) nameUtf8.length); + bb.put(nameUtf8); + bb.putInt(toBlockGlobalNumber); + bb.put(toBlockHash32); + + bb.putShort((short) msgUtf8.length); + bb.put(msgUtf8); + return bb.array(); + + } else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { + // target(no bch) + text + if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_REPLY missing toBlockGlobalNumber"); + if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_REPLY toBlockHash32 != 32"); + + int cap = (4 + 32) + 2 + msgUtf8.length; + + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + bb.putInt(toBlockGlobalNumber); + bb.put(toBlockHash32); + + bb.putShort((short) msgUtf8.length); + bb.put(msgUtf8); + return bb.array(); + + } else { + throw new IllegalStateException("Unsupported Text subType: " + st); + } } - private static boolean isAllZero32(byte[] b) { - if (b == null || b.length != 32) return true; - for (int i = 0; i < 32; i++) if (b[i] != 0) return false; - return true; + /* ===================================================================== */ + /* ========================== Helpers ================================== */ + /* ===================================================================== */ + + private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) { + int len = Short.toUnsignedInt(bb.getShort()); + if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty"); + if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")"); + + byte[] bytes = new byte[len]; + bb.get(bytes); + + var decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + + try { + String s = decoder.decode(ByteBuffer.wrap(bytes)).toString(); + if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank"); + return s; + } catch (CharacterCodingException e) { + throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e); + } + } + + private static void ensureMin(ByteBuffer bb, int need, String msg) { + if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")"); + } + + private static void ensureNoTail(ByteBuffer bb, String ctx) { + if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining()); } /* ====================== BodyHasLine ====================== */ @Override public int prevLineNumber() { return prevLineNumber; } - @Override public byte[] prevLineHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); } + @Override public byte[] prevLineHash32() { + if (prevLineHash32 == null) return null; + return Arrays.copyOf(prevLineHash32, 32); + } @Override public int thisLineNumber() { return thisLineNumber; } /* ====================== BodyHasTarget ===================== */ - @Override public String toBchName() { return isHasTargetSubType(subType) ? toBlockchainName : null; } - @Override public Integer toBlockGlobalNumber() { return isHasTargetSubType(subType) ? toBlockGlobalNumber : null; } - @Override public byte[] toBlockHashBytes() { return isHasTargetSubType(subType) ? toBlockHash32 : null; } + @Override public String toBchName() { return toBlockchainName; } + @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } + @Override public byte[] toBlockHashBytes() { return toBlockHash32; } + + + + /* ===================================================================== */ + /* ===================== Удобные хелперы (для ChainState) =============== */ + /* ===================================================================== */ + + /** true только для POST / EDIT_POST (т.е. это сообщение в линии канала). */ + public boolean isLineMessage() { + int st = subType & 0xFFFF; + return st == (MsgSubType.TEXT_POST & 0xFFFF) + || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF); + } + + /** true только для EDIT_POST / EDIT_REPLY. */ + public boolean isEditMessage() { + int st = subType & 0xFFFF; + return st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) + || st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF); + } + + /** true только для REPLY / EDIT_REPLY (т.е. “не в линии”). */ + public boolean isReplyFamily() { + int st = subType & 0xFFFF; + return st == (MsgSubType.TEXT_REPLY & 0xFFFF) + || st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF); + } } \ No newline at end of file diff --git a/src/test/java/test/it/blockchain/AddBlockSender.java b/src/test/java/test/it/blockchain/AddBlockSender.java index 80e77cb..5d0935f 100644 --- a/src/test/java/test/it/blockchain/AddBlockSender.java +++ b/src/test/java/test/it/blockchain/AddBlockSender.java @@ -21,8 +21,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; * - signature = Ed25519.sign(hash32) * * ВАЖНО: - * - Линии (prevLine/thisLine) по ТЗ нужны только для TEXT/CONNECTION/USER_PARAM. - * - Здесь НЕТ обращения к blockchain.LineIndex. + * - Линии по ТЗ ведём на стороне сервера/БД (триггеры), а в тестах считаем локально. */ public final class AddBlockSender { @@ -75,7 +74,6 @@ public final class AddBlockSender { byte[] bodyBytes = body.toBytes(); - // preimage -> hash32 -> signature byte[] preimage = buildPreimage(prevHash32, blockNumber, tsSec, type, subType, version, bodyBytes); byte[] hash32 = blockchain.BchCryptoVerifier.sha256(preimage); byte[] signature64 = utils.crypto.Ed25519Util.sign(hash32, loginPrivKey); @@ -102,7 +100,6 @@ public final class AddBlockSender { String serverLastHash = JsonMini.extractPayloadString(resp, "serverLastBlockHash"); if (serverLastHash == null) { - // на всякий случай, но ты говорил старое не поддерживаем — оставил мягко serverLastHash = JsonMini.extractPayloadString(resp, "serverLastGlobalHash"); } @@ -118,15 +115,7 @@ public final class AddBlockSender { assertEquals(localHashHex, serverLastHash, op + ": serverLastBlockHash must match local hash"); - // фиксируем в state глобальную цепочку + (если нужно) line-state по TYPE - state.applyAppendedBlock(blockNumber, entry.getHash32(), isHeader, type); - - // если это line-body — обновим thisLineNumber в state (для nextLineByType()) - if (body instanceof BodyHasLine hl) { - if (ChainState.isLineType(type)) { - state.applyThisLineNumberByType(type, hl.thisLineNumber()); - } - } + state.applyAppendedBlock(blockNumber, entry.getHash32(), isHeader, type, body); if (TestConfig.DEBUG()) TestLog.info(op + ": state updated"); } @@ -174,6 +163,7 @@ public final class AddBlockSender { private static short typeOf(BodyRecord body) { if (body instanceof HeaderBody) return HeaderBody.TYPE; + if (body instanceof CreateChannelBody) return CreateChannelBody.TYPE; if (body instanceof TextBody) return TextBody.TYPE; if (body instanceof ReactionBody) return ReactionBody.TYPE; if (body instanceof ConnectionBody) return ConnectionBody.TYPE; @@ -183,6 +173,7 @@ public final class AddBlockSender { private static short subTypeOf(BodyRecord body) { if (body instanceof HeaderBody hb) return hb.subType; + if (body instanceof CreateChannelBody cb) return cb.subType; if (body instanceof TextBody tb) return tb.subType; if (body instanceof ReactionBody rb) return rb.subType; if (body instanceof ConnectionBody cb) return cb.subType; @@ -192,6 +183,7 @@ public final class AddBlockSender { private static short versionOf(BodyRecord body) { if (body instanceof HeaderBody hb) return hb.version; + if (body instanceof CreateChannelBody cb) return cb.version; if (body instanceof TextBody tb) return tb.version; if (body instanceof ReactionBody rb) return rb.version; if (body instanceof ConnectionBody cb) return cb.version; diff --git a/src/test/java/test/it/blockchain/ChainState.java b/src/test/java/test/it/blockchain/ChainState.java index ad8e411..32d8fdc 100644 --- a/src/test/java/test/it/blockchain/ChainState.java +++ b/src/test/java/test/it/blockchain/ChainState.java @@ -1,32 +1,34 @@ package test.it.blockchain; -import java.util.Arrays; +import blockchain.MsgSubType; +import blockchain.body.BodyRecord; +import blockchain.body.BodyHasLine; +import blockchain.body.CreateChannelBody; +import blockchain.body.TextBody; + import java.util.HashMap; import java.util.Map; /** - * ChainState — состояние глобальной цепочки + состояние линий (только тех, где они нужны). + * ChainState — состояние глобальной цепочки + состояние линий. * * Глобальная цепочка: * - lastBlockNumber / lastBlockHashHex - * - map blockNumber -> hash32 (для ссылок reply/edit/reaction) + * - map blockNumber -> hash32 * - * Линии по ТЗ нужны только для: - * - TEXT (type=1) - * - CONNECTION (type=3) - * - USER_PARAM (type=4) + * Линии: + * - TECH (type=0): только CREATE_CHANNEL (hasLine), root = HEADER + * - TEXT (type=1): линии каналов, root = HEADER (канал "0") или CREATE_CHANNEL (канал "X") + * - CONNECTION (type=3): одна линия + * - USER_PARAM (type=4): одна линия * - * prevLineNumber по ТЗ — это GLOBAL blockNumber предыдущего блока линии. - * thisLineNumber — внутренний номер линии (мы ведём локально: 1,2,3...) - * - * ВАЖНО: - * - Здесь НЕТ обращения к blockchain.LineIndex. - * - Линии адресуются по msg_type (type). + * Важно: + * - prevLineNumber — это GLOBAL blockNumber предыдущего блока линии. + * - thisLineNumber — внутренний номер линии (для постов: 0,1,2...; для тех-линии: 1,2,3...) */ public final class ChainState { - // какие msg_type имеют линейную цепочку по ТЗ - public static final short TYPE_HEADER = 0; + public static final short TYPE_TECH = 0; // header/create_channel public static final short TYPE_TEXT = 1; public static final short TYPE_REACTION = 2; public static final short TYPE_CONNECTION = 3; @@ -42,13 +44,25 @@ public final class ChainState { // header (block#0) private byte[] headerHash32 = null; - /** - * line state per TYPE (только для TEXT/CONNECTION/USER_PARAM): - * - lastGlobalNumber: последний GLOBAL blockNumber в линии - * - lastHashHex: hash последнего блока линии - * - lastThisLineNumber: последний thisLineNumber (внутренний) - */ - private static final class LineState { + private final Map hash32ByNumber = new HashMap<>(); + + // ---------- TECH line state ---------- + private static final class TechLineState { + int lastGlobalNumber = -1; // последний TECH-блок (HEADER или CREATE_CHANNEL) + String lastHashHex = ""; + int lastThisLineNumber = 0; // 0 у HEADER (логически), дальше 1,2,3... + + void reset() { + lastGlobalNumber = -1; + lastHashHex = ""; + lastThisLineNumber = 0; + } + } + + private final TechLineState techLine = new TechLineState(); + + // ---------- CONNECTION/USER_PARAM line state ---------- + private static final class SimpleLineState { int lastGlobalNumber = -1; String lastHashHex = ""; int lastThisLineNumber = 0; @@ -60,14 +74,32 @@ public final class ChainState { } } - private final LineState textLine = new LineState(); - private final LineState connectionLine = new LineState(); - private final LineState userParamLine = new LineState(); + private final SimpleLineState connectionLine = new SimpleLineState(); + private final SimpleLineState userParamLine = new SimpleLineState(); - private final Map hash32ByNumber = new HashMap<>(); + // ---------- TEXT channels ---------- + public static final class ChannelLineState { + final int rootBlockNumber; + final String rootHashHex; + + int lastGlobalNumber; + String lastHashHex; + int lastThisLineNumber; // перед первым постом = -1, чтобы первый был 0 + + ChannelLineState(int rootBlockNumber, String rootHashHex) { + this.rootBlockNumber = rootBlockNumber; + this.rootHashHex = rootHashHex; + this.lastGlobalNumber = rootBlockNumber; + this.lastHashHex = rootHashHex; + this.lastThisLineNumber = -1; + } + } + + // rootBlockNumber -> state + private final Map textChannels = new HashMap<>(); public ChainState() { - textLine.reset(); + techLine.reset(); connectionLine.reset(); userParamLine.reset(); } @@ -113,38 +145,72 @@ public final class ChainState { } } - /** Является ли type "линейным" по ТЗ (т.е. нужно вести prevLine/thisLine). */ - public static boolean isLineType(short type) { - int t = type & 0xFFFF; - return t == TYPE_TEXT || t == TYPE_CONNECTION || t == TYPE_USER_PARAM; - } - - /** Следующие line-поля для указанного TYPE (только TEXT/CONNECTION/USER_PARAM). */ + /** Следующие line-поля для TECH/CONNECTION/USER_PARAM. */ public NextLine nextLineByType(short type) { - if (!isLineType(type)) { - throw new IllegalArgumentException("Type " + (type & 0xFFFF) + " не использует line-поля по ТЗ"); - } if (!hasHeader()) { throw new IllegalStateException("Нельзя формировать line-поля до HEADER (нет headerHash32)"); } - LineState ls = lineStateByType(type); + int t = type & 0xFFFF; + if (t == TYPE_TECH) { + // tech-line: prev = последний TECH; первый CREATE_CHANNEL -> prev = HEADER + if (techLine.lastGlobalNumber == -1) { + // после HEADER мы должны инициализировать techLine (делаем в applyHeader) + throw new IllegalStateException("TECH line is not initialized yet"); + } + return new NextLine(techLine.lastGlobalNumber, hexToBytes32(techLine.lastHashHex), techLine.lastThisLineNumber + 1); + } + + if (t == TYPE_CONNECTION) { + return nextSimpleLine(connectionLine); + } + if (t == TYPE_USER_PARAM) { + return nextSimpleLine(userParamLine); + } + + throw new IllegalArgumentException("Type " + t + " не поддерживает nextLineByType()"); + } + + private NextLine nextSimpleLine(SimpleLineState ls) { if (ls.lastGlobalNumber == -1) { // первый блок линии ссылается на HEADER (block#0) return new NextLine(0, headerHash32.clone(), 1); } - if (ls.lastHashHex == null || ls.lastHashHex.isBlank()) { - throw new IllegalStateException("LineState.lastHashHex пуст, но lastGlobalNumber!=-1 (type=" + (type & 0xFFFF) + ")"); + throw new IllegalStateException("LineState.lastHashHex пуст, но lastGlobalNumber!=-1"); } - return new NextLine(ls.lastGlobalNumber, hexToBytes32(ls.lastHashHex), ls.lastThisLineNumber + 1); } + /** Следующие line-поля для TEXT-канала по rootBlockNumber. */ + public NextLine nextTextLineByRoot(int rootBlockNumber) { + if (!hasHeader()) throw new IllegalStateException("No HEADER"); + ChannelLineState cs = textChannels.get(rootBlockNumber); + if (cs == null) throw new IllegalStateException("Unknown TEXT channel rootBlockNumber=" + rootBlockNumber); + + return new NextLine( + cs.lastGlobalNumber, + hexToBytes32(cs.lastHashHex), + cs.lastThisLineNumber + 1 + ); + } + + /** Зарегистрировать новый канал TEXT по root = CREATE_CHANNEL block. */ + public void registerTextChannelRoot(int rootBlockNumber, byte[] rootHash32) { + if (rootBlockNumber <= 0) throw new IllegalArgumentException("rootBlockNumber must be > 0 for custom channel"); + if (rootHash32 == null || rootHash32.length != 32) throw new IllegalArgumentException("rootHash32 invalid"); + textChannels.put(rootBlockNumber, new ChannelLineState(rootBlockNumber, bytesToHex64(rootHash32))); + } + + /** root канала "0" (по умолчанию) — это HEADER block#0. */ + public int rootChannel0() { + return 0; + } + // -------------------- apply -------------------- - public void applyAppendedBlock(int blockNumber, byte[] hash32, boolean isHeader, short type) { + public void applyAppendedBlock(int blockNumber, byte[] hash32, boolean isHeader, short type, BodyRecord body) { if (hash32 == null || hash32.length != 32) { throw new IllegalArgumentException("hash32 must be 32 bytes"); } @@ -167,30 +233,64 @@ public final class ChainState { hash32ByNumber.put(blockNumber, hash32.clone()); - // обновляем line-state только если этот type по ТЗ линейный - if (isLineType(type)) { - LineState ls = lineStateByType(type); - ls.lastGlobalNumber = blockNumber; - ls.lastHashHex = hex64; - // thisLineNumber обновляется отдельным вызовом (см. applyThisLineNumberByType) + // ---- init after HEADER ---- + if (isHeader) { + // TECH line root = HEADER + techLine.lastGlobalNumber = 0; + techLine.lastHashHex = hex64; + techLine.lastThisLineNumber = 0; + + // TEXT channel "0" root = HEADER, первый пост будет thisLineNumber=0 + textChannels.put(0, new ChannelLineState(0, hex64)); + + return; + } + + int t = type & 0xFFFF; + + // ---- TECH (CREATE_CHANNEL) ---- + if (t == TYPE_TECH && body instanceof CreateChannelBody ccb) { + techLine.lastGlobalNumber = blockNumber; + techLine.lastHashHex = hex64; + techLine.lastThisLineNumber = ccb.thisLineNumber; + return; + } + + // ---- CONNECTION / USER_PARAM ---- + if (t == TYPE_CONNECTION && body instanceof BodyHasLine hlc) { + connectionLine.lastGlobalNumber = blockNumber; + connectionLine.lastHashHex = hex64; + connectionLine.lastThisLineNumber = hlc.thisLineNumber(); + return; + } + if (t == TYPE_USER_PARAM && body instanceof BodyHasLine hlu) { + userParamLine.lastGlobalNumber = blockNumber; + userParamLine.lastHashHex = hex64; + userParamLine.lastThisLineNumber = hlu.thisLineNumber(); + return; + } + + // ---- TEXT channels (POST/EDIT_POST) ---- + if (t == TYPE_TEXT && body instanceof TextBody tb) { + if (tb.isLineMessage()) { + // ищем канал по совпадению prevLineNumber с lastGlobalNumber канала + ChannelLineState channel = findTextChannelByLastGlobal(tb.prevLineNumber); + if (channel == null) { + throw new IllegalStateException("TEXT line message prevLineNumber=" + tb.prevLineNumber + " не привязан ни к одному каналу (канал root не зарегистрирован?)"); + } + + channel.lastGlobalNumber = blockNumber; + channel.lastHashHex = hex64; + channel.lastThisLineNumber = tb.thisLineNumber; + } } } - /** В тестах удобно явно обновлять thisLineNumber после успешной отправки line-body. */ - public void applyThisLineNumberByType(short type, int thisLineNumber) { - if (!isLineType(type)) return; - LineState ls = lineStateByType(type); - ls.lastThisLineNumber = thisLineNumber; - } - - private LineState lineStateByType(short type) { - int t = type & 0xFFFF; - return switch (t) { - case TYPE_TEXT -> textLine; - case TYPE_CONNECTION -> connectionLine; - case TYPE_USER_PARAM -> userParamLine; - default -> throw new IllegalArgumentException("Type " + t + " не имеет LineState по ТЗ"); - }; + private ChannelLineState findTextChannelByLastGlobal(int prevLineNumber) { + for (ChannelLineState cs : textChannels.values()) { + if (cs.lastGlobalNumber == prevLineNumber) return cs; + } + return null; } // -------------------- utils -------------------- diff --git a/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java b/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java index 91dd70e..f41e9a3 100644 --- a/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java +++ b/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java @@ -1,7 +1,7 @@ package test.it.cases; -import blockchain.body.*; import blockchain.MsgSubType; +import blockchain.body.*; import test.it.blockchain.AddBlockSender; import test.it.blockchain.ChainState; import test.it.utils.TestConfig; @@ -14,12 +14,12 @@ import java.time.Duration; import static org.junit.jupiter.api.Assertions.*; /** - * IT_03_AddBlock_NoAuth — обновлён под новый формат блоков (ТЗ). + * IT_03_AddBlock_NoAuth — сценарий блоков (новый формат + каналы). * * ВАЖНО: - * - НЕТ обращения к blockchain.LineIndex (можно удалить LineIndex.java). - * - Линии берём через ChainState.nextLineByType(TYPE_...). - * - ConnectionBody: toLogin в байтах НЕ хранится, вычисляется из toBlockchainName. + * - TECH: Header + CreateChannel идут по тех-линии (hasLine у CreateChannel). + * - TEXT: посты в каналах — отдельные линии, root = Header(канал "0") или CreateChannel(канал "X"). + * - REPLY (subType=20): без линии, target может указывать на чужой блокчейн, и ОБЯЗАТЕЛЬНО содержит toBlockNumber+toBlockHash32. */ public class IT_03_AddBlock_NoAuth { @@ -54,184 +54,123 @@ public class IT_03_AddBlock_NoAuth { ); } + // ========================= // USER1 + // ========================= ChainState st1 = new ChainState(); AddBlockSender sender1 = new AddBlockSender(ws, st1, u1, bch1, TestConfig.getBlockchainPrivatKey(u1)); sender1.send(new HeaderBody(u1), t); assertTrue(st1.hasHeader()); - // TEXT_NEW x3 (с line) + // канал "0" (root=HEADER) — по умолчанию существует + int root0 = st1.rootChannel0(); + + // POST в канал "0" { - var ln = st1.nextLineByType(ChainState.TYPE_TEXT); - sender1.send(new TextBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - MsgSubType.TEXT_NEW, - "Hello #1 (NEW) from IT_03 test", - null, null, null - ), t); - } - { - var ln = st1.nextLineByType(ChainState.TYPE_TEXT); - sender1.send(new TextBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - MsgSubType.TEXT_NEW, - "Hello #2 (NEW) from IT_03 test", - null, null, null - ), t); - } - { - var ln = st1.nextLineByType(ChainState.TYPE_TEXT); - sender1.send(new TextBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - MsgSubType.TEXT_NEW, - "Hello #3 (NEW) from IT_03 test", + var ln = st1.nextTextLineByRoot(root0); + sender1.send(new TextBody( + MsgSubType.TEXT_POST, + ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, + "U1: story/post in channel 0", null, null, null ), t); } - byte[] text1Hash = st1.getHash32(1); - byte[] text2Hash = st1.getHash32(2); - byte[] text3Hash = st1.getHash32(3); - assertNotNull(text1Hash); - assertNotNull(text2Hash); - assertNotNull(text3Hash); + int post0Block = st1.lastBlockNumber(); + byte[] post0Hash = st1.getHash32(post0Block); + assertNotNull(post0Hash); - // TEXT_REPLY x2 (с line + target) + // CREATE_CHANNEL "News" (TECH line) + int newsRootBlock; + byte[] newsRootHash; { - var ln = st1.nextLineByType(ChainState.TYPE_TEXT); - sender1.send(new TextBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - MsgSubType.TEXT_REPLY, - "Reply to TEXT#1", - bch1, 1, text1Hash + var ln = st1.nextLineByType(ChainState.TYPE_TECH); + sender1.send(new CreateChannelBody( + ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, + "News" ), t); + + newsRootBlock = st1.lastBlockNumber(); + newsRootHash = st1.getHash32(newsRootBlock); + assertNotNull(newsRootHash); + + // зарегистрируем root канала для тестового state, чтобы nextTextLineByRoot() работал + st1.registerTextChannelRoot(newsRootBlock, newsRootHash); } + + // POST #0 в канал "News" + int newsPost0Block; + byte[] newsPost0Hash; { - var ln = st1.nextLineByType(ChainState.TYPE_TEXT); - sender1.send(new TextBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - MsgSubType.TEXT_REPLY, - "Reply to TEXT#3", - bch1, 3, text3Hash + var ln = st1.nextTextLineByRoot(newsRootBlock); + sender1.send(new TextBody( + MsgSubType.TEXT_POST, + ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, + "U1: News post #0", + null, null, null + ), t); + + newsPost0Block = st1.lastBlockNumber(); + newsPost0Hash = st1.getHash32(newsPost0Block); + assertNotNull(newsPost0Hash); + } + + // POST #1 в канал "News" + { + var ln = st1.nextTextLineByRoot(newsRootBlock); + sender1.send(new TextBody( + MsgSubType.TEXT_POST, + ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, + "U1: News post #1", + null, null, null ), t); } - // REACTION_LIKE x2 (без line) - sender1.send(new ReactionBody(bch1, 1, text1Hash), t); - sender1.send(new ReactionBody(bch1, 2, text2Hash), t); - - // TEXT_EDIT x3 (с line + target) + // EDIT_POST (не увеличивает thisLineNumber, но является частью линии) { - var ln = st1.nextLineByType(ChainState.TYPE_TEXT); - sender1.send(new TextBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - MsgSubType.TEXT_EDIT, - "Hello #2 (EDIT#1) from IT_03 test", - bch1, 2, text2Hash - ), t); - } - { - var ln = st1.nextLineByType(ChainState.TYPE_TEXT); - sender1.send(new TextBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - MsgSubType.TEXT_EDIT, - "Hello #2 (EDIT#2) from IT_03 test", - bch1, 2, text2Hash - ), t); - } - { - var ln = st1.nextLineByType(ChainState.TYPE_TEXT); - sender1.send(new TextBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - MsgSubType.TEXT_EDIT, - "Hello #3 (EDIT#1) from IT_03 test", - bch1, 3, text3Hash + var ln = st1.nextTextLineByRoot(newsRootBlock); + // edit должен иметь thisLineNumber как у предыдущего сообщения линии (ChainState это уже даёт) + sender1.send(new TextBody( + MsgSubType.TEXT_EDIT_POST, + ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, + "U1: News post #0 (EDIT)", + null, + newsPost0Block, + newsPost0Hash ), t); } - assertEquals(10, st1.lastBlockNumber(), "USER1: lastBlockNumber должен быть 10 (всего 11 блоков включая HEADER)"); - - // USER2 + // ========================= + // USER2 (ответ в чужой канал) + // ========================= ChainState st2 = new ChainState(); AddBlockSender sender2 = new AddBlockSender(ws, st2, u2, bch2, TestConfig.getBlockchainPrivatKey(u2)); sender2.send(new HeaderBody(u2), t); assertTrue(st2.hasHeader()); - // USER_PARAM (с line) + // REPLY (20): ответ на post в чужом блокчейне/канале { - var ln = st2.nextLineByType(ChainState.TYPE_USER_PARAM); - sender2.send(new UserParamBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - "Anya", "Amsterdam, Example street 10" + sender2.send(new TextBody( + MsgSubType.TEXT_REPLY, + -1, new byte[32], -1, // для replies линии нет + "U2: reply to U1 News post #0 (cross-chain)", + bch1, + newsPost0Block, + newsPost0Hash ), t); } - // USER3 (нужен, чтобы u1 мог подписаться на существующий блокчейн) + // ========================= + // USER3 (просто чтобы оставалось как раньше) + // ========================= ChainState st3 = new ChainState(); AddBlockSender sender3 = new AddBlockSender(ws, st3, u3, bch3, TestConfig.getBlockchainPrivatKey(u3)); sender3.send(new HeaderBody(u3), t); assertTrue(st3.hasHeader()); - // ----------------------------------------------------------------- - // Подписки: - // - u1 follows u2 и u3 - // - u2 follows только u1 - // Все CONNECTION идут по линии CONNECTION (по ТЗ "да надо") - // ----------------------------------------------------------------- - - // u1 -> follow u2 - { - var ln = st1.nextLineByType(ChainState.TYPE_CONNECTION); - sender1.send(new ConnectionBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - MsgSubType.CONNECTION_FOLLOW, - bch2, 0, new byte[32] - ), t); - } - - // u1 -> follow u3 - { - var ln = st1.nextLineByType(ChainState.TYPE_CONNECTION); - sender1.send(new ConnectionBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - MsgSubType.CONNECTION_FOLLOW, - bch3, 0, new byte[32] - ), t); - } - - // u2 -> follow u1 - { - var ln = st2.nextLineByType(ChainState.TYPE_CONNECTION); - sender2.send(new ConnectionBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - MsgSubType.CONNECTION_FOLLOW, - bch1, 0, new byte[32] - ), t); - } - - // friend/unfriend как было, но тоже по CONNECTION линии - { - var ln = st2.nextLineByType(ChainState.TYPE_CONNECTION); - sender2.send(new ConnectionBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - MsgSubType.CONNECTION_FRIEND, - bch1, 0, new byte[32] - ), t); - } - - // user1 param + friend to u2 - { - var ln = st1.nextLineByType(ChainState.TYPE_USER_PARAM); - sender1.send(new UserParamBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - "Anna", "Gareeva" - ), t); - } - { - var ln = st1.nextLineByType(ChainState.TYPE_CONNECTION); - sender1.send(new ConnectionBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - MsgSubType.CONNECTION_FRIEND, - bch2, 0, new byte[32] - ), t); - } - - { - var ln = st2.nextLineByType(ChainState.TYPE_CONNECTION); - sender2.send(new ConnectionBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - MsgSubType.CONNECTION_UNFRIEND, - bch1, 0, new byte[32] - ), t); - } - r.ok("IT_03 сценарий блоков выполнен"); } catch (Throwable e) {