package blockchain; import blockchain.body.BodyRecord; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.time.Instant; import java.util.Arrays; import java.util.Objects; /** * BchBlockEntry — универсальный блок формата SHiNE (Frame v0). * * ========================================================================= * FRAME v0 — ФИКСИРОВАННЫЙ ФОРМАТ БЛОКА (ДОКУМЕНТ ПРОТОКОЛА) * ========================================================================= * * Все числа BigEndian. * * PREIMAGE (входит в blockSize, подписывается): * [2] frameCode (uint16) код/версия рамки: * - 0x0000 = Frame v0 (текущий) * [32] prevHash32 (bytes) SHA-256(preimage) предыдущего блока (цепочка) * [4] blockSize (int32) размер preimage (в байтах), ВКЛЮЧАЯ frameCode, * НО БЕЗ sigMarker и БЕЗ signature64 * [4] blockNumber (int32) глобальный номер блока (>=0) * [8] timestamp (int64) unix seconds * [2] type (uint16) тип сообщения * [2] subType (uint16) подтип сообщения * [2] version (uint16) версия формата сообщения * [N] bodyBytes (bytes) тело сообщения (БЕЗ type/subType/version) * * TAIL (НЕ входит в blockSize, НЕ подписывается в Frame v0): * [2] sigMarker (uint16) маркер подписи: * - 0x0100 (256) = далее подпись Ed25519 64 байта * [64] signature64 (bytes) Ed25519 signature над hash32 * * hash32 НЕ хранится в блоке. * hash32 вычисляется при парсинге: * preimage = первые blockSize байт * hash32 = SHA-256(preimage) * * Правила MVP-парсера (Frame v0): * - frameCode должен быть строго 0x0000, иначе REJECT. * - sigMarker должен быть строго 0x0100, иначе REJECT. * - подпись обязана присутствовать всегда (sigMarker+signature64). * - НИКАКИХ fallback-веток “если маркер другой, то подписи нет/другой хвост”. * * Важно по безопасности: * - sigMarker в v0 не входит в подписываемые байты → его можно подменить, * поэтому единственная безопасная логика: "если не 0x0100 — reject". * ========================================================================= */ public final class BchBlockEntry { public static final int SIGNATURE_LEN = 64; public static final int HASH_LEN = 32; public static final int FRAME_CODE_LEN = 2; public static final int SIG_MARKER_LEN = 2; /** Frame v0 */ public static final int FRAME_CODE_V0 = 0x0000; /** sigMarker: 256 = 0x0100 */ public static final int SIG_MARKER_ED25519 = 0x0100; /** * Максимальный допустимый размер блока (fullBytes = preimage + sigMarker + signature), * чтобы не уложить сервер по памяти/диску. */ public static final int MAX_BLOCK_FULL_BYTES = 4 * 1024 * 1024; /** * Насколько блок может “обгонять” текущее время (защита от кривых часов/вбросов). * Если timestamp больше now + 60 сек — блок считаем неверным. */ public static final long MAX_FUTURE_SECONDS = 60; /** * Размер фиксированной части PREIMAGE (без bodyBytes). * * PREIMAGE header: * frameCode(2) + prevHash32(32) + blockSize(4) + blockNumber(4) + timestamp(8) * + type(2) + subType(2) + version(2) */ public static final int PREIMAGE_HEADER_SIZE = 2 // frameCode + 32 // prevHash32 + 4 // blockSize + 4 // blockNumber + 8 // timestamp + 2 // type + 2 // subType + 2; // version /** Минимальный полный размер блока (без bodyBytes). */ public static final int MIN_FULL_BYTES = PREIMAGE_HEADER_SIZE + SIG_MARKER_LEN + SIGNATURE_LEN; // --- HEADER (PREIMAGE) --- public final int frameCode; // uint16 (v0=0) public final byte[] prevHash32; // 32 public final int blockSize; // preimage size (включая frameCode) public final int blockNumber; // >=0 public final long timestamp; public final short type; public final short subType; public final short version; // --- BODY (PREIMAGE) --- public final byte[] bodyBytes; /** Распарсенное тело (создаётся сразу при парсинге блока). */ public final BodyRecord body; // --- TAIL --- public final int sigMarker; // uint16 (v0: 0x0100) private final byte[] signature64; // 64 // --- derived --- private final byte[] hash32; // 32, computed private final byte[] preimage; // blockSize bytes private final byte[] fullBytes; // preimage + sigMarker + signature /* ===================================================================== */ /* ====================== Конструктор из байт ========================== */ /* ===================================================================== */ public BchBlockEntry(byte[] fullBytes) { Objects.requireNonNull(fullBytes, "fullBytes == null"); if (fullBytes.length < MIN_FULL_BYTES) { throw new IllegalArgumentException("Block too short: " + fullBytes.length + " < " + MIN_FULL_BYTES); } if (fullBytes.length > MAX_BLOCK_FULL_BYTES) { throw new IllegalArgumentException("Block too large: " + fullBytes.length + " > " + MAX_BLOCK_FULL_BYTES); } ByteBuffer bb = ByteBuffer.wrap(fullBytes).order(ByteOrder.BIG_ENDIAN); // [2] frameCode this.frameCode = Short.toUnsignedInt(bb.getShort()); if (this.frameCode != FRAME_CODE_V0) { throw new IllegalArgumentException(String.format( "Bad frameCode: 0x%04X (expected 0x%04X)", this.frameCode, FRAME_CODE_V0 )); } // [32] prevHash32 this.prevHash32 = new byte[32]; bb.get(this.prevHash32); // [4] blockSize this.blockSize = bb.getInt(); if (blockSize < PREIMAGE_HEADER_SIZE) { throw new IllegalArgumentException("blockSize too small: " + blockSize + " < " + PREIMAGE_HEADER_SIZE); } // fullLen must match exactly: blockSize + sigMarker(2) + signature(64) int expectedFullLen = blockSize + SIG_MARKER_LEN + SIGNATURE_LEN; if (expectedFullLen != fullBytes.length) { throw new IllegalArgumentException("blockSize mismatch: blockSize=" + blockSize + " expectedFullLen=" + expectedFullLen + " fullLen=" + fullBytes.length); } if (expectedFullLen > MAX_BLOCK_FULL_BYTES) { throw new IllegalArgumentException("Block too large by blockSize: " + expectedFullLen + " > " + MAX_BLOCK_FULL_BYTES); } // [4] blockNumber this.blockNumber = bb.getInt(); if (this.blockNumber < 0) { throw new IllegalArgumentException("blockNumber < 0: " + this.blockNumber); } // [8] timestamp this.timestamp = bb.getLong(); // запрет “в будущее” больше чем на 1 минуту long now = Instant.now().getEpochSecond(); if (this.timestamp > now + MAX_FUTURE_SECONDS) { throw new IllegalArgumentException("timestamp is too far in future: ts=" + this.timestamp + " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS); } // [2][2][2] type/subType/version this.type = bb.getShort(); this.subType = bb.getShort(); this.version = bb.getShort(); // [N] bodyBytes int bodyLen = blockSize - PREIMAGE_HEADER_SIZE; if (bodyLen < 0) { throw new IllegalArgumentException("Invalid body length: " + bodyLen); } this.bodyBytes = new byte[bodyLen]; bb.get(this.bodyBytes); // TAIL: [2] sigMarker this.sigMarker = Short.toUnsignedInt(bb.getShort()); if (this.sigMarker != SIG_MARKER_ED25519) { throw new IllegalArgumentException(String.format( "Bad sigMarker: 0x%04X (expected 0x%04X)", this.sigMarker, SIG_MARKER_ED25519 )); } // TAIL: [64] signature64 this.signature64 = new byte[SIGNATURE_LEN]; bb.get(this.signature64); // preimage = первые blockSize байт (включая frameCode) this.preimage = Arrays.copyOfRange(fullBytes, 0, blockSize); // hash32 = sha256(preimage) this.hash32 = BchCryptoVerifier.sha256(preimage); // parse body по header.type/subType/version + ОБЯЗАТЕЛЬНЫЙ check() this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes); this.fullBytes = Arrays.copyOf(fullBytes, fullBytes.length); if (bb.remaining() != 0) { throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); } } /* ===================================================================== */ /* ====================== Конструктор сборки ============================ */ /* ===================================================================== */ public BchBlockEntry(byte[] prevHash32, int blockNumber, long timestamp, short type, short subType, short version, byte[] bodyBytes, byte[] signature64) { Objects.requireNonNull(prevHash32, "prevHash32 == null"); Objects.requireNonNull(bodyBytes, "bodyBytes == null"); Objects.requireNonNull(signature64, "signature64 == null"); if (prevHash32.length != 32) throw new IllegalArgumentException("prevHash32 != 32"); if (signature64.length != SIGNATURE_LEN) throw new IllegalArgumentException("signature64 != 64"); if (blockNumber < 0) { throw new IllegalArgumentException("blockNumber < 0: " + blockNumber); } // запрет “в будущее” больше чем на 1 минуту long now = Instant.now().getEpochSecond(); if (timestamp > now + MAX_FUTURE_SECONDS) { throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp + " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS); } this.frameCode = FRAME_CODE_V0; this.prevHash32 = Arrays.copyOf(prevHash32, 32); this.blockNumber = blockNumber; this.timestamp = timestamp; this.type = type; this.subType = subType; this.version = version; this.bodyBytes = Arrays.copyOf(bodyBytes, bodyBytes.length); // blockSize = размер preimage (включая frameCode) this.blockSize = PREIMAGE_HEADER_SIZE + this.bodyBytes.length; int fullLen = this.blockSize + SIG_MARKER_LEN + SIGNATURE_LEN; if (fullLen > MAX_BLOCK_FULL_BYTES) { throw new IllegalArgumentException("Block too large: " + fullLen + " > " + MAX_BLOCK_FULL_BYTES); } // parse body по header + ОБЯЗАТЕЛЬНЫЙ check() this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes); // tail marker фиксирован this.sigMarker = SIG_MARKER_ED25519; this.signature64 = Arrays.copyOf(signature64, SIGNATURE_LEN); // build preimage ByteBuffer pre = ByteBuffer.allocate(blockSize).order(ByteOrder.BIG_ENDIAN); pre.putShort((short) (FRAME_CODE_V0 & 0xFFFF)); pre.put(this.prevHash32); pre.putInt(this.blockSize); pre.putInt(this.blockNumber); pre.putLong(this.timestamp); pre.putShort(this.type); pre.putShort(this.subType); pre.putShort(this.version); pre.put(this.bodyBytes); this.preimage = pre.array(); this.hash32 = BchCryptoVerifier.sha256(preimage); // build fullBytes: preimage + sigMarker + signature64 ByteBuffer full = ByteBuffer.allocate(fullLen).order(ByteOrder.BIG_ENDIAN); full.put(this.preimage); full.putShort((short) (SIG_MARKER_ED25519 & 0xFFFF)); full.put(this.signature64); this.fullBytes = full.array(); } /* ===================================================================== */ /* ============================ Getters ================================= */ /* ===================================================================== */ public byte[] getPreimageBytes() { return Arrays.copyOf(preimage, preimage.length); } /** Возвращает подпись Ed25519 (64 байта). */ public byte[] getSignature64() { return Arrays.copyOf(signature64, SIGNATURE_LEN); } /** Возвращает hash32 = SHA-256(preimage). */ public byte[] getHash32() { return Arrays.copyOf(hash32, HASH_LEN); } /** Возвращает полный блок: preimage + sigMarker + signature. */ public byte[] toBytes() { return Arrays.copyOf(fullBytes, fullBytes.length); } @Override public String toString() { String timeIso; try { timeIso = Instant.ofEpochSecond(timestamp).toString(); } catch (Exception e) { timeIso = "некорректныйTimestamp"; } return "BchBlockEntry{" + "FRAME{frameCode=0x" + hex4(frameCode) + "}, HDR{" + "blockSize=" + blockSize + ", blockNumber=" + blockNumber + ", timestamp=" + timestamp + " (" + timeIso + ")" + ", type=" + (type & 0xFFFF) + ", subType=" + (subType & 0xFFFF) + ", version=" + (version & 0xFFFF) + ", prevHash32(hex)=" + toHex(prevHash32) + "}" + ", BODY{len=" + (bodyBytes == null ? -1 : bodyBytes.length) + "}" + ", TAIL{sigMarker=0x" + hex4(sigMarker) + ", signature64(hex)=" + toHex(signature64) + "}" + ", DERIVED{hash32(hex)=" + toHex(hash32) + "}" + "}"; } private static String hex4(int v) { String s = Integer.toHexString(v & 0xFFFF); while (s.length() < 4) s = "0" + s; return s; } private static String toHex(byte[] bytes) { if (bytes == null) return "null"; char[] HEX = "0123456789abcdef".toCharArray(); char[] out = new char[bytes.length * 2]; for (int i = 0; i < bytes.length; i++) { int vv = bytes[i] & 0xFF; out[i * 2] = HEX[vv >>> 4]; out[i * 2 + 1] = HEX[vv & 0x0F]; } return new String(out); } } package blockchain; import utils.crypto.Ed25519Util; import java.security.MessageDigest; import java.util.Objects; /** * Верификатор SHiNE (Frame v0): * * preimage = первые blockSize байт блока (ВКЛЮЧАЯ frameCode=0x0000), * = всё до TAIL (sigMarker+signature). * * hash32 = SHA-256(preimage) * verify = Ed25519.verify(hash32, signature64, pubKey32) */ public final class BchCryptoVerifier { private BchCryptoVerifier() {} public static byte[] sha256(byte[] data) { Objects.requireNonNull(data, "data == null"); try { MessageDigest d = MessageDigest.getInstance("SHA-256"); return d.digest(data); } catch (Exception e) { throw new IllegalStateException("SHA-256 unavailable", e); } } public static boolean verifyBlock(BchBlockEntry block, byte[] publicKey32) { Objects.requireNonNull(block, "block == null"); Objects.requireNonNull(publicKey32, "publicKey32 == null"); if (publicKey32.length != 32) throw new IllegalArgumentException("publicKey32 != 32"); byte[] hash32 = block.getHash32(); byte[] sig64 = block.getSignature64(); return Ed25519Util.verify(hash32, sig64, publicKey32); } } package blockchain.body; /** * BodyHasLine — для типов, которые имеют линейные поля в body. * * Line-prefix (BigEndian) в НАЧАЛЕ bodyBytes: * [4] lineCode код линии (root-идентификатор): * - 0 для дефолтной линии/канала "0" (root = HEADER, blockNumber=0) * - для канала "X": blockNumber root-блока канала (CREATE_CHANNEL) * * [4] prevLineBlockGlobalNumber глобальный номер предыдущего блока в этой линии * [32] prevLineBlockHash32 hash32 предыдущего блока в этой линии * * [4] lineSeq порядковый номер сообщения внутри линии (1..N) * * Важно: * - Проверка связности линии (prevLineBlockGlobalNumber ↔ prevLineBlockHash32) и корректности lineSeq * выполняется на сервере/в БД при вставке (а не в body.check()). */ public interface BodyHasLine { int lineCode(); int prevLineBlockGlobalNumber(); byte[] prevLineBlockHash32(); int lineSeq(); } package blockchain.body; import utils.blockchain.BlockchainNameUtil; /** * BodyHasTarget — дополнительный интерфейс для body, которые "ссылаются" на цель (to-поля). * * Новое правило: * - toLogin НЕ храним в байтах блока. * - toLogin всегда вычисляется из toBchName по стандарту login+"-NNN". * * Все методы могут возвращать null. */ public interface BodyHasTarget { /** login цели (nullable). Вычисляется из toBchName(). */ default String toLogin() { String bch = toBchName(); if (bch == null) return null; return BlockchainNameUtil.loginFromBlockchainName(bch); } /** blockchainName цели (nullable). */ String toBchName(); /** globalNumber цели (nullable). */ Integer toBlockGlobalNumber(); /** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */ byte[] toBlockHashBytes(); } package blockchain.body; /** * BodyRecord — общий контракт для всех типов body (тела блока). * * ВАЖНО (новый формат): * - type/subType/version НЕ лежат в bodyBytes. * - type/subType/version читаются из заголовка блока (BchBlockEntry). * * Поэтому из интерфейса УБРАНЫ: * - type() * - subType() * - version() * - expectedLineIndex() */ public interface BodyRecord { /** Проверить корректность содержимого и вернуть этот объект (или кинуть исключение). */ BodyRecord check(); /** * Сериализовать тело записи в байты (ровно то, что кладётся в block.bodyBytes). * Важно: НЕ включает type/subType/version. */ byte[] toBytes(); } package blockchain.body; import blockchain.MsgSubType; import utils.blockchain.BlockchainNameUtil; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Objects; /** * ConnectionBody — type=3, ver=1 (в заголовке блока). * * subType (в заголовке блока) как MsgSubType: * FRIEND=10, UNFRIEND=11 * CONTACT=20, UNCONTACT=21 * FOLLOW=30, UNFOLLOW=31 * * bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ): * [4] lineCode * [4] prevLineNumber * [32] prevLineHash32 * [4] thisLineNumber * * [1] toBlockchainNameLen (uint8) * [N] toBlockchainName UTF-8 * [4] toBlockGlobalNumber (int32) * [32] toBlockHash32 (raw 32 bytes) * * toLogin вычисляется автоматически из toBlockchainName: * toLogin = BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) */ /** * ========================================================================= * ПРАВИЛО TARGET/ROOT ДЛЯ КАНАЛОВ И СВЯЗЕЙ (важно для подписок/друзей/контактов) * ========================================================================= * * Термины: * - ROOT линии/канала = блок, который "начинает" линию: * * для канала "0" root = HEADER (blockNumber=0) * * для канала "X" root = CREATE_CHANNEL (blockNumber этого блока) * * 1) СВЯЗИ МЕЖДУ ПОЛЬЗОВАТЕЛЯМИ (CONNECTION_*): * FRIEND / CONTACT -> цель ВСЕГДА HEADER пользователя: * toBlockNumber = 0 * toBlockHash32 = hash32(HEADER цели) * * 2) ПОДПИСКИ НА КОНТЕНТ (FOLLOW/SUBSCRIBE): * FOLLOW пользователя (в целом) -> цель = ROOT дефолтного канала "0" (то есть HEADER): * toBlockNumber = 0 * toBlockHash32 = hash32(HEADER цели) * * FOLLOW/подписка на конкретный канал пользователя -> * цель = ROOT этого канала: * - канал "0": toBlockNumber=0, toBlockHash32=hash32(HEADER) * - канал "X": toBlockNumber=blockNumber(CREATE_CHANNEL), * toBlockHash32=hash32(CREATE_CHANNEL) * * 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД): * - CONNECTION_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено). * - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала: * разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL). * * Зачем так: * - связи и подписки всегда стабильны и не ломаются при новых постах, * - один понятный инвариант: "подписка всегда указывает на root линии". * ========================================================================= */ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasLine { public static final short TYPE = 3; public static final short VER = 1; public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); public final short subType; // из header public final short version; // из header // line public final int lineCode; public final int prevLineNumber; public final byte[] prevLineHash32; public final int thisLineNumber; // payload public final String toBlockchainName; public final int toBlockGlobalNumber; public final byte[] toBlockHash32; public ConnectionBody(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("ConnectionBody version must be 1, got=" + (this.version & 0xFFFF)); } if (!isValidSubType(this.subType)) { throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF)); } // минимум: // lineCode(4) + line(4+32+4) + toBchLen[1]+toBch[1] + global[4] + hash[32] if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1 + 4 + 32) { throw new IllegalArgumentException("ConnectionBody too short"); } ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); this.lineCode = bb.getInt(); this.prevLineNumber = bb.getInt(); this.prevLineHash32 = new byte[32]; bb.get(this.prevLineHash32); this.thisLineNumber = bb.getInt(); int bchLen = Byte.toUnsignedInt(bb.get()); if (bchLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0"); if (bb.remaining() < bchLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short"); byte[] bchBytes = new byte[bchLen]; bb.get(bchBytes); this.toBlockchainName = new String(bchBytes, StandardCharsets.UTF_8); this.toBlockGlobalNumber = bb.getInt(); this.toBlockHash32 = new byte[32]; bb.get(this.toBlockHash32); if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); } public ConnectionBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) { Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF)); if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); // Железное правило формата: bchName -> login + "-NNN" if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null) { throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName); } if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); this.lineCode = lineCode; this.prevLineNumber = prevLineNumber; this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); this.thisLineNumber = thisLineNumber; this.subType = subType; this.version = VER; this.toBlockchainName = toBlockchainName; this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); } private static boolean isValidSubType(short st) { int v = st & 0xFFFF; return v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF) || v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF) || v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF) || v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF) || v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF) || v == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF); } @Override public ConnectionBody check() { if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF)); // line 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"); } else { if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid"); } if (toBlockchainName == null || toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); // гарантируем вычислимый toLogin (иначе target “битый” по стандарту) if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null) throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid"); return this; } @Override public byte[] toBytes() { byte[] bchBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8); if (bchBytes.length == 0 || bchBytes.length > 255) throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255"); if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); int cap = 4 + (4 + 32 + 4) + 1 + bchBytes.length + 4 + 32; ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); bb.putInt(lineCode); bb.putInt(prevLineNumber); bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); bb.putInt(thisLineNumber); bb.put((byte) bchBytes.length); bb.put(bchBytes); bb.putInt(toBlockGlobalNumber); bb.put(toBlockHash32); return bb.array(); } 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; } /* ====================== BodyHasLine ====================== */ @Override public int lineCode() { return lineCode; } @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } @Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); } @Override public int lineSeq() { return thisLineNumber; } /* ====================== BodyHasTarget ===================== */ @Override public String toBchName() { return toBlockchainName; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; } } 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), новый формат line-prefix: * [4] lineCode (для TECH линии обычно 0) * [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 lineCode; 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)); } // минимум: lineCode(4) + line(4+32+4) + nameLen(1) + name(1) if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1) { throw new IllegalArgumentException("CreateChannelBody too short"); } ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); this.lineCode = bb.getInt(); 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 lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, String channelName) { Objects.requireNonNull(channelName, "channelName == null"); if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); this.subType = SUBTYPE; this.version = VER; this.lineCode = lineCode; this.prevLineNumber = prevLineNumber; this.prevLineHash32 = (prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32)); this.thisLineNumber = thisLineNumber; this.channelName = channelName; } @Override public CreateChannelBody check() { if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); 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 + (4 + 32 + 4) + 1 + nameUtf8.length; ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); bb.putInt(lineCode); 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 lineCode() { return lineCode; } @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } @Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); } @Override public int lineSeq() { return thisLineNumber; } } package blockchain.body; import utils.config.ShineSignatureConstants; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; import java.util.Objects; /** * HeaderBody — type=0, version=1. * * В новом формате type/subType/version живут в HEADER блока, * поэтому bodyBytes для HeaderBody содержат только payload: * * bodyBytes (BigEndian): * [TAG_LEN] tag ASCII "SHiNE" * [1] loginLength=N (uint8) * [N] login UTF-8 */ public final class HeaderBody implements BodyRecord { public static final short TYPE = 0; public static final short VER = 1; public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); /** Для header subType всегда 0 (служебная совместимость). */ public static final short SUBTYPE_COMPAT = 0; /** TAG формата (ASCII). */ public static final String TAG = ShineSignatureConstants.BLOCKCHAIN_HEADER_TAG; private static final byte[] TAG_ASCII = TAG.getBytes(StandardCharsets.US_ASCII); private static final int TAG_LEN = TAG_ASCII.length; public final short subType; // всегда 0 (из заголовка блока) public final short version; // из заголовка блока public final String tag; // "SHiNE" public final String login; /** Десериализация из payload bodyBytes (без type/subType/version). */ public HeaderBody(short subType, short version, byte[] bodyBytes) { Objects.requireNonNull(bodyBytes, "bodyBytes == null"); this.subType = subType; this.version = version; if ((this.subType & 0xFFFF) != (SUBTYPE_COMPAT & 0xFFFF)) { throw new IllegalArgumentException("HeaderBody subType must be 0, got=" + (this.subType & 0xFFFF)); } if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { throw new IllegalArgumentException("HeaderBody version must be 1, got=" + (this.version & 0xFFFF)); } // минимум: tag[TAG_LEN] + loginLen[1] if (bodyBytes.length < TAG_LEN + 1) throw new IllegalArgumentException("HeaderBody too short"); ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); byte[] tagBytes = new byte[TAG_LEN]; bb.get(tagBytes); String t = new String(tagBytes, StandardCharsets.US_ASCII); if (!TAG.equals(t)) throw new IllegalArgumentException("Bad tag: " + t); this.tag = t; int loginLen = Byte.toUnsignedInt(bb.get()); if (loginLen <= 0 || bb.remaining() < loginLen) throw new IllegalArgumentException("Bad login length"); byte[] loginBytes = new byte[loginLen]; bb.get(loginBytes); this.login = new String(loginBytes, StandardCharsets.UTF_8); if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); } /** Создание “вручную”. */ public HeaderBody(String login) { Objects.requireNonNull(login, "login == null"); this.subType = SUBTYPE_COMPAT; this.version = VER; this.tag = TAG; this.login = login; } @Override public HeaderBody check() { if ((subType & 0xFFFF) != (SUBTYPE_COMPAT & 0xFFFF)) throw new IllegalArgumentException("HeaderBody subType must be 0"); if (login == null || login.isBlank()) throw new IllegalArgumentException("Login is blank"); if (!login.matches("^[A-Za-z0-9_]+$")) throw new IllegalArgumentException("Login must match ^[A-Za-z0-9_]+$"); return this; } @Override public byte[] toBytes() { byte[] loginUtf8 = login.getBytes(StandardCharsets.UTF_8); if (loginUtf8.length == 0 || loginUtf8.length > 255) throw new IllegalArgumentException("Login utf8 len must be 1..255"); int cap = TAG_LEN + 1 + loginUtf8.length; ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); bb.put(TAG_ASCII); bb.put((byte) loginUtf8.length); bb.put(loginUtf8); return bb.array(); } @Override public String toString() { return """ HeaderBody { тип записи : HEADER (type=0, ver=1) [в заголовке блока] subType : 0 (compat) тег формата : "%s" login владельца : "%s" } """.formatted(tag, login); } } 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; /** * ReactionBody — type=2, version=1 (в заголовке блока). * * subType (в заголовке блока): * 1 = LIKE * * bodyBytes (BigEndian), новый формат: * [1] toBlockchainNameLen (uint8) * [N] toBlockchainName UTF-8 * [4] toBlockGlobalNumber (int32) * [32] toBlockHash32 (raw 32 bytes) * * ЛИНИИ НЕТ. */ public final class ReactionBody implements BodyRecord, BodyHasTarget { public static final short TYPE = 2; public static final short VER = 1; public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); public final short subType; // из header public final short version; // из header public final String toBlockchainName; public final int toBlockGlobalNumber; public final byte[] toBlockHash32; public ReactionBody(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("ReactionBody version must be 1, got=" + (this.version & 0xFFFF)); } if ((this.subType & 0xFFFF) != (MsgSubType.REACTION_LIKE & 0xFFFF)) { throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF)); } // минимум: nameLen[1]+name[1]+global[4]+hash[32] if (bodyBytes.length < 1 + 1 + 4 + 32) throw new IllegalArgumentException("ReactionBody too short"); ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); int nameLen = Byte.toUnsignedInt(bb.get()); if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0"); if (bb.remaining() < nameLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short"); byte[] nameBytes = new byte[nameLen]; bb.get(nameBytes); this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); this.toBlockGlobalNumber = bb.getInt(); this.toBlockHash32 = new byte[32]; bb.get(this.toBlockHash32); if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); } public ReactionBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) { Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); this.subType = MsgSubType.REACTION_LIKE; this.version = VER; if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); this.toBlockchainName = toBlockchainName; this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); } @Override public ReactionBody check() { if ((subType & 0xFFFF) != (MsgSubType.REACTION_LIKE & 0xFFFF)) throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF)); 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"); return this; } @Override public byte[] toBytes() { byte[] nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8); if (nameBytes.length == 0 || nameBytes.length > 255) throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255"); int cap = 1 + nameBytes.length + 4 + 32; ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); bb.put((byte) nameBytes.length); bb.put(nameBytes); bb.putInt(toBlockGlobalNumber); bb.put(toBlockHash32); return bb.array(); } /* ====================== BodyHasTarget ====================== */ @Override public String toBchName() { return toBlockchainName; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; } } package blockchain; import blockchain.body.*; /** * Парсер body выбирает класс по header: type/subType/version, * потому что bodyBytes больше НЕ содержат type/subType/version. */ public final class BodyRecordParser { private BodyRecordParser() {} public static BodyRecord parse(short type, short subType, short version, byte[] bodyBytes) { if (bodyBytes == null) throw new IllegalArgumentException("bodyBytes == null"); int t = type & 0xFFFF; int v = version & 0xFFFF; int key = (t << 16) | v; BodyRecord r = switch (key) { 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); } // TEXT type=1 ver=1: выбираем класс по subType case TextBody.KEY -> { int st = subType & 0xFFFF; if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { yield new TextLineBody(subType, version, bodyBytes); } if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { yield new TextReplyBody(subType, version, bodyBytes); } throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st); } 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) )); }; return r.check(); } } package blockchain.body; import blockchain.MsgSubType; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.CharacterCodingException; import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Objects; /** * TextBody — type=1, ver=1 (в заголовке блока). * * subType (в заголовке блока): * 10 = POST * 11 = EDIT_POST * 20 = REPLY * 21 = EDIT_REPLY * * ========================================================================= * КОНЦЕПЦИЯ ЛИНИЙ ДЛЯ ТЕКСТОВЫХ СООБЩЕНИЙ: * * POST и EDIT_POST принадлежат ЛИНИИ КАНАЛА и имеют hasLine. * В новом формате добавлен lineCode: * lineCode = 0 для канала "0" * lineCode = blockNumber "заглавия линии/канала" (например CREATE_CHANNEL) * * REPLY и EDIT_REPLY НЕ имеют линии (нет hasLine в байтах). * * ========================================================================= * ФОРМАТЫ bodyBytes (BigEndian): * * 1) POST (subType=10): * [4] lineCode * [4] prevLineNumber * [32] prevLineHash32 * [4] thisLineNumber * [2] textLenBytes (uint16) * [N] text UTF-8 * * 2) EDIT_POST (subType=11): * [4] lineCode * [4] prevLineNumber * [32] prevLineHash32 * [4] 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 */ public final class TextBody implements BodyRecord, BodyHasTarget, BodyHasLine { public static final short TYPE = 1; public static final short VER = 1; public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); public final short subType; // из header public final short version; // из header // ===== line fields (только для POST/EDIT_POST) ===== // Для REPLY/EDIT_REPLY эти поля НЕ сериализуются; значения держим как "пустые". public final int lineCode; // только для line-message; иначе -1 public final int prevLineNumber; public final byte[] prevLineHash32; // 32 or null public final int thisLineNumber; // ===== message text ===== public final String message; // ===== 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"); this.subType = subType; this.version = version; 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)); } ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); int st = this.subType & 0xFFFF; if (st == (MsgSubType.TEXT_POST & 0xFFFF)) { // POST: hasLine(lineCode+line) + text ensureMin(bb, (4 + 4 + 32 + 4) + 2, "POST too short"); this.lineCode = bb.getInt(); this.prevLineNumber = bb.getInt(); this.prevLineHash32 = new byte[32]; bb.get(this.prevLineHash32); this.thisLineNumber = bb.getInt(); this.message = readStrictUtf8Len16(bb, "POST text"); this.toBlockchainName = null; this.toBlockGlobalNumber = null; this.toBlockHash32 = null; ensureNoTail(bb, "POST"); } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { // EDIT_POST: hasLine(lineCode+line) + target(no bch) + text ensureMin(bb, (4 + 4 + 32 + 4) + (4 + 32) + 2, "EDIT_POST too short"); this.lineCode = bb.getInt(); this.prevLineNumber = bb.getInt(); this.prevLineHash32 = new byte[32]; bb.get(this.prevLineHash32); this.thisLineNumber = bb.getInt(); 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 (без line) ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short"); int nameLen = Byte.toUnsignedInt(bb.get()); 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); this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); this.toBlockGlobalNumber = bb.getInt(); this.toBlockHash32 = new byte[32]; bb.get(this.toBlockHash32); this.message = readStrictUtf8Len16(bb, "REPLY text"); // line fields отсутствуют в байтах this.lineCode = -1; 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 (без line) 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.lineCode = -1; this.prevLineNumber = -1; this.prevLineHash32 = null; this.thisLineNumber = -1; ensureNoTail(bb, "EDIT_REPLY"); } else { throw new IllegalArgumentException("Unsupported Text subType: " + st); } } /* ===================================================================== */ /* ====================== Фабрики (удобно) ============================= */ /* ===================================================================== */ public static TextBody newPost(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, String message) { return new TextBody(MsgSubType.TEXT_POST, lineCode, prevLineNumber, prevLineHash32, thisLineNumber, message, null, null, null); } public static TextBody newEditPost(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, int targetBlockNumber, byte[] targetHash32, String message) { return new TextBody(MsgSubType.TEXT_EDIT_POST, lineCode, 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, -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, -1, null, -1, message, null, targetBlockNumber, targetHash32); } /** * Универсальный конструктор “вручную”. * Для REPLY/EDIT_REPLY line поля игнорируются при сериализации (их в формате нет). */ public TextBody(short subType, int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, 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.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)) { if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0 for line message"); this.lineCode = lineCode; this.prevLineNumber = prevLineNumber; this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); this.thisLineNumber = thisLineNumber; } else { this.lineCode = -1; this.prevLineNumber = -1; this.prevLineHash32 = null; this.thisLineNumber = -1; } this.message = message; // 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"); if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); 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 = null; this.toBlockHash32 = null; } } private static boolean isValidSubType(short st) { int v = st & 0xFFFF; return v == (MsgSubType.TEXT_POST & 0xFFFF) || v == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || v == (MsgSubType.TEXT_REPLY & 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"); int st = subType & 0xFFFF; // локальные проверки line (БД не трогаем) if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0 for line message"); if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid"); } else { // reply/edit_reply: line отсутствует if (prevLineHash32 != null) throw new IllegalArgumentException("REPLY/EDIT_REPLY must not contain line hash"); } // 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; } @Override public byte[] toBytes() { byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty"); if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); int st = subType & 0xFFFF; if (st == (MsgSubType.TEXT_POST & 0xFFFF)) { // hasLine(lineCode+line) + text int cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length; ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); bb.putInt(lineCode); 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(); } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { // hasLine(lineCode+line) + 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"); int cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length; ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); bb.putInt(lineCode); bb.putInt(prevLineNumber); bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); bb.putInt(thisLineNumber); bb.putInt(toBlockGlobalNumber); bb.put(toBlockHash32); 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); } } /* ===================================================================== */ /* ========================== 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 lineCode() { return lineCode; } @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } @Override public byte[] prevLineBlockHash32() { if (prevLineHash32 == null) return null; return Arrays.copyOf(prevLineHash32, 32); } @Override public int lineSeq() { return thisLineNumber; } /* ====================== BodyHasTarget ===================== */ @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); } } package blockchain.body; import blockchain.MsgSubType; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.CharacterCodingException; import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Objects; /** * TextLineBody — type=1, ver=1. * * subType: * - POST (10) * - EDIT_POST (11) * * Формат bodyBytes (BigEndian): * * POST: * [4] lineCode * [4] prevLineNumber * [32] prevLineHash32 * [4] thisLineNumber * [2] textLenBytes (uint16) * [N] text UTF-8 * * EDIT_POST: * [4] lineCode * [4] prevLineNumber * [32] prevLineHash32 * [4] thisLineNumber * [4] toBlockGlobalNumber (int32) * [32] toBlockHash32 * [2] textLenBytes (uint16) * [N] text UTF-8 */ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarget { public static final short TYPE = 1; public static final short VER = 1; public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); public final short subType; // из header public final short version; // из header (=1) // line public final int lineCode; public final int prevLineNumber; public final byte[] prevLineHash32; // 32 (может быть нули) public final int thisLineNumber; // target (только для EDIT_POST) public final Integer toBlockGlobalNumber; // nullable для POST public final byte[] toBlockHash32; // nullable для POST // text public final String message; /* ====================== parse from bytes ====================== */ public TextLineBody(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("TextLineBody version must be 1, got=" + (this.version & 0xFFFF)); } int st = this.subType & 0xFFFF; if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST, got subType=" + st); } ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); // минимум line + textLen(2) ensureMin(bb, (4 + 4 + 32 + 4) + 2, "TextLineBody too short"); this.lineCode = bb.getInt(); this.prevLineNumber = bb.getInt(); this.prevLineHash32 = new byte[32]; bb.get(this.prevLineHash32); this.thisLineNumber = bb.getInt(); if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { // нужен target ensureMin(bb, (4 + 32) + 2, "EDIT_POST missing target"); int tgtNum = bb.getInt(); byte[] tgtHash = new byte[32]; bb.get(tgtHash); this.toBlockGlobalNumber = tgtNum; this.toBlockHash32 = tgtHash; } else { this.toBlockGlobalNumber = null; this.toBlockHash32 = null; } this.message = readStrictUtf8Len16(bb, "TextLineBody text"); ensureNoTail(bb, "TextLineBody"); } /* ====================== manual ctor ====================== */ public TextLineBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, short subType, Integer toBlockGlobalNumber, byte[] toBlockHash32, String message) { Objects.requireNonNull(message, "message == null"); int st = subType & 0xFFFF; if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST"); } if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); if (message.isBlank()) throw new IllegalArgumentException("message is blank"); this.subType = subType; this.version = VER; this.lineCode = lineCode; this.prevLineNumber = prevLineNumber; this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); this.thisLineNumber = thisLineNumber; 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.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); } else { this.toBlockGlobalNumber = null; this.toBlockHash32 = null; } this.message = message; } @Override public TextLineBody check() { int st = subType & 0xFFFF; if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) throw new IllegalArgumentException("Bad TextLineBody subType: " + st); if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid"); if (message == null || message.isBlank()) throw new IllegalArgumentException("Text message is blank"); if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { 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 (toBlockGlobalNumber != null || toBlockHash32 != null) throw new IllegalArgumentException("POST must not contain target fields"); } return this; } @Override public byte[] toBytes() { byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty"); if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); int st = subType & 0xFFFF; int cap; if (st == (MsgSubType.TEXT_POST & 0xFFFF)) { cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length; } else { // EDIT_POST if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber"); if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32"); cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length; } ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); bb.putInt(lineCode); bb.putInt(prevLineNumber); bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); bb.putInt(thisLineNumber); if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { bb.putInt(toBlockGlobalNumber); bb.put(toBlockHash32); } bb.putShort((short) msgUtf8.length); bb.put(msgUtf8); return bb.array(); } /* ====================== BodyHasLine ====================== */ @Override public int lineCode() { return lineCode; } @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } @Override public byte[] prevLineBlockHash32() { return Arrays.copyOf(prevLineHash32, 32); } @Override public int lineSeq() { return thisLineNumber; } /* ====================== BodyHasTarget ===================== */ @Override public String toBchName() { return null; } // по ТЗ: не хранить @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; } /* ====================== helpers ====================== */ public boolean isEditPost() { return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF); } 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()); } } package blockchain.body; import blockchain.MsgSubType; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.CharacterCodingException; import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Objects; /** * TextReplyBody — type=1, ver=1. * * subType: * - REPLY (20) * - EDIT_REPLY (21) * * Форматы bodyBytes (BigEndian): * * REPLY: * [1] toBlockchainNameLen (uint8) * [N] toBlockchainName UTF-8 * [4] toBlockGlobalNumber * [32] toBlockHash32 * [2] textLenBytes (uint16) * [M] text UTF-8 * * EDIT_REPLY: * [4] toBlockGlobalNumber * [32] toBlockHash32 * [2] textLenBytes (uint16) * [N] text UTF-8 */ public final class TextReplyBody implements BodyRecord, BodyHasTarget { public static final short TYPE = 1; public static final short VER = 1; public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); public final short subType; // из header public final short version; // (=1) // target public final String toBlockchainName; // nullable для EDIT_REPLY public final int toBlockGlobalNumber; public final byte[] toBlockHash32; // 32 // text public final String message; public TextReplyBody(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("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF)); } int st = this.subType & 0xFFFF; if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY, got subType=" + st); } ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { // минимум: nameLen[1]+name[1]+global[4]+hash[32]+textLen[2] ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short"); int nameLen = Byte.toUnsignedInt(bb.get()); 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); this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); this.toBlockGlobalNumber = bb.getInt(); this.toBlockHash32 = new byte[32]; bb.get(this.toBlockHash32); } else { // EDIT_REPLY: target без имени ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short"); this.toBlockchainName = null; this.toBlockGlobalNumber = bb.getInt(); this.toBlockHash32 = new byte[32]; bb.get(this.toBlockHash32); } this.message = readStrictUtf8Len16(bb, "TextReplyBody text"); ensureNoTail(bb, "TextReplyBody"); } public TextReplyBody(short subType, int toBlockGlobalNumber, byte[] toBlockHash32, String toBlockchainName, String message) { Objects.requireNonNull(message, "message == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); int st = subType & 0xFFFF; if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY"); } if (message.isBlank()) throw new IllegalArgumentException("message is blank"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); this.toBlockchainName = toBlockchainName; } else { // EDIT_REPLY: имя не хранить this.toBlockchainName = null; } this.subType = subType; this.version = VER; this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); this.message = message; } @Override public TextReplyBody check() { int st = subType & 0xFFFF; if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) throw new IllegalArgumentException("Bad TextReplyBody subType: " + st); if (message == null || message.isBlank()) throw new IllegalArgumentException("Text message is blank"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid"); if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { if (toBlockchainName == null || toBlockchainName.isBlank()) throw new IllegalArgumentException("REPLY toBlockchainName is blank"); } else { if (toBlockchainName != null) throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName"); } return this; } @Override public byte[] toBytes() { byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty"); if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); int st = subType & 0xFFFF; if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName"); 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(); } // EDIT_REPLY 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(); } /* ====================== BodyHasTarget ====================== */ @Override public String toBchName() { return toBlockchainName; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; } public boolean isEditReply() { return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF); } /* ====================== 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()); } } package blockchain.body; import blockchain.MsgSubType; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.CharacterCodingException; import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Objects; /** * UserParamBody — type=4, ver=1 (в заголовке блока). * * subType (в заголовке блока): * 1 = TEXT_TEXT * * bodyBytes (BigEndian), новый формат: * [4] lineCode * [4] prevLineNumber * [32] prevLineHash32 * [4] thisLineNumber * * [2] keyLenBytes (uint16) * [N] keyUtf8 * * [2] valueLenBytes (uint16) * [M] valueUtf8 */ public final class UserParamBody implements BodyRecord, BodyHasLine { public static final short TYPE = 4; public static final short VER = 1; public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); public final short subType; // из header public final short version; // из header // line public final int lineCode; public final int prevLineNumber; public final byte[] prevLineHash32; public final int thisLineNumber; public final String paramKey; public final String paramValue; public UserParamBody(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("UserParamBody version must be 1, got=" + (this.version & 0xFFFF)); } if ((this.subType & 0xFFFF) != (MsgSubType.USER_PARAM_TEXT_TEXT & 0xFFFF)) { throw new IllegalArgumentException("Bad UserParam subType: " + (this.subType & 0xFFFF)); } // минимум: lineCode(4)+line(4+32+4) + keyLen(2)+key(1) + valLen(2)+val(1) if (bodyBytes.length < 4 + (4 + 32 + 4) + 2 + 1 + 2 + 1) { throw new IllegalArgumentException("UserParamBody too short"); } ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); this.lineCode = bb.getInt(); this.prevLineNumber = bb.getInt(); this.prevLineHash32 = new byte[32]; bb.get(this.prevLineHash32); this.thisLineNumber = bb.getInt(); int keyLen = Short.toUnsignedInt(bb.getShort()); if (keyLen <= 0) throw new IllegalArgumentException("paramKeyLen is 0"); if (bb.remaining() < keyLen + 2) throw new IllegalArgumentException("UserParam key payload too short"); byte[] keyBytes = new byte[keyLen]; bb.get(keyBytes); int valLen = Short.toUnsignedInt(bb.getShort()); if (valLen <= 0) throw new IllegalArgumentException("paramValueLen is 0"); if (bb.remaining() < valLen) throw new IllegalArgumentException("UserParam value payload too short"); byte[] valBytes = new byte[valLen]; bb.get(valBytes); if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); this.paramKey = strictUtf8(keyBytes, "paramKey"); this.paramValue = strictUtf8(valBytes, "paramValue"); if (this.paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank"); if (this.paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank"); } public UserParamBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, String paramKey, String paramValue) { Objects.requireNonNull(paramKey, "paramKey == null"); Objects.requireNonNull(paramValue, "paramValue == null"); if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); this.subType = MsgSubType.USER_PARAM_TEXT_TEXT; this.version = VER; this.lineCode = lineCode; this.prevLineNumber = prevLineNumber; this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); this.thisLineNumber = thisLineNumber; if (paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank"); if (paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank"); this.paramKey = paramKey; this.paramValue = paramValue; } @Override public UserParamBody check() { if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); if ((subType & 0xFFFF) != (MsgSubType.USER_PARAM_TEXT_TEXT & 0xFFFF)) throw new IllegalArgumentException("Bad UserParam subType: " + (subType & 0xFFFF)); 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"); } else { if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid"); } if (paramKey == null || paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank"); if (paramValue == null || paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank"); return this; } @Override public byte[] toBytes() { byte[] keyUtf8 = paramKey.getBytes(StandardCharsets.UTF_8); byte[] valUtf8 = paramValue.getBytes(StandardCharsets.UTF_8); if (keyUtf8.length == 0 || keyUtf8.length > 65535) throw new IllegalArgumentException("paramKey utf8 len must be 1..65535"); if (valUtf8.length == 0 || valUtf8.length > 65535) throw new IllegalArgumentException("paramValue utf8 len must be 1..65535"); int cap = 4 + (4 + 32 + 4) + 2 + keyUtf8.length + 2 + valUtf8.length; ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); bb.putInt(lineCode); bb.putInt(prevLineNumber); bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); bb.putInt(thisLineNumber); bb.putShort((short) keyUtf8.length); bb.put(keyUtf8); bb.putShort((short) valUtf8.length); bb.put(valUtf8); return bb.array(); } private static String strictUtf8(byte[] bytes, String fieldName) { var decoder = StandardCharsets.UTF_8.newDecoder() .onMalformedInput(CodingErrorAction.REPORT) .onUnmappableCharacter(CodingErrorAction.REPORT); try { return decoder.decode(ByteBuffer.wrap(bytes)).toString(); } catch (CharacterCodingException e) { throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e); } } 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; } /* ====================== BodyHasLine ====================== */ @Override public int lineCode() { return lineCode; } @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } @Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); } @Override public int lineSeq() { return thisLineNumber; } } //package blockchain; // ///** // * LineIndex — канонические номера линий блокчейна. // * // * Линия = независимая последовательность блоков внутри одного блокчейна. // */ //public final class LineIndex { // // private LineIndex() {} // // public static final short HEADER = 0; // genesis / идентификация // public static final short TEXT = 1; // сообщения да надо // public static final short REACTION = 2; // реакции не надо // public static final short CONNECTION = 3; // связи (friend/contact/follow) да надо // public static final short USER_PARAM = 4; // параметры профиля да надо //} package blockchain; /** * MsgSubType — единое место для ВСЕХ subType сообщений (msg_sub_type). * * Правило: * - НИКАКИХ "магических чисел" subType по проекту. * - В тестах, в body-классах и в SQL-триггерах используем только эти константы. * * Важно: * - Значения менять после релиза нельзя (иначе сломается совместимость). * * ========================================================================= * Про EDIT-типы (важные правила, чтобы не было “двойных правок”): * * 1) EDIT разрешён ТОЛЬКО автору (в своём блокчейне). * Никаких “я отредачу чужое” — нельзя. * * 2) EDIT всегда ссылается ТОЛЬКО на ОРИГИНАЛ: * - EDIT_POST -> на исходный POST * - EDIT_REPLY -> на исходный REPLY * НЕЛЬЗЯ ссылаться на предыдущий EDIT (цепочка edit-ов запрещена). * * 3) REPLY может ссылаться на блоки в чужих линиях / чужих каналах, * и существование цели на уровне check() не проверяется * (check() БД не видит). Если цели нет — “никто не увидит” и ок. * ========================================================================= */ public final class MsgSubType { private MsgSubType() {} /* ===================== HEADER (msg_type=0) ===================== */ /** HeaderBody: subType всегда 0 (compat). */ public static final short HEADER_COMPAT = 0; public static final short TECH_CREATE_CHANNEL = 1; /* ===================== TEXT (msg_type=1) ===================== */ /** * POST — обычный пост в канале (в линии канала). * Имеет hasLine (prevLineNumber/prevLineHash32/thisLineNumber). */ public static final short TEXT_POST = 10; /** * EDIT_POST — редактирование ПОСТА. * Имеет hasLine (принадлежит линии канала) * И имеет target на ОРИГИНАЛЬНЫЙ POST (без toBlockchainName). */ public static final short TEXT_EDIT_POST = 11; /** * REPLY — ответ на сообщение. * НЕ в линии. Имеет target (toBlockchainName + blockNumber + hash32). * Может указывать на чужой блокчейн/чужую линию/чужой канал. */ public static final short TEXT_REPLY = 20; /** * EDIT_REPLY — редактирование ОТВЕТА. * НЕ в линии. Имеет target на ОРИГИНАЛЬНЫЙ REPLY (без toBlockchainName). */ public static final short TEXT_EDIT_REPLY = 21; /* ===================== REACTION (msg_type=2) ===================== */ /** Лайк (LIKE). */ public static final short REACTION_LIKE = 1; /* ===================== CONNECTION (msg_type=3) ===================== */ /** Добавить в друзья. */ 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; /* ===================== USER_PARAM (msg_type=4) ===================== */ /** Параметр профиля key/value (обе строки). */ public static final short USER_PARAM_TEXT_TEXT = 1; } package utils.blockchain; import java.util.Objects; public final class BlockchainNameUtil { /** * Теперь новое правило: * blockchainName = login + "-"+ 3 цифры * Пример: "Dima-001" -> "Dima" * * Сколько символов отрезаем с конца blockchainName, чтобы получить login: "-001" = 4 */ public static final int BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN = 4; private BlockchainNameUtil() {} /** * Извлечь login из blockchainName: отрезаем последние 4 символа ("-NNN"). * Пример: "Dima-001" -> "Dima" */ public static String loginFromBlockchainName(String blockchainName) { if (blockchainName == null) return null; String s = blockchainName.trim(); if (!hasDashAnd3DigitsSuffix(s)) return null; return s.substring(0, s.length() - BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN); } /** * Проверка правила: * - blockchainName должен оканчиваться на "-"+3 цифры * - blockchainName без суффикса "-NNN" должен равняться login * * ВАЖНО: * - сравнение строгое (case-sensitive) * - null/blank считаем невалидным */ public static boolean isBlockchainNameMatchesLogin(String blockchainName, String login) { if (blockchainName == null || login == null) return false; String bn = blockchainName.trim(); String lg = login.trim(); if (bn.isEmpty() || lg.isEmpty()) return false; if (!hasDashAnd3DigitsSuffix(bn)) return false; String extracted = bn.substring(0, bn.length() - BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN); return Objects.equals(extracted, lg); } private static boolean hasDashAnd3DigitsSuffix(String s) { if (s == null) return false; int len = s.length(); if (len <= BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN) return false; int dashPos = len - 4; if (s.charAt(dashPos) != '-') return false; char c1 = s.charAt(len - 3); char c2 = s.charAt(len - 2); char c3 = s.charAt(len - 1); return isDigit(c1) && isDigit(c2) && isDigit(c3); } private static boolean isDigit(char c) { return c >= '0' && c <= '9'; } } package utils.files; import java.io.IOException; import java.nio.file.*; import java.util.Objects; /** * =============================================================== * FileStoreUtil — утилита работы с файлами в папке data/. * * Теперь поддерживает: * - основной файл блокчейна: .bch * - временный файл блокчейна: .tmp_bch * * Важное: * - validateSimpleFileName() запрещает path traversal. * - atomicReplaceBlockchainFile(): пытается сделать ATOMIC_MOVE (если ФС поддерживает), * иначе делает обычный REPLACE_EXISTING move. * =============================================================== */ public final class FileStoreUtil { /** Базовая папка для хранения всех файлов (создаётся автоматически). */ public static final String DATA_DIR_NAME = "data"; /** Расширение основного файла блокчейна. */ public static final String BLOCKCHAIN_FILE_EXTENSION = ".bch"; /** Расширение временного файла (старое+новое). */ public static final String BLOCKCHAIN_TMP_EXTENSION = ".tmp_bch"; private static final FileStoreUtil INSTANCE = new FileStoreUtil(); private final Path dataDirPath; private FileStoreUtil() { this.dataDirPath = Paths.get(DATA_DIR_NAME); ensureDataDirExists(); } public static FileStoreUtil getInstance() { return INSTANCE; } /* ===================================================================== */ /* ======================== Базовые операции =========================== */ /* ===================================================================== */ public void newFile(String fileName, byte[] data) { Objects.requireNonNull(data, "data == null"); Path target = resolveSafe(fileName); try { Files.write(target, data, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); } catch (IOException e) { throw new IllegalStateException("Не удалось записать файл: " + target, e); } } public void addDataToFile(String fileName, byte[] data) { Objects.requireNonNull(data, "data == null"); Path target = resolveSafe(fileName); try { Files.write(target, data, StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND); } catch (IOException e) { throw new IllegalStateException("Не удалось дописать файл: " + target, e); } } public byte[] readAllDataFromFile(String fileName) { Path target = resolveSafe(fileName); if (!Files.exists(target)) { throw new IllegalStateException("Файл не найден: " + target); } try { return Files.readAllBytes(target); } catch (IOException e) { throw new IllegalStateException("Не удалось прочитать файл: " + target, e); } } public boolean exists(String fileName) { Path target = resolveSafe(fileName); return Files.exists(target); } public long size(String fileName) { Path target = resolveSafe(fileName); try { return Files.size(target); } catch (IOException e) { throw new IllegalStateException("Не удалось получить размер файла: " + target, e); } } /* ===================================================================== */ /* ===================== Блокчейн-файлы по имени ======================= */ /* ===================================================================== */ /** .bch */ public String buildBlockchainFileName(String blockchainName) { validateSimpleFileName(blockchainName); return blockchainName + BLOCKCHAIN_FILE_EXTENSION; } /** .tmp_bch */ public String buildBlockchainTmpFileName(String blockchainName) { validateSimpleFileName(blockchainName); return blockchainName + BLOCKCHAIN_TMP_EXTENSION; } public Path resolveBlockchainPath(String blockchainName) { return resolveSafe(buildBlockchainFileName(blockchainName)); } public Path resolveBlockchainTmpPath(String blockchainName) { return resolveSafe(buildBlockchainTmpFileName(blockchainName)); } public byte[] readBlockchain(String blockchainName) { return readAllDataFromFile(buildBlockchainFileName(blockchainName)); } public void writeBlockchainTmp(String blockchainName, byte[] data) { newFile(buildBlockchainTmpFileName(blockchainName), data); } /** * Атомарно заменить основной файл блокчейна временным: * .tmp_bch -> .bch * * Стратегия: * 1) Пытаемся Files.move(..., ATOMIC_MOVE, REPLACE_EXISTING) * 2) Если ATOMIC_MOVE не поддерживается — делаем move с REPLACE_EXISTING без атомарности * * Важный нюанс: * - атомарность гарантируется только в пределах одной файловой системы. */ public void atomicReplaceBlockchainFile(String blockchainName) { Path tmp = resolveBlockchainTmpPath(blockchainName); Path main = resolveBlockchainPath(blockchainName); if (!Files.exists(tmp)) { throw new IllegalStateException("TMP-файл не найден: " + tmp); } try { // 1) Пытаемся атомарный move Files.move(tmp, main, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } catch (AtomicMoveNotSupportedException e) { // 2) Если ФС не поддерживает атомарный move — делаем обычный replace try { Files.move(tmp, main, StandardCopyOption.REPLACE_EXISTING); } catch (IOException ex) { throw new IllegalStateException("Не удалось заменить файл блокчейна (non-atomic): " + main, ex); } } catch (IOException e) { throw new IllegalStateException("Не удалось заменить файл блокчейна (atomic): " + main, e); } } /* ===================================================================== */ /* ============================ Helpers ================================= */ /* ===================================================================== */ private void ensureDataDirExists() { try { if (!Files.exists(dataDirPath)) { Files.createDirectories(dataDirPath); } } catch (IOException e) { throw new IllegalStateException("Не удалось создать директорию хранения: " + dataDirPath, e); } } private Path resolveSafe(String fileName) { validateSimpleFileName(fileName); return dataDirPath.resolve(fileName); } /** * Валидация "простого имени": * - запрещаем слэши, обратные слэши, ".." * - запрещаем пустоту * * Важно: сюда у нас попадает и blockchainName (как часть имени файла), * поэтому blockchainName должен быть "простым": без путей. */ private void validateSimpleFileName(String fileName) { Objects.requireNonNull(fileName, "fileName == null"); if (fileName.isBlank()) { throw new IllegalArgumentException("Имя файла не должно быть пустым"); } if (fileName.contains("/") || fileName.contains("\\") || fileName.contains("..")) { throw new IllegalArgumentException("Недопустимое имя файла: " + fileName); } } }