From db909c792629b103f1b10266d26db90c91c28afb Mon Sep 17 00:00:00 2001 From: Jingyu Date: Mon, 12 Jan 2026 11:10:08 +0800 Subject: [PATCH 1/6] Refactor PeerInfo and update DHT peer requests - Adjust PeerInfo for real-world scenarios - Refactor Json utilities for maintainability - Align DHT announce and lookup requests with PeerInfo changes --- .../access/impl/AccessManager.java | 2 +- .../access/impl/AccessControlListTests.java | 4 +- .../access/impl/AccessManagerTests.java | 2 +- .../access/impl/PermissionTests.java | 4 +- api/docs/datatypes.md | 97 ++ api/src/main/java/io/bosonnetwork/Node.java | 226 ++- .../main/java/io/bosonnetwork/NodeInfo.java | 6 +- .../main/java/io/bosonnetwork/PeerInfo.java | 897 +++++++---- api/src/main/java/io/bosonnetwork/Value.java | 588 ++++--- .../bosonnetwork/database/VertxDatabase.java | 3 +- .../java/io/bosonnetwork/identifier/Card.java | 2 +- .../bosonnetwork/identifier/Credential.java | 2 +- .../identifier/FileSystemResolverCache.java | 2 +- .../io/bosonnetwork/identifier/Vouch.java | 2 +- .../bosonnetwork/identifier/W3CDIDFormat.java | 2 +- .../main/java/io/bosonnetwork/json/Json.java | 581 +++++++ .../io/bosonnetwork/json/JsonContext.java | 284 ++++ .../json/internal/DataFormat.java | 57 + .../json/internal/DateDeserializer.java | 93 ++ .../json/internal/DateFormat.java | 65 + .../json/internal/DateSerializer.java | 72 + .../json/internal/IdDeserializer.java | 71 + .../json/internal/IdSerializer.java | 78 + .../internal/InetAddressDeserializer.java | 71 + .../json/internal/InetAddressSerializer.java | 75 + .../json/internal/NodeInfoDeserializer.java | 109 ++ .../json/internal/NodeInfoSerializer.java | 97 ++ .../json/internal/PeerInfoDeserializer.java | 143 ++ .../json/internal/PeerInfoSerializer.java | 135 ++ .../json/internal/ValueDeserializer.java | 120 ++ .../json/internal/ValueSerializer.java | 119 ++ .../main/java/io/bosonnetwork/utils/Json.java | 1424 ----------------- .../java/io/bosonnetwork/web/AccessToken.java | 2 +- .../bosonnetwork/web/CompactWebTokenAuth.java | 2 +- .../test/java/io/bosonnetwork/IdTests.java | 26 + .../java/io/bosonnetwork/NodeInfoTests.java | 169 ++ .../java/io/bosonnetwork/PeerInfoTests.java | 510 ++++++ .../test/java/io/bosonnetwork/ValueTests.java | 290 ++++ .../io/bosonnetwork/database/FilterTests.java | 2 +- .../database/VersionedSchemaTests.java | 20 + .../identifier/DHTRegistryTest.java | 37 +- .../bosonnetwork/identifier/ProofTests.java | 2 +- .../identifier/VerificationMethodTests.java | 2 +- .../{utils => json}/JsonTests.java | 130 +- .../io/bosonnetwork/utils/JsonPerfTests.java | 1259 --------------- .../web/CompactWebTokenAuthTest.java | 2 +- .../java/io/bosonnetwork/am/AmCommand.java | 2 +- .../java/io/bosonnetwork/launcher/Main.java | 2 +- .../shell/AnnouncePeerCommand.java | 44 +- .../shell/DisplayCacheCommand.java | 2 +- .../bosonnetwork/shell/FindPeerCommand.java | 7 +- .../main/java/io/bosonnetwork/shell/Main.java | 2 +- .../bosonnetwork/shell/StoreValueCommand.java | 17 +- .../io/bosonnetwork/kademlia/KadNode.java | 184 ++- .../io/bosonnetwork/kademlia/impl/DHT.java | 105 +- .../impl/SimpleNodeConfiguration.java | 3 +- .../protocol/AnnouncePeerRequest.java | 101 +- .../kademlia/protocol/FindPeerRequest.java | 49 +- .../kademlia/protocol/FindPeerResponse.java | 83 - .../kademlia/protocol/FindValueRequest.java | 2 + .../kademlia/protocol/FindValueResponse.java | 5 +- .../kademlia/protocol/Message.java | 25 +- .../kademlia/protocol/StoreValueRequest.java | 10 +- .../kademlia/routing/RoutingTable.java | 2 +- .../kademlia/security/FileBlacklist.java | 2 +- .../kademlia/storage/DataStorage.java | 90 +- .../kademlia/storage/DatabaseStorage.java | 322 ++-- .../kademlia/storage/InMemoryStorage.java | 458 ------ .../kademlia/storage/SQLiteStorage.java | 1 + .../kademlia/storage/SqlDialect.java | 76 +- .../kademlia/tasks/PeerAnnounceTask.java | 8 +- .../kademlia/tasks/PeerLookupTask.java | 13 +- .../kadnode/postgres/001_initial_schema.sql | 17 +- .../db/kadnode/sqlite/001_initial_schema.sql | 19 +- .../bosonnetwork/kademlia/NodeAsyncTests.java | 48 +- .../bosonnetwork/kademlia/NodeSyncTests.java | 35 +- .../kademlia/TestNodeLauncher.java | 2 +- .../kademlia/protocol/AnnouncePeerTests.java | 40 +- .../kademlia/protocol/FindPeerTests.java | 64 +- .../kademlia/protocol/FindValueTests.java | 15 +- .../kademlia/protocol/MessageTests.java | 2 +- .../kademlia/protocol/StoreValueTests.java | 18 +- .../kademlia/rpc/RPCServerTests.java | 41 +- .../kademlia/storage/DataStorageTests.java | 615 ++++--- 84 files changed, 5569 insertions(+), 4848 deletions(-) create mode 100644 api/docs/datatypes.md create mode 100644 api/src/main/java/io/bosonnetwork/json/Json.java create mode 100644 api/src/main/java/io/bosonnetwork/json/JsonContext.java create mode 100644 api/src/main/java/io/bosonnetwork/json/internal/DataFormat.java create mode 100644 api/src/main/java/io/bosonnetwork/json/internal/DateDeserializer.java create mode 100644 api/src/main/java/io/bosonnetwork/json/internal/DateFormat.java create mode 100644 api/src/main/java/io/bosonnetwork/json/internal/DateSerializer.java create mode 100644 api/src/main/java/io/bosonnetwork/json/internal/IdDeserializer.java create mode 100644 api/src/main/java/io/bosonnetwork/json/internal/IdSerializer.java create mode 100644 api/src/main/java/io/bosonnetwork/json/internal/InetAddressDeserializer.java create mode 100644 api/src/main/java/io/bosonnetwork/json/internal/InetAddressSerializer.java create mode 100644 api/src/main/java/io/bosonnetwork/json/internal/NodeInfoDeserializer.java create mode 100644 api/src/main/java/io/bosonnetwork/json/internal/NodeInfoSerializer.java create mode 100644 api/src/main/java/io/bosonnetwork/json/internal/PeerInfoDeserializer.java create mode 100644 api/src/main/java/io/bosonnetwork/json/internal/PeerInfoSerializer.java create mode 100644 api/src/main/java/io/bosonnetwork/json/internal/ValueDeserializer.java create mode 100644 api/src/main/java/io/bosonnetwork/json/internal/ValueSerializer.java delete mode 100644 api/src/main/java/io/bosonnetwork/utils/Json.java create mode 100644 api/src/test/java/io/bosonnetwork/NodeInfoTests.java create mode 100644 api/src/test/java/io/bosonnetwork/PeerInfoTests.java create mode 100644 api/src/test/java/io/bosonnetwork/ValueTests.java rename api/src/test/java/io/bosonnetwork/{utils => json}/JsonTests.java (63%) delete mode 100644 api/src/test/java/io/bosonnetwork/utils/JsonPerfTests.java delete mode 100644 dht/src/main/java/io/bosonnetwork/kademlia/storage/InMemoryStorage.java diff --git a/accesscontrol/src/main/java/io/bosonnetwork/access/impl/AccessManager.java b/accesscontrol/src/main/java/io/bosonnetwork/access/impl/AccessManager.java index 8a28e13..21d3f22 100644 --- a/accesscontrol/src/main/java/io/bosonnetwork/access/impl/AccessManager.java +++ b/accesscontrol/src/main/java/io/bosonnetwork/access/impl/AccessManager.java @@ -45,7 +45,7 @@ import io.bosonnetwork.Id; import io.bosonnetwork.Node; import io.bosonnetwork.access.Permission; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; /** * @hidden diff --git a/accesscontrol/src/test/java/io/bosonnetwork/access/impl/AccessControlListTests.java b/accesscontrol/src/test/java/io/bosonnetwork/access/impl/AccessControlListTests.java index 8db4452..1c304a3 100644 --- a/accesscontrol/src/test/java/io/bosonnetwork/access/impl/AccessControlListTests.java +++ b/accesscontrol/src/test/java/io/bosonnetwork/access/impl/AccessControlListTests.java @@ -39,7 +39,7 @@ import com.google.common.collect.Maps; import io.bosonnetwork.access.Permission.Access; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; public class AccessControlListTests { @Test @@ -198,4 +198,4 @@ void ACLSerializeTests() throws IOException { assertNull(p); } } -} +} \ No newline at end of file diff --git a/accesscontrol/src/test/java/io/bosonnetwork/access/impl/AccessManagerTests.java b/accesscontrol/src/test/java/io/bosonnetwork/access/impl/AccessManagerTests.java index cdfc889..ff5f6f9 100644 --- a/accesscontrol/src/test/java/io/bosonnetwork/access/impl/AccessManagerTests.java +++ b/accesscontrol/src/test/java/io/bosonnetwork/access/impl/AccessManagerTests.java @@ -55,7 +55,7 @@ import io.bosonnetwork.access.Permission.Access; import io.bosonnetwork.crypto.Random; import io.bosonnetwork.utils.FileUtils; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; @Disabled("TODO: enable later") public class AccessManagerTests { diff --git a/accesscontrol/src/test/java/io/bosonnetwork/access/impl/PermissionTests.java b/accesscontrol/src/test/java/io/bosonnetwork/access/impl/PermissionTests.java index 24f2bb6..96e4825 100644 --- a/accesscontrol/src/test/java/io/bosonnetwork/access/impl/PermissionTests.java +++ b/accesscontrol/src/test/java/io/bosonnetwork/access/impl/PermissionTests.java @@ -34,7 +34,7 @@ import org.junit.jupiter.api.Test; import io.bosonnetwork.access.Permission.Access; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; public class PermissionTests { @Test @@ -116,4 +116,4 @@ void testSerializeNonPropertiesPermission() throws IOException { var json2 = Json.objectMapper().writeValueAsString(perm2); assertEquals(json, json2); } -} +} \ No newline at end of file diff --git a/api/docs/datatypes.md b/api/docs/datatypes.md new file mode 100644 index 0000000..eae662b --- /dev/null +++ b/api/docs/datatypes.md @@ -0,0 +1,97 @@ +# Boson DHT Datatypes + +This document describes the core datatypes used in the Boson DHT network, including their purpose and serialization formats for JSON and CBOR. + +--- + +## Id + +The `Id` is a 256-bit identifier used to uniquely identify nodes, values, peers, and other objects in the Boson network. It is based on Ed25519 public keys and serves as the address space for the DHT XOR metric. + +### Serialization Format + +| Format | Representation | Description | +| :--- | :--- | :--- | +| **JSON** | `String` | Base58 encoded string (default) or W3C DID format (`did:boson:`). | +| **CBOR** | `Byte String` | 32 bytes of raw binary data. | + +--- + +## NodeInfo + +`NodeInfo` contains basic network information about a DHT node, allowing other nodes to establish communication. It consists of a node's `Id`, IP address, and port number. + +### Serialization Format + +`NodeInfo` is serialized as a **fixed-order array** of three elements: `[Id, Address, Port]`. + +| Index | Field | Type | Description | +| :--- | :--- | :--- | :--- | +| 0 | `id` | `Id` | The node's 256-bit identifier. | +| 1 | `host` | `String` / `Binary` | The IP address or hostname. | +| 2 | `port` | `Integer` | The UDP port number. | + +#### Format Details + +| Format | id | host | port | +| :--- | :--- | :--- | :--- | +| **JSON** | Base58 String | IP Address String | Number | +| **CBOR** | 32-byte Binary | Raw IP Byte Array (4 or 16 bytes) | Number | + +--- + +## PeerInfo + +`PeerInfo` describes a service published over the Boson DHT. It can be **Authenticated** (includes node signature) or **Regular**. + +### Serialization Format + +`PeerInfo` is serialized as a JSON **object** with the following fields: + +| Field | Key | Type | Description | +| :--- | :--- | :--- | :--- | +| Peer ID | `id` | `Id` | Public key of the service peer. | +| Nonce | `n` | `Binary` | 24-byte nonce for signing. | +| Sequence | `seq` | `Integer` | Incremental version number. | +| Origin Node | `o` | `Id` | (Optional) Node ID providing the peer. | +| Node Sig | `os` | `Binary` | (Optional) Signature from the origin node. | +| Signature | `sig` | `Binary` | Signature of the peer owner. | +| Fingerprint | `f` | `Long` | Unique number for disambiguating peers. | +| Endpoint | `e` | `String` | Service URI (e.g., `http://...`). | +| Extra Data | `ex` | `Binary` | (Optional) Additional service-specific data. | + +#### Format Details + +| Format | Binary Fields (`n`, `sig`, `os`, `ex`) | Id Fields (`id`, `o`) | +| :--- | :--- | :--- | +| **JSON** | Base64 (URL-safe, no padding) | Base58 String | +| **CBOR** | Raw Byte String | 32-byte Binary | + +--- + +## Value + +`Value` represents data stored in the DHT. It supports three types: **Immutable** (content-hashed), **Mutable** (signed), and **Encrypted** (signed & encrypted for a recipient). + +### Serialization Format + +`Value` is serialized as a JSON **object** with the following fields: + +| Field | Key | Type | Description | +| :--- | :--- | :--- | :--- | +| Public Key | `k` | `Id` | (Mutable/Encrypted) Owner's public key. | +| Recipient | `rec` | `Id` | (Optional) Recipient's public key for encrypted values. | +| Nonce | `n` | `Binary` | 24-byte nonce for signing. | +| Sequence | `seq` | `Integer` | (Mutable/Encrypted) Incremental version number. | +| Signature | `sig` | `Binary` | (Mutable/Encrypted) Signature of the data. | +| Data | `v` | `Binary` | The actual value data (encrypted if `rec` is present). | + +#### Format Details + +| Format | Binary Fields (`n`, `sig`, `v`) | Id Fields (`k`, `rec`) | +| :--- | :--- | :--- | +| **JSON** | Base64 (URL-safe, no padding) | Base58 String | +| **CBOR** | Raw Byte String | 32-byte Binary | + +> [!NOTE] +> Immutable values only contain the `v` (data) field and are identified by the SHA-256 hash of that data. diff --git a/api/src/main/java/io/bosonnetwork/Node.java b/api/src/main/java/io/bosonnetwork/Node.java index 519297e..30b3222 100644 --- a/api/src/main/java/io/bosonnetwork/Node.java +++ b/api/src/main/java/io/bosonnetwork/Node.java @@ -42,17 +42,9 @@ * */ public interface Node extends Identity { - /** - * A constant representing the maximum age for a peer, expressed in milliseconds. - * This value is used to define the threshold after which a peer entity is - * considered outdated or expired. The value is set to 2 hours (120 minutes). - */ + /** The maximum age for a peer (2 hours). */ static final int MAX_PEER_AGE = 120 * 60 * 1000; // 2 hours in milliseconds - /** - * A constant representing the maximum age for a value, expressed in milliseconds. - * This value is used to define the threshold after which a value entity is - * considered outdated or expired. The value is set to 2 hours (120 minutes). - */ + /** The maximum age for a value (2 hours). */ static final int MAX_VALUE_AGE = 120 * 60 * 1000; // 2 hours in milliseconds /** @@ -174,11 +166,12 @@ default CompletableFuture findValue(Id id) { } /** - * Finds a value by its ID with the expected sequence number and default lookup option. + * Finds a value by its ID with the expected sequence number and default lookup option. * * @param id the unique identifier for the value to be retrieved - * @param expectedSequenceNumber the expected sequence number to match for the value - * @return a CompletableFuture containing the result value if found, otherwise may resolve to null + * @param expectedSequenceNumber the expected sequence number to match for the value, + * use -1 if no specific sequence number is expected + * @return a CompletableFuture containing the result value if found, or null if not found */ default CompletableFuture findValue(Id id, int expectedSequenceNumber) { return findValue(id, expectedSequenceNumber, null); @@ -188,8 +181,8 @@ default CompletableFuture findValue(Id id, int expectedSequenceNumber) { * Finds a value by its ID without the expected sequence number and the specified lookup option. * * @param id the identifier for which the value is to be looked up - * @param option the lookup option to use during the search - * @return a CompletableFuture that completes with the found value or an appropriate result based on the lookup + * @param option the {@link LookupOption} to use + * @return a {@link CompletableFuture} that completes with the found value or null if not found */ default CompletableFuture findValue(Id id, LookupOption option) { return findValue(id, -1, option); @@ -206,10 +199,10 @@ default CompletableFuture findValue(Id id, LookupOption option) { CompletableFuture findValue(Id id, int expectedSequenceNumber, LookupOption option); /** - * Stores a value in the network without persistency. + * Stores a value in the network without persistence. * - * @param value the value to store. - * @return a {@code CompletableFuture} representing the completion of the operation. + * @param value the {@link Value} to store. + * @return a {@link CompletableFuture} representing the completion of the operation. */ default CompletableFuture storeValue(Value value) { return storeValue(value, -1, false); @@ -227,23 +220,24 @@ default CompletableFuture storeValue(Value value, int expectedSequenceNumb } /** - * Stores a value in the network. + * Stores a value in the network with optional persistence. * - * @param value the value to store. + * @param value the {@link Value} to store. * @param persistent {@code true} if the value should be stored persistently, {@code false} otherwise. - * @return a {@code CompletableFuture} representing the completion of the operation. + * @return a {@link CompletableFuture} representing the completion of the operation. */ default CompletableFuture storeValue(Value value, boolean persistent) { return storeValue(value, -1, persistent); } /** - * Stores a value in the network. + * Stores a value in the network with optional persistence and expected sequence number. * - * @param value the value to be stored - * @param expectedSequenceNumber the expected sequence number for the value - * @param persistent determines whether the value should be stored persistently - * @return a {@code CompletableFuture} representing the completion of the operation. + * @param value the {@link Value} to be stored + * @param expectedSequenceNumber the expected sequence number for the value, + * use -1 if no specific sequence number is expected + * @param persistent determines whether the value should be stored persistently + * @return a {@link CompletableFuture} representing the completion of the operation. */ CompletableFuture storeValue(Value value, int expectedSequenceNumber, boolean persistent); @@ -253,108 +247,172 @@ default CompletableFuture storeValue(Value value, boolean persistent) { * @param id the {@link Id} to find peers for * @return a {@link CompletableFuture} containing the list of {@link PeerInfo} */ - default CompletableFuture> findPeer(Id id) { - return findPeer(id, 0, null); + default CompletableFuture findPeer(Id id) { + return findPeer(id, -1, 1, null) + .thenApply(peers -> peers.isEmpty() ? null : peers.get(0)); + } + + + /** + * Finds a peer in the network by ID with the expected sequence number. + * + * @param id the {@link Id} to find peers for + * @param expectedSequenceNumber the expected sequence number for consistency, + * use -1 if no specific sequence number is expected + * @return a {@link CompletableFuture} containing the {@link PeerInfo} or null if not found + */ + default CompletableFuture findPeer(Id id, int expectedSequenceNumber) { + return findPeer(id, expectedSequenceNumber, 1,null) + .thenApply(peers -> peers.isEmpty() ? null : peers.get(0)); } /** - * Finds peers in the network by ID, with expected number of peers. + * Finds a peer in the network by ID using the specified lookup option. * - * @param id the unique identifier to search for. - * @param expected the number of peers expected to be found. - * @return a {@code CompletableFuture} containing a list of PeerInfo objects representing the found peers. + * @param id the {@link Id} to find peers for + * @param option the {@link LookupOption} to use + * @return a {@link CompletableFuture} containing the {@link PeerInfo} or null if not found */ - default CompletableFuture> findPeer(Id id, int expected) { - return findPeer(id, expected, null); + default CompletableFuture findPeer(Id id, LookupOption option) { + return findPeer(id, -1, 1, option) + .thenApply(peers -> peers.isEmpty() ? null : peers.get(0)); } /** - * Finds peers in the network by ID using the specified lookup option. + * Finds a peer in the network by ID with the given expected sequence number and lookup option. * - * @param id the unique identifier for which peers are being searched - * @param option the lookup option specifying how the search should be conducted - * @return a {@code CompletableFuture} containing a list of PeerInfo objects representing the peers found + * @param id the {@link Id} to find peers for + * @param expectedSequenceNumber the expected sequence number for consistency, + * use -1 if no specific sequence number is expected + * @param option the {@link LookupOption} to use + * @return a {@link CompletableFuture} containing the {@link PeerInfo} or null if not found */ - default CompletableFuture> findPeer(Id id, LookupOption option) { - return findPeer(id, 0, option); + default CompletableFuture findPeer(Id id, int expectedSequenceNumber, LookupOption option) { + return findPeer(id, expectedSequenceNumber, 1, option) + .thenApply(peers -> peers.isEmpty() ? null : peers.get(0)); } /** - * Lookup peers in the network with the given ID. + * Finds multiple peers in the network associated with the given identifier. * - * @param id the ID to find peers for. - * @param expected the expected number of peers to lookup. - * @param option lookup option to use. - * @return a {@code CompletableFuture} object to retrieve the list of peers found. + * @param id the {@link Id} of the peers to find + * @param expectedSequenceNumber the expected sequence number for consistency, + * use -1 if no specific sequence number is expected + * @param expectedCount the maximum number of peers to retrieve + * @param option the {@link LookupOption} to use + * @return a {@link CompletableFuture} that will complete with a list of {@link PeerInfo} objects */ - CompletableFuture> findPeer(Id id, int expected, LookupOption option); + CompletableFuture> findPeer(Id id, int expectedSequenceNumber, int expectedCount, LookupOption option); /** * Announces a peer to the network without persistence. * * @param peer the {@link PeerInfo} to announce - * @return a {@link CompletableFuture} representing completion + * @return a {@link CompletableFuture} representing the completion of the operation */ default CompletableFuture announcePeer(PeerInfo peer) { - return announcePeer(peer, false); + return announcePeer(peer, -1, false); + } + + /** + * Announces a peer to the network with the given expected sequence number and without persistence. + * + * @param peer the {@link PeerInfo} to announce + * @param expectedSequenceNumber the expected sequence number for consistency, + * use -1 if no specific sequence number is expected + * @return a {@link CompletableFuture} representing the completion of the operation + */ + default CompletableFuture announcePeer(PeerInfo peer, int expectedSequenceNumber) { + return announcePeer(peer, expectedSequenceNumber, false); } /** * Announces a peer to the network with optional persistence. * - * @param peer the {@link PeerInfo} to announce + * @param peer the {@link PeerInfo} to announce * @param persistent whether to announce persistently - * @return a {@link CompletableFuture} representing completion + * @return a {@link CompletableFuture} representing the completion of the operation + */ + default CompletableFuture announcePeer(PeerInfo peer, boolean persistent) { + return announcePeer(peer, -1, persistent); + } + + /** + * Announces a peer to the network with optional persistence and expected sequence number. + * + * @param peer the {@link PeerInfo} to announce + * @param expectedSequenceNumber the expected sequence number for consistency, + * use -1 if no specific sequence number is expected + * @param persistent whether to announce persistently + * @return a {@link CompletableFuture} representing the completion of the operation */ - CompletableFuture announcePeer(PeerInfo peer, boolean persistent); + CompletableFuture announcePeer(PeerInfo peer, int expectedSequenceNumber, boolean persistent); /** * Gets the value associated with the given ID from the node's local storage. * - * @param valueId the ID of the value to retrieve. - * @return a {@code CompletableFuture} representing the completion of the operation. + * @param valueId the {@link Id} of the value to retrieve. + * @return a {@link CompletableFuture} representing the completion of the operation. */ CompletableFuture getValue(Id valueId); /** * Removes the value with the given ID from the node's local storage. * - * @param valueId the ID of the value to remove. - * @return a {@code CompletableFuture} representing the completion of the operation. + * @param valueId the {@link Id} of the value to remove. + * @return a {@link CompletableFuture} representing the completion of the operation. */ CompletableFuture removeValue(Id valueId); /** - * Gets the peer information with the given ID from the node's local storage. + * Gets all peer information with the given ID from the node's local storage. + * + * @param peerId the {@link Id} of the peers to retrieve information for. + * @return a {@link CompletableFuture} representing the completion of the operation. + */ + CompletableFuture> getPeers(Id peerId); + + /** + * Removes all peer entries with the given ID from the node's local storage. + * + * @param peerId the {@link Id} of the peers to remove. + * @return a {@link CompletableFuture} representing the completion of the operation. + */ + CompletableFuture removePeers(Id peerId); + + /** + * Gets the peer information with the given ID and fingerprint from the node's local storage. * - * @param peerId the ID of the peer to retrieve information for. - * @return a {@code CompletableFuture} representing the completion of the operation. + * @param peerId the {@link Id} of the peer to retrieve information for. + * @param fingerprint the fingerprint of the peer to retrieve information for. + * @return a {@link CompletableFuture} representing the completion of the operation. */ - CompletableFuture getPeer(Id peerId); + CompletableFuture getPeer(Id peerId, long fingerprint); /** - * Removes the peer entry with the given ID from the node's local storage. + * Removes the peer entry with the given ID and fingerprint from the node's local storage. * - * @param peerId the ID of the peer to remove. - * @return a {@code CompletableFuture} representing the completion of the operation. + * @param peerId the {@link Id} of the peer to remove. + * @param fingerprint the fingerprint of the peer to remove. + * @return a {@link CompletableFuture} representing the completion of the operation. */ - CompletableFuture removePeer(Id peerId); + CompletableFuture removePeer(Id peerId, long fingerprint); /** - * Signs the given data. + * Signs the given data using the node's private key. * - * @param data the data to sign. - * @return the signature. + * @param data the data to sign + * @return the signature */ @Override byte[] sign(byte[] data); /** - * Verifies the signature of the given data. + * Verifies the signature of the given data using the node's public key. * - * @param data the data to verify. - * @param signature the signature to verify. - * @return {@code true} if the signature is valid, {@code false} otherwise. + * @param data the data to verify + * @param signature the signature to verify + * @return {@code true} if the signature is valid, {@code false} otherwise */ @Override boolean verify(byte[] data, byte[] signature); @@ -362,9 +420,10 @@ default CompletableFuture announcePeer(PeerInfo peer) { /** * Encrypts the given data for a specific recipient. * - * @param recipient the ID of the recipient. - * @param data the data to encrypt. - * @return the encrypted data. + * @param recipient the {@link Id} of the recipient + * @param data the data to encrypt + * @return the encrypted data + * @throws CryptoException if encryption fails */ @Override byte[] encrypt(Id recipient, byte[] data) throws CryptoException; @@ -372,19 +431,20 @@ default CompletableFuture announcePeer(PeerInfo peer) { /** * Decrypts the given data from a specific sender. * - * @param sender the ID of the sender. - * @param data the data to decrypt. - * @return the decrypted data. - * @throws CryptoException if an error occurs during decryption. + * @param sender the {@link Id} of the sender + * @param data the data to decrypt + * @return the decrypted data + * @throws CryptoException if decryption fails */ @Override byte[] decrypt(Id sender, byte[] data) throws CryptoException; /** - * Create {@code CryptoContext} object for the target Id. + * Creates a {@link CryptoContext} object for the target ID. * - * @param id the target id - * @return the {@code CryptoContext} object for id + * @param id the target {@link Id} + * @return the {@link CryptoContext} object for the ID + * @throws CryptoException if context creation fails */ @Override CryptoContext createCryptoContext(Id id) throws CryptoException; @@ -392,9 +452,9 @@ default CompletableFuture announcePeer(PeerInfo peer) { /** * Creates and initializes a new KadNode instance using the provided configuration. * - * @param config the configuration object used to initialize the KadNode. - * @return an initialized instance of a {@code Node} representing the KadNode. - * @throws BosonException if the KadNode class is not found or an error occurs during instantiation. + * @param config the node configuration + * @return an initialized {@link Node} instance + * @throws BosonException if the KadNode cannot be initialized */ static Node kadNode(NodeConfiguration config) throws BosonException { try { diff --git a/api/src/main/java/io/bosonnetwork/NodeInfo.java b/api/src/main/java/io/bosonnetwork/NodeInfo.java index 3b9708d..2aa73eb 100644 --- a/api/src/main/java/io/bosonnetwork/NodeInfo.java +++ b/api/src/main/java/io/bosonnetwork/NodeInfo.java @@ -95,12 +95,8 @@ public NodeInfo(Id id, String host, int port) { if (port <= 0 || port > 65535) throw new IllegalArgumentException("Invalid port: " + port); - // TODO: not try to do the name resolution if the host is an host name - this.addr = new InetSocketAddress(host, port); - if (addr.isUnresolved()) - throw new IllegalArgumentException("Unknown host"); - this.id = id; + this.addr = new InetSocketAddress(host, port); } /** diff --git a/api/src/main/java/io/bosonnetwork/PeerInfo.java b/api/src/main/java/io/bosonnetwork/PeerInfo.java index 1482523..0a502a3 100644 --- a/api/src/main/java/io/bosonnetwork/PeerInfo.java +++ b/api/src/main/java/io/bosonnetwork/PeerInfo.java @@ -29,500 +29,532 @@ import java.security.MessageDigest; import java.text.Normalizer; import java.util.Arrays; +import java.util.Collections; +import java.util.Map; import java.util.Objects; import io.bosonnetwork.crypto.Hash; +import io.bosonnetwork.crypto.Random; import io.bosonnetwork.crypto.Signature; +import io.bosonnetwork.utils.Hex; +import io.bosonnetwork.json.Json; /** + * PeerInfo describes the service information published over the Boson DHT network. * - * Represents peer information in the Boson network. + *

PeerInfo has 2 types: + *

    + *
  • Authenticated PeerInfo: The service peer is provided by a Boson DHT node, and includes the + * nodeId and node signature as proof.
  • + *
  • Regular PeerInfo: Without nodeId and node signature.
  • + *
+ * + *

The PeerInfo is signed by the peer keypair, which is held by the service peer owner. + * + *

PeerInfo is uniquely identified by the peer ID (public key) and fingerprint. */ + public class PeerInfo { - /** - * Attribute key to omit the peer ID in the peer info used in JsonContext. - */ + /** The number of bytes in the nonce. */ + public static int NONCE_BYTES = 24; + + /** Attribute key to omit the peer ID in the peer info used in JsonContext. */ public static final Object ATTRIBUTE_OMIT_PEER_ID = new Object(); - /** - * Attribute key of the peer ID used in JsonContext. - */ + /** Attribute key of the peer ID used in JsonContext. */ public static final Object ATTRIBUTE_PEER_ID = new Object(); - private final Id publicKey; // Peer ID - private final byte[] privateKey; // Private key to sign the peer info - private final Id nodeId; // The node that provide the service peer - private final Id origin; // The node that announces the peer - private final int port; - private final String alternativeURI; + /** The peer ID. */ + private final Id publicKey; + /** The private key to sign the peer info. */ + private final byte[] privateKey; + /** The nonce. */ + private final byte[] nonce; + /** The sequence number. */ + private final int sequenceNumber; + /** Optional: The node that provides the peer. */ + private final Id nodeId; + /** Optional: Signature of the node, mandatory if nodeId is present. */ + private final byte[] nodeSig; + /** The signature. */ private final byte[] signature; + /** Unique fingerprint number for the peer with same peer id. */ + private final long fingerprint; + /** The service endpoint URI of the peer. */ + private final String endpoint; + /** The extra data in binary format. */ + private final byte[] extraData; - /** - * Constructs an instance of PeerInfo with the specified parameters. - * - * @param peerId the identifier of the peer; must not be null - * @param privateKey the private key associated with the peer; should be of length {@link Signature.PrivateKey#BYTES}, or null if not provided - * @param nodeId the node identifier of the peer; must not be null - * @param origin the origin identifier of the peer; can be null or the same as nodeId - * @param port the port number associated with the peer; must be greater than 0 and less than or equal to 65,535 - * @param alternativeURI an optional alternative URL associated with the peer; may be null or an empty string - * @param signature the signature associated with the peer; must not be null and should be of length {@link Signature#BYTES} - * @throws IllegalArgumentException if peerId is null, the privateKey length is invalid, nodeId is null, - * the port is out of the valid range, or the signature is invalid - */ - private PeerInfo(Id peerId, byte[] privateKey, Id nodeId, Id origin, int port, - String alternativeURI, byte[] signature) { - if (peerId == null) - throw new IllegalArgumentException("Invalid peer id"); - - if (privateKey != null && privateKey.length != Signature.PrivateKey.BYTES) - throw new IllegalArgumentException("Invalid private key"); - - if (nodeId == null) - throw new IllegalArgumentException("Invalid node id"); - - if (port <= 0 || port > 65535) - throw new IllegalArgumentException("Invalid port"); - - if (signature == null || signature.length != Signature.BYTES) - throw new IllegalArgumentException("Invalid signature"); + private transient Map extra; + private PeerInfo(Id peerId, byte[] privateKey, byte[] nonce, int sequenceNumber, Id nodeId, byte[] nodeSig, + byte[] signature, long fingerprint, String endpoint, byte[] extraData) { this.publicKey = peerId; this.privateKey = privateKey; + this.nonce = nonce; + this.sequenceNumber = sequenceNumber; this.nodeId = nodeId; - this.origin = origin == null || origin.equals(nodeId) ? null : origin; - this.port = port; - if (alternativeURI != null && !alternativeURI.isEmpty()) - this.alternativeURI = Normalizer.normalize(alternativeURI, Normalizer.Form.NFC); - else - this.alternativeURI = null; + this.nodeSig = nodeSig; this.signature = signature; + this.fingerprint = fingerprint; + this.endpoint = endpoint; + this.extraData = extraData; } /** - * Constructor for the PeerInfo class. - * - * @param peerId The unique identifier for the peer. - * @param nodeId The unique identifier for the node. - * @param origin The origin identifier associated with the peer. - * @param port The port number used by the peer. - * @param alternativeURI An alternative URI for accessing the peer. - * @param signature A byte array representing the signature for security validation. + * Creates a new PeerInfo instance from the existing peer information. + * + * @param peerId The peer ID. + * @param nonce The nonce. + * @param sequenceNumber The sequence number. + * @param nodeId The node ID (optional). + * @param nodeSig The node signature (optional). + * @param signature The signature. + * @param fingerprint The fingerprint. + * @param endpoint The endpoint. + * @param extraData The extra data. + * @return The new PeerInfo instance. */ - protected PeerInfo(Id peerId, Id nodeId, Id origin, int port, String alternativeURI, byte[] signature) { - this(peerId, null, nodeId, origin, port, alternativeURI, signature); + public static PeerInfo of(Id peerId, byte[] nonce, int sequenceNumber, Id nodeId, byte[] nodeSig, + byte[] signature, long fingerprint, String endpoint, byte[] extraData) { + return of(peerId, null, nonce, sequenceNumber, nodeId, nodeSig, signature, fingerprint, endpoint, extraData); } /** - * Constructs a PeerInfo object with the specified parameters. - * - * @param keypair the key pair to be used for signing and key generation; must not be null - * @param nodeId the unique identifier of the node; must not be null - * @param origin the origin node's identifier; can be null or equal to nodeId - * @param port the port number for the peer; must be between 1 and 65535 - * @param alternativeURI an optional alternative URI for the peer; can be null or empty - * @throws IllegalArgumentException if the keypair is null, nodeId is null, or port is invalid - */ - private PeerInfo(Signature.KeyPair keypair, Id nodeId, Id origin, int port, String alternativeURI) { - if (keypair == null) - throw new IllegalArgumentException("Invalid keypair"); + * Creates a new PeerInfo instance from the existing peer information. + * + * @param peerId The peer ID. + * @param privateKey The private key (optional). + * @param nonce The nonce. + * @param sequenceNumber The sequence number. + * @param nodeId The node ID (optional). + * @param nodeSig The node signature (optional). + * @param signature The signature. + * @param fingerprint The fingerprint. + * @param endpoint The endpoint. + * @param extraData The extra data. + * @return The new PeerInfo instance. + */ + public static PeerInfo of(Id peerId, byte[] privateKey, byte[] nonce, int sequenceNumber, Id nodeId, byte[] nodeSig, + byte[] signature, long fingerprint, String endpoint, byte[] extraData) { + if (peerId == null) + throw new IllegalArgumentException("Invalid peer id"); - if (nodeId == null) - throw new IllegalArgumentException("Invalid node id"); + // noinspection DuplicatedCode + if (privateKey != null && privateKey.length != Signature.PrivateKey.BYTES) + throw new IllegalArgumentException("Invalid private key"); - if (port <= 0 || port > 65535) - throw new IllegalArgumentException("Invalid port"); + if (nonce == null || nonce.length != NONCE_BYTES) + throw new IllegalArgumentException("Invalid nonce"); - this.publicKey = new Id(keypair.publicKey().bytes()); - this.privateKey = keypair.privateKey().bytes(); - this.nodeId = nodeId; - this.origin = origin == null || origin.equals(nodeId) ? null : origin; - this.port = port; - if (alternativeURI != null && !alternativeURI.isEmpty()) - this.alternativeURI = Normalizer.normalize(alternativeURI, Normalizer.Form.NFC); - else - this.alternativeURI = null; - this.signature = Signature.sign(getSignData(), keypair.privateKey()); + if (sequenceNumber < 0) + throw new IllegalArgumentException("Invalid sequence number"); + + if (nodeId != null) { + if (nodeSig == null || nodeSig.length != Signature.BYTES) + throw new IllegalArgumentException("Invalid node signature"); + } else { + if (nodeSig != null) + throw new IllegalArgumentException("Invalid node signature, should be null if nodeId is null"); + } + + if (signature == null || signature.length != Signature.BYTES) + throw new IllegalArgumentException("Invalid signature"); + + if (endpoint == null || endpoint.isEmpty()) + throw new IllegalArgumentException("Invalid endpoint"); + + return new PeerInfo(peerId, privateKey, nonce, sequenceNumber, nodeId, nodeSig, signature, fingerprint, endpoint, extraData); } /** - * Rebuilds a PeerInfo object with specified information. + * Creates a new PeerInfo builder. * - * @param peerId the peer ID. - * @param nodeId the ID of the node providing the service peer. - * @param port the port on which the peer is available. - * @param signature the signature of the peer info. - * @return a created PeerInfo object. + * @return a new PeerInfo builder */ - public static PeerInfo of(Id peerId, Id nodeId, int port, byte[] signature) { - return new PeerInfo(peerId, null, nodeId, null, port, null, signature); + public static Builder builder() { + return new Builder(); } /** - * Rebuilds a PeerInfo object with specified information. - * - * @param peerId the peer ID. - * @param privateKey the private key associated with the peer. - * @param nodeId the ID of the node providing the service peer. - * @param port the port on which the peer is available. - * @param signature the signature of the peer info. - * @return a created PeerInfo object. - */ - public static PeerInfo of(Id peerId, byte[] privateKey, Id nodeId, int port, byte[] signature) { - return new PeerInfo(peerId, privateKey, nodeId, null, port, null, signature); - } + * Helper method to create a new PeerInfo instance with common logic. + * + * @param keypair The peer keypair. + * @param node The node identity (optional). + * @param sequenceNumber The sequence number. + * @param fingerprint The fingerprint. + * @param endpoint The endpoint. + * @param extraData The extra data. + * @return The new PeerInfo instance. + */ + private static PeerInfo create(Signature.KeyPair keypair, Identity node, int sequenceNumber, + long fingerprint, String endpoint, byte[] extraData) { + byte[] nonce = new byte[NONCE_BYTES]; + Random.secureRandom().nextBytes(nonce); + + Id peerId = Id.of(keypair.publicKey().bytes()); + Id nodeId; + byte[] nodeSig; + if (node != null) { + nodeId = node.getId(); + byte[] digest = Hash.sha256(peerId.bytes(), nodeId.bytes(), nonce); + nodeSig = node.sign(digest); + } else { + nodeId = null; + nodeSig = null; + } - /** - * Rebuilds a PeerInfo object with specified information. - * - * @param peerId the peer ID. - * @param nodeId the ID of the node providing the service peer. - * @param port the port on which the peer is available. - * @param alternativeURI an alternative URI for the peer. - * @param signature the signature of the peer info. - * @return a created PeerInfo object. - */ - public static PeerInfo of(Id peerId, Id nodeId, int port, String alternativeURI, byte[] signature) { - return new PeerInfo(peerId, null, nodeId, null, port, alternativeURI, signature); + byte[] digest = new PeerInfo(peerId, null, nonce, sequenceNumber, + nodeId, nodeSig, null, fingerprint, endpoint, extraData).digest(); + byte[] sig = Signature.sign(digest, keypair.privateKey()); + + return new PeerInfo(peerId, keypair.privateKey().bytes(), nonce, sequenceNumber, + nodeId, nodeSig, sig, fingerprint, endpoint, extraData); } /** - * Rebuilds a PeerInfo object with specified information. - * - * @param peerId the peer ID. - * @param privateKey the private key associated with the peer. - * @param nodeId the ID of the node providing the service peer. - * @param port the port on which the peer is available. - * @param alternativeURI an alternative URI for the peer. - * @param signature the signature of the peer info. - * @return a created PeerInfo object. + * Gets the peer ID. + * + * @return The peer ID. */ - public static PeerInfo of(Id peerId, byte[] privateKey, Id nodeId, int port, - String alternativeURI, byte[] signature) { - return new PeerInfo(peerId, privateKey, nodeId, null, port, alternativeURI, signature); + public Id getId() { + return publicKey; } /** - * Rebuilds a PeerInfo object with specified information. - * - * @param peerId the peer ID. - * @param nodeId the ID of the node providing the service peer. - * @param origin the node that announces the peer. - * @param port the port on which the peer is available. - * @param signature the signature of the peer info. - * @return a created PeerInfo object. + * Checks if the current node has the peer's private key. + * + * @return {@code true} if the node has the private key, {@code false} otherwise. */ - public static PeerInfo of(Id peerId, Id nodeId, Id origin, int port, byte[] signature) { - return new PeerInfo(peerId, null, nodeId, origin, port, null, signature); + public boolean hasPrivateKey() { + return privateKey != null; } /** - * Rebuilds a PeerInfo object with specified information. - * - * @param peerId the peer ID. - * @param privateKey the private key associated with the peer. - * @param nodeId the ID of the node providing the service peer. - * @param origin the node that announces the peer. - * @param port the port on which the peer is available. - * @param signature the signature of the peer info. - * @return a created PeerInfo object. + * Gets the private key associated with the peer. + * + * @return The private key. */ - public static PeerInfo of(Id peerId, byte[] privateKey, Id nodeId, Id origin, int port, byte[] signature) { - return new PeerInfo(peerId, privateKey, nodeId, origin, port, null, signature); + public byte[] getPrivateKey() { + return privateKey; } /** - * Rebuilds a PeerInfo object with specified information. - * - * @param peerId the peer ID. - * @param nodeId the ID of the node providing the service peer. - * @param origin the node that announces the peer. - * @param port the port on which the peer is available. - * @param alternativeURI an alternative URI for the peer. - * @param signature the signature of the peer info. - * @return a created PeerInfo object. + * Gets the nonce. + * + * @return the nonce */ - public static PeerInfo of(Id peerId, Id nodeId, Id origin, int port, String alternativeURI, byte[] signature) { - return new PeerInfo(peerId, null, nodeId, origin, port, alternativeURI, signature); + public byte[] getNonce() { + return nonce; } /** - * Rebuilds a PeerInfo object with specified information. - * - * @param peerId the peer ID. - * @param privateKey the private key associated with the peer. - * @param nodeId the ID of the node providing the service peer. - * @param origin the node that announces the peer. - * @param port the port on which the peer is available. - * @param alternativeURI an alternative URI for the peer. - * @param signature the signature of the peer info. - * @return a created PeerInfo object. + * Gets the sequence number. + * + * @return the sequence number */ - public static PeerInfo of(Id peerId, byte[] privateKey, Id nodeId, Id origin, int port, - String alternativeURI, byte[] signature) { - return new PeerInfo(peerId, privateKey, nodeId, origin, port, alternativeURI, signature); + public int getSequenceNumber() { + return sequenceNumber; } /** - * Creates a PeerInfo object with specified information, newly created - * PeerInfo will be signed by a new generated random key pair. + * Gets the ID of the node providing the service peer. * - * @param nodeId the ID of the node providing the service peer. - * @param port the port on which the peer is available. - * @return a created PeerInfo object. + * @return The node ID. */ - public static PeerInfo create(Id nodeId, int port) { - return create(null, nodeId, null, port, null); + public Id getNodeId() { + return nodeId; } /** - * Creates a PeerInfo object with specified information and key pair. + * Gets the node signature. * - * @param keypair the key pair key to sign the peer information. - * @param nodeId the ID of the node providing the service peer. - * @param port the port on which the peer is available. - * @return a created PeerInfo object. + * @return the node signature */ - public static PeerInfo create(Signature.KeyPair keypair, Id nodeId, int port) { - return create(keypair, nodeId, null, port, null); + public byte[] getNodeSignature() { + return nodeSig; } /** - * Creates a PeerInfo object with specified information, newly created - * PeerInfo will be signed by a new generated random key pair. + * Checks if the peer info is authenticated. * - * @param nodeId the ID of the node providing the service peer. - * @param origin the node that announces the peer. - * @param port the port on which the peer is available. - * @return a created PeerInfo object. + *

Authenticated means the service peer provided by a Boson DHT node, and include the nodeId and + * node signature to proof. + * + * @return {@code true} if the peer info is authenticated, {@code false} otherwise. */ - public static PeerInfo create(Id nodeId, Id origin, int port) { - return create(null, nodeId, origin, port, null); + public boolean isAuthenticated() { + return nodeId != null && nodeSig != null; } /** - * Creates a PeerInfo object with specified information and key pair. + * Gets the signature of the peer info. * - * @param keypair the key pair key to sign the peer information. - * @param nodeId the ID of the node providing the service peer. - * @param origin the node that announces the peer. - * @param port the port on which the peer is available. - * @return a created PeerInfo object. + * @return The signature. */ - public static PeerInfo create(Signature.KeyPair keypair, Id nodeId, Id origin, int port) { - return create(keypair, nodeId, origin, port, null); + public byte[] getSignature() { + return signature; } /** - * Creates a PeerInfo object with specified information, newly created - * PeerInfo will be signed by a new generated random key pair. + * Gets the fingerprint. * - * @param nodeId the ID of the node providing the service peer. - * @param port the port on which the peer is available. - * @param alternativeURI an alternative URI for the peer. - * @return a created PeerInfo object. + * @return the fingerprint */ - public static PeerInfo create(Id nodeId, int port, String alternativeURI) { - return create(null, nodeId, null, port, alternativeURI); + public long getFingerprint() { + return fingerprint; } /** - * Creates a PeerInfo object with specified information and key pair. + * Gets the endpoint. * - * @param keypair the key pair key to sign the peer information. - * @param nodeId the ID of the node providing the service peer. - * @param port the port on which the peer is available. - * @param alternativeURI an alternative URI for the peer. - * @return a created PeerInfo object. + * @return the endpoint */ - public static PeerInfo create(Signature.KeyPair keypair, Id nodeId, int port, String alternativeURI) { - return create(keypair, nodeId, null, port, alternativeURI); + public String getEndpoint() { + return endpoint; } /** - * Creates a PeerInfo object with specified information, newly created PeerInfo will - * be signed by a new generated random key pair. - * - * @param nodeId the ID of the node providing the service peer. - * @param origin the node that announces the peer. - * @param port the port on which the peer is available. - * @param alternativeURI an alternative URI for the peer. - * @return a created PeerInfo object. + * Checks if the extra data is present. + * + * @return {@code true} if the extra data is present, {@code false} otherwise. */ - public static PeerInfo create(Id nodeId, Id origin, int port, String alternativeURI) { - return create(null, nodeId, origin, port, alternativeURI); + public boolean hasExtra() { + return extraData != null && extraData.length > 0; } /** - * Creates a PeerInfo object with specified information and key pair. - * - * @param keypair the key pair key to sign the peer information. - * @param nodeId the ID of the node providing the service peer. - * @param origin the node that announces the peer. - * @param port the port on which the peer is available. - * @param alternativeURI an alternative URI for the peer. - * @return a created PeerInfo object. + * Gets the extra data. + * + * @return the extra data */ - public static PeerInfo create(Signature.KeyPair keypair, Id nodeId, Id origin, - int port, String alternativeURI) { - if (keypair == null) - keypair = Signature.KeyPair.random(); - - return new PeerInfo(keypair, nodeId, origin, port, alternativeURI); + public byte[] getExtraData() { + return extraData; } /** - * Gets the peer ID. + * Gets the extra data as a map. * - * @return The peer ID. - */ - public Id getId() { - return publicKey; + * @return the extra data map + */ + public Map getExtra() { + if (extra == null) { + if (hasExtra()) { + try { + extra = Collections.unmodifiableMap(Json.parse(extraData)); + } catch (Exception e) { + throw new IllegalStateException("Invalid extra data", e); + } + } else { + extra = Collections.emptyMap(); + } + } + + return extra; } /** - * Checks if the current node has the peer's private key. + * Computes the digest for signing the peer info. * - * @return {@code true} if the node has the private key, {@code false} otherwise. + * @return the digest */ - public boolean hasPrivateKey() { - return privateKey != null; + private byte[] digest() { + MessageDigest sha = Hash.sha256(); + sha.update(publicKey.bytes()); + sha.update(nonce); + sha.update(ByteBuffer.allocate(Integer.BYTES).putInt(sequenceNumber).array()); + if (nodeId != null) { + sha.update(nodeId.bytes()); + sha.update(nodeSig); + } + sha.update(ByteBuffer.allocate(Long.BYTES).putLong(fingerprint).array()); + sha.update(endpoint.getBytes(UTF_8)); + if (extraData != null) + sha.update(extraData); + return sha.digest(); } /** - * Gets the private key associated with the peer. + * Checks if the PeerInfo object is valid. * - * @return The private key. + *

This method performs the following checks: + *

    + *
  • Data integrity checks (non-null fields where expected).
  • + *
  • Signature verification: + *
      + *
    • If authenticated (nodeId present), verifies the node signature against the node ID.
    • + *
    • Verifies the peer signature against the peer public key.
    • + *
    + *
  • + *
+ * + * @return {@code true} if the value is valid, {@code false} otherwise. */ - public byte[] getPrivateKey() { - return privateKey; + public boolean isValid() { + if (signature == null || signature.length != Signature.BYTES) + return false; + + if (nonce == null || nonce.length != NONCE_BYTES) + return false; + + if (nodeId != null) { + if (nodeSig == null || nodeSig.length != Signature.BYTES) + return false; + + Signature.PublicKey nodePk = nodeId.toSignatureKey(); + byte[] digest = Hash.sha256(publicKey.getBytes(), nodeId.bytes(), nonce); + if (!Signature.verify(digest, nodeSig, nodePk)) + return false; + } else { + if (nodeSig != null) + return false; + } + + Signature.PublicKey peerPk = publicKey.toSignatureKey(); + return Signature.verify(digest(), signature, peerPk); } /** - * Gets the ID of the node providing the service peer. + * Returns a new PeerInfo instance with the same properties as the current instance, + * but with the private key set to null. If the current instance already has a null + * private key, it returns the current instance itself. * - * @return The node ID. + * @return a new PeerInfo instance without a private key, or the current instance if the private key is already null. */ - public Id getNodeId() { - return nodeId; + public PeerInfo withoutPrivateKey() { + if (privateKey == null) + return this; + + return new PeerInfo(publicKey, null, nonce, sequenceNumber, nodeId, nodeSig, signature, fingerprint, endpoint, extraData); } /** - * Gets the node that announces the peer. + * Updates the endpoint of the peer info. * - * @return The origin node ID. null if the peer is not delegated. + *

This method creates a new PeerInfo instance with the updated endpoint and an incremented sequence number. + * The new instance will be signed with the private key held by this instance. + * + * @param endpoint The new endpoint. + * @return The updated PeerInfo instance. */ - public Id getOrigin() { - return origin; + public PeerInfo update(String endpoint) { + return update(null, endpoint, this.extraData); } /** - * Checks if the peer is delegated (announced by a different node). + * Updates the endpoint and extra data of the peer info. + * + *

This method creates a new PeerInfo instance with the updated endpoint/extra data and an incremented sequence number. + * The new instance will be signed with the private key held by this instance. * - * @return {@code true} if the peer is delegated, {@code false} otherwise. + * @param endpoint The new endpoint. + * @param extraData The new extra data. + * @return The updated PeerInfo instance. */ - public boolean isDelegated() { - return origin != null; + public PeerInfo update(String endpoint, byte[] extraData) { + return update(null, endpoint, extraData); } /** - * Gets the port on which the peer is available. + * Updates the endpoint and extra data of the peer info. + * + *

This method creates a new PeerInfo instance with the updated endpoint/extra data and an incremented sequence number. + * The new instance will be signed with the private key held by this instance. * - * @return The port number. + * @param endpoint The new endpoint. + * @param extra The new extra data map. + * @return The updated PeerInfo instance. */ - public int getPort() { - return port; + public PeerInfo update(String endpoint, Map extra) { + byte[] extraData = extra != null && !extra.isEmpty() ? Json.toBytes(extra) : null; + return update(null, endpoint, extraData); } /** - * Gets the alternative URI for the peer. + * Updates the endpoint of the peer info, optionally adding node authentication. * - * @return The alternative URI. + *

This method creates a new PeerInfo instance with the updated endpoint and an incremented sequence number. + * The new instance will be signed with the private key held by this instance. + * + * @param node The node identity for authentication (optional). + * @param endpoint The new endpoint. + * @return The updated PeerInfo instance. */ - public String getAlternativeURI() { - return alternativeURI; + public PeerInfo update(Identity node, String endpoint) { + return update(node, endpoint, this.extraData); } /** - * Checks if the peer has an alternative URI. + * Updates the endpoint and extra data of the peer info, optionally adding node authentication. + * + *

This method creates a new PeerInfo instance with the updated endpoint/extra data and an incremented sequence number. + * The new instance will be signed with the private key held by this instance. * - * @return {@code true} if the peer has an alternative URI, {@code false} otherwise. + * @param node The node identity for authentication (optional). + * @param endpoint The new endpoint. + * @param extra The new extra data map. + * @return The updated PeerInfo instance. */ - public boolean hasAlternativeURI() { - return alternativeURI != null && !alternativeURI.isEmpty(); + public PeerInfo update(Identity node, String endpoint, Map extra) { + byte[] extraData = extra != null && !extra.isEmpty() ? Json.toBytes(extra) : null; + return update(node, endpoint, extraData); } /** - * Gets the signature of the peer info. + * Updates the endpoint and extra data of the peer info, optionally adding node authentication. * - * @return The signature. - */ - public byte[] getSignature() { - return signature; - } + *

This method creates a new PeerInfo instance with the updated endpoint/extra data and an incremented sequence number. + * The new instance will be signed with the private key held by this instance. + * + * @param node The node identity for authentication (optional). + * @param endpoint The new endpoint. + * @param extraData The new extra data. + * @return The updated PeerInfo instance. + * @throws UnsupportedOperationException If this instance does not hold the private key. + * @throws IllegalArgumentException If the endpoint is invalid or if attempting to change the authenticating node ID. + */ + public PeerInfo update(Identity node, String endpoint, byte[] extraData) { + if (privateKey == null) + throw new UnsupportedOperationException("Not the owner of the peer info"); - private byte[] getSignData() { - // TODO: optimize with incremental digest, and return sha256 hash as sign input - /*/ - byte[] alt = alternativeURI == null || alternativeURI.isEmpty() ? - null : alternativeURI.getBytes(UTF_8); + if (endpoint == null || endpoint.isEmpty()) + throw new IllegalArgumentException("Invalid endpoint"); - byte[] toSign = new byte[Id.BYTES * 2 + Short.BYTES + (alt == null ? 0 : alt.length)]; - ByteBuffer buf = ByteBuffer.wrap(toSign); - buf.put(nodeId.bytes()) - .put(origin.bytes()) - .putShort((short)port); - if (alt != null) - buf.put(alt); + if (this.nodeId != null) { + if (node == null) + throw new UnsupportedOperationException("Cannot update node authenticated peer info without the owner node"); + if (!node.getId().equals(this.nodeId)) + throw new IllegalArgumentException("Cannot update node authenticated peer info with a different node"); + } - return toSign; - */ + String _endpoint = Normalizer.normalize(endpoint, Normalizer.Form.NFC); + byte[] _extraData = extraData == null || extraData.length == 0 ? null : extraData; + Id nodeId = node != null ? node.getId() : null; - MessageDigest sha = Hash.sha256(); - sha.update(publicKey.bytes()); - sha.update(nodeId.bytes()); - if (origin != null) - sha.update(origin.bytes()); - sha.update(ByteBuffer.allocate(Short.BYTES).putShort((short)port).array()); - if (alternativeURI != null) - sha.update(alternativeURI.getBytes(UTF_8)); + if (_endpoint.equals(this.endpoint) && Objects.equals(this.nodeId, nodeId) && Arrays.equals(this.extraData, _extraData)) + return this; // nothing to update - return sha.digest(); + int sequenceNumber = this.sequenceNumber + 1; + Signature.KeyPair keyPair = Signature.KeyPair.fromPrivateKey(this.privateKey); + return create(keyPair, node, sequenceNumber, fingerprint, _endpoint, _extraData); } /** - * Checks if the PeerInfo object is valid, including checks for data integrity and - * signature verification. + * Returns a hash code value for the object. * - * @return {@code true} if the value is valid, {@code false} otherwise. + * @return a hash code value for this object. */ - public boolean isValid() { - if (signature == null || signature.length != Signature.BYTES) - return false; - - Signature.PublicKey pk = publicKey.toSignatureKey(); - - return Signature.verify(getSignData(), signature, pk); + @Override + public int hashCode() { + return 0x6030A + Objects.hash(publicKey, Arrays.hashCode(nonce), sequenceNumber, nodeId, + Arrays.hashCode(nodeSig), Arrays.hashCode(signature), fingerprint, endpoint, Arrays.hashCode(extraData)); } /** - * Returns a new PeerInfo instance with the same properties as the current instance, - * but with the private key set to null. If the current instance already has a null - * private key, it returns the current instance itself. + * Indicates whether some other object is "equal to" this one. * - * @return a new PeerInfo instance without a private key, or the current instance if the private key is already null. + * @param o the reference object with which to compare. + * @return {@code true} if this object is the same as the obj argument; {@code false} otherwise. */ - public PeerInfo withoutPrivateKey() { - if (privateKey == null) - return this; - - return new PeerInfo(publicKey, null, nodeId, origin, port, alternativeURI, signature); - } - - @Override - public int hashCode() { - return 0x6030A + Objects.hash(publicKey, nodeId, origin, port, alternativeURI, Arrays.hashCode(signature)); - } - @Override public boolean equals(Object o) { if (o == this) @@ -530,28 +562,193 @@ public boolean equals(Object o) { if (o instanceof PeerInfo that) { return Objects.equals(this.publicKey, that.publicKey) && + Arrays.equals(this.nonce, that.nonce) && + this.sequenceNumber == that.sequenceNumber && Objects.equals(this.nodeId, that.nodeId) && - Objects.equals(this.origin, that.origin) && - this.port == that.port && - Objects.equals(this.alternativeURI, that.alternativeURI) && - Arrays.equals(this.signature, that.signature); + Arrays.equals(this.nodeSig, that.nodeSig) && + Arrays.equals(this.signature, that.signature) && + this.fingerprint == that.fingerprint && + Objects.equals(this.endpoint, that.endpoint) && + Arrays.equals(this.extraData, that.extraData); } return false; } + /** + * Returns a string representation of the object. + * + * @return a string representation of the object. + */ @Override public String toString() { - StringBuilder sb = new StringBuilder(); - sb.append("<") - .append(publicKey.toString()).append(',') - .append(nodeId.toString()).append(','); - if (isDelegated()) - sb.append(getOrigin().toString()).append(','); - sb.append(port); - if (hasAlternativeURI()) - sb.append(",").append(alternativeURI); - sb.append(">"); - return sb.toString(); + StringBuilder repr = new StringBuilder(); + repr.append("id:").append(publicKey) + .append(",endpoint:").append(endpoint); + if (hasExtra()) { + if (extra != null) + repr.append(",extra:").append(extra); + else + repr.append(",extra:").append(Hex.encode(extraData)); + } + + if (fingerprint != 0) + repr.append(",sn:").append(fingerprint); + + if (sequenceNumber > 0) + repr.append(",seq:").append(sequenceNumber); + + if (nodeId != null) { + repr.append(",nodeId:").append(nodeId); + repr.append(",nodeSig:").append(Hex.encode(nodeSig)); + } + repr.append(",sig:").append(Hex.encode(signature)); + return repr.toString(); + } + + /** + * PeerInfo builder. + */ + public static class Builder { + private Signature.KeyPair keyPair = null; + private int sequenceNumber = 0; + private Identity node = null; + private long fingerprint = 0; + private String endpoint = null; + private byte[] extraData = null; + + private Builder() { + } + + /** + * Sets the endpoint for the peer info. + * + * @param endpoint the endpoint + * @return the builder instance + * @throws IllegalArgumentException if the endpoint is null or empty + */ + public Builder endpoint(String endpoint) { + if (endpoint == null || endpoint.isEmpty()) + throw new IllegalArgumentException("Endpoint cannot be null or empty"); + this.endpoint = Normalizer.normalize(endpoint, Normalizer.Form.NFC); + return this; + } + + /** + * Sets the extra data for the peer info. + * + * @param extra the extra data map + * @return the builder instance + */ + public Builder extra(Map extra) { + this.extraData = extra != null && !extra.isEmpty() ? Json.toBytes(extra) : null; + return this; + } + + /** + * Sets the extra data for the peer info. + * + * @param extra the extra data + * @return the builder instance + */ + public Builder extra(byte[] extra) { + this.extraData = extra; + return this; + } + + /** + * Sets the fingerprint for the peer info. + * + * @param fingerprint the fingerprint + * @return the builder instance + */ + public Builder fingerprint(long fingerprint) { + this.fingerprint = fingerprint; + return this; + } + + /** + * Sets the node identity for authentication. + * + * @param node the node identity + * @return the builder instance + */ + public Builder node(Identity node) { + this.node = node; + return this; + } + + /** + * Sets the sequence number for the peer info. + * + * @param sequenceNumber the sequence number + * @return the builder instance + * @throws IllegalArgumentException if the sequence number is negative + */ + public Builder sequenceNumber(int sequenceNumber) { + if (sequenceNumber < 0) + throw new IllegalArgumentException("Invalid sequence number"); + this.sequenceNumber = sequenceNumber; + return this; + } + + /** + * Sets the keypair for the peer info. + * + * @param keyPair the keypair + * @return the builder instance + */ + public Builder key(Signature.KeyPair keyPair) { + this.keyPair = keyPair; + return this; + } + + /** + * Sets the private key for the peer info. + * + * @param privateKey the private key + * @return the builder instance + * @throws NullPointerException if the private key is null + */ + public Builder key(Signature.PrivateKey privateKey) { + Objects.requireNonNull(privateKey); + this.keyPair = Signature.KeyPair.fromPrivateKey(privateKey); + return this; + } + + /** + * Sets the private key for the peer info. + * + * @param privateKey the private key bytes + * @return the builder instance + * @throws NullPointerException if the private key is null + * @throws IllegalArgumentException if the private key length is invalid + */ + public Builder key(byte[] privateKey) { + Objects.requireNonNull(privateKey); + if (privateKey.length != Signature.PrivateKey.BYTES) + throw new IllegalArgumentException("Invalid private key"); + this.keyPair = Signature.KeyPair.fromPrivateKey(privateKey); + return this; + } + + /** + * Builds the PeerInfo instance. + * + * @return the created PeerInfo instance + * @throws IllegalStateException if the endpoint is missing + */ + public PeerInfo build() { + if (endpoint == null || endpoint.isEmpty()) + throw new IllegalStateException("Missing endpoint"); + + if (keyPair == null) + keyPair = Signature.KeyPair.random(); + + if (fingerprint == 0) + fingerprint = Random.secureRandom().nextLong(); + + return PeerInfo.create(keyPair, node, sequenceNumber, fingerprint, endpoint, extraData); + } } } \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/Value.java b/api/src/main/java/io/bosonnetwork/Value.java index 3da4703..de3139b 100644 --- a/api/src/main/java/io/bosonnetwork/Value.java +++ b/api/src/main/java/io/bosonnetwork/Value.java @@ -24,6 +24,7 @@ package io.bosonnetwork; import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.Arrays; import java.util.Objects; @@ -31,53 +32,43 @@ import io.bosonnetwork.crypto.CryptoBox; import io.bosonnetwork.crypto.CryptoException; import io.bosonnetwork.crypto.Hash; +import io.bosonnetwork.crypto.Random; import io.bosonnetwork.crypto.Signature; import io.bosonnetwork.utils.Hex; /** - * Represents a value in the Boson network. Values can be immutable, mutable or encrypted, - * and they are identified by their public key or SHA-256 hash. + * Represents a value in the Boson network. + * + *

Values can be of three types: + *

    + *
  • Immutable: Identified by the SHA-256 hash of their data.
  • + *
  • Mutable: Identified by a public key and can be updated by the owner.
  • + *
  • Encrypted: A mutable value whose data is encrypted for a specific recipient.
  • + *
*/ public class Value { + /** The number of bytes in the nonce. */ + public static int NONCE_BYTES = 24; + + /** The public key for mutable values. */ private final Id publicKey; + /** The private key to sign or update the value. */ private final byte[] privateKey; + /** The recipient's public key for encrypted values. */ private final Id recipient; + /** The nonce for mutable or encrypted values. */ private final byte[] nonce; + /** The sequence number for mutable values. */ private final int sequenceNumber; + /** The signature for mutable values. */ private final byte[] signature; + /** The data of the value. */ private final byte[] data; - private transient Id id; + /** The unique ID of the value. */ + private final transient Id id; - /** - * Private constructor to create a new Value object. - * - * @param publicKey the public key associated with the value. - * @param privateKey the private key associated with the value. - * @param recipient the recipient's ID if the value is encrypted. - * @param nonce the nonce used for encryption or signing. - * @param sequenceNumber the sequence number of the value. - * @param signature the signature of the value. - * @param data the data of the value. - */ private Value(Id publicKey, byte[] privateKey, Id recipient, byte[] nonce, int sequenceNumber, byte[] signature, byte[] data) { - if (publicKey != null) { - if (privateKey != null && privateKey.length != Signature.PrivateKey.BYTES) - throw new IllegalArgumentException("Invalid private key"); - - if (nonce == null || nonce.length != CryptoBox.Nonce.BYTES) - throw new IllegalArgumentException("Invalid nonce"); - - if (sequenceNumber < 0) - throw new IllegalArgumentException("Invalid sequence number"); - - if (signature == null || signature.length != Signature.BYTES) - throw new IllegalArgumentException("Invalid signature"); - } - - if (data == null || data.length == 0) - throw new IllegalArgumentException("Invalid data"); - this.publicKey = publicKey; this.privateKey = privateKey; this.recipient = recipient; @@ -85,118 +76,95 @@ private Value(Id publicKey, byte[] privateKey, Id recipient, byte[] nonce, int s this.sequenceNumber = sequenceNumber; this.signature = signature; this.data = data; + + this.id = calculateId(publicKey, data); } - /** - * Constructs a new Value object with the specified parameters. - * - * @param publicKey the identifier of the public key associated with this value - * @param recipient the identifier of the recipient - * @param nonce a byte array representing the nonce value - * @param sequenceNumber the sequence number of this value - * @param signature a byte array containing the signature - * @param data a byte array containing the associated data - */ - protected Value(Id publicKey, Id recipient, byte[] nonce, int sequenceNumber, byte[] signature, byte[] data) { - this(publicKey, null, recipient, nonce, sequenceNumber, signature, data); + private Value(Id id, byte[] data) { + this.id = id; + this.publicKey = null; + this.privateKey = null; + this.recipient = null; + this.nonce = null; + this.sequenceNumber = 0; + this.signature = null; + this.data = data; } /** - * Private constructor to create a new Value object. + * Creates a new Value instance from existing information. * - * @param keypair the signing key pair associated with the value. - * @param recipient the recipient's ID if the value is encrypted. - * @param nonce the nonce used for encryption or signing. - * @param sequenceNumber the sequence number of the value. - * @param data the data of the value. - * @throws CryptoException if a cryptographic error occurs during signing. + * @param publicKey The public key for mutable values (optional). + * @param privateKey The private key (optional). + * @param recipient The recipient's public key for encrypted values (optional). + * @param nonce The nonce. + * @param sequenceNumber The sequence number. + * @param signature The signature. + * @param data The data. + * @return The new Value instance. + * @throws IllegalArgumentException if parameters are invalid. */ - private Value(Signature.KeyPair keypair, Id recipient, CryptoBox.Nonce nonce, int sequenceNumber, byte[] data) throws CryptoException { - if (keypair == null) - throw new IllegalArgumentException("Invalid keypair"); + public static Value of(Id publicKey, byte[] privateKey, Id recipient, byte[] nonce, int sequenceNumber, + byte[] signature, byte[] data) { + if (publicKey != null) { + // noinspection DuplicatedCode + if (privateKey != null && privateKey.length != Signature.PrivateKey.BYTES) + throw new IllegalArgumentException("Invalid private key"); - if (nonce == null) - throw new IllegalArgumentException("Invalid nonce"); + if (nonce == null || nonce.length != NONCE_BYTES) + throw new IllegalArgumentException("Invalid nonce"); - if (sequenceNumber < 0) - throw new IllegalArgumentException("Invalid sequence number"); + if (sequenceNumber < 0) + throw new IllegalArgumentException("Invalid sequence number"); + + if (signature == null || signature.length != Signature.BYTES) + throw new IllegalArgumentException("Invalid signature"); + } if (data == null || data.length == 0) throw new IllegalArgumentException("Invalid data"); - this.publicKey = new Id(keypair.publicKey().bytes()); - this.privateKey = keypair.privateKey().bytes(); - this.recipient = recipient; - this.nonce = nonce.bytes(); - this.sequenceNumber = sequenceNumber; - - if (recipient != null) { - CryptoBox.PublicKey recipientPk = recipient.toEncryptionKey(); - CryptoBox.PrivateKey ownerSk = CryptoBox.PrivateKey.fromSignatureKey(keypair.privateKey()); - - this.data = CryptoBox.encrypt(data, recipientPk, ownerSk, nonce); - } else { - this.data = data; - } - - this.signature = Signature.sign(getSignData(), keypair.privateKey()); + return new Value(publicKey, privateKey, recipient, nonce, sequenceNumber, signature, data); } /** - * Rebuilds an immutable {@code Value} object from the data. + * Creates a new Value instance from existing information. * - * @param data the data of the value. - * @return a new immutable {@code Value} object. + * @param publicKey The public key for mutable values. + * @param recipient The recipient's public key (optional). + * @param nonce The nonce. + * @param sequenceNumber The sequence number. + * @param signature The signature. + * @param data The data. + * @return The new Value instance. */ - public static Value of(byte[] data) { - return new Value(null, null, null, null, 0, null, data); + public static Value of(Id publicKey, Id recipient, byte[] nonce, int sequenceNumber, byte[] signature, byte[] data) { + return of(publicKey, null, recipient, nonce, sequenceNumber, signature, data); } /** - * Rebuilds a mutable {@code Value} object from the data. + * Creates a new mutable Value instance from existing information. * - * @param publicKey the public key associated with the value. - * @param nonce the nonce used for encryption or signing. - * @param sequenceNumber the sequence number of the value. - * @param signature the signature of the value. - * @param data the data of the value. - * @return a new mutable {@code Value} object. + * @param publicKey The public key. + * @param nonce The nonce. + * @param sequenceNumber The sequence number. + * @param signature The signature. + * @param data The data. + * @return The new Value instance. */ public static Value of(Id publicKey, byte[] nonce, int sequenceNumber, byte[] signature, byte[] data) { - return new Value(publicKey, null, null, nonce, sequenceNumber, signature, data); + return of(publicKey, null, null, nonce, sequenceNumber, signature, data); } /** - * Rebuilds an encrypted {@code Value} object from the data. + * Creates a new immutable Value instance from an ID and data. * - * @param publicKey the public key associated with the value. - * @param recipient the recipient's ID if the value is encrypted. - * @param nonce the nonce used for encryption or signing. - * @param sequenceNumber the sequence number of the value. - * @param signature the signature of the value. - * @param data the data of the value. - * @return a new mutable {@code Value} object. + * @param id The ID of the value. + * @param data The data. + * @return The new Value instance. */ - public static Value of(Id publicKey, Id recipient, byte[] nonce, int sequenceNumber, - byte[] signature, byte[] data) { - return new Value(publicKey, null, recipient, nonce, sequenceNumber, signature, data); - } - - /** - * Rebuilds an encrypted {@code Value} object from the data. - * - * @param publicKey the public key associated with the value. - * @param privateKey the private key associated with the value. - * @param recipient the recipient's ID if the value is encrypted. - * @param nonce the nonce used for encryption or signing. - * @param sequenceNumber the sequence number of the value. - * @param signature the signature of the value. - * @param data the data of the value. - * @return a new mutable {@code Value} object. - */ - public static Value of(Id publicKey, byte[] privateKey, Id recipient, byte[] nonce, - int sequenceNumber, byte[] signature, byte[] data) { - return new Value(publicKey, privateKey, recipient, nonce, sequenceNumber, signature, data); + public static Value of(Id id, byte[] data) { + return new Value(id, data); } /** @@ -205,7 +173,10 @@ public static Value of(Id publicKey, byte[] privateKey, Id recipient, byte[] non * @param data the data of the value. * @return a new immutable {@code Value} object. */ - public static Value createValue(byte[] data) { + private static Value create(byte[] data) { + if (data == null || data.length == 0) + throw new IllegalArgumentException("Invalid data"); + return new Value(null, null, null, null, 0, null, data); } @@ -215,98 +186,79 @@ public static Value createValue(byte[] data) { * * @param data the data of the value. * @return a new mutable {@code Value} object. - * @throws CryptoException if a cryptographic error occurs during signing. */ - public static Value createSignedValue(byte[] data) throws CryptoException { - return createSignedValue(null, null, 0, data); - } + private static Value createSigned(Signature.KeyPair keypair, int sequenceNumber, byte[] data) { + // noinspection DuplicatedCode + if (sequenceNumber < 0) + throw new IllegalArgumentException("Invalid sequence number"); - /** - * Creates a mutable {@code Value} object from the data with the given key pair and nonce. - * - * @param keypair the key pair to sign the {@code Value} - * @param nonce the nonce used for encryption or signing. - * @param data the data of the value. - * @return a new mutable {@code Value} object. - * @throws CryptoException if a cryptographic error occurs during signing. - */ - public static Value createSignedValue(Signature.KeyPair keypair, CryptoBox.Nonce nonce, - byte[] data) throws CryptoException { - return createSignedValue(keypair, nonce, 0, data); - } + if (data == null || data.length == 0) + throw new IllegalArgumentException("Invalid data"); - /** - * Creates a mutable {@code Value} object from the data with the given key pair and nonce. - * - * @param keypair the key pair to sign the {@code Value} - * @param nonce the nonce used for encryption or signing. - * @param sequenceNumber the initial sequence number of the value. - * @param data the data of the value. - * @return a new mutable {@code Value} object. - * @throws CryptoException if a cryptographic error occurs during signing. - */ - public static Value createSignedValue(Signature.KeyPair keypair, CryptoBox.Nonce nonce, - int sequenceNumber, byte[] data) throws CryptoException { if (keypair == null) keypair = Signature.KeyPair.random(); - if (nonce == null) - nonce = CryptoBox.Nonce.random(); + byte[] nonce = new byte[NONCE_BYTES]; + Random.secureRandom().nextBytes(nonce); - return new Value(keypair, null, nonce, sequenceNumber, data); - } + Id publicKey = Id.of(keypair.publicKey().bytes()); + byte[] digest = new Value(publicKey, null, null, nonce, sequenceNumber, null, data).digest(); + byte[] signature = Signature.sign(digest, keypair.privateKey()); - /** - * Creates an encrypted {@code Value} object from the data. The new value will be encrypted - * and signed by a new generated random key pair. - * - * @param recipient the recipient's ID if the value is encrypted. - * @param data the data of the value. - * @return a new encrypted {@code Value} object. - * @throws CryptoException if a cryptographic error occurs during signing. - */ - public static Value createEncryptedValue(Id recipient, byte[] data) throws CryptoException { - return createEncryptedValue(null, recipient, null, 0, data); - } - - /** - * Creates an encrypted {@code Value} object from the data with the given key pair and nonce. - * - * @param keypair the key pair to sign the {@code Value} - * @param recipient the recipient's ID if the value is encrypted. - * @param nonce the nonce used for encryption or signing. - * @param data the data of the value. - * @return a new encrypted {@code Value} object. - * @throws CryptoException if a cryptographic error occurs during signing. - */ - public static Value createEncryptedValue(Signature.KeyPair keypair, Id recipient, - CryptoBox.Nonce nonce, byte[] data) throws CryptoException { - return createEncryptedValue(keypair, recipient, nonce, 0, data); + return new Value(publicKey, keypair.privateKey().bytes(), null, nonce, sequenceNumber, signature, data); } /** - * Creates an encrypted {@code Value} object from the data with the given key pair and nonce. + * Creates a new mutable Value object from the data, encrypted for a specific recipient. * - * @param keypair the key pair to sign the {@code Value} - * @param recipient the recipient's ID if the value is encrypted. - * @param nonce the nonce used for encryption or signing. - * @param sequenceNumber the initial sequence number of the value. - * @param data the data of the value. - * @return a new encrypted {@code Value} object. - * @throws CryptoException if a cryptographic error occurs during signing. + * @param keypair The owner's keypair. + * @param recipient The recipient's ID. + * @param sequenceNumber The sequence number. + * @param data The data to encrypt. + * @return The new encrypted Value instance. + * @throws IllegalArgumentException if parameters are invalid. */ - public static Value createEncryptedValue(Signature.KeyPair keypair, Id recipient, - CryptoBox.Nonce nonce, int sequenceNumber, byte[] data) throws CryptoException { + private static Value createEncrypted(Signature.KeyPair keypair, Id recipient, int sequenceNumber, byte[] data) { if (recipient == null) throw new IllegalArgumentException("Invalid recipient"); + // noinspection DuplicatedCode + if (sequenceNumber < 0) + throw new IllegalArgumentException("Invalid sequence number"); + + if (data == null || data.length == 0) + throw new IllegalArgumentException("Invalid data"); + if (keypair == null) keypair = Signature.KeyPair.random(); - if (nonce == null) - nonce = CryptoBox.Nonce.random(); + byte[] nonce = new byte[NONCE_BYTES]; + Random.secureRandom().nextBytes(nonce); - return new Value(keypair, recipient, nonce, sequenceNumber, data); + byte[] encryptData; + try { + CryptoBox.PublicKey recipientPk = recipient.toEncryptionKey(); + CryptoBox.PrivateKey ownerSk = CryptoBox.PrivateKey.fromSignatureKey(keypair.privateKey()); + encryptData = CryptoBox.encrypt(data, recipientPk, ownerSk, CryptoBox.Nonce.fromBytes(nonce)); + } catch (Exception e) { + // only will error on the recipient id is an invalid ED25519 public key + throw new IllegalArgumentException("Invalid recipient Id", e); + } + + Id publicKey = Id.of(keypair.publicKey().bytes()); + byte[] digest = new Value(publicKey, null, recipient, nonce, sequenceNumber, null, encryptData).digest(); + byte[] signature = Signature.sign(digest, keypair.privateKey()); + + return new Value(publicKey, keypair.privateKey().bytes(), recipient, nonce, sequenceNumber, signature, encryptData); + } + + /** + * Creates a new Value builder. + * + * @return a new Value builder + */ + public static Builder builder() { + return new Builder(); } /** @@ -315,9 +267,6 @@ public static Value createEncryptedValue(Signature.KeyPair keypair, Id recipient * @return the ID of the value. */ public Id getId() { - if (id == null) - id = calculateId(this.publicKey, this.data); - return id; } @@ -331,9 +280,9 @@ public Id getPublicKey() { } /** - * Checks if current node has the private key. + * Checks if the current node has the value's private key. * - * @return true if the node has the private key, false otherwise. + * @return {@code true} if the node has the private key, {@code false} otherwise. */ public boolean hasPrivateKey() { return privateKey != null; @@ -393,6 +342,37 @@ public byte[] getData() { return data; } + /** + * Decrypts the value's data for the recipient. + * + * @param recipientSk The recipient's private key. + * @return the decrypted data + * @throws CryptoException if decryption fails. + * @throws UnsupportedOperationException if the value is not encrypted. + * @throws IllegalArgumentException if the recipient key is invalid. + * @throws IllegalStateException if the value is not valid. + */ + public byte[] decryptData(Signature.PrivateKey recipientSk) throws CryptoException { + if (recipient == null) + throw new UnsupportedOperationException("Value is not encrypted"); + + if (recipientSk == null) + throw new IllegalArgumentException("Invalid recipient private key"); + + if (!isValid()) + throw new IllegalStateException("Value is not valid"); + + Signature.KeyPair recipientKeypair = Signature.KeyPair.fromPrivateKey(recipientSk); + if (!Arrays.equals(recipientKeypair.publicKey().bytes(), recipient.bytes())) + throw new IllegalArgumentException("Invalid recipient private key: not matching recipient public key"); + + CryptoBox.PublicKey pk = publicKey.toEncryptionKey(); + CryptoBox.PrivateKey sk = CryptoBox.PrivateKey.fromSignatureKey(recipientSk); + + return CryptoBox.decrypt(data, pk, sk, CryptoBox.Nonce.fromBytes(nonce)); + } + + /** * Calculates the ID of the value. * @@ -400,14 +380,8 @@ public byte[] getData() { * @param data The data contained in the value. * @return The calculated ID of the value. */ - public static Id calculateId(Id publicKey, byte[] data) { - if(publicKey != null) { - return publicKey; - } else { - MessageDigest digest = Hash.sha256(); - digest.reset(); - return new Id(digest.digest(data)); - } + private static Id calculateId(Id publicKey, byte[] data) { + return publicKey != null ? publicKey : new Id(Hash.sha256(data)); } /** @@ -428,7 +402,12 @@ public boolean isEncrypted() { return recipient != null; } - private byte[] getSignData() { + /** + * Computes the digest for signing the value. + * + * @return the digest + */ + private byte[] digest() { MessageDigest sha = Hash.sha256(); if (publicKey != null) { sha.update(publicKey.bytes()); @@ -453,58 +432,50 @@ public boolean isValid() { return false; if (isMutable()) { + if (signature == null || signature.length != Signature.BYTES) + return false; + if (nonce == null || nonce.length != CryptoBox.Nonce.BYTES) return false; - if (signature == null || signature.length != Signature.BYTES) + if (sequenceNumber < 0) return false; Signature.PublicKey pk = publicKey.toSignatureKey(); + return Signature.verify(digest(), signature, pk); + } else { + if (id == null || recipient != null || nonce != null || sequenceNumber < 0) + return false; - return Signature.verify(getSignData(), signature, pk); + return id.equals(calculateId(null, data)); } - - return true; } - /** - * Decrypts the data using the recipient's private key. - * - * @param recipientSk the recipient's private key. - * @return the decrypted data, or {@code null} if decryption fails. - * @throws CryptoException if a cryptographic error occurs during decryption. - */ - public byte[] decryptData(Signature.PrivateKey recipientSk) throws CryptoException { - if (!isValid()) - return null; - - if (recipient == null) - return null; - - CryptoBox.PublicKey pk = publicKey.toEncryptionKey(); - CryptoBox.PrivateKey sk = CryptoBox.PrivateKey.fromSignatureKey(recipientSk); - - return CryptoBox.decrypt(data, pk, sk, CryptoBox.Nonce.fromBytes(nonce)); - } /** * Updates the value with new data, incrementing the sequence number. * * @param data the new data to be included in the updated value. * @return the updated Value object. - * @throws CryptoException if a cryptographic error occurs during the update. */ - public Value update(byte[] data) throws CryptoException { + public Value update(byte[] data) { + if (data == null || data.length == 0) + throw new IllegalArgumentException("Invalid data"); + if (!isMutable()) - throw new IllegalStateException("Immutable value " + getId()); + throw new UnsupportedOperationException("Immutable value"); if (!hasPrivateKey()) - throw new IllegalStateException("Not the owner of the value " + getId()); + throw new UnsupportedOperationException("Not the owner of the value"); - Signature.KeyPair kp = Signature.KeyPair.fromPrivateKey(getPrivateKey()); - CryptoBox.Nonce nonce = CryptoBox.Nonce.random(); + if (!isEncrypted() && Arrays.equals(this.data, data)) + return this; // no need to update - return new Value(kp, recipient, nonce, sequenceNumber + 1, data); + Signature.KeyPair keypair = Signature.KeyPair.fromPrivateKey(getPrivateKey()); + if (isEncrypted()) + return createEncrypted(keypair, recipient, sequenceNumber + 1, data); + else + return createSigned(keypair, sequenceNumber + 1, data); } /** @@ -519,7 +490,7 @@ public Value withoutPrivateKey() { if (privateKey == null) return this; - return new Value(publicKey, recipient, nonce, sequenceNumber, signature, data); + return new Value(publicKey, null, recipient, nonce, sequenceNumber, signature, data); } @Override @@ -556,9 +527,6 @@ public String toString() { if (recipient != null) repr.append(",recipient:").append(recipient); - if (nonce != null) - repr.append(",nonce: ").append(Hex.encode(nonce)); - if (publicKey != null) repr.append(",seq:").append(sequenceNumber); @@ -569,4 +537,154 @@ public String toString() { return repr.toString(); } + + /** + * Value builder. + */ + public static class Builder { + private Signature.KeyPair keyPair = null; + Id recipient = null; + private int sequenceNumber = 0; + private byte[] data = null; + + private Builder() { + } + + /** + * Sets the data for the value. + * + * @param value the data + * @return the builder instance + * @throws IllegalArgumentException if the data is null or empty + */ + public Builder data(byte[] value) { + if (value == null || value.length == 0) + throw new IllegalArgumentException("value cannot be null or empty"); + this.data = value; + return this; + } + + /** + * Sets the data for the value from a string. + * + * @param value the string data + * @return the builder instance + */ + public Builder data(String value) { + return data(value.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Sets the recipient for the encrypted value. + * + * @param recipient the recipient's public key + * @return the builder instance + */ + public Builder recipient(Id recipient) { + this.recipient = recipient; + return this; + } + + /** + * Sets the sequence number for the mutable value. + * + * @param sequenceNumber the sequence number + * @return the builder instance + * @throws IllegalArgumentException if the sequence number is negative + */ + public Builder sequenceNumber(int sequenceNumber) { + if (sequenceNumber < 0) + throw new IllegalArgumentException("Invalid sequence number"); + this.sequenceNumber = sequenceNumber; + return this; + } + + /** + * Sets the keypair for the mutable/encrypted value. + * + * @param keyPair the keypair + * @return the builder instance + */ + public Builder key(Signature.KeyPair keyPair) { + this.keyPair = keyPair; + return this; + } + + /** + * Sets the private key for the mutable/encrypted value. + * + * @param privateKey the private key + * @return the builder instance + * @throws NullPointerException if the private key is null + */ + public Builder key(Signature.PrivateKey privateKey) { + Objects.requireNonNull(privateKey); + this.keyPair = Signature.KeyPair.fromPrivateKey(privateKey); + return this; + } + + /** + * Sets the private key for the mutable/encrypted value from bytes. + * + * @param privateKey the private key bytes + * @return the builder instance + * @throws NullPointerException if the private key is null + * @throws IllegalArgumentException if the length is invalid + */ + public Builder key(byte[] privateKey) { + Objects.requireNonNull(privateKey); + if (privateKey.length != Signature.PrivateKey.BYTES) + throw new IllegalArgumentException("Invalid private key"); + this.keyPair = Signature.KeyPair.fromPrivateKey(privateKey); + return this; + } + + /** + * Builds an immutable Value instance. + * + * @return the new immutable Value instance + * @throws IllegalStateException if the data is missing + */ + public Value build() { + if (data == null) + throw new IllegalStateException("Value data cannot be null"); + + return create(data); + } + + /** + * Builds a signed mutable Value instance. + * + * @return the new mutable Value instance + * @throws IllegalStateException if data is missing + */ + public Value buildSigned() { + if (data == null) + throw new IllegalStateException("Value data cannot be null"); + + if (keyPair == null) + keyPair = Signature.KeyPair.random(); + + return createSigned(keyPair, sequenceNumber, data); + } + + /** + * Builds an encrypted mutable Value instance. + * + * @return the new encrypted Value instance + * @throws IllegalStateException if data or recipient is missing + */ + public Value buildEncrypted() { + if (data == null) + throw new IllegalStateException("Value data cannot be null"); + if (recipient == null) + throw new IllegalStateException("Value recipient cannot be null"); + + if (keyPair == null) + keyPair = Signature.KeyPair.random(); + + return createEncrypted(keyPair, recipient, sequenceNumber, data); + } + + } } \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/database/VertxDatabase.java b/api/src/main/java/io/bosonnetwork/database/VertxDatabase.java index ddd760d..8d2af9a 100644 --- a/api/src/main/java/io/bosonnetwork/database/VertxDatabase.java +++ b/api/src/main/java/io/bosonnetwork/database/VertxDatabase.java @@ -25,6 +25,7 @@ import java.util.List; import java.util.function.BiFunction; import java.util.function.Function; +import java.util.stream.Collectors; import io.vertx.core.Future; import io.vertx.sqlclient.Pool; @@ -266,7 +267,7 @@ default T findUnique(RowSet rowSet, Function mapper) { * @return list of mapped values (possibly empty) */ default List findMany(RowSet rowSet, Function mapper) { - return rowSet.stream().map(mapper).toList(); + return rowSet.stream().map(mapper).collect(Collectors.toList()); } /** diff --git a/api/src/main/java/io/bosonnetwork/identifier/Card.java b/api/src/main/java/io/bosonnetwork/identifier/Card.java index d5918b8..fdfea0e 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/Card.java +++ b/api/src/main/java/io/bosonnetwork/identifier/Card.java @@ -44,7 +44,7 @@ import io.bosonnetwork.Identity; import io.bosonnetwork.InvalidSignatureException; import io.bosonnetwork.crypto.Signature; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; /** * Compact representation of a DID Document for the Boson project. diff --git a/api/src/main/java/io/bosonnetwork/identifier/Credential.java b/api/src/main/java/io/bosonnetwork/identifier/Credential.java index 5e6ee3a..3d4f92b 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/Credential.java +++ b/api/src/main/java/io/bosonnetwork/identifier/Credential.java @@ -45,7 +45,7 @@ import io.bosonnetwork.Identity; import io.bosonnetwork.InvalidSignatureException; import io.bosonnetwork.crypto.Signature; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; /** * A compact representation of a Verifiable Credential (VC) using Boson's compact format. diff --git a/api/src/main/java/io/bosonnetwork/identifier/FileSystemResolverCache.java b/api/src/main/java/io/bosonnetwork/identifier/FileSystemResolverCache.java index 2dcbba9..91a87a6 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/FileSystemResolverCache.java +++ b/api/src/main/java/io/bosonnetwork/identifier/FileSystemResolverCache.java @@ -35,7 +35,7 @@ import org.slf4j.LoggerFactory; import io.bosonnetwork.Id; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; /** * A file system-based implementation of {@link ResolverCache} that provides persistent storage diff --git a/api/src/main/java/io/bosonnetwork/identifier/Vouch.java b/api/src/main/java/io/bosonnetwork/identifier/Vouch.java index 197d257..f76d088 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/Vouch.java +++ b/api/src/main/java/io/bosonnetwork/identifier/Vouch.java @@ -40,7 +40,7 @@ import io.bosonnetwork.Identity; import io.bosonnetwork.InvalidSignatureException; import io.bosonnetwork.crypto.Signature; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; /** * Represents the Boson compacted version of a Verifiable Presentation (VP). diff --git a/api/src/main/java/io/bosonnetwork/identifier/W3CDIDFormat.java b/api/src/main/java/io/bosonnetwork/identifier/W3CDIDFormat.java index cf07d36..35f7cba 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/W3CDIDFormat.java +++ b/api/src/main/java/io/bosonnetwork/identifier/W3CDIDFormat.java @@ -27,7 +27,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.cfg.ContextAttributes; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; /** * Abstract base class providing common JSON and CBOR serialization and deserialization diff --git a/api/src/main/java/io/bosonnetwork/json/Json.java b/api/src/main/java/io/bosonnetwork/json/Json.java new file mode 100644 index 0000000..c7fc491 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/json/Json.java @@ -0,0 +1,581 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.json; + +import java.io.IOException; +import java.net.InetAddress; +import java.util.Base64; +import java.util.Date; +import java.util.Map; + +import com.fasterxml.jackson.core.Base64Variants; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.dataformat.cbor.CBORFactory; +import com.fasterxml.jackson.dataformat.cbor.databind.CBORMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import io.vertx.core.json.jackson.DatabindCodec; + +import io.bosonnetwork.Id; +import io.bosonnetwork.NodeInfo; +import io.bosonnetwork.PeerInfo; +import io.bosonnetwork.Value; +import io.bosonnetwork.json.internal.DateDeserializer; +import io.bosonnetwork.json.internal.DateSerializer; +import io.bosonnetwork.json.internal.IdDeserializer; +import io.bosonnetwork.json.internal.IdSerializer; +import io.bosonnetwork.json.internal.InetAddressDeserializer; +import io.bosonnetwork.json.internal.InetAddressSerializer; +import io.bosonnetwork.json.internal.NodeInfoDeserializer; +import io.bosonnetwork.json.internal.NodeInfoSerializer; +import io.bosonnetwork.json.internal.PeerInfoDeserializer; +import io.bosonnetwork.json.internal.PeerInfoSerializer; +import io.bosonnetwork.json.internal.ValueDeserializer; +import io.bosonnetwork.json.internal.ValueSerializer; + + +/** + * Utility class for serialization and deserialization of data to and from JSON, CBOR, and YAML formats. + *

+ * This class provides common methods for encoding and decoding objects using Jackson, with special support for Boson-specific + * types and extensions. It supports both text-based (JSON, YAML) and binary (CBOR) formats, and registers Jackson modules + * to handle custom serialization and deserialization of Boson domain objects such as {@link io.bosonnetwork.Id}, {@link io.bosonnetwork.NodeInfo}, + * {@link io.bosonnetwork.PeerInfo}, and {@link io.bosonnetwork.Value}. + *

+ * The utility also provides factory methods for obtaining pre-configured Jackson {@link ObjectMapper}, {@link CBORMapper}, + * and {@link YAMLMapper} instances, as well as methods for pretty-printing, byte encoding, and parsing from various formats. + *

+ * Boson-specific serialization features include: + *

    + *
  • Binary and text encoding of IDs and network addresses depending on the chosen format.
  • + *
  • Support for ISO8601/RFC3339 date/time formats and epoch milliseconds.
  • + *
  • Context-aware serialization using {@link JsonContext} for configuration of serialization details.
  • + *
+ */ +public class Json { + private static final String BOSON_JSON_MODULE_NAME = "io.bosonnetwork.utils.json.module"; + + /** Pre-configured Base64 encoder for URL-safe encoding without padding. */ + public static final Base64.Encoder BASE64_ENCODER = Base64.getUrlEncoder().withoutPadding(); + /** Pre-configured Base64 decoder for URL-safe decoding. */ + public static final Base64.Decoder BASE64_DECODER = Base64.getUrlDecoder(); + + private static TypeReference> _mapType; + + private static SimpleModule _bosonJsonModule; + + private static JsonFactory _jsonFactory; + private static CBORFactory _cborFactory; + + private static ObjectMapper _objectMapper; + private static CBORMapper _cborMapper; + private static YAMLMapper _yamlMapper; + + /** + * Returns the Jackson module for Boson types. + * + * @return the {@link SimpleModule} object + */ + protected static SimpleModule bosonJsonModule() { + if (_bosonJsonModule == null) { + SimpleModule module = new SimpleModule(BOSON_JSON_MODULE_NAME); + module.addSerializer(Date.class, new DateSerializer()); + module.addDeserializer(Date.class, new DateDeserializer()); + module.addSerializer(Id.class, new IdSerializer()); + module.addDeserializer(Id.class, new IdDeserializer()); + module.addSerializer(InetAddress.class, new InetAddressSerializer()); + module.addDeserializer(InetAddress.class, new InetAddressDeserializer()); + + module.addSerializer(NodeInfo.class, new NodeInfoSerializer()); + module.addDeserializer(NodeInfo.class, new NodeInfoDeserializer()); + module.addSerializer(PeerInfo.class, new PeerInfoSerializer()); + module.addDeserializer(PeerInfo.class, new PeerInfoDeserializer()); + module.addSerializer(Value.class, new ValueSerializer()); + module.addDeserializer(Value.class, new ValueDeserializer()); + + _bosonJsonModule = module; + } + + return _bosonJsonModule; + } + + /** + * Returns the Jackson JSON factory. + * + * @return the {@link JsonFactory} object + */ + public static JsonFactory jsonFactory() { + if (_jsonFactory == null) { + JsonFactory factory = new JsonFactory(); + factory.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); + factory.disable(JsonParser.Feature.AUTO_CLOSE_SOURCE); + _jsonFactory = factory; + } + + return _jsonFactory; + } + + /** + * Returns the Jackson CBOR factory. + * + * @return the {@link CBORFactory} object + */ + public static CBORFactory cborFactory() { + if (_cborFactory == null) { + CBORFactory factory = new CBORFactory(); + factory.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); + factory.disable(JsonParser.Feature.AUTO_CLOSE_SOURCE); + _cborFactory = factory; + } + + return _cborFactory; + } + + /** + * Creates the Jackson object mapper, with basic Boson types support. + * + * @return the new {@code ObjectMapper} object. + */ + public static ObjectMapper objectMapper() { + if (_objectMapper == null) { + _objectMapper = JsonMapper.builder(jsonFactory()) + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .disable(MapperFeature.AUTO_DETECT_CREATORS) + .disable(MapperFeature.AUTO_DETECT_FIELDS) + .disable(MapperFeature.AUTO_DETECT_GETTERS) + .disable(MapperFeature.AUTO_DETECT_IS_GETTERS) + .disable(MapperFeature.AUTO_DETECT_SETTERS) + .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + // .defaultDateFormat(getDateFormat()) + .defaultBase64Variant(Base64Variants.MODIFIED_FOR_URL) + .addModule(bosonJsonModule()) + .build(); + } + + return _objectMapper; + } + + /** + * Creates the Jackson CBOR mapper, with basic Boson types support. + * + * @return the new {@code CBORMapper} object. + */ + public static CBORMapper cborMapper() { + if (_cborMapper == null) { + _cborMapper = CBORMapper.builder(cborFactory()) + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .disable(MapperFeature.AUTO_DETECT_CREATORS) + .disable(MapperFeature.AUTO_DETECT_FIELDS) + .disable(MapperFeature.AUTO_DETECT_GETTERS) + .disable(MapperFeature.AUTO_DETECT_IS_GETTERS) + .disable(MapperFeature.AUTO_DETECT_SETTERS) + .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + // .defaultDateFormat(getDateFormat()) + .defaultBase64Variant(Base64Variants.MODIFIED_FOR_URL) + .addModule(bosonJsonModule()) + .build(); + } + + return _cborMapper; + } + + /** + * Creates the Jackson YAML mapper, with basic Boson types support. + * + * @return the new {@code YAMLMapper} object. + */ + public static YAMLMapper yamlMapper() { + if (_yamlMapper == null) { + YAMLFactory factory = new YAMLFactory(); + factory.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); + factory.disable(JsonParser.Feature.AUTO_CLOSE_SOURCE); + + _yamlMapper = YAMLMapper.builder(factory) + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .disable(MapperFeature.AUTO_DETECT_CREATORS) + .disable(MapperFeature.AUTO_DETECT_FIELDS) + .disable(MapperFeature.AUTO_DETECT_GETTERS) + .disable(MapperFeature.AUTO_DETECT_IS_GETTERS) + .disable(MapperFeature.AUTO_DETECT_SETTERS) + .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) + .enable(YAMLGenerator.Feature.INDENT_ARRAYS) + .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES) + //.defaultDateFormat(getDateFormat()) + .defaultBase64Variant(Base64Variants.MODIFIED_FOR_URL) + .addModule(bosonJsonModule()) + .build(); + } + + return _yamlMapper; + } + + /** + * Serializes the given object to a JSON string using the provided {@link JsonContext}. + * + * @param object the object to serialize + * @param context the serialization context, or {@code null} for default context + * @return the JSON string representation of the object + * @throws IllegalArgumentException if the object cannot be serialized + */ + public static String toString(Object object, JsonContext context) { + try { + if (context == null || context.isEmpty()) + return objectMapper().writeValueAsString(object); + else + return objectMapper().writer(context).writeValueAsString(object); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("object can not be serialized", e); + } + } + + /** + * Serializes the given object to a JSON string using the default context. + * + * @param object the object to serialize + * @return the JSON string representation of the object + * @throws IllegalArgumentException if the object cannot be serialized + */ + public static String toString(Object object) { + try { + return objectMapper().writeValueAsString(object); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("object can not be serialized", e); + } + } + + /** + * Serializes the given object to a pretty-printed JSON string using the provided {@link JsonContext}. + * + * @param object the object to serialize + * @param context the serialization context, or {@code null} for default context + * @return the pretty-printed JSON string representation of the object + * @throws IllegalArgumentException if the object cannot be serialized + */ + public static String toPrettyString(Object object, JsonContext context) { + try { + ObjectWriter writer = context == null || context.isEmpty() ? + objectMapper().writerWithDefaultPrettyPrinter() : + objectMapper().writerWithDefaultPrettyPrinter().with(context); + + return writer.writeValueAsString(object); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("object can not be serialized", e); + } + } + + /** + * Serializes the given object to a pretty-printed JSON string using the default context. + * + * @param object the object to serialize + * @return the pretty-printed JSON string representation of the object + * @throws IllegalArgumentException if the object cannot be serialized + */ + public static String toPrettyString(Object object) { + try { + return objectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(object); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("object can not be serialized", e); + } + } + + /** + * Serializes the given object to a CBOR-encoded byte array using the provided {@link JsonContext}. + * + * @param object the object to serialize + * @param context the serialization context, or {@code null} for default context + * @return the CBOR-encoded byte array representation of the object + * @throws IllegalArgumentException if the object cannot be serialized + */ + public static byte[] toBytes(Object object, JsonContext context) { + try { + if (context == null || context.isEmpty()) + return cborMapper().writeValueAsBytes(object); + else + return cborMapper().writer(context).writeValueAsBytes(object); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("object can not be serialized", e); + } + } + + /** + * Serializes the given object to a CBOR-encoded byte array using the default context. + * + * @param object the object to serialize + * @return the CBOR-encoded byte array representation of the object + * @throws IllegalArgumentException if the object cannot be serialized + */ + public static byte[] toBytes(Object object) { + try { + return cborMapper().writeValueAsBytes(object); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("object can not be serialized", e); + } + } + + /** + * Returns a Jackson {@link TypeReference} for {@code Map} for generic map parsing. + * + * @return the {@code TypeReference} for {@code Map} + */ + public static TypeReference> mapType() { + if (_mapType == null) + _mapType = new TypeReference<>() { }; + + return _mapType; + } + + /** + * Parses a JSON string into a {@code Map} using the provided {@link JsonContext}. + * + * @param json the JSON string to parse + * @param context the deserialization context, or {@code null} for default context + * @return a map representation of the JSON input + * @throws IllegalArgumentException if the JSON cannot be parsed + */ + public static Map parse(String json, JsonContext context) { + return parse(json, mapType(), context); + } + + /** + * Parses a JSON string into a {@code Map} using the default context. + * + * @param json the JSON string to parse + * @return a map representation of the JSON input + * @throws IllegalArgumentException if the JSON cannot be parsed + */ + public static Map parse(String json) { + return parse(json, mapType()); + } + + /** + * Parses a JSON string into an object of the specified class using the provided {@link JsonContext}. + * + * @param json the JSON string to parse + * @param clazz the class of the object to return + * @param context the deserialization context, or {@code null} for default context + * @param the type of the desired object + * @return the parsed object + * @throws IllegalArgumentException if the JSON cannot be parsed + */ + public static T parse(String json, Class clazz, JsonContext context) { + try { + if (context == null || context.isEmpty()) + return objectMapper().readValue(json, clazz); + else + return objectMapper().reader(context).forType(clazz).readValue(json); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("json can not be parsed", e); + } + } + + /** + * Parses a JSON string into an object of the specified class using the default context. + * + * @param json the JSON string to parse + * @param clazz the class of the object to return + * @param the type of the desired object + * @return the parsed object + * @throws IllegalArgumentException if the JSON cannot be parsed + */ + public static T parse(String json, Class clazz) { + try { + return objectMapper().readValue(json, clazz); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("json can not be parsed", e); + } + } + + /** + * Parses a JSON string into an object of the specified type using the provided {@link JsonContext}. + * + * @param json the JSON string to parse + * @param type the type reference describing the type to return + * @param context the deserialization context, or {@code null} for default context + * @param the type of the desired object + * @return the parsed object + * @throws IllegalArgumentException if the JSON cannot be parsed + */ + public static T parse(String json, TypeReference type, JsonContext context) { + try { + if (context == null || context.isEmpty()) + return objectMapper().readValue(json, type); + else + return objectMapper().reader(context).forType(type).readValue(json); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("json can not be parsed", e); + } + } + + /** + * Parses a JSON string into an object of the specified type using the default context. + * + * @param json the JSON string to parse + * @param type the type reference describing the type to return + * @param the type of the desired object + * @return the parsed object + * @throws IllegalArgumentException if the JSON cannot be parsed + */ + public static T parse(String json, TypeReference type) { + try { + return objectMapper().readValue(json, type); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("json can not be parsed", e); + } + } + + /** + * Parses a CBOR-encoded byte array into a {@code Map} using the provided {@link JsonContext}. + * + * @param cbor the CBOR-encoded byte array to parse + * @param context the deserialization context, or {@code null} for default context + * @return a map representation of the CBOR input + * @throws IllegalArgumentException if the CBOR cannot be parsed + */ + public static Map parse(byte[] cbor, JsonContext context) { + return parse(cbor, mapType(), context); + } + + /** + * Parses a CBOR-encoded byte array into a {@code Map} using the default context. + * + * @param cbor the CBOR-encoded byte array to parse + * @return a map representation of the CBOR input + * @throws IllegalArgumentException if the CBOR cannot be parsed + */ + public static Map parse(byte[] cbor) { + return parse(cbor, mapType()); + } + + /** + * Parses a CBOR-encoded byte array into an object of the specified class using the provided {@link JsonContext}. + * + * @param cbor the CBOR-encoded byte array to parse + * @param clazz the class of the object to return + * @param context the deserialization context, or {@code null} for default context + * @param the type of the desired object + * @return the parsed object + * @throws IllegalArgumentException if the CBOR cannot be parsed + */ + public static T parse(byte[] cbor, Class clazz, JsonContext context) { + try { + if (context == null || context.isEmpty()) + return cborMapper().readValue(cbor, clazz); + else + return cborMapper().reader(context).forType(clazz).readValue(cbor); + } catch (IOException e) { + throw new IllegalArgumentException("cbor can not be parsed", e); + } + } + + /** + * Parses a CBOR-encoded byte array into an object of the specified class using the default context. + * + * @param cbor the CBOR-encoded byte array to parse + * @param clazz the class of the object to return + * @param the type of the desired object + * @return the parsed object + * @throws IllegalArgumentException if the CBOR cannot be parsed + */ + public static T parse(byte[] cbor, Class clazz) { + try { + return cborMapper().readValue(cbor, clazz); + } catch (IOException e) { + throw new IllegalArgumentException("cbor can not be parsed", e); + } + } + + /** + * Parses a CBOR-encoded byte array into an object of the specified type using the provided {@link JsonContext}. + * + * @param cbor the CBOR-encoded byte array to parse + * @param type the type reference describing the type to return + * @param context the deserialization context, or {@code null} for default context + * @param the type of the desired object + * @return the parsed object + * @throws IllegalArgumentException if the CBOR cannot be parsed + */ + public static T parse(byte[] cbor, TypeReference type, JsonContext context) { + try { + if (context == null || context.isEmpty()) + return cborMapper().readValue(cbor, type); + else + return cborMapper().reader(context).forType(type).readValue(cbor); + } catch (IOException e) { + throw new IllegalArgumentException("cbor can not be parsed", e); + } + } + + /** + * Parses a CBOR-encoded byte array into an object of the specified type using the default context. + * + * @param cbor the CBOR-encoded byte array to parse + * @param type the type reference describing the type to return + * @param the type of the desired object + * @return the parsed object + * @throws IllegalArgumentException if the CBOR cannot be parsed + */ + public static T parse(byte[] cbor, TypeReference type) { + try { + return cborMapper().readValue(cbor, type); + } catch (IOException e) { + throw new IllegalArgumentException("cbor can not be parsed", e); + } + } + + /** + * Initializes and registers the Boson JSON Jackson module with the global Jackson DatabindCodec mapper. + *

+ * This method ensures the Boson JSON module is registered only once. If already registered, + * the method returns immediately. The module adds support for Boson-specific types and serialization behaviors. + */ + public static void initializeBosonJsonModule() { + if (DatabindCodec.mapper().getRegisteredModuleIds().stream() + .anyMatch(id -> id.equals(BOSON_JSON_MODULE_NAME))) + return; // already registered + + DatabindCodec.mapper().registerModule(bosonJsonModule()); + DatabindCodec.mapper().enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS); + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/json/JsonContext.java b/api/src/main/java/io/bosonnetwork/json/JsonContext.java new file mode 100644 index 0000000..808b668 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/json/JsonContext.java @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.json; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import com.fasterxml.jackson.databind.cfg.ContextAttributes; + +/** + * A context object for customizing JSON serialization and deserialization. + *

+ * This class extends {@link Impl} and provides a convenient, immutable, + * and type-safe way to manage shared (global) and per-call (thread-local) attributes for Jackson operations. + *

+ * Use static factory methods such as {@link #empty()}, {@link #perCall()}, and {@link #shared(Object, Object)} + * to construct a context instance with the desired attributes. Use instance methods to query or derive + * new contexts with added/removed attributes. + *

+ * Shared attributes are visible to all serialization/deserialization operations that use this context, + * while per-call attributes are specific to a single operation or thread. + */ +public class JsonContext extends ContextAttributes.Impl { + private static final long serialVersionUID = -385397772721358918L; + + /** + * Constructs a new {@code JsonContext} with the specified shared attributes and an empty per-call attribute map. + * + * @param shared the shared (global) attribute map + */ + protected JsonContext(Map shared) { + super(shared, new HashMap<>()); + } + + /** + * Constructs a new {@code JsonContext} with the specified shared and per-call (non-shared) attributes. + * + * @param shared the shared (global) attribute map + * @param nonShared the per-call (thread-local) attribute map + */ + protected JsonContext(Map shared, Map nonShared) { + super(shared, nonShared); + } + + /** + * Returns an empty {@code JsonContext} with no shared or per-call attributes. + * + * @return an empty context + */ + public static JsonContext empty() { + return new JsonContext(Collections.emptyMap(), Collections.emptyMap()); + } + + /** + * Returns a new {@code JsonContext} with no shared or per-call attributes, for use as a per-call context. + * + * @return a per-call context with no attributes + */ + public static JsonContext perCall() { + return new JsonContext(Collections.emptyMap(), Collections.emptyMap()); + } + + /** + * Returns a per-call {@code JsonContext} with the given attribute key and value. + * The attribute will only be visible to the current serialization/deserialization operation. + * + * @param key the attribute key + * @param value the attribute value + * @return a per-call context with the specified attribute + */ + public static JsonContext perCall(Object key, Object value) { + Map m = new HashMap<>(); + m.put(key, value); + return new JsonContext(Collections.emptyMap(), m); + } + + /** + * Returns a per-call {@code JsonContext} with two attribute key/value pairs. + * + * @param key1 the first attribute key + * @param value1 the first attribute value + * @param key2 the second attribute key + * @param value2 the second attribute value + * @return a per-call context with the specified attributes + */ + static JsonContext perCall(Object key1, Object value1, Object key2, Object value2) { + Map m = new HashMap<>(); + m.put(key1, value1); + m.put(key2, value2); + return new JsonContext(Collections.emptyMap(), m); + } + + /** + * Returns a per-call {@code JsonContext} with three attribute key/value pairs. + * + * @param key1 the first attribute key + * @param value1 the first attribute value + * @param key2 the second attribute key + * @param value2 the second attribute value + * @param key3 the third attribute key + * @param value3 the third attribute value + * @return a per-call context with the specified attributes + */ + static JsonContext perCall(Object key1, Object value1, Object key2, Object value2, Object key3, Object value3) { + Map m = new HashMap<>(); + m.put(key1, value1); + m.put(key2, value2); + m.put(key3, value3); + return new JsonContext(Collections.emptyMap(), m); + } + + /** + * Returns a new {@code JsonContext} with the given shared attribute key and value. + * Shared attributes are visible to all serialization/deserialization operations using this context. + * + * @param key the shared attribute key + * @param value the shared attribute value + * @return a context with the specified shared attribute + */ + public static JsonContext shared(Object key, Object value) { + return new JsonContext(Map.of(key, value), Collections.emptyMap()); + } + + /** + * Returns a new {@code JsonContext} with two shared attribute key/value pairs. + * + * @param key1 the first shared attribute key + * @param value1 the first shared attribute value + * @param key2 the second shared attribute key + * @param value2 the second shared attribute value + * @return a context with the specified shared attributes + */ + public static JsonContext shared(Object key1, Object value1, Object key2, Object value2) { + return new JsonContext(Map.of(key1, value1, key2, value2), Collections.emptyMap()); + } + + /** + * Returns a new {@code JsonContext} with three shared attribute key/value pairs. + * + * @param key1 the first shared attribute key + * @param value1 the first shared attribute value + * @param key2 the second shared attribute key + * @param value2 the second shared attribute value + * @param key3 the third shared attribute key + * @param value3 the third shared attribute value + * @return a context with the specified shared attributes + */ + public static JsonContext shared(Object key1, Object value1, Object key2, Object value2, Object key3, Object value3) { + return new JsonContext(Map.of(key1, value1, key2, value2, key3, value3), Collections.emptyMap()); + } + + /** + * Returns the value of the attribute for the specified key. + * If the key is {@code JsonContext.class} or {@code ContextAttributes.class}, returns this context instance. + * Otherwise, delegates to the parent implementation. + * + * @param key the attribute key + * @return the attribute value, or {@code null} if not present + */ + @Override + public Object getAttribute(Object key) { + if (key == JsonContext.class || key == ContextAttributes.class) + return this; + + return super.getAttribute(key); + } + + /** + * Returns {@code true} if this context contains no shared or per-call attributes. + * + * @return {@code true} if empty; {@code false} otherwise + */ + public boolean isEmpty() { + return _shared.isEmpty() && _nonShared.isEmpty(); + } + + /** + * Returns a new {@code JsonContext} with the given shared attribute key and value, replacing any previous value. + * Shared attributes are visible to all operations using this context. + * + * @param key the shared attribute key + * @param value the shared attribute value + * @return a new context with the updated shared attribute + */ + @Override + public JsonContext withSharedAttribute(Object key, Object value) { + if (_shared.isEmpty()) { + return new JsonContext(Map.of(key, value)); + } else { + Map newShared = new HashMap<>(_shared); + newShared.put(key, value); + return new JsonContext(newShared); + } + } + + /** + * Returns a new {@code JsonContext} with the specified shared attributes, replacing all previous shared attributes. + * + * @param attributes the shared attributes to set (maybe {@code null} or empty) + * @return a new context with the specified shared attributes + */ + @Override + public JsonContext withSharedAttributes(Map attributes) { + return new JsonContext(attributes == null || attributes.isEmpty() ? Collections.emptyMap() : attributes); + } + + /** + * Returns a new {@code JsonContext} without the specified shared attribute key. + * If the key does not exist, returns this context instance. + * + * @param key the shared attribute key to remove + * @return a new context without the specified shared attribute + */ + @Override + public JsonContext withoutSharedAttribute(Object key) { + if (_shared.isEmpty() || !_shared.containsKey(key)) + return this; + + if (_shared.size() == 1) + return empty(); + + Map newShared = new HashMap<>(_shared); + newShared.remove(key); + return new JsonContext(newShared); + } + + /** + * Returns a new {@code JsonContext} with the given per-call (non-shared) attribute key and value. + * Per-call attributes are visible only to a single serialization/deserialization operation. + *

+ * If {@code value} is {@code null}, and the key exists in shared attributes, + * a special null surrogate is used to mask the shared value. If the key does not exist in shared or per-call attributes, + * the context is returned unchanged. + * + * @param key the per-call attribute key + * @param value the per-call attribute value (maybe {@code null}, see behavior above) + * @return a new context with the updated per-call attribute + */ + @Override + public JsonContext withPerCallAttribute(Object key, Object value) { + // First: null value may need masking + if (value == null) { + // need to mask nulls to ensure default values won't be showing + if (_shared.containsKey(key)) { + value = NULL_SURROGATE; + } else if ((_nonShared == null) || !_nonShared.containsKey(key)) { + // except if an immutable shared list has no entry, we don't care + return this; + } else { + //noinspection RedundantCollectionOperation + if (_nonShared.containsKey(key)) // avoid exception on immutable map + _nonShared.remove(key); + return this; + } + } + + if (_nonShared == Collections.emptyMap()) + _nonShared = new HashMap<>(); + + _nonShared.put(key, value); + return this; + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/json/internal/DataFormat.java b/api/src/main/java/io/bosonnetwork/json/internal/DataFormat.java new file mode 100644 index 0000000..a947091 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/json/internal/DataFormat.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.json.internal; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.dataformat.cbor.CBORGenerator; +import com.fasterxml.jackson.dataformat.cbor.CBORParser; + +/** + * Utility class for identifying data formats being handled by JSON parsers and generators. + * This class focuses on distinguishing between binary formats, specifically + * CBOR (Concise Binary Object Representation), and non-binary formats such as JSON or TOML. + */ +public class DataFormat { + /** + * Determines if the provided {@link JsonParser} is operating in a binary format (CBOR). + * + * @param p the {@code JsonParser} to check + * @return {@code true} if the parser is handling a binary format (CBOR), {@code false} otherwise + */ + public static boolean isBinary(JsonParser p) { + // Now we only sport JSON, CBOR and TOML formats; CBOR is the only binary format + return p instanceof CBORParser; + } + + /** + * Determines if the provided {@link JsonGenerator} is operating in a binary format (CBOR). + * + * @param gen the {@code JsonGenerator} to check + * @return {@code true} if the generator is handling a binary format (CBOR), {@code false} otherwise + */ + public static boolean isBinary(JsonGenerator gen) { + // Now we only sport JSON, CBOR and TOML formats; CBOR is the only binary format + return gen instanceof CBORGenerator; + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/json/internal/DateDeserializer.java b/api/src/main/java/io/bosonnetwork/json/internal/DateDeserializer.java new file mode 100644 index 0000000..787efa7 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/json/internal/DateDeserializer.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.json.internal; + +import java.io.IOException; +import java.text.ParseException; +import java.util.Date; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; + +/** + * Deserializer for {@link java.util.Date} objects. + *

+ * Handles deserialization of Date objects from either ISO8601/RFC3339 string format or from epoch milliseconds, + * depending on the input format. In text formats, expects ISO8601 or RFC3339 strings; in binary formats, expects + * epoch milliseconds. + */ +public class DateDeserializer extends StdDeserializer { + private static final long serialVersionUID = -4252894239212420927L; + + /** + * Default constructor. + */ + public DateDeserializer() { + this(Date.class); + } + + /** + * Constructor with class type. + * + * @param t the class type + */ + public DateDeserializer(Class t) { + super(t); + } + + /** + * Deserializes the date. + * + * @param p the parser + * @param ctx the context + * @return the deserialized date + * @throws IOException if deserialization fails + */ + @Override + public Date deserialize(JsonParser p, DeserializationContext ctx) throws IOException { + if (p.getCurrentToken() == JsonToken.VALUE_NUMBER_INT) { + // Binary format: epoch milliseconds + return new Date(p.getValueAsLong()); + } else { + // Text format: RFC3339 / ISO8601 format + if (!p.getCurrentToken().equals(JsonToken.VALUE_STRING)) + throw ctx.wrongTokenException(p, String.class, JsonToken.VALUE_STRING, "Invalid datetime string"); + + final String dateStr = p.getValueAsString(); + try { + return DateFormat.getDefault().parse(dateStr); + } catch (ParseException ignore) { + } + + // Fail-back to ISO 8601 format. + try { + return DateFormat.getFailback().parse(dateStr); + } catch (ParseException e) { + throw ctx.weirdStringException(p.getText(), + Date.class, "Invalid datetime string"); + } + } + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/json/internal/DateFormat.java b/api/src/main/java/io/bosonnetwork/json/internal/DateFormat.java new file mode 100644 index 0000000..43317a3 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/json/internal/DateFormat.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.json.internal; + +import java.text.SimpleDateFormat; +import java.util.TimeZone; + +/** + * Utility class for date formatting. + */ +public class DateFormat { + + private static final TimeZone UTC = TimeZone.getTimeZone("UTC"); + @SuppressWarnings("SpellCheckingInspection") + private static final String ISO_8601 = "yyyy-MM-dd'T'HH:mm:ss'Z'"; + @SuppressWarnings("SpellCheckingInspection") + private static final String ISO_8601_WITH_MILLISECONDS = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"; + + /** + * Returns the default date and time format for serializing and deserializing {@link java.util.Date} objects. + *

+ * This format uses the ISO8601 pattern {@code yyyy-MM-dd'T'HH:mm:ss'Z'} in UTC timezone. + * + * @return the {@code DateFormat} object for ISO8601 date formatting. + */ + public static java.text.DateFormat getDefault() { + SimpleDateFormat dateFormat = new SimpleDateFormat(ISO_8601); + dateFormat.setTimeZone(UTC); + return dateFormat; + } + + /** + * Returns a failback date and time format for deserializing {@link java.util.Date} objects. + *

+ * This format uses the ISO8601 pattern with milliseconds: {@code yyyy-MM-dd'T'HH:mm:ss.SSS'Z'} in UTC timezone. + * Used if parsing with {@link #getDefault()} fails. + * + * @return the {@code DateFormat} object for ISO8601 date formatting with milliseconds. + */ + public static java.text.DateFormat getFailback() { + SimpleDateFormat dateFormat = new SimpleDateFormat(ISO_8601_WITH_MILLISECONDS); + dateFormat.setTimeZone(UTC); + return dateFormat; + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/json/internal/DateSerializer.java b/api/src/main/java/io/bosonnetwork/json/internal/DateSerializer.java new file mode 100644 index 0000000..66a62fe --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/json/internal/DateSerializer.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.json.internal; + +import java.io.IOException; +import java.util.Date; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; + +/** + * Serializer for {@link java.util.Date} objects. + *

+ * Handles serialization of Date objects to either ISO8601 string format (for text formats like JSON/YAML) + * or to epoch milliseconds (for binary formats like CBOR), depending on the output format. + */ +public class DateSerializer extends StdSerializer { + private static final long serialVersionUID = 4759684498722016230L; + + /** + * Default constructor. + */ + public DateSerializer() { + this(Date.class); + } + + /** + * Constructor with class type. + * + * @param t the class type + */ + public DateSerializer(Class t) { + super(t); + } + + /** + * Serializes the date. + * + * @param value the date to serialize + * @param gen the generator + * @param provider the provider + * @throws IOException if serialization fails + */ + @Override + public void serialize(Date value, JsonGenerator gen, SerializerProvider provider) throws IOException { + if (DataFormat.isBinary(gen)) + gen.writeNumber(value.getTime()); + else + gen.writeString(DateFormat.getDefault().format(value)); + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/json/internal/IdDeserializer.java b/api/src/main/java/io/bosonnetwork/json/internal/IdDeserializer.java new file mode 100644 index 0000000..0edb2ee --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/json/internal/IdDeserializer.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.json.internal; + +import java.io.IOException; + +import com.fasterxml.jackson.core.Base64Variants; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; + +import io.bosonnetwork.Id; + +/** + * Deserializer for {@link Id} objects. + *

+ * Handles decoding from either Base64-encoded binary (for binary formats like CBOR) + * or from string (Base58 or W3C DID) for text formats (JSON/YAML). + */ +public class IdDeserializer extends StdDeserializer { + private static final long serialVersionUID = 8977820243454538303L; + + /** + * Default constructor. + */ + public IdDeserializer() { + this(Id.class); + } + + /** + * Constructor with class type. + * + * @param vc the class type + */ + public IdDeserializer(Class vc) { + super(vc); + } + + /** + * Deserializes the {@link Id}. + * + * @param p the parser + * @param ctx the context + * @return the deserialized ID + * @throws IOException if deserialization fails + */ + @Override + public Id deserialize(JsonParser p, DeserializationContext ctx) throws IOException { + return DataFormat.isBinary(p) ? Id.of(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : Id.of(p.getText()); + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/json/internal/IdSerializer.java b/api/src/main/java/io/bosonnetwork/json/internal/IdSerializer.java new file mode 100644 index 0000000..6c993a5 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/json/internal/IdSerializer.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.json.internal; + +import java.io.IOException; + +import com.fasterxml.jackson.core.Base64Variants; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; + +import io.bosonnetwork.Id; +import io.bosonnetwork.identifier.DIDConstants; + +/** + * Serializer for {@link Id} objects. + *

+ * Handles serialization of Ids to either a Base64-encoded binary (for binary formats like CBOR) + * or as a string (either W3C DID or Base58, depending on the context attribute + * {@link io.bosonnetwork.identifier.DIDConstants#BOSON_ID_FORMAT_W3C}) for text formats (JSON/YAML). + */ +public class IdSerializer extends StdSerializer { + private static final long serialVersionUID = -1352630613285716899L; + + /** + * Default constructor. + */ + public IdSerializer() { + this(Id.class); + } + + /** + * Constructor with class type. + * + * @param t the class type + */ + public IdSerializer(Class t) { + super(t); + } + + /** + * Serializes the {@link Id}. + * + * @param value the ID to serialize + * @param gen the generator + * @param provider the provider + * @throws IOException if serialization fails + */ + @Override + public void serialize(Id value, JsonGenerator gen, SerializerProvider provider) throws IOException { + Boolean attr = (Boolean) provider.getAttribute(DIDConstants.BOSON_ID_FORMAT_W3C); + boolean w3cDID = attr != null && attr; + if (DataFormat.isBinary(gen)) + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.bytes(), 0, Id.BYTES); + else + gen.writeString(w3cDID ? value.toDIDString() : value.toBase58String()); + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/json/internal/InetAddressDeserializer.java b/api/src/main/java/io/bosonnetwork/json/internal/InetAddressDeserializer.java new file mode 100644 index 0000000..0b07a73 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/json/internal/InetAddressDeserializer.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.json.internal; + +import java.io.IOException; +import java.net.InetAddress; + +import com.fasterxml.jackson.core.Base64Variants; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; + +/** + * Deserializer for {@link InetAddress} objects. + *

+ * Decodes either a Base64-encoded binary address (for binary formats like CBOR) + * or a string representation (IP address or hostname) for text formats. + */ +public class InetAddressDeserializer extends StdDeserializer { + private static final long serialVersionUID = -5009935040580375373L; + + /** + * Default constructor. + */ + public InetAddressDeserializer() { + this(InetAddress.class); + } + + /** + * Constructor with class type. + * + * @param vc the class type + */ + public InetAddressDeserializer(Class vc) { + super(vc); + } + + /** + * Deserializes the {@link InetAddress}. + * + * @param p the parser + * @param ctx the context + * @return the deserialized address + * @throws IOException if deserialization fails + */ + @Override + public InetAddress deserialize(JsonParser p, DeserializationContext ctx) throws IOException { + return DataFormat.isBinary(p) ? InetAddress.getByAddress(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : + InetAddress.getByName(p.getText()); + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/json/internal/InetAddressSerializer.java b/api/src/main/java/io/bosonnetwork/json/internal/InetAddressSerializer.java new file mode 100644 index 0000000..4461389 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/json/internal/InetAddressSerializer.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.json.internal; + +import java.io.IOException; +import java.net.InetAddress; + +import com.fasterxml.jackson.core.Base64Variants; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; + +/** + * Serializer for {@link InetAddress} objects. + *

+ * Serializes InetAddress as a Base64-encoded binary value in binary formats (CBOR), + * or as a string (IP address or hostname) in text formats (JSON/YAML). + */ +public class InetAddressSerializer extends StdSerializer { + private static final long serialVersionUID = 618328089579234961L; + + /** + * Default constructor. + */ + public InetAddressSerializer() { + this(InetAddress.class); + } + + /** + * Constructor with class type. + * + * @param t the class type + */ + public InetAddressSerializer(Class t) { + super(t); + } + + /** + * Serializes the {@link InetAddress}. + * + * @param value the address to serialize + * @param gen the generator + * @param provider the provider + * @throws IOException if serialization fails + */ + @Override + public void serialize(InetAddress value, JsonGenerator gen, SerializerProvider provider) throws IOException { + if (DataFormat.isBinary(gen)) { + byte[] addr = value.getAddress(); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, addr, 0, addr.length); // binary ip address + } else { + gen.writeString(value.getHostAddress()); // ip address or host name + } + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/json/internal/NodeInfoDeserializer.java b/api/src/main/java/io/bosonnetwork/json/internal/NodeInfoDeserializer.java new file mode 100644 index 0000000..fdecfcf --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/json/internal/NodeInfoDeserializer.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.json.internal; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; + +import com.fasterxml.jackson.core.Base64Variants; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; + +import io.bosonnetwork.Id; +import io.bosonnetwork.NodeInfo; + +/** + * Deserializer for {@link NodeInfo} objects. + *

+ * Expects an array of [id, host, port]. In binary formats, id and host are decoded from Base64-encoded binary; + * in text formats, id is parsed from Base58 and host from a string (IP address or hostname). + */ +public class NodeInfoDeserializer extends StdDeserializer { + private static final long serialVersionUID = -1802423497777216345L; + + /** + * Default constructor. + */ + public NodeInfoDeserializer() { + this(NodeInfo.class); + } + + /** + * Constructor with class type. + * + * @param vc the class type + */ + public NodeInfoDeserializer(Class vc) { + super(vc); + } + + /** + * Deserializes the {@link NodeInfo}. + * + * @param p the parser + * @param ctx the context + * @return the deserialized node info + * @throws IOException if deserialization fails + */ + @Override + public NodeInfo deserialize(JsonParser p, DeserializationContext ctx) throws IOException { + if (p.currentToken() != JsonToken.START_ARRAY) + throw MismatchedInputException.from(p, NodeInfo.class, "Invalid NodeInfo, should be an array"); + + final boolean binaryFormat = DataFormat.isBinary(p); + + Id id = null; + InetAddress addr = null; + String host = null; + int port = 0; + + // id + if (p.nextToken() != JsonToken.VALUE_NULL) + id = binaryFormat ? Id.of(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : Id.of(p.getText()); + + // address + // text format: IP address string or hostname string + // binary format: binary ip address or host name string + JsonToken token = p.nextToken(); + if (token == JsonToken.VALUE_STRING) + host = p.getText(); + else if (token == JsonToken.VALUE_EMBEDDED_OBJECT) + addr = InetAddress.getByAddress(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)); + else + throw MismatchedInputException.from(p, NodeInfo.class, "Invalid NodeInfo: invalid node address"); + + // port + if (p.nextToken() != JsonToken.VALUE_NULL) + port = p.getIntValue(); + + if (p.nextToken() != JsonToken.END_ARRAY) + throw MismatchedInputException.from(p, NodeInfo.class, "Invalid NodeInfo: too many elements in array"); + + InetSocketAddress isa = addr != null ? new InetSocketAddress(addr, port) : new InetSocketAddress(host, port); + return new NodeInfo(id, isa); + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/json/internal/NodeInfoSerializer.java b/api/src/main/java/io/bosonnetwork/json/internal/NodeInfoSerializer.java new file mode 100644 index 0000000..062cf3d --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/json/internal/NodeInfoSerializer.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.json.internal; + +import java.io.IOException; + +import com.fasterxml.jackson.core.Base64Variants; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; + +import io.bosonnetwork.Id; +import io.bosonnetwork.NodeInfo; + +/** + * Serializer for {@link NodeInfo} objects. + *

+ * Encodes NodeInfo as a triple/array: [id, host, port]. In binary formats (CBOR), + * id and host are written as Base64-encoded binary; in text formats (JSON/YAML), + * id is written as Base58 and host as a string (IP address or hostname). + */ +public class NodeInfoSerializer extends StdSerializer { + private static final long serialVersionUID = 652112589617276783L; + + /** + * Default constructor. + */ + public NodeInfoSerializer() { + this(NodeInfo.class); + } + + /** + * Constructor with class type. + * + * @param t the class type + */ + public NodeInfoSerializer(Class t) { + super(t); + } + + /** + * Serializes the {@link NodeInfo}. + * + * @param value the node info to serialize + * @param gen the generator + * @param provider the provider + * @throws IOException if serialization fails + */ + @Override + public void serialize(NodeInfo value, JsonGenerator gen, SerializerProvider provider) throws IOException { + // Format: triple + // [id, host, port] + // + // host: + // text format: IP address string or hostname string + // binary format: binary ip address + gen.writeStartArray(); + + if (DataFormat.isBinary(gen)) { + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getId().bytes(), 0, Id.BYTES); + if (value.getAddress().isUnresolved()) { + // not attempting to do name resolution + gen.writeString(value.getAddress().getHostString()); + } else { + byte[] addr = value.getIpAddress().getAddress(); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, addr, 0, addr.length); // binary ip address + } + } else { + gen.writeString(value.getId().toBase58String()); + gen.writeString(value.getHost()); // host name or ip address + } + + gen.writeNumber(value.getPort()); + + gen.writeEndArray(); + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/json/internal/PeerInfoDeserializer.java b/api/src/main/java/io/bosonnetwork/json/internal/PeerInfoDeserializer.java new file mode 100644 index 0000000..dae5b53 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/json/internal/PeerInfoDeserializer.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.json.internal; + +import java.io.IOException; + +import com.fasterxml.jackson.core.Base64Variants; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; + +import io.bosonnetwork.Id; +import io.bosonnetwork.PeerInfo; + +/** + * Deserializer for {@link PeerInfo} objects. + *

+ * Expects a 6-element array: [peerId, nodeId, originNodeId, port, alternativeURI, signature]. + * In binary formats, ids and signature are decoded from Base64-encoded binary; in text formats, ids are Base58 strings. + * Special behavior: if peerId is omitted (null), it is taken from the context attribute + * {@link io.bosonnetwork.PeerInfo#ATTRIBUTE_PEER_ID}. + */ +public class PeerInfoDeserializer extends StdDeserializer { + private static final long serialVersionUID = 6475890164214322573L; + + /** + * Default constructor. + */ + public PeerInfoDeserializer() { + this(PeerInfo.class); + } + + /** + * Constructor with class type. + * + * @param vc the class type + */ + public PeerInfoDeserializer(Class vc) { + super(vc); + } + + /** + * Deserializes the {@link PeerInfo}. + * + * @param p the parser + * @param ctx the context + * @return the deserialized peer info + * @throws IOException if deserialization fails + */ + @Override + public PeerInfo deserialize(JsonParser p, DeserializationContext ctx) throws IOException { + if (p.currentToken() != JsonToken.START_OBJECT) + throw MismatchedInputException.from(p, PeerInfo.class, "Invalid PeerInfo, should be an object"); + + final boolean binaryFormat = DataFormat.isBinary(p); + + Id publicKey = null; + byte[] nonce = null; + int sequenceNumber = 0; + Id nodeId = null; + byte[] nodeSig = null; + byte[] signature = null; + long fingerprint = 0; + String endpoint = null; + byte[] extraData = null; + + while (p.nextToken() != JsonToken.END_OBJECT) { + String fieldName = p.currentName(); + JsonToken token = p.nextToken(); + switch (fieldName) { + case "id": + if (token != JsonToken.VALUE_NULL) + publicKey = binaryFormat ? Id.of(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : Id.of(p.getText()); + break; + case "n": + if (token != JsonToken.VALUE_NULL) + nonce = p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL); + break; + case "seq": + if (token != JsonToken.VALUE_NULL) + sequenceNumber = p.getIntValue(); + break; + case "o": + if (token != JsonToken.VALUE_NULL) + nodeId = binaryFormat ? Id.of(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : Id.of(p.getText()); + break; + case "os": + if (token != JsonToken.VALUE_NULL) + nodeSig = p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL); + break; + case "sig": + if (token != JsonToken.VALUE_NULL) + signature = p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL); + break; + case "f": + if (token != JsonToken.VALUE_NULL) + fingerprint = p.getLongValue(); + break; + case "e": + if (p.currentToken() != JsonToken.VALUE_NULL) + endpoint = p.getValueAsString(); + break; + case "ex": + if (p.currentToken() != JsonToken.VALUE_NULL) + extraData = p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL); + break; + default: + p.skipChildren(); + } + } + + // peer id is omitted, should retrieve it from the context + if (publicKey == null) { + publicKey = (Id) ctx.getAttribute(PeerInfo.ATTRIBUTE_PEER_ID); + if (publicKey == null) + throw MismatchedInputException.from(p, Id.class, "Invalid PeerInfo: peer id can not be null"); + } + + return PeerInfo.of(publicKey, nonce, sequenceNumber, nodeId, nodeSig, signature, fingerprint, endpoint, extraData); + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/json/internal/PeerInfoSerializer.java b/api/src/main/java/io/bosonnetwork/json/internal/PeerInfoSerializer.java new file mode 100644 index 0000000..257983b --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/json/internal/PeerInfoSerializer.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.json.internal; + +import java.io.IOException; + +import com.fasterxml.jackson.core.Base64Variants; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; + +import io.bosonnetwork.Id; +import io.bosonnetwork.PeerInfo; + +/** + * Serializer for {@link PeerInfo} objects. + *

+ * Encodes PeerInfo as a 6-element array: [peerId, nodeId, originNodeId, port, alternativeURI, signature]. + * In binary formats, ids and signature are written as Base64-encoded binary; in text formats, ids are Base58 strings. + * Special behavior: the peerId can be omitted if the context attribute + * {@link io.bosonnetwork.PeerInfo#ATTRIBUTE_OMIT_PEER_ID} is set. + */ +public class PeerInfoSerializer extends StdSerializer { + private static final long serialVersionUID = -2372725165793659632L; + + /** + * Default constructor. + */ + public PeerInfoSerializer() { + this(PeerInfo.class); + } + + /** + * Constructor with class type. + * + * @param t the class type + */ + public PeerInfoSerializer(Class t) { + super(t); + } + + /** + * Serializes the {@link PeerInfo}. + * + * @param value the peer info to serialize + * @param gen the generator + * @param provider the provider + * @throws IOException if serialization fails + */ + @Override + public void serialize(PeerInfo value, JsonGenerator gen, SerializerProvider provider) throws IOException { + final boolean binaryFormat = DataFormat.isBinary(gen); + + gen.writeStartObject(); + + // omit peer id? + final Boolean attr = (Boolean) provider.getAttribute(PeerInfo.ATTRIBUTE_OMIT_PEER_ID); + final boolean omitPeerId = attr != null && attr; + + if (!omitPeerId) { + if (binaryFormat) { + gen.writeFieldName("id"); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getId().bytes(), 0, Id.BYTES); + } else { + gen.writeStringField("id", value.getId().toBase58String()); + } + } + + // nonce + byte[] binary = value.getNonce(); + if (binary != null) { + gen.writeFieldName("n"); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, binary, 0, binary.length); + } + + // sequence number + if (value.getSequenceNumber() > 0) + gen.writeNumberField("seq", value.getSequenceNumber()); + + if (value.getNodeId() != null) { + if (binaryFormat) { + gen.writeFieldName("o"); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getNodeId().bytes(), 0, Id.BYTES); + } else { + gen.writeStringField("o", value.getNodeId().toBase58String()); + } + + binary = value.getNodeSignature(); + if (binary != null) { + gen.writeFieldName("os"); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, binary, 0, binary.length); + } + } + + // signature + binary = value.getSignature(); + if (binary != null) { + gen.writeFieldName("sig"); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, binary, 0, binary.length); + } + + if (value.getFingerprint() != 0) + gen.writeNumberField("f", value.getFingerprint()); + + gen.writeStringField("e", value.getEndpoint()); + + binary = value.getExtraData(); + if (binary != null) { + gen.writeFieldName("ex"); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, binary, 0, binary.length); + } + + gen.writeEndObject(); + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/json/internal/ValueDeserializer.java b/api/src/main/java/io/bosonnetwork/json/internal/ValueDeserializer.java new file mode 100644 index 0000000..3c4e694 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/json/internal/ValueDeserializer.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.json.internal; + +import java.io.IOException; + +import com.fasterxml.jackson.core.Base64Variants; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; + +import io.bosonnetwork.Id; +import io.bosonnetwork.Value; + +/** + * Deserializer for {@link Value} objects. + *

+ * Decodes a JSON object with fields corresponding to Value's structure. In binary formats, + * fields are decoded from Base64-encoded binary; in text formats, ids are parsed from Base58 strings. + * Handles missing or optional fields gracefully. + */ +public class ValueDeserializer extends StdDeserializer { + private static final long serialVersionUID = 2370471437259629126L; + + /** + * Default constructor. + */ + public ValueDeserializer() { + this(Value.class); + } + + /** + * Constructor with class type. + * + * @param vc the class type + */ + public ValueDeserializer(Class vc) { + super(vc); + } + + /** + * Deserializes the {@link Value}. + * + * @param p the parser + * @param ctx the context + * @return the deserialized value + * @throws IOException if deserialization fails + */ + @Override + public Value deserialize(JsonParser p, DeserializationContext ctx) throws IOException { + if (p.currentToken() != JsonToken.START_OBJECT) + throw MismatchedInputException.from(p, Value.class, "Invalid Value: should be an object"); + + final boolean binaryFormat = DataFormat.isBinary(p); + + Id publicKey = null; + Id recipient = null; + byte[] nonce = null; + int sequenceNumber = 0; + byte[] signature = null; + byte[] data = null; + + while (p.nextToken() != JsonToken.END_OBJECT) { + String fieldName = p.currentName(); + JsonToken token = p.nextToken(); + switch (fieldName) { + case "k": + if (token != JsonToken.VALUE_NULL) + publicKey = binaryFormat ? Id.of(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : Id.of(p.getText()); + break; + case "rec": + if (token != JsonToken.VALUE_NULL) + recipient = binaryFormat ? Id.of(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : Id.of(p.getText()); + break; + case "n": + if (token != JsonToken.VALUE_NULL) + nonce = p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL); + break; + case "seq": + if (token != JsonToken.VALUE_NULL) + sequenceNumber = p.getIntValue(); + break; + case "sig": + if (token != JsonToken.VALUE_NULL) + signature = p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL); + break; + case "v": + if (token != JsonToken.VALUE_NULL) + data = p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL); + break; + default: + p.skipChildren(); + } + } + + return Value.of(publicKey, recipient, nonce, sequenceNumber, signature, data); + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/json/internal/ValueSerializer.java b/api/src/main/java/io/bosonnetwork/json/internal/ValueSerializer.java new file mode 100644 index 0000000..cce7629 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/json/internal/ValueSerializer.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.json.internal; + +import java.io.IOException; + +import com.fasterxml.jackson.core.Base64Variants; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; + +import io.bosonnetwork.Id; +import io.bosonnetwork.Value; + +/** + * Serializer for {@link Value} objects. + *

+ * Serializes fields of Value as a JSON object. In binary formats, fields are written as Base64-encoded binary; + * in text formats, ids are written as Base58 strings and binary fields as Base64. Handles optional fields. + */ +public class ValueSerializer extends StdSerializer { + private static final long serialVersionUID = 5494303011447541850L; + + /** + * Default constructor. + */ + public ValueSerializer() { + this(Value.class); + } + + /** + * Constructor with class type. + * + * @param t the class type + */ + public ValueSerializer(Class t) { + super(t); + } + + /** + * Serializes the {@link Value}. + * + * @param value the value to serialize + * @param gen the generator + * @param provider the provider + * @throws IOException if serialization fails + */ + @Override + public void serialize(Value value, JsonGenerator gen, SerializerProvider provider) throws IOException { + final boolean binaryFormat = DataFormat.isBinary(gen); + gen.writeStartObject(); + + if (value.getPublicKey() != null) { + // public key + if (binaryFormat) { + gen.writeFieldName("k"); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getPublicKey().bytes(), 0, Id.BYTES); + } else { + gen.writeStringField("k", value.getPublicKey().toBase58String()); + } + + // recipient + if (value.getRecipient() != null) { + if (binaryFormat) { + gen.writeFieldName("rec"); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getRecipient().bytes(), 0, Id.BYTES); + } else { + gen.writeStringField("rec", value.getRecipient().toBase58String()); + } + } + + // nonce + byte[] binary = value.getNonce(); + if (binary != null) { + gen.writeFieldName("n"); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, binary, 0, binary.length); + } + + // sequence number + if (value.getSequenceNumber() > 0) + gen.writeNumberField("seq", value.getSequenceNumber()); + + // signature + binary = value.getSignature(); + if (binary != null) { + gen.writeFieldName("sig"); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, binary, 0, binary.length); + } + } + + byte[] data = value.getData(); + if (data != null && data.length > 0) { + gen.writeFieldName("v"); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, data, 0, data.length); + } + + gen.writeEndObject(); + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/utils/Json.java b/api/src/main/java/io/bosonnetwork/utils/Json.java deleted file mode 100644 index 91606bc..0000000 --- a/api/src/main/java/io/bosonnetwork/utils/Json.java +++ /dev/null @@ -1,1424 +0,0 @@ -/* - * Copyright (c) 2023 - bosonnetwork.io - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package io.bosonnetwork.utils; - -import java.io.IOException; -import java.net.InetAddress; -import java.text.DateFormat; -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.Base64; -import java.util.Collections; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; -import java.util.TimeZone; - -import com.fasterxml.jackson.core.Base64Variants; -import com.fasterxml.jackson.core.JsonFactory; -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.JsonToken; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.MapperFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.ObjectWriter; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.databind.SerializerProvider; -import com.fasterxml.jackson.databind.cfg.ContextAttributes; -import com.fasterxml.jackson.databind.deser.std.StdDeserializer; -import com.fasterxml.jackson.databind.exc.MismatchedInputException; -import com.fasterxml.jackson.databind.json.JsonMapper; -import com.fasterxml.jackson.databind.module.SimpleModule; -import com.fasterxml.jackson.databind.ser.std.StdSerializer; -import com.fasterxml.jackson.dataformat.cbor.CBORFactory; -import com.fasterxml.jackson.dataformat.cbor.CBORGenerator; -import com.fasterxml.jackson.dataformat.cbor.CBORParser; -import com.fasterxml.jackson.dataformat.cbor.databind.CBORMapper; -import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; -import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; -import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; -import io.vertx.core.json.jackson.DatabindCodec; - -import io.bosonnetwork.Id; -import io.bosonnetwork.NodeInfo; -import io.bosonnetwork.PeerInfo; -import io.bosonnetwork.Value; -import io.bosonnetwork.identifier.DIDConstants; - -/** - * Utility class for serialization and deserialization of data to and from JSON, CBOR, and YAML formats. - *

- * This class provides common methods for encoding and decoding objects using Jackson, with special support for Boson-specific - * types and extensions. It supports both text-based (JSON, YAML) and binary (CBOR) formats, and registers Jackson modules - * to handle custom serialization and deserialization of Boson domain objects such as {@link io.bosonnetwork.Id}, {@link io.bosonnetwork.NodeInfo}, - * {@link io.bosonnetwork.PeerInfo}, and {@link io.bosonnetwork.Value}. - *

- * The utility also provides factory methods for obtaining pre-configured Jackson {@link ObjectMapper}, {@link CBORMapper}, - * and {@link YAMLMapper} instances, as well as methods for pretty-printing, byte encoding, and parsing from various formats. - *

- * Boson-specific serialization features include: - *

    - *
  • Binary and text encoding of IDs and network addresses depending on the chosen format.
  • - *
  • Support for ISO8601/RFC3339 date/time formats and epoch milliseconds.
  • - *
  • Context-aware serialization using {@link Json.JsonContext} for configuration of serialization details.
  • - *
- */ -public class Json { - private static final String BOSON_JSON_MODULE_NAME = "io.bosonnetwork.utils.json.module"; - - /** The pre-configured Jackson {@link ObjectMapper} instance for JSON serialization and deserialization. */ - public static final Base64.Encoder BASE64_ENCODER = Base64.getUrlEncoder().withoutPadding(); - /** The pre-configured Jackson {@link ObjectMapper} instance for JSON serialization and deserialization. */ - public static final Base64.Decoder BASE64_DECODER = Base64.getUrlDecoder(); - - private static TypeReference> _mapType; - - private static SimpleModule _bosonJsonModule; - - private static JsonFactory _jsonFactory; - private static CBORFactory _cborFactory; - - private static ObjectMapper _objectMapper; - private static CBORMapper _cborMapper; - private static YAMLMapper _yamlMapper; - - private static final TimeZone UTC = TimeZone.getTimeZone("UTC"); - @SuppressWarnings("SpellCheckingInspection") - private static final String ISO_8601 = "yyyy-MM-dd'T'HH:mm:ss'Z'"; - @SuppressWarnings("SpellCheckingInspection") - private static final String ISO_8601_WITH_MILLISECONDS = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"; - - /** - * Determines if the provided {@link JsonParser} is operating in a binary format (CBOR). - * - * @param p the {@code JsonParser} to check - * @return {@code true} if the parser is handling a binary format (CBOR), {@code false} otherwise - */ - public static boolean isBinaryFormat(JsonParser p) { - // Now we only sport JSON, CBOR and TOML formats; CBOR is the only binary format - return p instanceof CBORParser; - } - - /** - * Determines if the provided {@link JsonGenerator} is operating in a binary format (CBOR). - * - * @param gen the {@code JsonGenerator} to check - * @return {@code true} if the generator is handling a binary format (CBOR), {@code false} otherwise - */ - public static boolean isBinaryFormat(JsonGenerator gen) { - // Now we only sport JSON, CBOR and TOML formats; CBOR is the only binary format - return gen instanceof CBORGenerator; - } - - /** - * Serializer for {@link Id} objects. - *

- * Handles serialization of Ids to either a Base64-encoded binary (for binary formats like CBOR) - * or as a string (either W3C DID or Base58, depending on the context attribute - * {@link io.bosonnetwork.identifier.DIDConstants#BOSON_ID_FORMAT_W3C}) for text formats (JSON/YAML). - */ - static class IdSerializer extends StdSerializer { - private static final long serialVersionUID = -1352630613285716899L; - - public IdSerializer() { - this(Id.class); - } - - public IdSerializer(Class t) { - super(t); - } - - @Override - public void serialize(Id value, JsonGenerator gen, SerializerProvider provider) throws IOException { - Boolean attr = (Boolean) provider.getAttribute(DIDConstants.BOSON_ID_FORMAT_W3C); - boolean w3cDID = attr != null && attr; - if (isBinaryFormat(gen)) - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.bytes(), 0, Id.BYTES); - else - gen.writeString(w3cDID ? value.toDIDString() : value.toBase58String()); - } - } - - /** - * Deserializer for {@link Id} objects. - *

- * Handles decoding from either Base64-encoded binary (for binary formats like CBOR) - * or from string (Base58 or W3C DID) for text formats (JSON/YAML). - */ - static class IdDeserializer extends StdDeserializer { - private static final long serialVersionUID = 8977820243454538303L; - - public IdDeserializer() { - this(Id.class); - } - - public IdDeserializer(Class vc) { - super(vc); - } - - @Override - public Id deserialize(JsonParser p, DeserializationContext ctx) throws IOException { - return isBinaryFormat(p) ? Id.of(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : Id.of(p.getText()); - } - } - - /** - * Serializer for {@link InetAddress} objects. - *

- * Serializes InetAddress as a Base64-encoded binary value in binary formats (CBOR), - * or as a string (IP address or hostname) in text formats (JSON/YAML). - */ - static class InetAddressSerializer extends StdSerializer { - private static final long serialVersionUID = 618328089579234961L; - - public InetAddressSerializer() { - this(InetAddress.class); - } - - public InetAddressSerializer(Class t) { - super(t); - } - - @Override - public void serialize(InetAddress value, JsonGenerator gen, SerializerProvider provider) throws IOException { - if (isBinaryFormat(gen)) { - byte[] addr = value.getAddress(); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, addr, 0, addr.length); // binary ip address - } else { - gen.writeString(value.getHostAddress()); // ip address or host name - } - } - } - - /** - * Deserializer for {@link InetAddress} objects. - *

- * Decodes either a Base64-encoded binary address (for binary formats like CBOR) - * or a string representation (IP address or hostname) for text formats. - */ - static class InetAddressDeserializer extends StdDeserializer { - private static final long serialVersionUID = -5009935040580375373L; - - public InetAddressDeserializer() { - this(InetAddress.class); - } - - public InetAddressDeserializer(Class vc) { - super(vc); - } - - @Override - public InetAddress deserialize(JsonParser p, DeserializationContext ctx) throws IOException { - return isBinaryFormat(p) ? InetAddress.getByAddress(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : - InetAddress.getByName(p.getText()); - } - } - - /** - * Serializer for {@link java.util.Date} objects. - *

- * Handles serialization of Date objects to either ISO8601 string format (for text formats like JSON/YAML) - * or to epoch milliseconds (for binary formats like CBOR), depending on the output format. - */ - static class DateSerializer extends StdSerializer { - private static final long serialVersionUID = 4759684498722016230L; - - public DateSerializer() { - this(Date.class); - } - - public DateSerializer(Class t) { - super(t); - } - - @Override - public void serialize(Date value, JsonGenerator gen, SerializerProvider provider) throws IOException { - if (isBinaryFormat(gen)) - gen.writeNumber(value.getTime()); - else - gen.writeString(getDateFormat().format(value)); - } - } - - /** - * Deserializer for {@link java.util.Date} objects. - *

- * Handles deserialization of Date objects from either ISO8601/RFC3339 string format or from epoch milliseconds, - * depending on the input format. In text formats, expects ISO8601 or RFC3339 strings; in binary formats, expects - * epoch milliseconds. - */ - static class DateDeserializer extends StdDeserializer { - private static final long serialVersionUID = -4252894239212420927L; - - public DateDeserializer() { - this(Date.class); - } - - public DateDeserializer(Class t) { - super(t); - } - - @Override - public Date deserialize(JsonParser p, DeserializationContext ctx) throws IOException { - if (p.getCurrentToken() == JsonToken.VALUE_NUMBER_INT) { - // Binary format: epoch milliseconds - return new Date(p.getValueAsLong()); - } else { - // Text format: RFC3339 / ISO8601 format - if (!p.getCurrentToken().equals(JsonToken.VALUE_STRING)) - throw ctx.wrongTokenException(p, String.class, JsonToken.VALUE_STRING, "Invalid datetime string"); - - final String dateStr = p.getValueAsString(); - try { - return getDateFormat().parse(dateStr); - } catch (ParseException ignore) { - } - - // Fail-back to ISO 8601 format. - try { - return getFailbackDateFormat().parse(dateStr); - } catch (ParseException e) { - throw ctx.weirdStringException(p.getText(), - Date.class, "Invalid datetime string"); - } - } - } - } - - - /** - * Returns the default date and time format for serializing and deserializing {@link java.util.Date} objects. - *

- * This format uses the ISO8601 pattern {@code yyyy-MM-dd'T'HH:mm:ss'Z'} in UTC timezone. - * - * @return the {@code DateFormat} object for ISO8601 date formatting. - */ - public static DateFormat getDateFormat() { - SimpleDateFormat dateFormat = new SimpleDateFormat(ISO_8601); - dateFormat.setTimeZone(UTC); - return dateFormat; - } - - /** - * Returns a failback date and time format for deserializing {@link java.util.Date} objects. - *

- * This format uses the ISO8601 pattern with milliseconds: {@code yyyy-MM-dd'T'HH:mm:ss.SSS'Z'} in UTC timezone. - * Used if parsing with {@link #getDateFormat()} fails. - * - * @return the {@code DateFormat} object for ISO8601 date formatting with milliseconds. - */ - protected static DateFormat getFailbackDateFormat() { - SimpleDateFormat dateFormat = new SimpleDateFormat(ISO_8601_WITH_MILLISECONDS); - dateFormat.setTimeZone(UTC); - return dateFormat; - } - - /** - * Serializer for {@link NodeInfo} objects. - *

- * Encodes NodeInfo as a triple/array: [id, host, port]. In binary formats (CBOR), - * id and host are written as Base64-encoded binary; in text formats (JSON/YAML), - * id is written as Base58 and host as a string (IP address or hostname). - */ - static class NodeInfoSerializer extends StdSerializer { - private static final long serialVersionUID = 652112589617276783L; - - public NodeInfoSerializer() { - this(NodeInfo.class); - } - - public NodeInfoSerializer(Class t) { - super(t); - } - - @Override - public void serialize(NodeInfo value, JsonGenerator gen, SerializerProvider provider) throws IOException { - // Format: triple - // [id, host, port] - // - // host: - // text format: IP address string or hostname string - // binary format: binary ip address - gen.writeStartArray(); - - if (isBinaryFormat(gen)) { - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getId().bytes(), 0, Id.BYTES); - byte[] addr = value.getIpAddress().getAddress(); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, addr, 0, addr.length); // binary ip address - } else { - gen.writeString(value.getId().toBase58String()); - gen.writeString(value.getHost()); // host name or ip address - } - - gen.writeNumber(value.getPort()); - - gen.writeEndArray(); - } - } - - /** - * Deserializer for {@link NodeInfo} objects. - *

- * Expects an array of [id, host, port]. In binary formats, id and host are decoded from Base64-encoded binary; - * in text formats, id is parsed from Base58 and host from a string (IP address or hostname). - */ - static class NodeInfoDeserializer extends StdDeserializer { - private static final long serialVersionUID = -1802423497777216345L; - - public NodeInfoDeserializer() { - this(NodeInfo.class); - } - - public NodeInfoDeserializer(Class vc) { - super(vc); - } - - @Override - public NodeInfo deserialize(JsonParser p, DeserializationContext ctx) throws IOException { - if (p.currentToken() != JsonToken.START_ARRAY) - throw MismatchedInputException.from(p, NodeInfo.class, "Invalid NodeInfo, should be an array"); - - final boolean binaryFormat = isBinaryFormat(p); - - Id id = null; - InetAddress addr = null; - int port = 0; - - // id - if (p.nextToken() != JsonToken.VALUE_NULL) - id = binaryFormat ? Id.of(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : Id.of(p.getText()); - - // address - // text format: IP address string or hostname string - // binary format: binary ip address or host name string - if (p.nextToken() != JsonToken.VALUE_NULL) - addr = binaryFormat ? InetAddress.getByAddress(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : - InetAddress.getByName(p.getText()); - - // port - if (p.nextToken() != JsonToken.VALUE_NULL) - port = p.getIntValue(); - - if (p.nextToken() != JsonToken.END_ARRAY) - throw MismatchedInputException.from(p, NodeInfo.class, "Invalid NodeInfo: too many elements in array"); - - return new NodeInfo(id, addr, port); - } - } - - /** - * Serializer for {@link PeerInfo} objects. - *

- * Encodes PeerInfo as a 6-element array: [peerId, nodeId, originNodeId, port, alternativeURI, signature]. - * In binary formats, ids and signature are written as Base64-encoded binary; in text formats, ids are Base58 strings. - * Special behavior: the peerId can be omitted if the context attribute - * {@link io.bosonnetwork.PeerInfo#ATTRIBUTE_OMIT_PEER_ID} is set. - */ - static class PeerInfoSerializer extends StdSerializer { - private static final long serialVersionUID = -2372725165793659632L; - - public PeerInfoSerializer() { - this(PeerInfo.class); - } - - public PeerInfoSerializer(Class t) { - super(t); - } - - @Override - public void serialize(PeerInfo value, JsonGenerator gen, SerializerProvider provider) throws IOException { - final boolean binaryFormat = isBinaryFormat(gen); - - // Format: 6-tuple - // [peerId, nodeId, originNodeId, port, alternativeURI, signature] - // If omit the peer id, format: - // [null, nodeId, originNodeId, port, alternativeURI, signature] - - gen.writeStartArray(); - - // omit peer id? - final Boolean attr = (Boolean) provider.getAttribute(PeerInfo.ATTRIBUTE_OMIT_PEER_ID); - final boolean omitPeerId = attr != null && attr; - - if (!omitPeerId) { - if (binaryFormat) - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getId().bytes(), 0, Id.BYTES); - else - gen.writeString(value.getId().toBase58String()); - } else { - gen.writeNull(); - } - - // node id - if (binaryFormat) - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getNodeId().bytes(), 0, Id.BYTES); - else - gen.writeString(value.getNodeId().toBase58String()); - - // origin node id - if (value.isDelegated()) { - if (binaryFormat) - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getOrigin().bytes(), 0, Id.BYTES); - else - gen.writeString(value.getOrigin().toBase58String()); - } else { - gen.writeNull(); - } - - // port - gen.writeNumber(value.getPort()); - - // alternative url - if (value.hasAlternativeURI()) - gen.writeString(value.getAlternativeURI()); - else - gen.writeNull(); - - // signature - byte[] sig = value.getSignature(); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, sig, 0, sig.length); - - gen.writeEndArray(); - } - } - - /** - * Deserializer for {@link PeerInfo} objects. - *

- * Expects a 6-element array: [peerId, nodeId, originNodeId, port, alternativeURI, signature]. - * In binary formats, ids and signature are decoded from Base64-encoded binary; in text formats, ids are Base58 strings. - * Special behavior: if peerId is omitted (null), it is taken from the context attribute - * {@link io.bosonnetwork.PeerInfo#ATTRIBUTE_PEER_ID}. - */ - static class PeerInfoDeserializer extends StdDeserializer { - private static final long serialVersionUID = 6475890164214322573L; - - public PeerInfoDeserializer() { - this(PeerInfo.class); - } - - public PeerInfoDeserializer(Class vc) { - super(vc); - } - - @Override - public PeerInfo deserialize(JsonParser p, DeserializationContext ctx) throws IOException { - if (p.currentToken() != JsonToken.START_ARRAY) - throw MismatchedInputException.from(p, PeerInfo.class, "Invalid PeerInfo, should be an array"); - - final boolean binaryFormat = isBinaryFormat(p); - - Id peerId; - Id nodeId = null; - Id origin = null; - int port = 0; - String alternativeURI = null; - byte[] signature = null; - - // peer id - if (p.nextToken() != JsonToken.VALUE_NULL) { - peerId = binaryFormat ? Id.of(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : Id.of(p.getText()); - } else { - // peer id is omitted, should retrieve it from the context - peerId = (Id) ctx.getAttribute(PeerInfo.ATTRIBUTE_PEER_ID); - if (peerId == null) - throw MismatchedInputException.from(p, Id.class, "Invalid PeerInfo: peer id can not be null"); - } - - // node id - if (p.nextToken() != JsonToken.VALUE_NULL) - nodeId = binaryFormat ? Id.of(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : Id.of(p.getText()); - - // origin node id - if (p.nextToken() != JsonToken.VALUE_NULL) - origin = binaryFormat ? Id.of(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : Id.of(p.getText()); - - // port - if (p.nextToken() != JsonToken.VALUE_NULL) - port = p.getIntValue(); - - // alternative url - if (p.nextToken() != JsonToken.VALUE_NULL) - alternativeURI = p.getText(); - - // signature - if (p.nextToken() != JsonToken.VALUE_NULL) - signature = p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL); - - if (p.nextToken() != JsonToken.END_ARRAY) - throw MismatchedInputException.from(p, PeerInfo.class, "Invalid PeerInfo: too many elements in array"); - - return PeerInfo.of(peerId, nodeId, origin, port, alternativeURI, signature); - } - } - - /** - * Serializer for {@link Value} objects. - *

- * Serializes fields of Value as a JSON object. In binary formats, fields are written as Base64-encoded binary; - * in text formats, ids are written as Base58 strings and binary fields as Base64. Handles optional fields. - */ - static class ValueSerializer extends StdSerializer { - private static final long serialVersionUID = 5494303011447541850L; - - public ValueSerializer() { - this(Value.class); - } - - public ValueSerializer(Class t) { - super(t); - } - - @Override - public void serialize(Value value, JsonGenerator gen, SerializerProvider provider) throws IOException { - final boolean binaryFormat = isBinaryFormat(gen); - gen.writeStartObject(); - - if (value.getPublicKey() != null) { - // public key - if (binaryFormat) { - gen.writeFieldName("k"); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getPublicKey().bytes(), 0, Id.BYTES); - } else { - gen.writeStringField("k", value.getPublicKey().toBase58String()); - } - - // recipient - if (value.getRecipient() != null) { - if (binaryFormat) { - gen.writeFieldName("rec"); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getRecipient().bytes(), 0, Id.BYTES); - } else { - gen.writeStringField("rec", value.getRecipient().toBase58String()); - } - } - - // nonce - byte[] binary = value.getNonce(); - if (binary != null) { - gen.writeFieldName("n"); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, binary, 0, binary.length); - } - - // sequence number - if (value.getSequenceNumber() > 0) - gen.writeNumberField("seq", value.getSequenceNumber()); - - // signature - binary = value.getSignature(); - if (binary != null) { - gen.writeFieldName("sig"); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, binary, 0, binary.length); - } - } - - byte[] data = value.getData(); - if (data != null && data.length > 0) { - gen.writeFieldName("v"); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, data, 0, data.length); - } - - gen.writeEndObject(); - } - } - - /** - * Deserializer for {@link Value} objects. - *

- * Decodes a JSON object with fields corresponding to Value's structure. In binary formats, - * fields are decoded from Base64-encoded binary; in text formats, ids are parsed from Base58 strings. - * Handles missing or optional fields gracefully. - */ - static class ValueDeserializer extends StdDeserializer { - private static final long serialVersionUID = 2370471437259629126L; - - public ValueDeserializer() { - this(Value.class); - } - - public ValueDeserializer(Class vc) { - super(vc); - } - - @Override - public Value deserialize(JsonParser p, DeserializationContext ctx) throws IOException { - if (p.currentToken() != JsonToken.START_OBJECT) - throw MismatchedInputException.from(p, Value.class, "Invalid Value: should be an object"); - - final boolean binaryFormat = isBinaryFormat(p); - - Id publicKey = null; - Id recipient = null; - byte[] nonce = null; - int sequenceNumber = 0; - byte[] signature = null; - byte[] data = null; - - while (p.nextToken() != JsonToken.END_OBJECT) { - String fieldName = p.currentName(); - JsonToken token = p.nextToken(); - switch (fieldName) { - case "k": - if (token != JsonToken.VALUE_NULL) - publicKey = binaryFormat ? Id.of(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : Id.of(p.getText()); - break; - case "rec": - if (token != JsonToken.VALUE_NULL) - recipient = binaryFormat ? Id.of(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : Id.of(p.getText()); - break; - case "n": - if (p.currentToken() != JsonToken.VALUE_NULL) - nonce = p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL); - break; - case "seq": - if (p.currentToken() != JsonToken.VALUE_NULL) - sequenceNumber = p.getIntValue(); - break; - case "sig": - if (p.currentToken() != JsonToken.VALUE_NULL) - signature = p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL); - break; - case "v": - if (p.currentToken() != JsonToken.VALUE_NULL) - data = p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL); - break; - default: - p.skipChildren(); - } - } - - return Value.of(publicKey, recipient, nonce, sequenceNumber, signature, data); - } - } - - /** - * Creates the Jackson module. - * - * @return the {@code SimpleModule} object. - */ - protected static SimpleModule bosonJsonModule() { - if (_bosonJsonModule == null) { - SimpleModule module = new SimpleModule(BOSON_JSON_MODULE_NAME); - module.addSerializer(Date.class, new DateSerializer()); - module.addDeserializer(Date.class, new DateDeserializer()); - module.addSerializer(Id.class, new IdSerializer()); - module.addDeserializer(Id.class, new IdDeserializer()); - module.addSerializer(InetAddress.class, new InetAddressSerializer()); - module.addDeserializer(InetAddress.class, new InetAddressDeserializer()); - - module.addSerializer(NodeInfo.class, new NodeInfoSerializer()); - module.addDeserializer(NodeInfo.class, new NodeInfoDeserializer()); - module.addSerializer(PeerInfo.class, new PeerInfoSerializer()); - module.addDeserializer(PeerInfo.class, new PeerInfoDeserializer()); - module.addSerializer(Value.class, new ValueSerializer()); - module.addDeserializer(Value.class, new ValueDeserializer()); - - _bosonJsonModule = module; - } - - return _bosonJsonModule; - } - - /** - * Creates the Jackson JSON factory without auto-close the source and target. - * - * @return the {@code JsonFactory} object. - */ - public static JsonFactory jsonFactory() { - if (_jsonFactory == null) { - JsonFactory factory = new JsonFactory(); - factory.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); - factory.disable(JsonParser.Feature.AUTO_CLOSE_SOURCE); - _jsonFactory = factory; - } - - return _jsonFactory; - } - - /** - * Creates the Jackson CBOR factory without auto-close the source and target. - * - * @return the {@code CBORFactory} object. - */ - public static CBORFactory cborFactory() { - if (_cborFactory == null) { - CBORFactory factory = new CBORFactory(); - factory.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); - factory.disable(JsonParser.Feature.AUTO_CLOSE_SOURCE); - _cborFactory = factory; - } - - return _cborFactory; - } - - /** - * Creates the Jackson object mapper, with basic Boson types support. - * - * @return the new {@code ObjectMapper} object. - */ - public static ObjectMapper objectMapper() { - if (_objectMapper == null) { - _objectMapper = JsonMapper.builder(jsonFactory()) - .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) - .disable(MapperFeature.AUTO_DETECT_CREATORS) - .disable(MapperFeature.AUTO_DETECT_FIELDS) - .disable(MapperFeature.AUTO_DETECT_GETTERS) - .disable(MapperFeature.AUTO_DETECT_IS_GETTERS) - .disable(MapperFeature.AUTO_DETECT_SETTERS) - .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) - // .defaultDateFormat(getDateFormat()) - .defaultBase64Variant(Base64Variants.MODIFIED_FOR_URL) - .addModule(bosonJsonModule()) - .build(); - } - - return _objectMapper; - } - - /** - * Creates the Jackson CBOR mapper, with basic Boson types support. - * - * @return the new {@code CBORMapper} object. - */ - public static CBORMapper cborMapper() { - if (_cborMapper == null) { - _cborMapper = CBORMapper.builder(cborFactory()) - .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) - .disable(MapperFeature.AUTO_DETECT_CREATORS) - .disable(MapperFeature.AUTO_DETECT_FIELDS) - .disable(MapperFeature.AUTO_DETECT_GETTERS) - .disable(MapperFeature.AUTO_DETECT_IS_GETTERS) - .disable(MapperFeature.AUTO_DETECT_SETTERS) - .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) - // .defaultDateFormat(getDateFormat()) - .defaultBase64Variant(Base64Variants.MODIFIED_FOR_URL) - .addModule(bosonJsonModule()) - .build(); - } - - return _cborMapper; - } - - /** - * Creates the Jackson YAML mapper, with basic Boson types support. - * - * @return the new {@code YAMLMapper} object. - */ - public static YAMLMapper yamlMapper() { - if (_yamlMapper == null) { - YAMLFactory factory = new YAMLFactory(); - factory.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); - factory.disable(JsonParser.Feature.AUTO_CLOSE_SOURCE); - - _yamlMapper = YAMLMapper.builder(factory) - .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) - .disable(MapperFeature.AUTO_DETECT_CREATORS) - .disable(MapperFeature.AUTO_DETECT_FIELDS) - .disable(MapperFeature.AUTO_DETECT_GETTERS) - .disable(MapperFeature.AUTO_DETECT_IS_GETTERS) - .disable(MapperFeature.AUTO_DETECT_SETTERS) - .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) - .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) - .enable(YAMLGenerator.Feature.INDENT_ARRAYS) - .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES) - //.defaultDateFormat(getDateFormat()) - .defaultBase64Variant(Base64Variants.MODIFIED_FOR_URL) - .addModule(bosonJsonModule()) - .build(); - } - - return _yamlMapper; - } - - /** - * Serializes the given object to a JSON string using the provided {@link JsonContext}. - * - * @param object the object to serialize - * @param context the serialization context, or {@code null} for default context - * @return the JSON string representation of the object - * @throws IllegalArgumentException if the object cannot be serialized - */ - public static String toString(Object object, JsonContext context) { - try { - if (context == null || context.isEmpty()) - return objectMapper().writeValueAsString(object); - else - return objectMapper().writer(context).writeValueAsString(object); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException("object can not be serialized", e); - } - } - - /** - * Serializes the given object to a JSON string using the default context. - * - * @param object the object to serialize - * @return the JSON string representation of the object - * @throws IllegalArgumentException if the object cannot be serialized - */ - public static String toString(Object object) { - try { - return objectMapper().writeValueAsString(object); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException("object can not be serialized", e); - } - } - - /** - * Serializes the given object to a pretty-printed JSON string using the provided {@link JsonContext}. - * - * @param object the object to serialize - * @param context the serialization context, or {@code null} for default context - * @return the pretty-printed JSON string representation of the object - * @throws IllegalArgumentException if the object cannot be serialized - */ - public static String toPrettyString(Object object, JsonContext context) { - try { - ObjectWriter writer = context == null || context.isEmpty() ? - objectMapper().writerWithDefaultPrettyPrinter() : - objectMapper().writerWithDefaultPrettyPrinter().with(context); - - return writer.writeValueAsString(object); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException("object can not be serialized", e); - } - } - - /** - * Serializes the given object to a pretty-printed JSON string using the default context. - * - * @param object the object to serialize - * @return the pretty-printed JSON string representation of the object - * @throws IllegalArgumentException if the object cannot be serialized - */ - public static String toPrettyString(Object object) { - return toPrettyString(object, null); - } - - /** - * Serializes the given object to a CBOR-encoded byte array using the provided {@link JsonContext}. - * - * @param object the object to serialize - * @param context the serialization context, or {@code null} for default context - * @return the CBOR-encoded byte array representation of the object - * @throws IllegalArgumentException if the object cannot be serialized - */ - public static byte[] toBytes(Object object, JsonContext context) { - try { - if (context == null || context.isEmpty()) - return cborMapper().writeValueAsBytes(object); - else - return cborMapper().writer(context).writeValueAsBytes(object); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException("object can not be serialized", e); - } - } - - /** - * Serializes the given object to a CBOR-encoded byte array using the default context. - * - * @param object the object to serialize - * @return the CBOR-encoded byte array representation of the object - * @throws IllegalArgumentException if the object cannot be serialized - */ - public static byte[] toBytes(Object object) { - return toBytes(object, null); - } - - /** - * Returns a Jackson {@link TypeReference} for {@code Map} for generic map parsing. - * - * @return the {@code TypeReference} for {@code Map} - */ - public static TypeReference> mapType() { - if (_mapType == null) - _mapType = new TypeReference<>() { }; - - return _mapType; - } - - /** - * Parses a JSON string into a {@code Map} using the provided {@link JsonContext}. - * - * @param json the JSON string to parse - * @param context the deserialization context, or {@code null} for default context - * @return a map representation of the JSON input - * @throws IllegalArgumentException if the JSON cannot be parsed - */ - public static Map parse(String json, JsonContext context) { - return parse(json, mapType(), context); - } - - /** - * Parses a JSON string into a {@code Map} using the default context. - * - * @param json the JSON string to parse - * @return a map representation of the JSON input - * @throws IllegalArgumentException if the JSON cannot be parsed - */ - public static Map parse(String json) { - return parse(json, mapType(), null); - } - - /** - * Parses a JSON string into an object of the specified class using the provided {@link JsonContext}. - * - * @param json the JSON string to parse - * @param clazz the class of the object to return - * @param context the deserialization context, or {@code null} for default context - * @param the type of the desired object - * @return the parsed object - * @throws IllegalArgumentException if the JSON cannot be parsed - */ - public static T parse(String json, Class clazz, JsonContext context) { - try { - if (context == null || context.isEmpty()) - return objectMapper().readValue(json, clazz); - else - return objectMapper().reader(context).forType(clazz).readValue(json); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException("json can not be parsed", e); - } - } - - /** - * Parses a JSON string into an object of the specified class using the default context. - * - * @param json the JSON string to parse - * @param clazz the class of the object to return - * @param the type of the desired object - * @return the parsed object - * @throws IllegalArgumentException if the JSON cannot be parsed - */ - public static T parse(String json, Class clazz) { - return parse(json, clazz, null); - } - - /** - * Parses a JSON string into an object of the specified type using the provided {@link JsonContext}. - * - * @param json the JSON string to parse - * @param type the type reference describing the type to return - * @param context the deserialization context, or {@code null} for default context - * @param the type of the desired object - * @return the parsed object - * @throws IllegalArgumentException if the JSON cannot be parsed - */ - public static T parse(String json, TypeReference type, JsonContext context) { - try { - if (context == null || context.isEmpty()) - return objectMapper().readValue(json, type); - else - return objectMapper().reader(context).forType(type).readValue(json); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException("json can not be parsed", e); - } - } - - /** - * Parses a JSON string into an object of the specified type using the default context. - * - * @param json the JSON string to parse - * @param type the type reference describing the type to return - * @param the type of the desired object - * @return the parsed object - * @throws IllegalArgumentException if the JSON cannot be parsed - */ - public static T parse(String json, TypeReference type) { - return parse(json, type, null); - } - - /** - * Parses a CBOR-encoded byte array into a {@code Map} using the provided {@link JsonContext}. - * - * @param cbor the CBOR-encoded byte array to parse - * @param context the deserialization context, or {@code null} for default context - * @return a map representation of the CBOR input - * @throws IllegalArgumentException if the CBOR cannot be parsed - */ - public static Map parse(byte[] cbor, JsonContext context) { - return parse(cbor, mapType(), context); - } - - /** - * Parses a CBOR-encoded byte array into a {@code Map} using the default context. - * - * @param cbor the CBOR-encoded byte array to parse - * @return a map representation of the CBOR input - * @throws IllegalArgumentException if the CBOR cannot be parsed - */ - public static Map parse(byte[] cbor) { - return parse(cbor, mapType(), null); - } - - /** - * Parses a CBOR-encoded byte array into an object of the specified class using the provided {@link JsonContext}. - * - * @param cbor the CBOR-encoded byte array to parse - * @param clazz the class of the object to return - * @param context the deserialization context, or {@code null} for default context - * @param the type of the desired object - * @return the parsed object - * @throws IllegalArgumentException if the CBOR cannot be parsed - */ - public static T parse(byte[] cbor, Class clazz, JsonContext context) { - try { - if (context == null || context.isEmpty()) - return cborMapper().readValue(cbor, clazz); - else - return cborMapper().reader(context).forType(clazz).readValue(cbor); - } catch (IOException e) { - throw new IllegalArgumentException("cbor can not be parsed", e); - } - } - - /** - * Parses a CBOR-encoded byte array into an object of the specified class using the default context. - * - * @param cbor the CBOR-encoded byte array to parse - * @param clazz the class of the object to return - * @param the type of the desired object - * @return the parsed object - * @throws IllegalArgumentException if the CBOR cannot be parsed - */ - public static T parse(byte[] cbor, Class clazz) { - return parse(cbor, clazz, null); - } - - /** - * Parses a CBOR-encoded byte array into an object of the specified type using the provided {@link JsonContext}. - * - * @param cbor the CBOR-encoded byte array to parse - * @param type the type reference describing the type to return - * @param context the deserialization context, or {@code null} for default context - * @param the type of the desired object - * @return the parsed object - * @throws IllegalArgumentException if the CBOR cannot be parsed - */ - public static T parse(byte[] cbor, TypeReference type, JsonContext context) { - try { - if (context == null || context.isEmpty()) - return cborMapper().readValue(cbor, type); - else - return cborMapper().reader(context).forType(type).readValue(cbor); - } catch (IOException e) { - throw new IllegalArgumentException("cbor can not be parsed", e); - } - } - - /** - * Parses a CBOR-encoded byte array into an object of the specified type using the default context. - * - * @param cbor the CBOR-encoded byte array to parse - * @param type the type reference describing the type to return - * @param the type of the desired object - * @return the parsed object - * @throws IllegalArgumentException if the CBOR cannot be parsed - */ - public static T parse(byte[] cbor, TypeReference type) { - return parse(cbor, type, null); - } - - /** - * Initializes and registers the Boson JSON Jackson module with the global Jackson DatabindCodec mapper. - *

- * This method ensures the Boson JSON module is registered only once. If already registered, - * the method returns immediately. The module adds support for Boson-specific types and serialization behaviors. - */ - public static void initializeBosonJsonModule() { - if (DatabindCodec.mapper().getRegisteredModuleIds().stream() - .anyMatch(id -> id.equals(BOSON_JSON_MODULE_NAME))) - return; // already registered - - DatabindCodec.mapper().registerModule(bosonJsonModule()); - DatabindCodec.mapper().enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS); - } - - /** - * A context object for customizing JSON serialization and deserialization. - *

- * This class extends {@link ContextAttributes.Impl} and provides a convenient, immutable, - * and type-safe way to manage shared (global) and per-call (thread-local) attributes for Jackson operations. - *

- * Use static factory methods such as {@link #empty()}, {@link #perCall()}, and {@link #shared(Object, Object)} - * to construct a context instance with the desired attributes. Use instance methods to query or derive - * new contexts with added/removed attributes. - *

- * Shared attributes are visible to all serialization/deserialization operations that use this context, - * while per-call attributes are specific to a single operation or thread. - */ - public static class JsonContext extends ContextAttributes.Impl { - private static final long serialVersionUID = -385397772721358918L; - - /** - * Constructs a new {@code JsonContext} with the specified shared attributes and an empty per-call attribute map. - * - * @param shared the shared (global) attribute map - */ - protected JsonContext(Map shared) { - super(shared, new HashMap<>()); - } - - /** - * Constructs a new {@code JsonContext} with the specified shared and per-call (non-shared) attributes. - * - * @param shared the shared (global) attribute map - * @param nonShared the per-call (thread-local) attribute map - */ - protected JsonContext(Map shared, Map nonShared) { - super(shared, nonShared); - } - - /** - * Returns an empty {@code JsonContext} with no shared or per-call attributes. - * - * @return an empty context - */ - public static JsonContext empty() { - return new JsonContext(Collections.emptyMap(), Collections.emptyMap()); - } - - /** - * Returns a new {@code JsonContext} with no shared or per-call attributes, for use as a per-call context. - * - * @return a per-call context with no attributes - */ - public static JsonContext perCall() { - return new JsonContext(Collections.emptyMap(), Collections.emptyMap()); - } - - /** - * Returns a per-call {@code JsonContext} with the given attribute key and value. - * The attribute will only be visible to the current serialization/deserialization operation. - * - * @param key the attribute key - * @param value the attribute value - * @return a per-call context with the specified attribute - */ - public static JsonContext perCall(Object key, Object value) { - Map m = new HashMap<>(); - m.put(key, value); - return new JsonContext(Collections.emptyMap(), m); - } - - /** - * Returns a per-call {@code JsonContext} with two attribute key/value pairs. - * - * @param key1 the first attribute key - * @param value1 the first attribute value - * @param key2 the second attribute key - * @param value2 the second attribute value - * @return a per-call context with the specified attributes - */ - static JsonContext perCall(Object key1, Object value1, Object key2, Object value2) { - Map m = new HashMap<>(); - m.put(key1, value1); - m.put(key2, value2); - return new JsonContext(Collections.emptyMap(), m); - } - - /** - * Returns a per-call {@code JsonContext} with three attribute key/value pairs. - * - * @param key1 the first attribute key - * @param value1 the first attribute value - * @param key2 the second attribute key - * @param value2 the second attribute value - * @param key3 the third attribute key - * @param value3 the third attribute value - * @return a per-call context with the specified attributes - */ - static JsonContext perCall(Object key1, Object value1, Object key2, Object value2, Object key3, Object value3) { - Map m = new HashMap<>(); - m.put(key1, value1); - m.put(key2, value2); - m.put(key3, value3); - return new JsonContext(Collections.emptyMap(), m); - } - - /** - * Returns a new {@code JsonContext} with the given shared attribute key and value. - * Shared attributes are visible to all serialization/deserialization operations using this context. - * - * @param key the shared attribute key - * @param value the shared attribute value - * @return a context with the specified shared attribute - */ - static JsonContext shared(Object key, Object value) { - return new JsonContext(Map.of(key, value), Collections.emptyMap()); - } - - /** - * Returns a new {@code JsonContext} with two shared attribute key/value pairs. - * - * @param key1 the first shared attribute key - * @param value1 the first shared attribute value - * @param key2 the second shared attribute key - * @param value2 the second shared attribute value - * @return a context with the specified shared attributes - */ - static JsonContext shared(Object key1, Object value1, Object key2, Object value2) { - return new JsonContext(Map.of(key1, value1, key2, value2), Collections.emptyMap()); - } - - /** - * Returns a new {@code JsonContext} with three shared attribute key/value pairs. - * - * @param key1 the first shared attribute key - * @param value1 the first shared attribute value - * @param key2 the second shared attribute key - * @param value2 the second shared attribute value - * @param key3 the third shared attribute key - * @param value3 the third shared attribute value - * @return a context with the specified shared attributes - */ - static JsonContext shared(Object key1, Object value1, Object key2, Object value2, Object key3, Object value3) { - return new JsonContext(Map.of(key1, value1, key2, value2, key3, value3), Collections.emptyMap()); - } - - /** - * Returns the value of the attribute for the specified key. - * If the key is {@code JsonContext.class} or {@code ContextAttributes.class}, returns this context instance. - * Otherwise, delegates to the parent implementation. - * - * @param key the attribute key - * @return the attribute value, or {@code null} if not present - */ - @Override - public Object getAttribute(Object key) { - if (key == JsonContext.class || key == ContextAttributes.class) - return this; - - return super.getAttribute(key); - } - - /** - * Returns {@code true} if this context contains no shared or per-call attributes. - * - * @return {@code true} if empty; {@code false} otherwise - */ - public boolean isEmpty() { - return _shared.isEmpty() && _nonShared.isEmpty(); - } - - /** - * Returns a new {@code JsonContext} with the given shared attribute key and value, replacing any previous value. - * Shared attributes are visible to all operations using this context. - * - * @param key the shared attribute key - * @param value the shared attribute value - * @return a new context with the updated shared attribute - */ - @Override - public JsonContext withSharedAttribute(Object key, Object value) { - if (_shared.isEmpty()) { - return new JsonContext(Map.of(key, value)); - } else { - Map newShared = new HashMap<>(_shared); - newShared.put(key, value); - return new JsonContext(newShared); - } - } - - /** - * Returns a new {@code JsonContext} with the specified shared attributes, replacing all previous shared attributes. - * - * @param attributes the shared attributes to set (maybe {@code null} or empty) - * @return a new context with the specified shared attributes - */ - @Override - public JsonContext withSharedAttributes(Map attributes) { - return new JsonContext(attributes == null || attributes.isEmpty() ? Collections.emptyMap() : attributes); - } - - /** - * Returns a new {@code JsonContext} without the specified shared attribute key. - * If the key does not exist, returns this context instance. - * - * @param key the shared attribute key to remove - * @return a new context without the specified shared attribute - */ - @Override - public JsonContext withoutSharedAttribute(Object key) { - if (_shared.isEmpty() || !_shared.containsKey(key)) - return this; - - if (_shared.size() == 1) - return empty(); - - Map newShared = new HashMap<>(_shared); - newShared.remove(key); - return new JsonContext(newShared); - } - - /** - * Returns a new {@code JsonContext} with the given per-call (non-shared) attribute key and value. - * Per-call attributes are visible only to a single serialization/deserialization operation. - *

- * If {@code value} is {@code null}, and the key exists in shared attributes, - * a special null surrogate is used to mask the shared value. If the key does not exist in shared or per-call attributes, - * the context is returned unchanged. - * - * @param key the per-call attribute key - * @param value the per-call attribute value (maybe {@code null}, see behavior above) - * @return a new context with the updated per-call attribute - */ - @Override - public JsonContext withPerCallAttribute(Object key, Object value) { - // First: null value may need masking - if (value == null) { - // need to mask nulls to ensure default values won't be showing - if (_shared.containsKey(key)) { - value = NULL_SURROGATE; - } else if ((_nonShared == null) || !_nonShared.containsKey(key)) { - // except if an immutable shared list has no entry, we don't care - return this; - } else { - //noinspection RedundantCollectionOperation - if (_nonShared.containsKey(key)) // avoid exception on immutable map - _nonShared.remove(key); - return this; - } - } - - if (_nonShared == Collections.emptyMap()) - _nonShared = new HashMap<>(); - - _nonShared.put(key, value); - return this; - } - } -} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/web/AccessToken.java b/api/src/main/java/io/bosonnetwork/web/AccessToken.java index 13672ea..9d90f2d 100644 --- a/api/src/main/java/io/bosonnetwork/web/AccessToken.java +++ b/api/src/main/java/io/bosonnetwork/web/AccessToken.java @@ -31,7 +31,7 @@ import io.bosonnetwork.Id; import io.bosonnetwork.Identity; import io.bosonnetwork.crypto.Random; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; /** * Helper class for generating Compact Web Tokens (CWT) signed by an Identity. diff --git a/api/src/main/java/io/bosonnetwork/web/CompactWebTokenAuth.java b/api/src/main/java/io/bosonnetwork/web/CompactWebTokenAuth.java index 37c935f..3893dce 100644 --- a/api/src/main/java/io/bosonnetwork/web/CompactWebTokenAuth.java +++ b/api/src/main/java/io/bosonnetwork/web/CompactWebTokenAuth.java @@ -44,7 +44,7 @@ import io.bosonnetwork.service.ClientUser; import io.bosonnetwork.service.FederatedNode; import io.bosonnetwork.service.ServiceInfo; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; import io.bosonnetwork.utils.Pair; /** diff --git a/api/src/test/java/io/bosonnetwork/IdTests.java b/api/src/test/java/io/bosonnetwork/IdTests.java index 5318bc1..6c5f9b4 100644 --- a/api/src/test/java/io/bosonnetwork/IdTests.java +++ b/api/src/test/java/io/bosonnetwork/IdTests.java @@ -39,6 +39,7 @@ import io.bosonnetwork.utils.Base58; import io.bosonnetwork.utils.Hex; +import io.bosonnetwork.json.Json; public class IdTests { @Test @@ -523,4 +524,29 @@ void testToStringPerf() { id.toBase58String(); }); } + + @Test + void testJson() { + Id id = Id.random(); + String jsonValue = Json.toString(id); + assertEquals("\"" + id.toBase58String() + "\"", jsonValue); + + Id id1 = Json.parse(jsonValue, Id.class); + assertEquals(id, id1); + } + + @Test + void testCbor() { + Id id = Id.random(); + byte[] cborValue = Json.toBytes(id); + assertEquals(34, cborValue.length); + // byte string with a length defined by the subsequent 1-byte unsigned integer + assertEquals(0x58, cborValue[0]); + // length of the byte string + assertEquals(32, cborValue[1]); + assertArrayEquals(id.bytes(), Arrays.copyOfRange(cborValue, 2, 34)); + + Id id1 = Json.parse(cborValue, Id.class); + assertEquals(id, id1); + } } \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/NodeInfoTests.java b/api/src/test/java/io/bosonnetwork/NodeInfoTests.java new file mode 100644 index 0000000..8e4d144 --- /dev/null +++ b/api/src/test/java/io/bosonnetwork/NodeInfoTests.java @@ -0,0 +1,169 @@ +package io.bosonnetwork; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import io.bosonnetwork.json.Json; +import io.bosonnetwork.utils.Hex; + +public class NodeInfoTests { + @Test + void testConstructors() throws Exception { + Id id = Id.random(); + String host = "203.0.113.10"; + int port = 12345; + InetAddress addr = InetAddress.getByName(host); + InetSocketAddress socketAddr = new InetSocketAddress(addr, port); + + // Test constructor with InetSocketAddress + NodeInfo ni1 = new NodeInfo(id, socketAddr); + assertEquals(id, ni1.getId()); + assertEquals(socketAddr, ni1.getAddress()); + assertEquals(host, ni1.getHost()); + assertEquals(port, ni1.getPort()); + + // Test constructor with InetAddress and port + NodeInfo ni2 = new NodeInfo(id, addr, port); + assertEquals(id, ni2.getId()); + assertEquals(socketAddr, ni2.getAddress()); + + // Test constructor with host string and port + NodeInfo ni3 = new NodeInfo(id, host, port); + assertEquals(id, ni3.getId()); + assertEquals(socketAddr, ni3.getAddress()); + + // Test constructor with raw byte address and port + NodeInfo ni4 = new NodeInfo(id, addr.getAddress(), port); + assertEquals(id, ni4.getId()); + assertEquals(socketAddr, ni4.getAddress()); + + // Test copy constructor + ni1.setVersion(5); + NodeInfo ni5 = new NodeInfo(ni1); + assertEquals(ni1, ni5); + assertEquals(5, ni5.getVersion()); + } + + @Test + void testInvalidConstructors() { + Id id = Id.random(); + InetAddress addr = InetAddress.getLoopbackAddress(); + + assertThrows(IllegalArgumentException.class, () -> new NodeInfo(null, addr, 1234)); + assertThrows(IllegalArgumentException.class, () -> new NodeInfo(id, (InetAddress) null, 1234)); + assertThrows(IllegalArgumentException.class, () -> new NodeInfo(id, addr, 0)); + assertThrows(IllegalArgumentException.class, () -> new NodeInfo(id, addr, 65536)); + + assertThrows(IllegalArgumentException.class, () -> new NodeInfo(id, (String) null, 1234)); + assertThrows(IllegalArgumentException.class, () -> new NodeInfo(id, (byte[]) null, 1234)); + assertThrows(IllegalArgumentException.class, () -> new NodeInfo(id, new byte[3], 1234)); // Invalid IP length + } + + @Test + void testMatches() { + Id id1 = Id.random(); + Id id2 = Id.random(); + String host1 = "1.1.1.1"; + String host2 = "2.2.2.2"; + int port = 1234; + + NodeInfo ni1 = new NodeInfo(id1, host1, port); + NodeInfo ni2 = new NodeInfo(id1, host2, port); // Same ID, different addr + NodeInfo ni3 = new NodeInfo(id2, host1, port); // Different ID, same addr + NodeInfo ni4 = new NodeInfo(id2, host2, port); // Different ID and addr + + assertTrue(ni1.matches(ni2)); + assertTrue(ni1.matches(ni3)); + assertFalse(ni1.matches(ni4)); + assertFalse(ni1.matches(null)); + } + + @Test + void testEqualsAndHashCode() { + Id id = Id.random(); + String host = "203.0.113.10"; + int port = 1234; + + NodeInfo ni1 = new NodeInfo(id, host, port); + NodeInfo ni2 = new NodeInfo(id, host, port); + NodeInfo ni3 = new NodeInfo(Id.random(), host, port); + + assertEquals(ni1, ni2); + assertEquals(ni1.hashCode(), ni2.hashCode()); + assertNotEquals(ni1, ni3); + } + + @Test + void testJson() { + Id id = Id.random(); + NodeInfo ni = new NodeInfo(id, "203.0.113.10", 1234); + + String json = Json.toString(ni); + System.out.println(json); + System.out.println(Json.toPrettyString(ni)); + + NodeInfo ni2 = Json.parse(json, NodeInfo.class); + assertEquals(ni, ni2); + + String json2 = Json.toString(ni2); + assertEquals(json, json2); + } + + @Test + void testJsonWithHostName() { + Id id = Id.random(); + NodeInfo ni = new NodeInfo(id, "github.com", 1234); + + String json = Json.toString(ni); + System.out.println(json); + System.out.println(Json.toPrettyString(ni)); + + NodeInfo ni2 = Json.parse(json, NodeInfo.class); + assertEquals(ni, ni2); + + String json2 = Json.toString(ni2); + assertEquals(json, json2); + } + + @Test + void testCbor() { + Id id = Id.random(); + NodeInfo ni = new NodeInfo(id, "203.0.113.10", 1234); + + byte[] cbor = Json.toBytes(ni); + System.out.println(Hex.encode(cbor)); + System.out.println(Json.toPrettyString(Json.parse(cbor, List.class))); + + NodeInfo ni2 = Json.parse(cbor, NodeInfo.class); + assertEquals(ni, ni2); + + byte[] cbor2 = Json.toBytes(ni2); + assertArrayEquals(cbor, cbor2); + } + + @Test + void testCborWithUnresolvedHostName() { + Id id = Id.random(); + NodeInfo ni = new NodeInfo(id, "non-exists-host.com", 1234); + + byte[] cbor = Json.toBytes(ni); + System.out.println(Hex.encode(cbor)); + System.out.println(Json.toPrettyString(Json.parse(cbor, List.class))); + + NodeInfo ni2 = Json.parse(cbor, NodeInfo.class); + assertEquals(ni, ni2); + + byte[] cbor2 = Json.toBytes(ni2); + assertArrayEquals(cbor, cbor2); + } +} \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/PeerInfoTests.java b/api/src/test/java/io/bosonnetwork/PeerInfoTests.java new file mode 100644 index 0000000..f2cf6d6 --- /dev/null +++ b/api/src/test/java/io/bosonnetwork/PeerInfoTests.java @@ -0,0 +1,510 @@ +package io.bosonnetwork; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; + +import com.fasterxml.jackson.databind.exc.MismatchedInputException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import io.bosonnetwork.crypto.CryptoIdentity; +import io.bosonnetwork.crypto.Random; +import io.bosonnetwork.crypto.Signature; +import io.bosonnetwork.json.Json; +import io.bosonnetwork.json.JsonContext; +import io.bosonnetwork.utils.Hex; + +public class PeerInfoTests { + @Test + void testPeerInfo() { + String endpoint = "tcp://203.0.113.10:5678"; + PeerInfo peer = PeerInfo.builder() + .endpoint(endpoint) + .build(); + + assertNotNull(peer); + assertTrue(peer.hasPrivateKey()); + assertNotNull(peer.getPrivateKey()); + assertNotNull(peer.getId()); + assertNotNull(peer.getNonce()); + assertEquals(0, peer.getSequenceNumber()); + assertFalse(peer.isAuthenticated()); + assertNull(peer.getNodeId()); + assertNull(peer.getNodeSignature()); + assertEquals(0, peer.getSequenceNumber()); + assertEquals(endpoint, peer.getEndpoint()); + assertFalse(peer.hasExtra()); + assertNotNull(peer.getSignature()); + assertTrue(peer.isValid()); + + String endpoint1 = "tcp://172.16.31.10:9876"; + PeerInfo peer1 = peer.update(endpoint1); + + assertNotNull(peer1); + assertTrue(peer1.hasPrivateKey()); + assertNotNull(peer1.getPrivateKey()); + assertNotNull(peer1.getId()); + assertEquals(peer.getId(), peer1.getId()); + assertNotNull(peer1.getNonce()); + assertNotEquals(peer.getNonce(), peer1.getNonce()); + assertEquals(1, peer1.getSequenceNumber()); + assertFalse(peer1.isAuthenticated()); + assertNull(peer1.getNodeId()); + assertNull(peer1.getNodeSignature()); + assertEquals(0, peer.getSequenceNumber()); + assertEquals(endpoint1, peer1.getEndpoint()); + assertFalse(peer1.hasExtra()); + assertNotNull(peer1.getSignature()); + assertTrue(peer1.isValid()); + + String endpoint2 = "tcp://203.0.113.126:5678"; + PeerInfo peer2 = peer1.update(endpoint2); + + assertNotNull(peer2); + assertTrue(peer2.hasPrivateKey()); + assertNotNull(peer2.getPrivateKey()); + assertNotNull(peer2.getId()); + assertEquals(peer.getId(), peer2.getId()); + assertNotNull(peer2.getNonce()); + assertNotEquals(peer1.getNonce(), peer2.getNonce()); + assertEquals(2, peer2.getSequenceNumber()); + assertFalse(peer2.isAuthenticated()); + assertNull(peer2.getNodeId()); + assertNull(peer2.getNodeSignature()); + assertEquals(0, peer.getSequenceNumber()); + assertEquals(endpoint2, peer2.getEndpoint()); + assertFalse(peer2.hasExtra()); + assertNotNull(peer2.getSignature()); + assertTrue(peer2.isValid()); + + PeerInfo peer3 = peer2.update(endpoint2); + assertSame(peer2, peer3); + + PeerInfo peer4 = peer3.withoutPrivateKey(); + assertFalse(peer4.hasPrivateKey()); + assertEquals(peer3, peer4); + assertThrows(UnsupportedOperationException.class, () -> peer4.update("tcp://hostname:2345")); + + peer.getNonce()[0] = (byte) (peer.getNonce()[0] + 1); + assertFalse(peer.isValid()); + } + + @Test + void testPeerInfoWithExtraData() { + Map extra = new LinkedHashMap<>(); + extra.put("foo", "bar"); + extra.put("baz", 123); + extra.put("qux", true); + extra.put("quux", Random.randomBytes(64)); + String endpoint = "tcp://203.0.113.10:5678"; + PeerInfo peer = PeerInfo.builder() + .endpoint(endpoint) + .extra(extra) + .fingerprint(10) + .build(); + + assertNotNull(peer); + assertTrue(peer.hasPrivateKey()); + assertNotNull(peer.getPrivateKey()); + assertNotNull(peer.getId()); + assertNotNull(peer.getNonce()); + assertEquals(0, peer.getSequenceNumber()); + assertFalse(peer.isAuthenticated()); + assertNull(peer.getNodeId()); + assertNull(peer.getNodeSignature()); + assertEquals(10, peer.getFingerprint()); + assertEquals(endpoint, peer.getEndpoint()); + assertTrue(peer.hasExtra()); + assertArrayEquals(Json.toBytes(extra), peer.getExtraData()); + assertNotNull(peer.getSignature()); + assertTrue(peer.isValid()); + + Map extra1 = new LinkedHashMap<>(); + extra1.put("foo", "baz"); + extra1.put("qux", false); + String endpoint1 = "tcp://172.16.31.10:9876"; + PeerInfo peer1 = peer.update(endpoint1, extra1); + + assertNotNull(peer1); + assertTrue(peer1.hasPrivateKey()); + assertNotNull(peer1.getPrivateKey()); + assertNotNull(peer1.getId()); + assertEquals(peer.getId(), peer1.getId()); + assertNotNull(peer1.getNonce()); + assertNotEquals(peer.getNonce(), peer1.getNonce()); + assertEquals(1, peer1.getSequenceNumber()); + assertFalse(peer1.isAuthenticated()); + assertNull(peer1.getNodeId()); + assertNull(peer1.getNodeSignature()); + assertEquals(10, peer.getFingerprint()); + assertEquals(endpoint1, peer1.getEndpoint()); + assertTrue(peer1.hasExtra()); + assertEquals(extra1, peer1.getExtra()); + assertNotNull(peer1.getSignature()); + assertTrue(peer1.isValid()); + + byte[] extraData2 = Random.randomBytes(128); + String endpoint2 = "tcp://203.0.113.126:5678"; + PeerInfo peer2 = peer1.update(endpoint2, extraData2); + + assertNotNull(peer2); + assertTrue(peer2.hasPrivateKey()); + assertNotNull(peer2.getPrivateKey()); + assertNotNull(peer2.getId()); + assertEquals(peer.getId(), peer2.getId()); + assertNotNull(peer2.getNonce()); + assertNotEquals(peer1.getNonce(), peer2.getNonce()); + assertEquals(2, peer2.getSequenceNumber()); + assertFalse(peer2.isAuthenticated()); + assertNull(peer2.getNodeId()); + assertNull(peer2.getNodeSignature()); + assertEquals(10, peer.getFingerprint()); + assertEquals(endpoint2, peer2.getEndpoint()); + assertTrue(peer2.hasExtra()); + assertArrayEquals(extraData2, peer2.getExtraData()); + assertNotNull(peer2.getSignature()); + assertTrue(peer2.isValid()); + + PeerInfo peer3 = peer2.update(endpoint2, extraData2); + assertSame(peer2, peer3); + + PeerInfo peer4 = peer3.withoutPrivateKey(); + assertFalse(peer4.hasPrivateKey()); + assertEquals(peer3, peer4); + assertThrows(UnsupportedOperationException.class, () -> peer4.update("tcp://hostname:2345")); + + peer.getExtraData()[0] = (byte) (peer.getExtraData()[0] + 1); + assertFalse(peer.isValid()); + } + + @Test + void testAuthenticatedPeerInfo() { + Identity node = new CryptoIdentity(); + + String endpoint = "tcp://203.0.113.10:5678"; + PeerInfo peer = PeerInfo.builder() + .node(node) + .endpoint(endpoint) + .build(); + + assertNotNull(peer); + assertTrue(peer.hasPrivateKey()); + assertNotNull(peer.getPrivateKey()); + assertNotNull(peer.getId()); + assertNotNull(peer.getNonce()); + assertEquals(0, peer.getSequenceNumber()); + assertTrue(peer.isAuthenticated()); + assertEquals(node.getId(), peer.getNodeId()); + assertNotNull(peer.getNodeSignature()); + assertEquals(0, peer.getSequenceNumber()); + assertEquals(endpoint, peer.getEndpoint()); + assertFalse(peer.hasExtra()); + assertNotNull(peer.getSignature()); + assertTrue(peer.isValid()); + + String endpoint1 = "tcp://172.16.31.10:9876"; + PeerInfo peer1 = peer.update(node, endpoint1); + + assertNotNull(peer1); + assertTrue(peer1.hasPrivateKey()); + assertNotNull(peer1.getPrivateKey()); + assertNotNull(peer1.getId()); + assertEquals(peer.getId(), peer1.getId()); + assertNotNull(peer1.getNonce()); + assertNotEquals(peer.getNonce(), peer1.getNonce()); + assertEquals(1, peer1.getSequenceNumber()); + assertTrue(peer.isAuthenticated()); + assertEquals(node.getId(), peer.getNodeId()); + assertNotNull(peer.getNodeSignature()); + assertEquals(0, peer.getSequenceNumber()); + assertEquals(endpoint1, peer1.getEndpoint()); + assertFalse(peer1.hasExtra()); + assertNotNull(peer1.getSignature()); + assertTrue(peer1.isValid()); + + String endpoint2 = "tcp://203.0.113.126:5678"; + PeerInfo peer2 = peer1.update(node, endpoint2); + + assertNotNull(peer2); + assertTrue(peer2.hasPrivateKey()); + assertNotNull(peer2.getPrivateKey()); + assertNotNull(peer2.getId()); + assertEquals(peer.getId(), peer2.getId()); + assertNotNull(peer2.getNonce()); + assertNotEquals(peer1.getNonce(), peer2.getNonce()); + assertEquals(2, peer2.getSequenceNumber()); + assertTrue(peer.isAuthenticated()); + assertEquals(node.getId(), peer.getNodeId()); + assertNotNull(peer.getNodeSignature()); + assertEquals(0, peer.getSequenceNumber()); + assertEquals(endpoint2, peer2.getEndpoint()); + assertFalse(peer2.hasExtra()); + assertNotNull(peer2.getSignature()); + assertTrue(peer2.isValid()); + + PeerInfo peer3 = peer2.update(node, endpoint2); + assertSame(peer2, peer3); + + PeerInfo peer4 = peer3.withoutPrivateKey(); + assertFalse(peer4.hasPrivateKey()); + assertEquals(peer3, peer4); + assertThrows(UnsupportedOperationException.class, () -> peer4.update(node, "tcp://hostname:2345")); + assertThrows(UnsupportedOperationException.class, () -> peer3.update(endpoint2)); + assertThrows(IllegalArgumentException.class, () -> peer3.update(new CryptoIdentity(), endpoint2)); + + peer.getNonce()[0] = (byte) (peer.getNonce()[0] + 1); + assertFalse(peer.isValid()); + } + + @Test + void testAuthenticatedPeerInfoWithExtraData() { + Identity node = new CryptoIdentity(); + + Map extra = new LinkedHashMap<>(); + extra.put("foo", "bar"); + extra.put("baz", 123); + extra.put("qux", true); + extra.put("quux", Random.randomBytes(64)); + String endpoint = "tcp://203.0.113.10:5678"; + PeerInfo peer = PeerInfo.builder() + .node(node) + .fingerprint(-57) + .endpoint(endpoint) + .extra(extra) + .build(); + + assertNotNull(peer); + assertTrue(peer.hasPrivateKey()); + assertNotNull(peer.getPrivateKey()); + assertNotNull(peer.getId()); + assertNotNull(peer.getNonce()); + assertEquals(0, peer.getSequenceNumber()); + assertTrue(peer.isAuthenticated()); + assertEquals(node.getId(), peer.getNodeId()); + assertNotNull(peer.getNodeSignature()); + assertEquals(-57, peer.getFingerprint()); + assertEquals(endpoint, peer.getEndpoint()); + assertTrue(peer.hasExtra()); + assertArrayEquals(Json.toBytes(extra), peer.getExtraData()); + assertNotNull(peer.getSignature()); + assertTrue(peer.isValid()); + + Map extra1 = new LinkedHashMap<>(); + extra1.put("foo", "baz"); + extra1.put("qux", false); + String endpoint1 = "tcp://172.16.31.10:9876"; + PeerInfo peer1 = peer.update(node, endpoint1, extra1); + + assertNotNull(peer1); + assertTrue(peer1.hasPrivateKey()); + assertNotNull(peer1.getPrivateKey()); + assertNotNull(peer1.getId()); + assertEquals(peer.getId(), peer1.getId()); + assertNotNull(peer1.getNonce()); + assertNotEquals(peer.getNonce(), peer1.getNonce()); + assertEquals(1, peer1.getSequenceNumber()); + assertTrue(peer.isAuthenticated()); + assertEquals(node.getId(), peer.getNodeId()); + assertNotNull(peer.getNodeSignature()); + assertEquals(-57, peer.getFingerprint()); + assertEquals(endpoint1, peer1.getEndpoint()); + assertTrue(peer1.hasExtra()); + assertEquals(extra1, peer1.getExtra()); + assertNotNull(peer1.getSignature()); + assertTrue(peer1.isValid()); + + byte[] extraData2 = Random.randomBytes(128); + String endpoint2 = "tcp://203.0.113.126:5678"; + PeerInfo peer2 = peer1.update(node, endpoint2, extraData2); + + assertNotNull(peer2); + assertTrue(peer2.hasPrivateKey()); + assertNotNull(peer2.getPrivateKey()); + assertNotNull(peer2.getId()); + assertEquals(peer.getId(), peer2.getId()); + assertNotNull(peer2.getNonce()); + assertNotEquals(peer1.getNonce(), peer2.getNonce()); + assertEquals(2, peer2.getSequenceNumber()); + assertTrue(peer.isAuthenticated()); + assertEquals(node.getId(), peer.getNodeId()); + assertNotNull(peer.getNodeSignature()); + assertEquals(-57, peer.getFingerprint()); + assertEquals(endpoint2, peer2.getEndpoint()); + assertTrue(peer2.hasExtra()); + assertArrayEquals(extraData2, peer2.getExtraData()); + assertNotNull(peer2.getSignature()); + assertTrue(peer2.isValid()); + + PeerInfo peer3 = peer2.update(node, endpoint2, extraData2); + assertSame(peer2, peer3); + + PeerInfo peer4 = peer3.withoutPrivateKey(); + assertFalse(peer4.hasPrivateKey()); + assertEquals(peer3, peer4); + assertThrows(UnsupportedOperationException.class, () -> peer4.update(node,"tcp://hostname:2345")); + assertThrows(UnsupportedOperationException.class, () -> peer3.update(endpoint2)); + assertThrows(IllegalArgumentException.class, () -> peer3.update(new CryptoIdentity(), endpoint2)); + + peer.getExtraData()[0] = (byte) (peer.getExtraData()[0] + 1); + assertFalse(peer.isValid()); + } + + @Test + void testInvalidPeerInfo() { + Id peerId = Id.random(); + byte[] nonce = Random.randomBytes(PeerInfo.NONCE_BYTES); + byte[] sig = Random.randomBytes(Signature.BYTES); + + // Invalid sequence number + assertThrows(IllegalArgumentException.class, () -> PeerInfo.of(peerId, nonce, -1, null, null, sig, 0, "uri", null)); + + // NodeId without NodeSig + assertThrows(IllegalArgumentException.class, () -> PeerInfo.of(peerId, nonce, 0, Id.random(), null, sig, 1, "uri", null)); + + // NodeSig without NodeId + assertThrows(IllegalArgumentException.class, () -> PeerInfo.of(peerId, nonce, 0, null, sig, sig, 2, "uri", null)); + } + + @Test + void testEqualsAndHashCode() { + PeerInfo p1 = PeerInfo.builder().endpoint("tcp://203.0.113.126:5678").build(); + PeerInfo p2 = p1.withoutPrivateKey(); + PeerInfo p3 = PeerInfo.builder().endpoint("tcp://203.0.113.126:5678").build(); + + assertEquals(p1, p2); + assertEquals(p1.hashCode(), p2.hashCode()); + assertNotEquals(p1, p3); // different keys and nonce + } + + @ParameterizedTest + @ValueSource(strings = {"simple", "simple+omitted", "simple+extra", "simple+extra+omitted", + "authenticated", "authenticated+omitted", "authenticated+extra", "authenticated+extra+omitted"}) + void testJson(String mode) throws Exception { + Identity nodeIdentity = new CryptoIdentity(); + Signature.KeyPair keypair = Signature.KeyPair.random(); + Id peerId = Id.of(keypair.publicKey().bytes()); + Map extra = Map.of("foo", 123, "bar", "hello world"); + + PeerInfo pi = switch (mode) { + case "simple", "simple+omitted" -> PeerInfo.builder() + .key(keypair) + .sequenceNumber(6) + .fingerprint(1000) + .endpoint("tcp://203.0.113.10:3456") + .build(); + case "simple+extra", "simple+extra+omitted" -> PeerInfo.builder() + .key(keypair) + .sequenceNumber(7) + .endpoint("tcp://203.0.113.10:3456") + .extra(extra) + .build(); + case "authenticated", "authenticated+omitted" -> PeerInfo.builder() + .key(keypair) + .node(nodeIdentity) + .sequenceNumber(8) + .endpoint("tcp://203.0.113.10:3456") + .build(); + case "authenticated+extra", "authenticated+extra+omitted" -> PeerInfo.builder() + .key(keypair) + .node(nodeIdentity) + .fingerprint(-1234) + .sequenceNumber(9) + .endpoint("tcp://203.0.113.10:3456") + .extra(extra) + .build(); + default -> throw new AssertionError("Unknown mode: " + mode); + }; + + boolean omitted = mode.endsWith("+omitted"); + JsonContext serializeContext = omitted ? JsonContext.perCall(PeerInfo.ATTRIBUTE_OMIT_PEER_ID, true) : null; + JsonContext deserializeContext = omitted ? JsonContext.perCall(PeerInfo.ATTRIBUTE_PEER_ID, peerId) : null; + + String json = Json.toString(pi, serializeContext); + System.out.println(json); + System.out.println(Json.toPrettyString(pi, serializeContext)); + + PeerInfo pi2 = Json.parse(json, PeerInfo.class, deserializeContext); + assertEquals(pi, pi2); + String json2 = Json.toString(pi2, serializeContext); + assertEquals(json, json2); + + if (omitted) { + Exception e = assertThrows(MismatchedInputException.class, () -> { + Json.objectMapper().readValue(json, PeerInfo.class); + }); + assertTrue(e.getMessage().startsWith("Invalid PeerInfo: peer id can not be null")); + } + } + + @ParameterizedTest + @ValueSource(strings = {"simple", "simple+omitted", "simple+extra", "simple+extra+omitted", + "authenticated", "authenticated+omitted", "authenticated+extra", "authenticated+extra+omitted"}) + void testCbor(String mode) throws Exception { + Identity nodeIdentity = new CryptoIdentity(); + Signature.KeyPair keypair = Signature.KeyPair.random(); + Id peerId = Id.of(keypair.publicKey().bytes()); + Map extra = Map.of("foo", 123, "bar", "hello world"); + + PeerInfo pi = switch (mode) { + case "simple", "simple+omitted" -> PeerInfo.builder() + .key(keypair) + .sequenceNumber(6) + .fingerprint(1000) + .endpoint("tcp://203.0.113.10:3456") + .build(); + case "simple+extra", "simple+extra+omitted" -> PeerInfo.builder() + .key(keypair) + .sequenceNumber(7) + .endpoint("tcp://203.0.113.10:3456") + .extra(extra) + .build(); + case "authenticated", "authenticated+omitted" -> PeerInfo.builder() + .key(keypair) + .node(nodeIdentity) + .sequenceNumber(8) + .endpoint("tcp://203.0.113.10:3456") + .build(); + case "authenticated+extra", "authenticated+extra+omitted" -> PeerInfo.builder() + .key(keypair) + .node(nodeIdentity) + .fingerprint(-1234) + .sequenceNumber(9) + .endpoint("tcp://203.0.113.10:3456") + .extra(extra) + .build(); + default -> throw new AssertionError("Unknown mode: " + mode); + }; + + boolean omitted = mode.endsWith("+omitted"); + JsonContext serializeContext = omitted ? JsonContext.perCall(PeerInfo.ATTRIBUTE_OMIT_PEER_ID, true) : null; + JsonContext deserializeContext = omitted ? JsonContext.perCall(PeerInfo.ATTRIBUTE_PEER_ID, peerId) : null; + + byte[] cbor = Json.toBytes(pi, serializeContext); + System.out.println(Hex.encode(cbor)); + System.out.println(Json.toPrettyString(Json.parse(cbor))); + + PeerInfo pi2 = Json.parse(cbor, PeerInfo.class, deserializeContext); + assertEquals(pi, pi2); + byte[] cbor2 = Json.toBytes(pi2, serializeContext); + assertArrayEquals(cbor, cbor2); + + if (omitted) { + Exception e = assertThrows(MismatchedInputException.class, () -> { + Json.cborMapper().readValue(cbor, PeerInfo.class); + }); + assertTrue(e.getMessage().startsWith("Invalid PeerInfo: peer id can not be null")); + } + } +} \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/ValueTests.java b/api/src/test/java/io/bosonnetwork/ValueTests.java new file mode 100644 index 0000000..ca65329 --- /dev/null +++ b/api/src/test/java/io/bosonnetwork/ValueTests.java @@ -0,0 +1,290 @@ +package io.bosonnetwork; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import io.bosonnetwork.crypto.Hash; +import io.bosonnetwork.crypto.Random; +import io.bosonnetwork.crypto.Signature; +import io.bosonnetwork.json.Json; +import io.bosonnetwork.utils.Hex; + +public class ValueTests { + @Test + void testImmutableValue() { + byte[] data = "Hello Boson".getBytes(); + Value value = Value.builder().data(data).build(); + + assertNotNull(value); + assertNotNull(value.getId()); + assertArrayEquals(Hash.sha256(data), value.getId().getBytes()); + assertFalse(value.isMutable()); + assertFalse(value.isEncrypted()); + assertNull(value.getPublicKey()); + assertNull(value.getRecipient()); + assertNull(value.getNonce()); + assertEquals(0, value.getSequenceNumber()); + assertNull(value.getSignature()); + assertArrayEquals(data, value.getData()); + assertTrue(value.isValid()); + + assertThrows(UnsupportedOperationException.class, () -> value.update(data)); + + value.getData()[0] = (byte) (value.getData()[0] + 1); + assertFalse(value.isValid()); + + Value value2 = value.withoutPrivateKey(); + assertSame(value, value2); + } + + @Test + void testSignedValue() { + byte[] data = "Mutable data".getBytes(); + Value value = Value.builder().data(data).buildSigned(); + + assertNotNull(value); + assertTrue(value.hasPrivateKey()); + assertTrue(value.isMutable()); + assertFalse(value.isEncrypted()); + assertNotNull(value.getPublicKey()); + assertEquals(value.getPublicKey(), value.getId()); + assertNull(value.getRecipient()); + assertNotNull(value.getNonce()); + assertEquals(0, value.getSequenceNumber()); + assertNotNull(value.getSignature()); + assertArrayEquals(data, value.getData()); + assertTrue(value.isValid()); + + byte[] data1 = "Updated mutable data".getBytes(); + Value value1 = value.update(data1); + + assertNotNull(value1); + assertTrue(value1.hasPrivateKey()); + assertEquals(value.getId(), value1.getId()); + assertTrue(value1.isMutable()); + assertFalse(value1.isEncrypted()); + assertEquals(value.getPublicKey(), value1.getPublicKey()); + assertNull(value1.getRecipient()); + assertNotNull(value1.getNonce()); + assertFalse(Arrays.equals(value.getNonce(), value1.getNonce())); + assertEquals(1, value1.getSequenceNumber()); + assertNotNull(value1.getSignature()); + assertArrayEquals(data1, value1.getData()); + assertTrue(value1.isValid()); + + byte[] data2 = "Updated mutable data 2".getBytes(); + Value value2 = value1.update(data2); + + assertNotNull(value2); + assertTrue(value2.hasPrivateKey()); + assertEquals(value.getId(), value2.getId()); + assertTrue(value2.isMutable()); + assertFalse(value2.isEncrypted()); + assertEquals(value.getPublicKey(), value2.getPublicKey()); + assertNull(value2.getRecipient()); + assertNotNull(value.getNonce()); + assertFalse(Arrays.equals(value1.getNonce(), value2.getNonce())); + assertEquals(2, value2.getSequenceNumber()); + assertNotNull(value2.getSignature()); + assertArrayEquals(data2, value2.getData()); + assertTrue(value2.isValid()); + + Value value3 = value2.update(data2); + assertSame(value2, value3); + + Value value4 = value3.withoutPrivateKey(); + assertFalse(value4.hasPrivateKey()); + assertEquals(value3, value4); + assertThrows(UnsupportedOperationException.class, () -> value4.update("should be failed".getBytes())); + assertThrows(UnsupportedOperationException.class, () -> value4.decryptData(Signature.KeyPair.random().privateKey())); + + value.getData()[0] = (byte) (value.getData()[0] + 1); + assertFalse(value.isValid()); + } + + @Test + void testEncryptedValue() throws Exception { + byte[] data = "Secret message".getBytes(); + Signature.KeyPair recipientKp = Signature.KeyPair.random(); + Id recipient = Id.of(recipientKp.publicKey().bytes()); + + Value value = Value.builder().recipient(recipient).data(data).buildEncrypted(); + + assertNotNull(value); + assertTrue(value.isMutable()); + assertTrue(value.isEncrypted()); + assertNotNull(value.getPublicKey()); + assertEquals(value.getPublicKey(), value.getId()); + assertEquals(recipient, value.getRecipient()); + assertNotNull(value.getNonce()); + assertEquals(0, value.getSequenceNumber()); + assertNotNull(value.getSignature()); + assertFalse(Arrays.equals(data, value.getData())); // Data should be encrypted + assertTrue(value.isValid()); + + byte[] decrypted = value.decryptData(recipientKp.privateKey()); + assertArrayEquals(data, decrypted); + + byte[] data1 = "Updated secret message".getBytes(); + Value value1 = value.update(data1); + + assertNotNull(value1); + assertTrue(value1.hasPrivateKey()); + assertEquals(value.getId(), value1.getId()); + assertTrue(value1.isMutable()); + assertTrue(value1.isEncrypted()); + assertEquals(value.getPublicKey(), value1.getPublicKey()); + assertEquals(recipient, value1.getRecipient()); + assertNotNull(value1.getNonce()); + assertFalse(Arrays.equals(value.getNonce(), value1.getNonce())); + assertEquals(1, value1.getSequenceNumber()); + assertNotNull(value1.getSignature()); + assertFalse(Arrays.equals(data1, value1.getData())); // Data should be encrypted + assertTrue(value1.isValid()); + + decrypted = value1.decryptData(recipientKp.privateKey()); + assertArrayEquals(data1, decrypted); + + byte[] data2 = "Updated secret message 2".getBytes(); + Value value2 = value1.update(data2); + + assertNotNull(value2); + assertTrue(value2.hasPrivateKey()); + assertEquals(value.getId(), value2.getId()); + assertTrue(value2.isMutable()); + assertTrue(value2.isEncrypted()); + assertEquals(value.getPublicKey(), value2.getPublicKey()); + assertEquals(recipient, value1.getRecipient()); + assertNotNull(value.getNonce()); + assertFalse(Arrays.equals(value1.getNonce(), value2.getNonce())); + assertEquals(2, value2.getSequenceNumber()); + assertNotNull(value2.getSignature()); + assertFalse(Arrays.equals(data2, value2.getData())); // Data should be encrypted + assertTrue(value2.isValid()); + + decrypted = value2.decryptData(recipientKp.privateKey()); + assertArrayEquals(data2, decrypted); + + Value value3 = value2.update(data2); + assertNotSame(value2, value3); + assertNotEquals(value2, value3); + assertFalse(Arrays.equals(value2.getNonce(), value3.getNonce())); + assertEquals(3, value3.getSequenceNumber()); + + decrypted = value3.decryptData(recipientKp.privateKey()); + assertArrayEquals(data2, decrypted); + + Value value4 = value3.withoutPrivateKey(); + assertFalse(value4.hasPrivateKey()); + assertEquals(value3, value4); + assertThrows(UnsupportedOperationException.class, () -> value4.update("should be failed".getBytes())); + assertThrows(IllegalArgumentException.class, () -> value4.decryptData(Signature.KeyPair.random().privateKey())); + + value4.getData()[0] = (byte) (value4.getData()[0] + 1); + assertFalse(value4.isValid()); + assertThrows(IllegalStateException.class, () -> value4.decryptData(recipientKp.privateKey())); + } + + @Test + void testInvalidValue() { + assertFalse(Value.of(Id.random(), "data".getBytes()).isValid()); + + byte[] data = "data".getBytes(); + Id pk = Id.random(); + byte[] nonce = Random.randomBytes(Value.NONCE_BYTES); + byte[] sig = Random.randomBytes(Signature.BYTES); + + // Invalid sequence number + assertThrows(IllegalArgumentException.class, () -> Value.of(pk, null, nonce, -1, sig, data)); + + // Invalid nonce length + assertThrows(IllegalArgumentException.class, () -> Value.of(pk, null, new byte[10], 0, sig, data)); + + // Invalid signature length + assertThrows(IllegalArgumentException.class, () -> Value.of(pk, null, nonce, 0, new byte[10], data)); + } + + @Test + void testEqualsAndHashCode() { + byte[] data = "data".getBytes(); + Value v1 = Value.builder().data(data).build(); + Value v2 = Value.builder().data(data).build(); + Value v3 = Value.builder().data(data).buildSigned(); + Value v4 = Value.builder().data(data).buildSigned(); + + assertEquals(v1, v2); + assertEquals(v1.hashCode(), v2.hashCode()); + assertNotEquals(v3, v4); + assertNotEquals(v1, v3); + } + + @ParameterizedTest + @ValueSource(strings = {"immutable", "signed", "encrypted"}) + void testJson(String mode) throws Exception { + Value v = switch (mode) { + case "immutable" -> Value.builder() + .data("Hello from bosonnetwork!\n".repeat(10).getBytes()) + .build(); + case "signed" -> Value.builder() + .data("Hello from bosonnetwork!\n".repeat(10).getBytes()) + .buildSigned(); + case "encrypted" -> Value.builder() + .recipient(Id.of(Signature.KeyPair.random().publicKey().bytes())) + .data("Hello from bosonnetwork!\n".repeat(10).getBytes()) + .buildEncrypted(); + default -> throw new AssertionError("Unknown mode: " + mode); + }; + + String json = Json.toString(v); + System.out.println(json); + System.out.println(Json.toPrettyString(v)); + + Value v2 = Json.parse(json, Value.class); + assertEquals(v, v2); + + String json2 = Json.toString(v2); + assertEquals(json, json2); + } + + @ParameterizedTest + @ValueSource(strings = {"immutable", "signed", "encrypted"}) + void testCbor(String mode) throws Exception { + Value v = switch (mode) { + case "immutable" -> Value.builder() + .data("Hello from bosonnetwork!\n".repeat(10).getBytes()) + .build(); + case "signed" -> Value.builder() + .data("Hello from bosonnetwork!\n".repeat(10).getBytes()) + .buildSigned(); + case "encrypted" -> Value.builder() + .recipient(Id.of(Signature.KeyPair.random().publicKey().bytes())) + .data("Hello from bosonnetwork!\n".repeat(10).getBytes()) + .buildEncrypted(); + default -> throw new AssertionError("Unknown mode: " + mode); + }; + + byte[] cbor = Json.toBytes(v); + System.out.println(Hex.encode(cbor)); + System.out.println(Json.toPrettyString(Json.parse(cbor))); + + Value v2 = Json.parse(cbor, Value.class); + assertEquals(v, v2); + + byte[] cbor2 = Json.toBytes(v2); + assertArrayEquals(cbor, cbor2); + } +} \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/database/FilterTests.java b/api/src/test/java/io/bosonnetwork/database/FilterTests.java index c59ce78..1092fb0 100644 --- a/api/src/test/java/io/bosonnetwork/database/FilterTests.java +++ b/api/src/test/java/io/bosonnetwork/database/FilterTests.java @@ -8,7 +8,7 @@ import org.junit.jupiter.api.Test; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; public class FilterTests { @Test diff --git a/api/src/test/java/io/bosonnetwork/database/VersionedSchemaTests.java b/api/src/test/java/io/bosonnetwork/database/VersionedSchemaTests.java index de7329f..21ba024 100644 --- a/api/src/test/java/io/bosonnetwork/database/VersionedSchemaTests.java +++ b/api/src/test/java/io/bosonnetwork/database/VersionedSchemaTests.java @@ -121,6 +121,16 @@ void testMigrate(String name, SqlClient client, Vertx vertx, VertxTestContext co @Test @Order(2) void testMigrateWithSchemaFoo(Vertx vertx, VertxTestContext context) { + if (pgServer == null) { + System.err.println("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"); + System.err.println("Check your Docker installation."); + System.err.println("Skipping Postgres tests."); + System.err.println("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"); + + context.completeNow(); + return; + } + Path migrationPath = Path.of(getClass().getResource("/db/schema_test/postgres").getPath()); VersionedSchema schema = VersionedSchema.init(vertx, postgres, "foo", migrationPath); @@ -138,6 +148,16 @@ void testMigrateWithSchemaFoo(Vertx vertx, VertxTestContext context) { @Test @Order(3) void testMigrateWithSchemaBar(Vertx vertx, VertxTestContext context) { + if (pgServer == null) { + System.err.println("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"); + System.err.println("Check your Docker installation."); + System.err.println("Skipping Postgres tests."); + System.err.println("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"); + + context.completeNow(); + return; + } + Path migrationPath = Path.of(getClass().getResource("/db/schema_test/postgres").getPath()); VersionedSchema schema = VersionedSchema.init(vertx, postgres, "bar", migrationPath); diff --git a/api/src/test/java/io/bosonnetwork/identifier/DHTRegistryTest.java b/api/src/test/java/io/bosonnetwork/identifier/DHTRegistryTest.java index f134a8b..e3681a1 100644 --- a/api/src/test/java/io/bosonnetwork/identifier/DHTRegistryTest.java +++ b/api/src/test/java/io/bosonnetwork/identifier/DHTRegistryTest.java @@ -35,6 +35,7 @@ import io.bosonnetwork.crypto.CryptoBox; import io.bosonnetwork.crypto.CryptoIdentity; import io.bosonnetwork.crypto.Hash; +import io.bosonnetwork.vertx.VertxFuture; @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class DHTRegistryTest { @@ -85,12 +86,12 @@ public CompletableFuture bootstrap(Collection bootstrapNodes){ @Override public CompletableFuture start() { - return CompletableFuture.completedFuture(null); + return VertxFuture.succeededFuture(); } @Override public CompletableFuture stop() { - return CompletableFuture.completedFuture(null); + return VertxFuture.succeededFuture(); } @Override @@ -130,7 +131,7 @@ public CompletableFuture> findNode(Id id, LookupOption option) @Override public CompletableFuture findValue(Id id, int expectedSequenceNumber, LookupOption option) { - return values.get(id) == null ? CompletableFuture.completedFuture(null) : CompletableFuture.completedFuture(values.get(id)); + return VertxFuture.succeededFuture(values.get(id)); } @Override @@ -150,33 +151,43 @@ public CompletableFuture storeValue(Value value, int expectedSequenceNumbe } @Override - public CompletableFuture> findPeer(Id id, int expected, LookupOption option) { - return null; + public CompletableFuture> findPeer(Id id, int expectedSequenceNumber, int expectedCount, LookupOption option) { + return VertxFuture.succeededFuture(List.of()); } @Override - public CompletableFuture announcePeer(PeerInfo peer, boolean persistent) { - return null; + public CompletableFuture announcePeer(PeerInfo peer, int expectedSequenceNumber, boolean persistent) { + return VertxFuture.failedFuture(new UnsupportedOperationException()); } @Override - public CompletableFuture getValue(Id valueId) { - return null; + public CompletableFuture getValue(Id id) { + return VertxFuture.succeededFuture(values.get(id)); } @Override public CompletableFuture removeValue(Id valueId) { - return null; + return VertxFuture.succeededFuture(values.remove(valueId) != null); } @Override - public CompletableFuture getPeer(Id peerId) { + public CompletableFuture> getPeers(Id peerId) { return null; } @Override - public CompletableFuture removePeer(Id peerId) { - return null; + public CompletableFuture removePeers(Id peerId) { + return VertxFuture.succeededFuture(false); + } + + @Override + public CompletableFuture getPeer(Id peerId, long fingerprint) { + return VertxFuture.succeededFuture(null); + } + + @Override + public CompletableFuture removePeer(Id peerId, long fingerprint) { + return VertxFuture.succeededFuture(false); } @Override diff --git a/api/src/test/java/io/bosonnetwork/identifier/ProofTests.java b/api/src/test/java/io/bosonnetwork/identifier/ProofTests.java index b1339e4..24318c8 100644 --- a/api/src/test/java/io/bosonnetwork/identifier/ProofTests.java +++ b/api/src/test/java/io/bosonnetwork/identifier/ProofTests.java @@ -28,7 +28,7 @@ import org.junit.jupiter.api.Test; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; public class ProofTests { @Test diff --git a/api/src/test/java/io/bosonnetwork/identifier/VerificationMethodTests.java b/api/src/test/java/io/bosonnetwork/identifier/VerificationMethodTests.java index 4f5378c..ff02157 100644 --- a/api/src/test/java/io/bosonnetwork/identifier/VerificationMethodTests.java +++ b/api/src/test/java/io/bosonnetwork/identifier/VerificationMethodTests.java @@ -31,7 +31,7 @@ import org.junit.jupiter.api.Test; import io.bosonnetwork.Id; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; public class VerificationMethodTests { @Test diff --git a/api/src/test/java/io/bosonnetwork/utils/JsonTests.java b/api/src/test/java/io/bosonnetwork/json/JsonTests.java similarity index 63% rename from api/src/test/java/io/bosonnetwork/utils/JsonTests.java rename to api/src/test/java/io/bosonnetwork/json/JsonTests.java index da0f3d5..01ff826 100644 --- a/api/src/test/java/io/bosonnetwork/utils/JsonTests.java +++ b/api/src/test/java/io/bosonnetwork/json/JsonTests.java @@ -1,10 +1,8 @@ -package io.bosonnetwork.utils; +package io.bosonnetwork.json; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; import java.net.InetAddress; import java.nio.ByteBuffer; @@ -18,44 +16,14 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.exc.MismatchedInputException; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; import io.bosonnetwork.Id; -import io.bosonnetwork.NodeInfo; -import io.bosonnetwork.PeerInfo; -import io.bosonnetwork.Value; -import io.bosonnetwork.crypto.Signature; - -// Functional tests for io.bosonnetwork.utils.Json -// Extra: -// - performance benchmarks: DataBind/ObjectMapper vs. streaming API -public class JsonTests { - private static final int TIMING_ITERATIONS = 1_000_000; - - @Test - void idTest() { - var id = Id.random(); - - var str = Json.toString(id); - System.out.println(str); - assertEquals("\"" +id.toBase58String() + "\"", str); - - var id2 = Json.parse(str, Id.class); - assertEquals(id, id2); - - var bytes = Json.toBytes(id); - System.out.println(Hex.encode(bytes)); - assertEquals(Id.BYTES + 2, bytes.length); - assertArrayEquals(id.bytes(), Arrays.copyOfRange(bytes, 2, bytes.length)); - - id2 = Json.parse(bytes, Id.class); - assertEquals(id, id2); - } +import io.bosonnetwork.json.internal.DateFormat; +import io.bosonnetwork.utils.Hex; +public class JsonTests { @Test void inetAddressV4Test() throws Exception { var addr = InetAddress.getByName("192.168.8.8"); @@ -263,7 +231,7 @@ void mapWithBinaryTest() throws Exception { assertEquals(map.size(), map2.size()); assertEquals(Base64.getUrlEncoder().withoutPadding().encodeToString(bytes), map2.get("bytes")); - assertEquals(Json.getDateFormat().format(now), map2.get("date")); + assertEquals(DateFormat.getDefault().format(now), map2.get("date")); assertEquals(id.toString(), map2.get("id")); assertEquals(ip4.getHostAddress(), map2.get("ip4")); assertEquals(ip6.getHostAddress(), map2.get("ip6")); @@ -283,92 +251,4 @@ void mapWithBinaryTest() throws Exception { assertEquals(true, map3.get("bool")); assertEquals(Arrays.asList(1, 2, 3), map3.get("array")); } - - @Test - void nodeInfoTest() throws Exception { - var ni = new NodeInfo(Id.random(), "10.0.8.8", 2345); - var s = Json.toString(ni); - System.out.println(s); - System.out.println(Json.toPrettyString(ni)); - - var ni2 = Json.parse(s, NodeInfo.class); - assertEquals(ni, ni2); - - var b = Json.toBytes(ni); - System.out.println(Hex.encode(b)); - - ni2 = Json.parse(b, NodeInfo.class); - assertEquals(ni, ni2); - } - - @ParameterizedTest - @ValueSource(strings = {"simple", "simple+omitted", "simple+url", "simple+url+omitted", - "delegated", "delegated+omitted", "delegated+url", "delegated+url+omitted"}) - void peerInfoTest(String mode) throws Exception { - var keypair = Signature.KeyPair.random(); - var peerId = Id.of(keypair.publicKey().bytes()); - var pi = switch (mode) { - case "simple", "simple+omitted" -> PeerInfo.create(keypair, Id.random(), 3456); - case "simple+url", "simple+url+omitted" -> PeerInfo.create(keypair, Id.random(), 3456, "https://echo.bns.io/"); - case "delegated", "delegated+omitted" -> PeerInfo.create(keypair, Id.random(), Id.random(), 3456); - case "delegated+url", "delegated+url+omitted" -> PeerInfo.create(keypair, Id.random(), Id.random(), 3456, "https://echo.bns.io/"); - default -> throw new AssertionError("Unknown mode: " + mode); - }; - - var omitted = mode.endsWith("+omitted"); - var serializeContext = omitted ? Json.JsonContext.perCall(PeerInfo.ATTRIBUTE_OMIT_PEER_ID, true) : null; - var deserializeContext = omitted ? Json.JsonContext.perCall(PeerInfo.ATTRIBUTE_PEER_ID, peerId) : null; - - var s = Json.toString(pi, serializeContext); - System.out.println(s); - System.out.println(Json.toPrettyString(pi, serializeContext)); - - var pi2 = Json.parse(s, PeerInfo.class, deserializeContext); - assertEquals(pi, pi2); - - if (omitted) { - var e = assertThrows(MismatchedInputException.class, () -> { - Json.objectMapper().readValue(s, PeerInfo.class); - }); - assertTrue(e.getMessage().startsWith("Invalid PeerInfo: peer id can not be null")); - } - - var b = Json.toBytes(pi, serializeContext); - System.out.println(Hex.encode(b)); - - pi2 = Json.parse(b, PeerInfo.class, deserializeContext); - assertEquals(pi, pi2); - - if (omitted) { - var e = assertThrows(MismatchedInputException.class, () -> { - Json.cborMapper().readValue(b, PeerInfo.class); - }); - assertTrue(e.getMessage().startsWith("Invalid PeerInfo: peer id can not be null")); - } - } - - @ParameterizedTest - @ValueSource(strings = {"immutable", "signed", "encrypted"}) - void valueTest(String mode) throws Exception { - var v = switch (mode) { - case "immutable" -> Value.createValue("Hello from bosonnetwork!\n".repeat(10).getBytes()); - case "signed" -> Value.createSignedValue("Hello from bosonnetwork!\n".repeat(10).getBytes()); - case "encrypted" -> Value.createEncryptedValue(Id.of(Signature.KeyPair.random().publicKey().bytes()), - "Hello from bosonnetwork!\n".repeat(10).getBytes()); - default -> throw new AssertionError("Unknown mode: " + mode); - }; - - var s = Json.toString(v); - System.out.println(s); - System.out.println(Json.toPrettyString(v)); - - var v2 = Json.parse(s, Value.class); - assertEquals(v, v2); - - var b = Json.toBytes(v); - System.out.println(Hex.encode(b)); - - v2 = Json.parse(b, Value.class); - assertEquals(v, v2); - } } \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/utils/JsonPerfTests.java b/api/src/test/java/io/bosonnetwork/utils/JsonPerfTests.java deleted file mode 100644 index c189b13..0000000 --- a/api/src/test/java/io/bosonnetwork/utils/JsonPerfTests.java +++ /dev/null @@ -1,1259 +0,0 @@ -package io.bosonnetwork.utils; - -import static io.bosonnetwork.utils.Json.isBinaryFormat; -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.io.IOException; -import java.net.InetAddress; -import java.util.Date; -import java.util.Map; -import java.util.Objects; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonFormat; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonPropertyOrder; -import com.fasterxml.jackson.core.Base64Variants; -import com.fasterxml.jackson.core.JacksonException; -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonToken; -import com.fasterxml.jackson.core.io.SegmentedStringWriter; -import com.fasterxml.jackson.core.util.ByteArrayBuilder; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.MapperFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializerProvider; -import com.fasterxml.jackson.databind.cfg.ContextAttributes; -import com.fasterxml.jackson.databind.deser.std.StdDeserializer; -import com.fasterxml.jackson.databind.exc.InvalidFormatException; -import com.fasterxml.jackson.databind.exc.MismatchedInputException; -import com.fasterxml.jackson.databind.json.JsonMapper; -import com.fasterxml.jackson.databind.module.SimpleModule; -import com.fasterxml.jackson.databind.ser.std.StdSerializer; -import com.fasterxml.jackson.dataformat.cbor.databind.CBORMapper; - -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import io.bosonnetwork.Id; -import io.bosonnetwork.NodeInfo; -import io.bosonnetwork.PeerInfo; -import io.bosonnetwork.Value; -import io.bosonnetwork.crypto.Signature; - -// Functional tests for io.bosonnetwork.utils.Json -// Extra: -// - performance benchmarks: DataBind/ObjectMapper vs. streaming API -public class JsonPerfTests { - private static ObjectMapper serdeJsonMapper; - private static CBORMapper serdeCborMapper; - private static ObjectMapper mixinJsonMapper; - private static CBORMapper mixinCborMapper; - - @BeforeAll - public static void setup() { - SimpleModule serdeModule = new SimpleModule(); - serdeModule.addSerializer(Date.class, new Json.DateSerializer()); - serdeModule.addDeserializer(Date.class, new Json.DateDeserializer()); - serdeModule.addSerializer(Id.class, new Json.IdSerializer()); - serdeModule.addDeserializer(Id.class, new Json.IdDeserializer()); - serdeModule.addSerializer(InetAddress.class, new Json.InetAddressSerializer()); - serdeModule.addDeserializer(InetAddress.class, new Json.InetAddressDeserializer()); - - serdeModule.addSerializer(NodeInfo.class, new NodeInfoSerializer()); - serdeModule.addDeserializer(NodeInfo.class, new NodeInfoDeserializer()); - serdeModule.addSerializer(PeerInfo.class, new PeerInfoSerializer()); - serdeModule.addDeserializer(PeerInfo.class, new PeerInfoDeserializer()); - serdeModule.addSerializer(Value.class, new ValueSerializer()); - serdeModule.addDeserializer(Value.class, new ValueDeserializer()); - - serdeJsonMapper = JsonMapper.builder(Json.jsonFactory()) - .disable(MapperFeature.AUTO_DETECT_CREATORS, - MapperFeature.AUTO_DETECT_FIELDS, - MapperFeature.AUTO_DETECT_GETTERS, - MapperFeature.AUTO_DETECT_SETTERS, - MapperFeature.AUTO_DETECT_IS_GETTERS) - .defaultBase64Variant(Base64Variants.MODIFIED_FOR_URL) - .addModule(serdeModule) - .build(); - - serdeCborMapper = CBORMapper.builder(Json.cborFactory()) - .disable(MapperFeature.AUTO_DETECT_CREATORS, - MapperFeature.AUTO_DETECT_FIELDS, - MapperFeature.AUTO_DETECT_GETTERS, - MapperFeature.AUTO_DETECT_SETTERS, - MapperFeature.AUTO_DETECT_IS_GETTERS) - .defaultBase64Variant(Base64Variants.MODIFIED_FOR_URL) - .addModule(serdeModule) - .build(); - - SimpleModule mixinModule = new SimpleModule(); - mixinModule.addSerializer(Date.class, new Json.DateSerializer()); - mixinModule.addDeserializer(Date.class, new Json.DateDeserializer()); - mixinModule.addSerializer(Id.class, new Json.IdSerializer()); - mixinModule.addDeserializer(Id.class, new Json.IdDeserializer()); - mixinModule.addSerializer(InetAddress.class, new Json.InetAddressSerializer()); - mixinModule.addDeserializer(InetAddress.class, new Json.InetAddressDeserializer()); - - mixinJsonMapper = JsonMapper.builder(Json.jsonFactory()) - .disable(MapperFeature.AUTO_DETECT_CREATORS, - MapperFeature.AUTO_DETECT_FIELDS, - MapperFeature.AUTO_DETECT_GETTERS, - MapperFeature.AUTO_DETECT_SETTERS, - MapperFeature.AUTO_DETECT_IS_GETTERS) - .defaultBase64Variant(Base64Variants.MODIFIED_FOR_URL) - .addModule(mixinModule) - .addMixIn(NodeInfo.class, NodeInfoMixin.class) - .addMixIn(PeerInfo.class, PeerInfoMixin.class) - .addMixIn(Value.class, ValueMixin.class) - .build(); - - mixinCborMapper = CBORMapper.builder(Json.cborFactory()) - .disable(MapperFeature.AUTO_DETECT_CREATORS, - MapperFeature.AUTO_DETECT_FIELDS, - MapperFeature.AUTO_DETECT_GETTERS, - MapperFeature.AUTO_DETECT_SETTERS, - MapperFeature.AUTO_DETECT_IS_GETTERS) - .defaultBase64Variant(Base64Variants.MODIFIED_FOR_URL) - .addModule(mixinModule) - .addMixIn(NodeInfo.class, NodeInfoMixin.class) - .addMixIn(PeerInfo.class, PeerInfoMixin.class) - .addMixIn(Value.class, ValueMixin.class) - .build(); - } - - @FunctionalInterface - interface JsonContext { - Object getAttribute(Object key); - - default T getAttribute(Object key, Class clazz) { - Object value = getAttribute(key); - return value == null ? null : clazz.cast(value); - } - - static JsonContext EMPTY = key -> null; - - static JsonContext empty() { - return EMPTY; - } - - static JsonContext withAttribute(Object key, Object value) { - return k -> Objects.equals(key, k) ? value : null; - } - - static JsonContext withAttributes(Map attributes) { - return attributes::get; - } - } - - @SuppressWarnings("unchecked") - static T parse(String json, Class clazz, JsonContext context) throws IOException { - try (JsonParser parser = Json.jsonFactory().createParser(json)) { - parser.nextToken(); - if (clazz == NodeInfo.class) - return (T) deserializeNodeInfo(parser, context); - else if (clazz == PeerInfo.class) - return (T) deserializePeerInfo(parser, context); - else if (clazz == Value.class) - return (T) deserializeValue(parser, context); - else - throw new IllegalStateException(); - } - } - - @SuppressWarnings("unchecked") - static T parse(byte[] cbor, Class clazz, JsonContext context) throws IOException { - try (JsonParser parser = Json.cborFactory().createParser(cbor)) { - parser.nextToken(); - if (clazz == NodeInfo.class) - return (T) deserializeNodeInfo(parser, context); - else if (clazz == PeerInfo.class) - return (T) deserializePeerInfo(parser, context); - else if (clazz == Value.class) - return (T) deserializeValue(parser, context); - else - throw new IllegalStateException(); - } - } - - // streaming serialization and deserialization - static void serializeNodeInfo(JsonGenerator gen, NodeInfo value, JsonContext context) throws IOException { - var binaryFormat = isBinaryFormat(gen); - - // Format: triple - // [id, host, port] - // - // host: - // text format: IP address string or hostname string - // binary format: binary ip address - gen.writeStartArray(); - - if (binaryFormat) { - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getId().bytes(), 0, Id.BYTES); - byte[] addr = value.getIpAddress().getAddress(); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, addr, 0, addr.length); // binary ip address - } else { - gen.writeString(value.getId().toBase58String()); - gen.writeString(value.getHost()); - } - - // port - gen.writeNumber(value.getPort()); - - gen.writeEndArray(); - } - - static NodeInfo deserializeNodeInfo(JsonParser p, JsonContext context) throws IOException, JacksonException { - if (p.currentToken() != JsonToken.START_ARRAY) - throw MismatchedInputException.from(p, NodeInfo.class, "Invalid NodeInfo, should be an array"); - - boolean binaryFormat = isBinaryFormat(p); - Id id; - InetAddress addr; - int port; - - // id - p.nextToken(); - if (p.currentToken() != JsonToken.VALUE_NULL) - id = binaryFormat ? Id.of(p.getBinaryValue()) : Id.of(p.getText()); - else - throw MismatchedInputException.from(p, Id.class, "Invalid NodeInfo: node id can not be null"); - - // address - // text format: IP address string or hostname string - // binary format: binary ip address or host name string - p.nextToken(); - if (p.currentToken() != JsonToken.VALUE_NULL) { - if (binaryFormat) - addr = p.currentToken() == JsonToken.VALUE_STRING ? - InetAddress.getByName(p.getText()) : InetAddress.getByAddress(p.getBinaryValue()); - else - addr = InetAddress.getByName(p.getText()); - } else { - throw MismatchedInputException.from(p, InetAddress.class, "Invalid NodeInfo: node address can not be null"); - } - - // port - p.nextToken(); - port = p.getIntValue(); - if (port < 0 || port > 65535) - throw InvalidFormatException.from(p, Integer.class, "Invalid NodeInfo: port " + port + " is out of range"); - - if (p.nextToken() != JsonToken.END_ARRAY) - throw MismatchedInputException.from(p, NodeInfo.class, "Invalid NodeInfo: too many elements in array"); - - return new NodeInfo(id, addr, port); - } - - private static String toString(NodeInfo value, JsonContext context) throws IOException { - try (SegmentedStringWriter sw = new SegmentedStringWriter(Json.jsonFactory()._getBufferRecycler())) { - var gen = Json.jsonFactory().createGenerator(sw); - serializeNodeInfo(gen, value, context); - gen.close(); - return sw.getAndClear(); - } - } - - private static byte[] toBytes(NodeInfo value, JsonContext context) throws IOException { - try (ByteArrayBuilder bb = new ByteArrayBuilder(Json.cborFactory()._getBufferRecycler(), 256)) { - var gen = Json.cborFactory().createGenerator(bb); - serializeNodeInfo(gen, value, context); - gen.close(); - final byte[] result = bb.toByteArray(); - bb.release(); - return result; - } - } - - static class NodeInfoSerializer extends StdSerializer { - private static final long serialVersionUID = 652112589617276783L; - - public NodeInfoSerializer() { - this(NodeInfo.class); - } - - public NodeInfoSerializer(Class t) { - super(t); - } - - @Override - public void serialize(NodeInfo value, JsonGenerator gen, SerializerProvider provider) throws IOException { - serializeNodeInfo(gen, value, JsonContext.empty()); - } - } - - static class NodeInfoDeserializer extends StdDeserializer { - private static final long serialVersionUID = -1802423497777216345L; - - public NodeInfoDeserializer() { - this(NodeInfo.class); - } - - public NodeInfoDeserializer(Class vc) { - super(vc); - } - - @Override - public NodeInfo deserialize(JsonParser p, DeserializationContext ctx) throws IOException, JacksonException { - return deserializeNodeInfo(p, JsonContext.empty()); - } - } - - @JsonFormat(shape = JsonFormat.Shape.ARRAY) - @JsonPropertyOrder({"id", "a", "p"}) - static abstract class NodeInfoMixin { - @JsonCreator - public NodeInfoMixin(@JsonProperty(value = "id", required = true) Id id, - @JsonProperty(value = "a", required = true) InetAddress addr, - @JsonProperty(value = "p", required = true) int port) { - } - - @JsonProperty("id") - public abstract Id getId(); - - @JsonProperty("a") - public abstract InetAddress getIpAddress(); - - @JsonProperty("p") - public abstract int getPort(); - } - - @Test - void nodeInfoJsonTest() throws IOException { - // Custom serializer/deserializer - NodeInfo ni = new NodeInfo(Id.random(), "192.168.8.1", 8080); - NodeInfo ni2 = null; - String s = null; - - var start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - s = serdeJsonMapper.writeValueAsString(ni); - var end = System.nanoTime(); - System.out.println(s); - var mappingSerializeTime = end - start; - System.out.printf(">>>>>>>> Serialize with custom serializer: %.2f ms\n", mappingSerializeTime / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - ni2 = serdeJsonMapper.readValue(s, NodeInfo.class); - end = System.nanoTime(); - assertEquals(ni, ni2); - var mappingDeserializeTime = end - start; - System.out.printf(">>>>>>>> Deserialize with custom serializer: %.2f ms\n", mappingDeserializeTime / 1000000.0); - - // MixIn - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - s = mixinJsonMapper.writeValueAsString(ni); - end = System.nanoTime(); - System.out.println(s); - System.out.printf(">>>>>>>> Serialize with MixIn: %.2f ms\n", (end - start) / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - ni2 = mixinJsonMapper.readValue(s, NodeInfo.class); - end = System.nanoTime(); - assertEquals(ni, ni2); - System.out.printf(">>>>>>>> Deserialize with MixIn: %.2f ms\n", (end - start) / 1000000.0); - - // Streaming with generator - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) { - s = toString(ni, JsonContext.empty()); - } - end = System.nanoTime(); - System.out.println(s); - var streamingSerializeTime = end - start; - System.out.printf(">>>>>>>> Serialize with generator: %.2f ms\n", streamingSerializeTime / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) { - ni2 = parse(s, NodeInfo.class, JsonContext.empty()); - } - end = System.nanoTime(); - assertEquals(ni, ni2); - var streamingDeserializeTime = end - start; - System.out.printf(">>>>>>>> Deserialize with generator: %.2f ms\n", streamingDeserializeTime / 1000000.0); - - System.out.println("\n================ JSON: NodeInfo"); - System.out.printf(" Serialize - Mapping : Streaming = %.2f : %.2f, %.4f\n", - mappingSerializeTime / 1000000.0, streamingSerializeTime / 1000000.0, - (double)mappingSerializeTime / (double)streamingSerializeTime); - System.out.printf("Deserialize - Mapping : Streaming = %.2f : %.2f, %.4f\n\n", - mappingDeserializeTime / 1000000.0, streamingDeserializeTime / 1000000.0, - (double)mappingDeserializeTime / (double)streamingDeserializeTime); - } - - @Test - void nodeInfoCborTest() throws IOException { - // Custom serializer/deserializer - - NodeInfo ni = new NodeInfo(Id.random(), "192.168.8.1", 8080); - NodeInfo ni2 = null; - byte[] s = null; - - var start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - s = serdeCborMapper.writeValueAsBytes(ni); - var end = System.nanoTime(); - System.out.println(Hex.encode(s)); - var mappingSerializeTime = end - start; - System.out.printf(">>>>>>>> Serialize with custom serializer: %.2f ms\n", mappingSerializeTime / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - ni2 = serdeCborMapper.readValue(s, NodeInfo.class); - end = System.nanoTime(); - assertEquals(ni, ni2); - var mappingDeserializeTime = end - start; - System.out.printf(">>>>>>>> Deserialize with custom serializer: %.2f ms\n", mappingDeserializeTime / 1000000.0); - - // MixIn - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - s = mixinCborMapper.writeValueAsBytes(ni); - end = System.nanoTime(); - System.out.println(Hex.encode(s)); - System.out.printf(">>>>>>>> Serialize with MixIn: %.2f ms\n", (end - start) / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - ni2 = mixinCborMapper.readValue(s, NodeInfo.class); - end = System.nanoTime(); - assertEquals(ni, ni2); - System.out.printf(">>>>>>>> Deserialize with MixIn: %.2f ms\n", (end - start) / 1000000.0); - - // Streaming with generator - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) { - s = toBytes(ni, JsonContext.empty()); - } - end = System.nanoTime(); - System.out.println(Hex.encode(s)); - var streamingSerializeTime = end - start; - System.out.printf(">>>>>>>> Serialize with generator: %.2f ms\n", streamingSerializeTime / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) { - ni2 = parse(s, NodeInfo.class, JsonContext.empty()); - } - end = System.nanoTime(); - assertEquals(ni, ni2); - var streamingDeserializeTime = end - start; - System.out.printf(">>>>>>>> Deserialize with generator: %.2f ms\n", streamingDeserializeTime / 1000000.0); - - System.out.println("\n================ CBOR: NodeInfo"); - System.out.printf(" Serialize - Mapping : Streaming = %.2f : %.2f, %.4f\n", - mappingSerializeTime / 1000000.0, streamingSerializeTime / 1000000.0, - (double)mappingSerializeTime / (double)streamingSerializeTime); - System.out.printf("Deserialize - Mapping : Streaming = %.2f : %.2f, %.4f\n\n", - mappingDeserializeTime / 1000000.0, streamingDeserializeTime / 1000000.0, - (double)mappingDeserializeTime / (double)streamingDeserializeTime); - } - - static void serializePeerInfo(JsonGenerator gen, PeerInfo value, JsonContext context) throws IOException { - boolean binaryFormat = isBinaryFormat(gen); - - // Format: 6-tuple - // [peerId, nodeId, originNodeId, port, alternativeURI, signature] - // If omit the peer id, format: - // [null, nodeId, originNodeId, port, alternativeURI, signature] - - gen.writeStartArray(); - - // peer id - Boolean attr = (Boolean) context.getAttribute(PeerInfo.ATTRIBUTE_OMIT_PEER_ID); - boolean omitPeerId = attr != null && attr; - if (!omitPeerId) { - if (binaryFormat) - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getId().bytes(), 0, Id.BYTES); - else - gen.writeString(value.getId().toBase58String()); - } else { - gen.writeNull(); - } - - // node id - if (binaryFormat) - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getNodeId().bytes(), 0, Id.BYTES); - else - gen.writeString(value.getNodeId().toBase58String()); - - // origin node id - if (value.isDelegated()) { - if (binaryFormat) - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getOrigin().bytes(), 0, Id.BYTES); - else - gen.writeString(value.getOrigin().toBase58String()); - } else { - gen.writeNull(); - } - - // port - gen.writeNumber(value.getPort()); - - // alternative url - if (value.hasAlternativeURI()) - gen.writeString(value.getAlternativeURI()); - else - gen.writeNull(); - - // signature - byte[] sig = value.getSignature(); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, sig, 0, sig.length); - - gen.writeEndArray(); - } - - static PeerInfo deserializePeerInfo(JsonParser p, JsonContext context) throws IOException, JacksonException { - if (p.currentToken() != JsonToken.START_ARRAY) - throw MismatchedInputException.from(p, PeerInfo.class, "Invalid PeerInfo, should be an array"); - - boolean binaryFormat = isBinaryFormat(p); - - Id peerId; - Id nodeId; - Id origin = null; - int port; - String alternativeURI; - byte[] signature; - - // peer id - p.nextToken(); - if (p.currentToken() != JsonToken.VALUE_NULL) { - peerId = binaryFormat ? Id.of(p.getBinaryValue()) : Id.of(p.getText()); - } else { - // peer id is omitted, should retrieve it from the context - peerId = (Id) context.getAttribute(PeerInfo.ATTRIBUTE_PEER_ID); - if (peerId == null) - throw MismatchedInputException.from(p, Id.class, "Invalid PeerInfo: peer id can not be null"); - } - - // node id - p.nextToken(); - if (p.currentToken() != JsonToken.VALUE_NULL) - nodeId = binaryFormat ? Id.of(p.getBinaryValue()) : Id.of(p.getText()); - else - throw MismatchedInputException.from(p, Id.class, "Invalid PeerInfo: node id can not be null"); - - // origin node id - p.nextToken(); - if (p.currentToken() != JsonToken.VALUE_NULL) - origin = binaryFormat ? Id.of(p.getBinaryValue()) : Id.of(p.getText()); - - // port - p.nextToken(); - port = p.getIntValue(); - if (port < 0 || port > 65535) - throw InvalidFormatException.from(p, Integer.class, "Invalid PeerInfo: port " + port + " is out of range"); - - // alternative url - p.nextToken(); - alternativeURI = p.currentToken() == JsonToken.VALUE_NULL ? null : p.getText(); - - // signature - p.nextToken(); - if (p.currentToken() != JsonToken.VALUE_NULL) - signature = p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL); - else - throw MismatchedInputException.from(p, byte[].class, "Invalid PeerInfo: signature can not be null"); - - if (p.nextToken() != JsonToken.END_ARRAY) - throw MismatchedInputException.from(p, PeerInfo.class, "Invalid PeerInfo: too many elements in array"); - - return PeerInfo.of(peerId, nodeId, origin, port, alternativeURI, signature); - } - - static String toString(PeerInfo value, JsonContext context) throws IOException { - try (SegmentedStringWriter sw = new SegmentedStringWriter(Json.jsonFactory()._getBufferRecycler())) { - var gen = Json.jsonFactory().createGenerator(sw); - serializePeerInfo(gen, value, context); - gen.close(); - return sw.getAndClear(); - } - } - - static byte[] toBytes(PeerInfo value, JsonContext context) throws IOException { - try (ByteArrayBuilder bb = new ByteArrayBuilder(Json.cborFactory()._getBufferRecycler())) { - var gen = Json.cborFactory().createGenerator(bb); - serializePeerInfo(gen, value, context); - gen.close(); - final byte[] result = bb.toByteArray(); - bb.release(); - return result; - } - } - - static class PeerInfoSerializer extends StdSerializer { - private static final long serialVersionUID = -2372725165793659632L; - - public PeerInfoSerializer() { - this(PeerInfo.class); - } - - public PeerInfoSerializer(Class t) { - super(t); - } - - @Override - public void serialize(PeerInfo value, JsonGenerator gen, SerializerProvider provider) throws IOException { - serializePeerInfo(gen, value, provider::getAttribute); - } - } - - static class PeerInfoDeserializer extends StdDeserializer { - private static final long serialVersionUID = 6475890164214322573L; - - public PeerInfoDeserializer() { - this(PeerInfo.class); - } - - public PeerInfoDeserializer(Class vc) { - super(vc); - } - - @Override - public PeerInfo deserialize(JsonParser p, DeserializationContext ctx) throws IOException, JacksonException { - return deserializePeerInfo(p, ctx::getAttribute); - } - } - - @JsonFormat(shape = JsonFormat.Shape.ARRAY) - @JsonPropertyOrder({"id", "n", "o", "p", "alt", "sig"}) - static abstract class PeerInfoMixin { - @JsonCreator - public PeerInfoMixin(@JsonProperty(value = "id", required = true) Id peerId, - @JsonProperty(value = "n", required = true) Id nodeId, - @JsonProperty(value = "o") Id origin, - @JsonProperty(value = "p", required = true) int port, - @JsonProperty(value = "alt") String alternativeURI, - @JsonProperty(value = "sig", required = true) byte[] signature) { } - - @JsonProperty("id") - public abstract Id getId(); - - @JsonProperty("n") - public abstract Id getNodeId(); - - @JsonProperty("o") - public abstract Id getOrigin(); - - @JsonProperty("p") - public abstract int getPort(); - - @JsonProperty("alt") - public abstract String getAlternativeURI(); - - @JsonProperty("sig") - public abstract byte[] getSignature(); - } - - @Test - void peerInfoJsonTest() throws IOException { - Signature.KeyPair keypair = Signature.KeyPair.random(); - PeerInfo pi = PeerInfo.create(keypair, Id.random(), Id.random(), 8888, "https://echo.bns.io"); - PeerInfo pi2 = null; - String s = null; - - // Custom serializer/deserializer - var start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - s = serdeJsonMapper.writeValueAsString(pi); - var end = System.nanoTime(); - System.out.println(s); - var mappingSerializeTime = end - start; - System.out.printf(">>>>>>>> Serialize with custom serializer: %.2f ms\n", mappingSerializeTime / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - pi2 = serdeJsonMapper.readValue(s, PeerInfo.class); - end = System.nanoTime(); - assertEquals(pi, pi2); - var mappingDeserializeTime = end - start; - System.out.printf(">>>>>>>> Deserialize with custom serializer: %.2f ms\n", mappingDeserializeTime / 1000000.0); - - // MixIn - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - s = mixinJsonMapper.writeValueAsString(pi); - end = System.nanoTime(); - System.out.println(s); - System.out.printf(">>>>>>>> Serialize with MixIn: %.2f ms\n", (end - start) / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - pi2 = mixinJsonMapper.readValue(s, PeerInfo.class); - end = System.nanoTime(); - assertEquals(pi, pi2); - System.out.printf(">>>>>>>> Deserialize with MixIn: %.2f ms\n", (end - start) / 1000000.0); - - // Streaming with generator - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) { - s = toString(pi, JsonContext.empty()); - } - end = System.nanoTime(); - System.out.println(s); - var streamingSerializeTime = end - start; - System.out.printf(">>>>>>>> Serialize with generator: %.2f ms\n", streamingSerializeTime / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) { - pi2 = parse(s, PeerInfo.class, JsonContext.empty()); - } - end = System.nanoTime(); - assertEquals(pi, pi2); - var streamingDeserializeTime = end - start; - System.out.printf(">>>>>>>> Deserialize with generator: %.2f ms\n", streamingDeserializeTime / 1000000.0); - - System.out.println("\n================ JSON: PeerInfo/full"); - System.out.printf(" Serialize - Mapping : Streaming = %.2f : %.2f, %.4f\n", - mappingSerializeTime / 1000000.0, streamingSerializeTime / 1000000.0, - (double) mappingSerializeTime / (double) streamingSerializeTime); - System.out.printf("Deserialize - Mapping : Streaming = %.2f : %.2f, %.4f\n\n", - mappingDeserializeTime / 1000000.0, streamingDeserializeTime / 1000000.0, - (double) mappingDeserializeTime / (double) streamingDeserializeTime); - } - - @Test - void peerInfoOptionalJsonTest() throws IOException { - // Custom serializer/deserializer - Signature.KeyPair keypair = Signature.KeyPair.random(); - var peerId = Id.of(keypair.publicKey().bytes()); - PeerInfo pi = PeerInfo.create(keypair, Id.random(), null, 8888, null); - PeerInfo pi2 = null; - String s = null; - - var start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - s = serdeJsonMapper.writer(ContextAttributes.getEmpty().withPerCallAttribute(PeerInfo.ATTRIBUTE_OMIT_PEER_ID, true)).writeValueAsString(pi); - var end = System.nanoTime(); - System.out.println(s); - var mappingSerializeTime = end - start; - System.out.printf(">>>>>>>> Serialize with custom serializer: %.2f ms\n", mappingSerializeTime / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - pi2 = serdeJsonMapper.reader(ContextAttributes.getEmpty().withPerCallAttribute(PeerInfo.ATTRIBUTE_PEER_ID, peerId)).readValue(s, PeerInfo.class); - end = System.nanoTime(); - assertEquals(pi, pi2); - var mappingDeserializeTime = end - start; - System.out.printf(">>>>>>>> Deserialize with custom serializer: %.2f ms\n", mappingDeserializeTime / 1000000.0); - - // MixIn - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - s = mixinJsonMapper.writer(ContextAttributes.getEmpty().withPerCallAttribute(PeerInfo.ATTRIBUTE_OMIT_PEER_ID, true)).writeValueAsString(pi); - end = System.nanoTime(); - System.out.println(s); - System.out.printf(">>>>>>>> Serialize with MixIn: %.2f ms\n", (end - start) / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - pi2 = mixinJsonMapper.reader(ContextAttributes.getEmpty().withPerCallAttribute(PeerInfo.ATTRIBUTE_PEER_ID, peerId)).readValue(s, PeerInfo.class); - end = System.nanoTime(); - assertEquals(pi, pi2); - System.out.printf(">>>>>>>> Deserialize with MixIn: %.2f ms\n", (end - start) / 1000000.0); - - // Streaming with generator - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) { - s = toString(pi, JsonContext.withAttribute(PeerInfo.ATTRIBUTE_OMIT_PEER_ID, true)); - } - end = System.nanoTime(); - System.out.println(s); - var streamingSerializeTime = end - start; - System.out.printf(">>>>>>>> Serialize with generator: %.2f ms\n", streamingSerializeTime / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) { - pi2 = parse(s, PeerInfo.class, JsonContext.withAttribute(PeerInfo.ATTRIBUTE_PEER_ID, peerId)); - } - end = System.nanoTime(); - assertEquals(pi, pi2); - var streamingDeserializeTime = end - start; - System.out.printf(">>>>>>>> Deserialize with generator: %.2f ms\n", streamingDeserializeTime / 1000000.0); - - System.out.println("\n================ JSON: PeerInfo/optional"); - System.out.printf(" Serialize - Mapping : Streaming = %.2f : %.2f, %.4f\n", - mappingSerializeTime / 1000000.0, streamingSerializeTime / 1000000.0, - (double)mappingSerializeTime / (double)streamingSerializeTime); - System.out.printf("Deserialize - Mapping : Streaming = %.2f : %.2f, %.4f\n\n", - mappingDeserializeTime / 1000000.0, streamingDeserializeTime / 1000000.0, - (double)mappingDeserializeTime / (double)streamingDeserializeTime); - } - - @Test - void peerInfoCborTest() throws IOException { - // Custom serializer/deserializer - PeerInfo pi = PeerInfo.create(Signature.KeyPair.random(), Id.random(), Id.random(), 8888, "https://echo.bns.io"); - PeerInfo pi2 = null; - byte[] s = null; - - var start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - s = serdeCborMapper.writeValueAsBytes(pi); - var end = System.nanoTime(); - System.out.println(Hex.encode(s)); - var mappingSerializeTime = end - start; - System.out.printf(">>>>>>>> Serialize with custom serializer: %.2f ms\n", mappingSerializeTime / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - pi2 = serdeCborMapper.readValue(s, PeerInfo.class); - end = System.nanoTime(); - assertEquals(pi, pi2); - var mappingDeserializeTime = end - start; - System.out.printf(">>>>>>>> Deserialize with custom serializer: %.2f ms\n", mappingDeserializeTime / 1000000.0); - - // MixIn - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - s = mixinCborMapper.writeValueAsBytes(pi); - end = System.nanoTime(); - System.out.println(Hex.encode(s)); - System.out.printf(">>>>>>>> Serialize with MixIn: %.2f ms\n", (end - start) / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - pi2 = mixinCborMapper.readValue(s, PeerInfo.class); - end = System.nanoTime(); - assertEquals(pi, pi2); - System.out.printf(">>>>>>>> Deserialize with MixIn: %.2f ms\n", (end - start) / 1000000.0); - - // Streaming with generator - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) { - s = toBytes(pi, JsonContext.empty()); - } - end = System.nanoTime(); - System.out.println(Hex.encode(s)); - var streamingSerializeTime = end - start; - System.out.printf(">>>>>>>> Serialize with generator: %.2f ms\n", streamingSerializeTime / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) { - pi2 = parse(s, PeerInfo.class, JsonContext.empty()); - } - end = System.nanoTime(); - assertEquals(pi, pi2); - var streamingDeserializeTime = end - start; - System.out.printf(">>>>>>>> Deserialize with generator: %.2f ms\n", streamingDeserializeTime / 1000000.0); - - System.out.println("\n================ CBOR: PeerInfo/full"); - System.out.printf(" Serialize - Mapping : Streaming = %.2f : %.2f, %.4f\n", - mappingSerializeTime / 1000000.0, streamingSerializeTime / 1000000.0, - (double)mappingSerializeTime / (double)streamingSerializeTime); - System.out.printf("Deserialize - Mapping : Streaming = %.2f : %.2f, %.4f\n\n", - mappingDeserializeTime / 1000000.0, streamingDeserializeTime / 1000000.0, - (double)mappingDeserializeTime / (double)streamingDeserializeTime); - } - - @Test - void peerInfoOptionalCborTest() throws IOException { - // Custom serializer/deserializer - Signature.KeyPair keypair = Signature.KeyPair.random(); - var peerId = Id.of(keypair.publicKey().bytes()); - PeerInfo pi = PeerInfo.create(keypair, Id.random(), null, 8888, null); - PeerInfo pi2 = null; - byte[] s = null; - - var start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - s = serdeCborMapper.writer(ContextAttributes.getEmpty().withPerCallAttribute(PeerInfo.ATTRIBUTE_OMIT_PEER_ID, true)).writeValueAsBytes(pi); - var end = System.nanoTime(); - System.out.println(Hex.encode(s)); - var mappingSerializeTime = end - start; - System.out.printf(">>>>>>>> Serialize with custom serializer: %.2f ms\n", mappingSerializeTime / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - pi2 = serdeCborMapper.reader(ContextAttributes.getEmpty().withPerCallAttribute(PeerInfo.ATTRIBUTE_PEER_ID, peerId)).readValue(s, PeerInfo.class); - end = System.nanoTime(); - assertEquals(pi, pi2); - var mappingDeserializeTime = end - start; - System.out.printf(">>>>>>>> Deserialize with custom serializer: %.2f ms\n", mappingDeserializeTime / 1000000.0); - - // MixIn - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - s = mixinCborMapper.writer(ContextAttributes.getEmpty().withPerCallAttribute(PeerInfo.ATTRIBUTE_OMIT_PEER_ID, true)).writeValueAsBytes(pi); - end = System.nanoTime(); - System.out.println(Hex.encode(s)); - System.out.printf(">>>>>>>> Serialize with MixIn: %.2f ms\n", (end - start) / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - pi2 = mixinCborMapper.reader(ContextAttributes.getEmpty().withPerCallAttribute(PeerInfo.ATTRIBUTE_PEER_ID, peerId)).readValue(s, PeerInfo.class); - end = System.nanoTime(); - assertEquals(pi, pi2); - System.out.printf(">>>>>>>> Deserialize with MixIn: %.2f ms\n", (end - start) / 1000000.0); - - // Streaming with generator - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) { - s = toBytes(pi, JsonContext.withAttribute(PeerInfo.ATTRIBUTE_OMIT_PEER_ID, true)); - } - end = System.nanoTime(); - System.out.println(Hex.encode(s)); - var streamingSerializeTime = end - start; - System.out.printf(">>>>>>>> Serialize with generator: %.2f ms\n", streamingSerializeTime / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) { - pi2 = parse(s, PeerInfo.class, JsonContext.withAttribute(PeerInfo.ATTRIBUTE_PEER_ID, peerId)); - } - end = System.nanoTime(); - assertEquals(pi, pi2); - var streamingDeserializeTime = end - start; - System.out.printf(">>>>>>>> Deserialize with generator: %.2f ms\n", streamingDeserializeTime / 1000000.0); - - System.out.println("\n================ CBOR: PeerInfo/optional"); - System.out.printf(" Serialize - Mapping : Streaming = %.2f : %.2f, %.4f\n", - mappingSerializeTime / 1000000.0, streamingSerializeTime / 1000000.0, - (double)mappingSerializeTime / (double)streamingSerializeTime); - System.out.printf("Deserialize - Mapping : Streaming = %.2f : %.2f, %.4f\n\n", - mappingDeserializeTime / 1000000.0, streamingDeserializeTime / 1000000.0, - (double)mappingDeserializeTime / (double)streamingDeserializeTime); - } - - static void serializeValue(JsonGenerator gen, Value value, JsonContext context) throws IOException { - boolean binaryFormat = isBinaryFormat(gen); - gen.writeStartObject(); - - if (value.getPublicKey() != null) { - // public key - if (binaryFormat) { - gen.writeFieldName("k"); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getPublicKey().bytes(), 0, Id.BYTES); - } else { - gen.writeStringField("k", value.getPublicKey().toBase58String()); - } - - // recipient - if (value.getRecipient() != null) { - if (binaryFormat) { - gen.writeFieldName("rec"); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getRecipient().bytes(), 0, Id.BYTES); - } else { - gen.writeStringField("rec", value.getRecipient().toBase58String()); - } - } - - // nonce - byte[] binary = value.getNonce(); - if (binary != null) { - gen.writeFieldName("n"); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, binary, 0, binary.length); - } - - // sequence number - if (value.getSequenceNumber() > 0) - gen.writeNumberField("seq", value.getSequenceNumber()); - - // signature - binary = value.getSignature(); - if (binary != null) { - gen.writeFieldName("sig"); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, binary, 0, binary.length); - } - } - - byte[] data = value.getData(); - if (data != null && data.length > 0) { - gen.writeFieldName("v"); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, data, 0, data.length); - } - - gen.writeEndObject(); - } - - static Value deserializeValue(JsonParser p, JsonContext context) throws IOException, JacksonException { - if (p.currentToken() != JsonToken.START_OBJECT) - throw MismatchedInputException.from(p, Value.class, "Invalid Value: should be an object"); - - boolean binaryFormat = isBinaryFormat(p); - - Id publicKey = null; - Id recipient = null; - byte[] nonce = null; - int sequenceNumber = 0; - byte[] signature = null; - byte[] data = null; - - while (p.nextToken() != JsonToken.END_OBJECT) { - String fieldName = p.currentName(); - p.nextToken(); - switch (fieldName) { - case "k": - if (p.currentToken() != JsonToken.VALUE_NULL) - publicKey = binaryFormat ? Id.of(p.getBinaryValue()) : Id.of(p.getText()); - break; - case "rec": - if (p.currentToken() != JsonToken.VALUE_NULL) - recipient = binaryFormat ? Id.of(p.getBinaryValue()) : Id.of(p.getText()); - break; - case "n": - if (p.currentToken() != JsonToken.VALUE_NULL) - nonce = p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL); - break; - case "seq": - if (p.currentToken() != JsonToken.VALUE_NULL) - sequenceNumber = p.getIntValue(); - break; - case "sig": - if (p.currentToken() != JsonToken.VALUE_NULL) - signature = p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL); - break; - case "v": - if (p.currentToken() != JsonToken.VALUE_NULL) - data = p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL); - else - throw MismatchedInputException.from(p, byte[].class, "Invalid Value: data can not be null"); - break; - default: - p.skipChildren(); - } - } - - return Value.of(publicKey, recipient, nonce, sequenceNumber, signature, data); - } - - static String toString(Value value, JsonContext context) throws IOException { - try (SegmentedStringWriter sw = new SegmentedStringWriter(Json.jsonFactory()._getBufferRecycler())) { - var gen = Json.jsonFactory().createGenerator(sw); - serializeValue(gen, value, context); - gen.close(); - return sw.getAndClear(); - } - } - - static byte[] toBytes(Value value, JsonContext context) throws IOException { - try (ByteArrayBuilder bb = new ByteArrayBuilder(Json.cborFactory()._getBufferRecycler(), value.getData().length + 256)) { - var gen = Json.cborFactory().createGenerator(bb); - serializeValue(gen, value, context); - gen.close(); - final byte[] result = bb.toByteArray(); - bb.release(); - return result; - } - } - - static class ValueSerializer extends StdSerializer { - private static final long serialVersionUID = 5494303011447541850L; - - public ValueSerializer() { - this(Value.class); - } - - public ValueSerializer(Class t) { - super(t); - } - - @Override - public void serialize(Value value, JsonGenerator gen, SerializerProvider provider) throws IOException { - serializeValue(gen, value, JsonContext.empty()); - } - } - - static class ValueDeserializer extends StdDeserializer { - private static final long serialVersionUID = 2370471437259629126L; - - public ValueDeserializer() { - this(Value.class); - } - - public ValueDeserializer(Class vc) { - super(vc); - } - - @Override - public Value deserialize(JsonParser p, DeserializationContext ctx) throws IOException, JacksonException { - return deserializeValue(p, JsonContext.empty()); - } - } - - @JsonPropertyOrder({"k", "rec", "n", "seq", "sig", "v"}) - static abstract class ValueMixin { - @JsonCreator - public ValueMixin(@JsonProperty(value = "k") Id publicKey, - @JsonProperty(value = "rec") Id recipient, - @JsonProperty(value = "n") byte[] nonce, - @JsonProperty(value = "seq") int sequenceNumber, - @JsonProperty(value = "sig") byte[] signature, - @JsonProperty(value = "v", required = true) byte[] data) { - } - - @JsonProperty("k") - @JsonInclude(JsonInclude.Include.NON_NULL) - public abstract Id getPublicKey(); - - @JsonProperty("rec") - @JsonInclude(JsonInclude.Include.NON_NULL) - public abstract Id getRecipient(); - - @JsonProperty("n") - @JsonInclude(JsonInclude.Include.NON_EMPTY) - public abstract byte[] getNonce(); - - @JsonProperty("seq") - public abstract int getSequenceNumber(); - - @JsonProperty("sig") - @JsonInclude(JsonInclude.Include.NON_EMPTY) - public abstract byte[] getSignature(); - - @JsonProperty("v") - public abstract byte[] getData(); - } - - @ParameterizedTest - @ValueSource(strings = {"immutable", "signed", "encrypted"}) - void valueJsonTest(String mode) throws Exception { - // Custom serializer/deserializer - Value v = switch (mode) { - case "immutable" -> Value.createValue("Hello from bosonnetwork!\n".repeat(10).getBytes()); - case "signed" -> Value.createSignedValue("Hello from bosonnetwork!\n".repeat(10).getBytes()); - case "encrypted" -> Value.createEncryptedValue(Id.of(Signature.KeyPair.random().publicKey().bytes()), - "Hello from bosonnetwork!\n".repeat(10).getBytes()); - default -> throw new IllegalArgumentException("Unexpected value: " + mode); - }; - Value v2 = null; - String s = null; - - var start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - s = serdeJsonMapper.writeValueAsString(v); - var end = System.nanoTime(); - System.out.println(s); - var mappingSerializeTime = end - start; - System.out.printf(">>>>>>>> Serialize with custom serializer: %.2f ms\n", mappingSerializeTime / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - v2 = serdeJsonMapper.readValue(s, Value.class); - end = System.nanoTime(); - assertEquals(v, v2); - var mappingDeserializeTime = end - start; - System.out.printf(">>>>>>>> Deserialize with custom serializer: %.2f ms\n", mappingDeserializeTime / 1000000.0); - - // MixIn - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - s = mixinJsonMapper.writeValueAsString(v); - end = System.nanoTime(); - System.out.println(s); - System.out.printf(">>>>>>>> Serialize with MixIn: %.2f ms\n", (end - start) / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - v2 = mixinJsonMapper.readValue(s, Value.class); - end = System.nanoTime(); - assertEquals(v, v2); - System.out.printf(">>>>>>>> Deserialize with MixIn: %.2f ms\n", (end - start) / 1000000.0); - - // Streaming with generator - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) { - s = toString(v, JsonContext.empty()); - } - end = System.nanoTime(); - System.out.println(s); - var streamingSerializeTime = end - start; - System.out.printf(">>>>>>>> Serialize with generator: %.2f ms\n", streamingSerializeTime / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) { - v2 = parse(s, Value.class, JsonContext.empty()); - } - end = System.nanoTime(); - assertEquals(v, v2); - var streamingDeserializeTime = end - start; - System.out.printf(">>>>>>>> Deserialize with generator: %.2f ms\n", streamingDeserializeTime / 1000000.0); - - System.out.printf("\n================ JSON: Value/%s\n", mode); - System.out.printf(" Serialize - Mapping : Streaming = %.2f : %.2f, %.4f\n", - mappingSerializeTime / 1000000.0, streamingSerializeTime / 1000000.0, - (double)mappingSerializeTime / (double)streamingSerializeTime); - System.out.printf("Deserialize - Mapping : Streaming = %.2f : %.2f, %.4f\n\n", - mappingDeserializeTime / 1000000.0, streamingDeserializeTime / 1000000.0, - (double)mappingDeserializeTime / (double)streamingDeserializeTime); - } - - @ParameterizedTest - @ValueSource(strings = {"immutable", "signed", "encrypted"}) - void valueCborTest(String mode) throws Exception { - // Custom serializer/deserializer - Value v = switch (mode) { - case "immutable" -> Value.createValue("Hello from bosonnetwork!\n".repeat(10).getBytes()); - case "signed" -> Value.createSignedValue("Hello from bosonnetwork!\n".repeat(10).getBytes()); - case "encrypted" -> Value.createEncryptedValue(Id.of(Signature.KeyPair.random().publicKey().bytes()), - "Hello from bosonnetwork!\n".repeat(10).getBytes()); - default -> throw new IllegalArgumentException("Unexpected value: " + mode); - }; - Value v2 = null; - byte[] s = null; - - var start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - s = serdeCborMapper.writeValueAsBytes(v); - var end = System.nanoTime(); - System.out.println(Hex.encode(s)); - var mappingSerializeTime = end - start; - System.out.printf(">>>>>>>> Serialize with custom serializer: %.2f ms\n", mappingSerializeTime / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - v2 = serdeCborMapper.readValue(s, Value.class); - end = System.nanoTime(); - assertEquals(v, v2); - var mappingDeserializeTime = end - start; - System.out.printf(">>>>>>>> Deserialize with custom serializer: %.2f ms\n", mappingDeserializeTime / 1000000.0); - - // MixIn - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - s = mixinCborMapper.writeValueAsBytes(v); - end = System.nanoTime(); - System.out.println(Hex.encode(s)); - System.out.printf(">>>>>>>> Serialize with MixIn: %.2f ms\n", (end - start) / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) - v2 = mixinCborMapper.readValue(s, Value.class); - end = System.nanoTime(); - assertEquals(v, v2); - System.out.printf(">>>>>>>> Deserialize with MixIn: %.2f ms\n", (end - start) / 1000000.0); - - // Streaming with generator - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) { - s = toBytes(v, JsonContext.empty()); - } - end = System.nanoTime(); - System.out.println(Hex.encode(s)); - var streamingSerializeTime = end - start; - System.out.printf(">>>>>>>> Serialize with generator: %.2f ms\n", streamingSerializeTime / 1000000.0); - - start = System.nanoTime(); - for (int i = 0; i < 1000000; i++) { - v2 = parse(s, Value.class, JsonContext.empty()); - } - end = System.nanoTime(); - assertEquals(v, v2); - var streamingDeserializeTime = end - start; - System.out.printf(">>>>>>>> Deserialize with generator: %.2f ms\n", streamingDeserializeTime / 1000000.0); - - System.out.printf("\n================ CBOR: Value/%s\n", mode); - System.out.printf(" Serialize - Mapping : Streaming = %.2f : %.2f, %.4f\n", - mappingSerializeTime / 1000000.0, streamingSerializeTime / 1000000.0, - (double)mappingSerializeTime / (double)streamingSerializeTime); - System.out.printf("Deserialize - Mapping : Streaming = %.2f : %.2f, %.4f\n\n", - mappingDeserializeTime / 1000000.0, streamingDeserializeTime / 1000000.0, - (double)mappingDeserializeTime / (double)streamingDeserializeTime); - } -} \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/web/CompactWebTokenAuthTest.java b/api/src/test/java/io/bosonnetwork/web/CompactWebTokenAuthTest.java index 9d2904f..dbf5c30 100644 --- a/api/src/test/java/io/bosonnetwork/web/CompactWebTokenAuthTest.java +++ b/api/src/test/java/io/bosonnetwork/web/CompactWebTokenAuthTest.java @@ -28,7 +28,7 @@ import io.bosonnetwork.crypto.Signature; import io.bosonnetwork.service.ClientDevice; import io.bosonnetwork.service.ClientUser; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; @ExtendWith(VertxExtension.class) public class CompactWebTokenAuthTest { diff --git a/cmds/src/main/java/io/bosonnetwork/am/AmCommand.java b/cmds/src/main/java/io/bosonnetwork/am/AmCommand.java index bcf40fb..3af19aa 100644 --- a/cmds/src/main/java/io/bosonnetwork/am/AmCommand.java +++ b/cmds/src/main/java/io/bosonnetwork/am/AmCommand.java @@ -32,7 +32,7 @@ import io.bosonnetwork.DefaultNodeConfiguration; import io.bosonnetwork.NodeConfiguration; import io.bosonnetwork.access.impl.AccessManager; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; /** * @hidden diff --git a/cmds/src/main/java/io/bosonnetwork/launcher/Main.java b/cmds/src/main/java/io/bosonnetwork/launcher/Main.java index ecf4f58..439e4bc 100644 --- a/cmds/src/main/java/io/bosonnetwork/launcher/Main.java +++ b/cmds/src/main/java/io/bosonnetwork/launcher/Main.java @@ -54,7 +54,7 @@ import io.bosonnetwork.service.FederationAuthenticator; import io.bosonnetwork.service.ServiceContext; import io.bosonnetwork.utils.ApplicationLock; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; /** * @hidden diff --git a/cmds/src/main/java/io/bosonnetwork/shell/AnnouncePeerCommand.java b/cmds/src/main/java/io/bosonnetwork/shell/AnnouncePeerCommand.java index b82439c..5a2dee5 100644 --- a/cmds/src/main/java/io/bosonnetwork/shell/AnnouncePeerCommand.java +++ b/cmds/src/main/java/io/bosonnetwork/shell/AnnouncePeerCommand.java @@ -29,9 +29,9 @@ import picocli.CommandLine.Option; import picocli.CommandLine.Parameters; -import io.bosonnetwork.Id; import io.bosonnetwork.PeerInfo; import io.bosonnetwork.crypto.Signature; +import io.bosonnetwork.json.Json; import io.bosonnetwork.utils.Hex; import io.bosonnetwork.vertx.VertxFuture; @@ -50,14 +50,15 @@ public class AnnouncePeerCommand implements Callable { @Option(names = {"-k", "--private-key"}, description = "The private key.") private String privateKey = null; - @Option(names = {"-n", "--node-id"}, description = "The node id.") - private String nodeId = null; + @Option(names = {"-a", "--authenticated"}, description = "Authenticated peer info.") + private boolean authenticated = false; - @Option(names = {"-a", "--alternative-url"}, description = "The alternative URL.") - private String alt = null; + @Option(names = {"-e", "--extra"}, description = "The extra information(json format is prefered).") + private String extra = null; + + @Parameters(paramLabel = "ENDPOINT", index = "0", description = "The peer endpoint URI/URL.") + private String endpoint = null; - @Parameters(paramLabel = "PORT", index = "0", description = "The peer port to be announce.") - private int port = 0; @Override public Integer call() throws Exception { @@ -70,21 +71,30 @@ public Integer call() throws Exception { return -1; } - Id peerNodeId = Main.getBosonNode().getId(); - try { - if (nodeId != null) - peerNodeId = Id.of(nodeId); - } catch (Exception e) { - System.out.println("Invalid node id: " + nodeId + ", " + e.getMessage()); + if (endpoint == null) { + System.out.println("Endpoint is required."); return -1; } - if (port <= 0) { - System.out.println("Invalid port: " + port); - return -1; + byte[] extraData = null; + if (extra != null) { + try { + extraData = Json.toBytes(Json.parse(extra)); + } catch (Exception e) { + System.out.println("Extra data is not json, treat as byte string"); + extraData = extra.getBytes(); + } } - PeerInfo peer = PeerInfo.create(keypair, peerNodeId, Main.getBosonNode().getId(), port, alt); + PeerInfo.Builder pb = PeerInfo.builder().endpoint(endpoint); + if (keypair != null) + pb.key(keypair); + if (authenticated) + pb.node(Main.getBosonNode()); + if (extraData != null) + pb.extra(extraData); + PeerInfo peer = pb.build(); + if (localOnly) VertxFuture.of(Main.getBosonNode().getStorage().putPeer(peer)).get(); else diff --git a/cmds/src/main/java/io/bosonnetwork/shell/DisplayCacheCommand.java b/cmds/src/main/java/io/bosonnetwork/shell/DisplayCacheCommand.java index a6aa397..33d4c4a 100644 --- a/cmds/src/main/java/io/bosonnetwork/shell/DisplayCacheCommand.java +++ b/cmds/src/main/java/io/bosonnetwork/shell/DisplayCacheCommand.java @@ -17,7 +17,7 @@ import io.bosonnetwork.Id; import io.bosonnetwork.kademlia.routing.KBucketEntry; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; /** * @hidden diff --git a/cmds/src/main/java/io/bosonnetwork/shell/FindPeerCommand.java b/cmds/src/main/java/io/bosonnetwork/shell/FindPeerCommand.java index 00abfdd..c3a7113 100644 --- a/cmds/src/main/java/io/bosonnetwork/shell/FindPeerCommand.java +++ b/cmds/src/main/java/io/bosonnetwork/shell/FindPeerCommand.java @@ -42,8 +42,11 @@ public class FindPeerCommand implements Callable { @Option(names = { "-m", "--mode" }, description = "lookup mode: arbitrary, optimistic, conservative.") private String mode = "conservative"; + @Option(names = { "-s", "--expected-sequence-number" }, description = "expected sequence number of peers") + private int expectedSequenceNumber = -1; + @Option(names = { "-x", "--expected-count" }, description = "expected number of peers") - private int expected = -1; + private int expectedCount = 1; @Parameters(paramLabel = "ID", index = "0", description = "The peer id to be find.") private String id; @@ -59,7 +62,7 @@ public Integer call() throws Exception { } Id peerId = Id.of(id); - Main.getBosonNode().findPeer(peerId, expected, option).thenAccept(peers -> { + Main.getBosonNode().findPeer(peerId, expectedSequenceNumber, expectedCount, option).thenAccept(peers -> { if (!peers.isEmpty()) { for (PeerInfo p : peers) System.out.println(p); diff --git a/cmds/src/main/java/io/bosonnetwork/shell/Main.java b/cmds/src/main/java/io/bosonnetwork/shell/Main.java index 939b742..c0816b7 100644 --- a/cmds/src/main/java/io/bosonnetwork/shell/Main.java +++ b/cmds/src/main/java/io/bosonnetwork/shell/Main.java @@ -58,7 +58,7 @@ import io.bosonnetwork.NodeInfo; import io.bosonnetwork.kademlia.KadNode; import io.bosonnetwork.utils.ApplicationLock; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; /** * @hidden diff --git a/cmds/src/main/java/io/bosonnetwork/shell/StoreValueCommand.java b/cmds/src/main/java/io/bosonnetwork/shell/StoreValueCommand.java index 1258bb9..b86a900 100644 --- a/cmds/src/main/java/io/bosonnetwork/shell/StoreValueCommand.java +++ b/cmds/src/main/java/io/bosonnetwork/shell/StoreValueCommand.java @@ -63,14 +63,15 @@ public Integer call() throws Exception { Node node = Main.getBosonNode(); Value value = null; - if (recipient != null) - mutable = true; - if (target == null) { - if (mutable) { + Value.Builder vb = Value.builder().data(text.getBytes()); + + if (!mutable) { + value = vb.build(); + } else { if (recipient == null) { - value = Value.createSignedValue(text.getBytes()); - } else { + value = vb.buildSigned(); + } else { Id recipientId = null; try { recipientId = Id.of(recipient); @@ -79,10 +80,8 @@ public Integer call() throws Exception { return -1; } - value = Value.createEncryptedValue(recipientId, text.getBytes()); + value = vb.recipient(recipientId).buildEncrypted(); } - } else { - value = Value.createValue(text.getBytes()); } } else { Id id = null; diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java b/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java index 431621b..1771380 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java @@ -37,6 +37,10 @@ import io.bosonnetwork.crypto.CachedCryptoIdentity; import io.bosonnetwork.crypto.CryptoException; import io.bosonnetwork.crypto.Signature; +import io.bosonnetwork.kademlia.exceptions.ImmutableSubstitutionFail; +import io.bosonnetwork.kademlia.exceptions.NotOwnerException; +import io.bosonnetwork.kademlia.exceptions.SequenceNotExpected; +import io.bosonnetwork.kademlia.exceptions.SequenceNotMonotonic; import io.bosonnetwork.kademlia.impl.DHT; import io.bosonnetwork.kademlia.impl.SimpleNodeConfiguration; import io.bosonnetwork.kademlia.impl.TokenManager; @@ -449,24 +453,21 @@ public VertxFuture findValue(Id id, int expectedSequenceNumber, LookupOpt runOnContext(v -> { Variable localValue = Variable.empty(); - storage.getValue(id).map(local -> { - if (local == null) - return null; + storage.getValue(id).compose(local -> { + if (local != null) { + if (!local.isMutable()) + return Future.succeededFuture(local); - localValue.set(local); - if (!local.isMutable()) - return local; + if (expectedSequenceNumber >= 0 && local.getSequenceNumber() >= expectedSequenceNumber) { + if (lookupOption == LookupOption.LOCAL) + return Future.succeededFuture(local); - if (expectedSequenceNumber >= 0 && local.getSequenceNumber() >= expectedSequenceNumber) - return local; + localValue.set(local); + } - return null; - }).compose(local -> { - if (lookupOption == LookupOption.LOCAL) - return Future.succeededFuture(local); - - if (local != null && (!local.isMutable() || lookupOption != LookupOption.CONSERVATIVE)) - return Future.succeededFuture(local); + if (lookupOption != LookupOption.CONSERVATIVE) + return Future.succeededFuture(local); + } return doFindValue(id, expectedSequenceNumber, lookupOption).map(value -> { if (value == null && local == null) @@ -524,6 +525,36 @@ private Future doFindValue(Id id, int expectedSequenceNumber, LookupOptio } } + private Future checkValue(Value value, int expectedSequenceNumber) { + return storage.getValue(value.getId()).compose(existing -> { + if (existing == null) + return Future.succeededFuture(); + + // Immutable check + if (existing.isMutable() != value.isMutable()) { + log.warn("Rejecting value {}: cannot replace mismatched mutable/immutable", value.getId()); + return Future.failedFuture(new ImmutableSubstitutionFail("Cannot replace mismatched mutable/immutable value")); + } + + if (value.getSequenceNumber() < existing.getSequenceNumber()) { + log.warn("Rejecting value {}: sequence number not monotonic", value.getId()); + return Future.failedFuture(new SequenceNotMonotonic("Sequence number less than current")); + } + + if (expectedSequenceNumber >= 0 && existing.getSequenceNumber() > expectedSequenceNumber) { + log.warn("Rejecting value {}: sequence number not expected", value.getId()); + return Future.failedFuture(new SequenceNotExpected("Sequence number not expected")); + } + + if (existing.hasPrivateKey() && !value.hasPrivateKey()) { + log.warn("Rejecting value {}: new value not owned by this node", value.getId()); + return Future.failedFuture(new NotOwnerException("new value no private key")); + } + + return Future.succeededFuture(); + }); + } + @Override public VertxFuture storeValue(Value value, int expectedSequenceNumber, boolean persistent) { Objects.requireNonNull(value, "Invalid value"); @@ -531,12 +562,12 @@ public VertxFuture storeValue(Value value, int expectedSequenceNumber, boo Promise promise = Promise.promise(); - runOnContext(na -> - storage.putValue(value, persistent, expectedSequenceNumber).compose(v -> - doStoreValue(value, expectedSequenceNumber) - ).compose(v -> - storage.updateValueAnnouncedTime(value.getId()).map((Void) null) - ).onComplete(promise) + runOnContext(na -> checkValue(value, expectedSequenceNumber) + .compose(v -> storage.putValue(value, persistent)) + .compose(v -> doStoreValue(value, expectedSequenceNumber)) + .compose(v -> storage.updateValueAnnouncedTime(value.getId())) + .mapEmpty() + .onComplete(promise) ); return VertxFuture.of(promise.future()); @@ -554,8 +585,12 @@ private Future doStoreValue(Value value, int expectedSequenceNumber) { } @Override - public VertxFuture> findPeer(Id id, int expected, LookupOption option) { + public VertxFuture> findPeer(Id id, int expectedSequenceNumber, int expectedCount, LookupOption option) { Objects.requireNonNull(id, "Invalid peer id"); + if (expectedSequenceNumber < -1) + throw new IllegalArgumentException("Invalid sequence number"); + if (expectedCount < 0) + throw new IllegalArgumentException("Invalid expected number of peers"); if (!running) throw new IllegalStateException("Node is not running"); @@ -566,15 +601,20 @@ public VertxFuture> findPeer(Id id, int expected, LookupOption op Variable> localPeers = Variable.empty(); storage.getPeers(id).compose(local -> { - localPeers.set(local); + if (!local.isEmpty() && expectedSequenceNumber >= 0) + local.removeIf(p -> p.getSequenceNumber() < expectedSequenceNumber); - if (lookupOption == LookupOption.LOCAL) - return Future.succeededFuture(local); + if (!local.isEmpty()) { + if (lookupOption == LookupOption.LOCAL) + return Future.succeededFuture(local); - if (local.size() >= expected && lookupOption != LookupOption.CONSERVATIVE) - return Future.succeededFuture(local); + if (lookupOption != LookupOption.CONSERVATIVE && expectedCount > 0 && local.size() >= expectedCount) + return Future.succeededFuture(local); - return doFindPeer(id, expected, lookupOption).map(peers -> { + localPeers.set(local); + } + + return doFindPeer(id, expectedSequenceNumber, expectedCount, lookupOption).map(peers -> { if (local.isEmpty() && peers.isEmpty()) return Collections.emptyList(); @@ -587,6 +627,7 @@ public VertxFuture> findPeer(Id id, int expected, LookupOption op return new ArrayList<>(dedup.values()); }); }).compose(peers -> { + // TODO: if (!peers.isEmpty() && peers != localPeers.orElse(null)) return storage.putPeers(peers); @@ -597,6 +638,7 @@ public VertxFuture> findPeer(Id id, int expected, LookupOption op return VertxFuture.of(promise.future()); } + // TODO: private List mergePeers(CompositeFuture future) { Map dedup = new HashMap<>(16); if (future.isComplete(0)) { @@ -612,22 +654,22 @@ private List mergePeers(CompositeFuture future) { return new ArrayList<>(dedup.values()); } - private Future> doFindPeer(Id id, int expected, LookupOption option) { + private Future> doFindPeer(Id id, int expectedSequenceNumber, int expectedCount, LookupOption option) { if (dht4 == null || dht6 == null) { DHT dht = dht4 != null ? dht4 : dht6; - return dht.findPeer(id, expected, option); + return dht.findPeer(id, expectedSequenceNumber, expectedCount, option); } else { - Future> future4 = dht4.findPeer(id, expected, option); - Future> future6 = dht6.findPeer(id, expected, option); + Future> future4 = dht4.findPeer(id, expectedSequenceNumber, expectedCount, option); + Future> future6 = dht6.findPeer(id, expectedSequenceNumber, expectedCount, option); if (option == LookupOption.CONSERVATIVE) return Future.all(future4, future6).map(this::mergePeers); return Future.any(future4, future6).compose(cf -> { - if (future4.isComplete() && future4.result().size() < expected) + if (future4.isComplete() && future4.result().size() < expectedCount) return Future.all(future4, future6).map(this::mergePeers); - if (future6.isComplete() && future6.result().size() < expected) + if (future6.isComplete() && future6.result().size() < expectedCount) return Future.all(future4, future6).map(this::mergePeers); return Future.succeededFuture(mergePeers(cf)); @@ -635,31 +677,55 @@ private Future> doFindPeer(Id id, int expected, LookupOption opti } } + private Future checkPeer(PeerInfo peer, int expectedSequenceNumber) { + return storage.getPeer(peer.getId(), peer.getFingerprint()).compose(existing -> { + if (existing == null) + return Future.succeededFuture(); + + if (peer.getSequenceNumber() < existing.getSequenceNumber()) { + log.warn("Rejecting peer {}: sequence number not monotonic", peer.getId()); + return Future.failedFuture(new SequenceNotMonotonic("Sequence number less than current")); + } + + if (expectedSequenceNumber >= 0 && existing.getSequenceNumber() > expectedSequenceNumber) { + log.warn("Rejecting peer {}: sequence number not expected", peer.getId()); + return Future.failedFuture(new SequenceNotExpected("Sequence number not expected")); + } + + if (existing.hasPrivateKey() && !peer.hasPrivateKey()) { + log.warn("Rejecting peer {}: new peer not owned by this node", peer.getId()); + return Future.failedFuture(new NotOwnerException("new peer no private key")); + } + + return Future.succeededFuture(); + }); + } + @Override - public VertxFuture announcePeer(PeerInfo peer, boolean persistent) { + public VertxFuture announcePeer(PeerInfo peer, int expectedSequenceNumber, boolean persistent) { Objects.requireNonNull(peer, "Invalid value"); checkRunning(); Promise promise = Promise.promise(); - runOnContext(na -> - storage.putPeer(peer, persistent).compose(v -> - doAnnouncePeer(peer) - ).compose(v -> - storage.updatePeerAnnouncedTime(peer.getId(), identity.getId()).map((Void) null) - ).onComplete(promise) + runOnContext(na -> checkPeer(peer, expectedSequenceNumber) + .compose(v -> storage.putPeer(peer, persistent)) + .compose(v -> doAnnouncePeer(peer, expectedSequenceNumber)) + .compose(v -> storage.updatePeerAnnouncedTime(peer.getId(), peer.getFingerprint())) + .mapEmpty() + .onComplete(promise) ); return VertxFuture.of(promise.future()); } - private Future doAnnouncePeer(PeerInfo peer) { + private Future doAnnouncePeer(PeerInfo peer, int expectedSequenceNumber) { if (dht4 == null || dht6 == null) { DHT dht = dht4 != null ? dht4 : dht6; - return dht.announcePeer(peer); + return dht.announcePeer(peer, expectedSequenceNumber); } else { - Future future4 = dht4.announcePeer(peer); - Future future6 = dht6.announcePeer(peer); + Future future4 = dht4.announcePeer(peer, expectedSequenceNumber); + Future future6 = dht6.announcePeer(peer, expectedSequenceNumber); return Future.all(future4, future6).mapEmpty(); } } @@ -711,8 +777,8 @@ private void persistentAnnounce() { storage.getPeers(true, before).map(peers -> { for (PeerInfo peer : peers) { log.debug("Re-announce the peer: {}", peer.getId()); - doAnnouncePeer(peer).compose(v -> - storage.updatePeerAnnouncedTime(peer.getId(), identity.getId()) + doAnnouncePeer(peer, -1).compose(v -> + storage.updatePeerAnnouncedTime(peer.getId(), peer.getFingerprint()) ).andThen(ar -> { if (ar.succeeded()) log.debug("Re-announce the peer {} success", peer.getId()); @@ -744,18 +810,34 @@ public VertxFuture removeValue(Id valueId) { } @Override - public VertxFuture getPeer(Id peerId) { + public VertxFuture> getPeers(Id peerId) { + Objects.requireNonNull(peerId, "peerId"); + checkRunning(); + Future> future = storage.getPeers(peerId); + return VertxFuture.of(future); + } + + @Override + public VertxFuture removePeers(Id peerId) { + Objects.requireNonNull(peerId, "peerId"); + checkRunning(); + Future future = storage.removePeers(peerId); + return VertxFuture.of(future); + } + + @Override + public VertxFuture getPeer(Id peerId, long fingerprint) { Objects.requireNonNull(peerId, "peerId"); checkRunning(); - Future future = storage.getPeer(peerId, this.getId()); + Future future = storage.getPeer(peerId, fingerprint); return VertxFuture.of(future); } @Override - public VertxFuture removePeer(Id peerId) { + public VertxFuture removePeer(Id peerId, long fingerprint) { Objects.requireNonNull(peerId, "peerId"); checkRunning(); - Future future = storage.removePeer(peerId, this.getId()); + Future future = storage.removePeer(peerId, fingerprint); return VertxFuture.of(future); } diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/impl/DHT.java b/dht/src/main/java/io/bosonnetwork/kademlia/impl/DHT.java index 255913a..e4fee67 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/impl/DHT.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/impl/DHT.java @@ -31,10 +31,13 @@ import io.bosonnetwork.PeerInfo; import io.bosonnetwork.Result; import io.bosonnetwork.Value; +import io.bosonnetwork.kademlia.exceptions.ImmutableSubstitutionFail; import io.bosonnetwork.kademlia.exceptions.InvalidPeer; import io.bosonnetwork.kademlia.exceptions.InvalidToken; import io.bosonnetwork.kademlia.exceptions.InvalidValue; import io.bosonnetwork.kademlia.exceptions.KadException; +import io.bosonnetwork.kademlia.exceptions.SequenceNotExpected; +import io.bosonnetwork.kademlia.exceptions.SequenceNotMonotonic; import io.bosonnetwork.kademlia.metrics.DHTMetrics; import io.bosonnetwork.kademlia.protocol.AnnouncePeerRequest; import io.bosonnetwork.kademlia.protocol.Error; @@ -69,13 +72,13 @@ import io.bosonnetwork.vertx.BosonVerticle; public class DHT extends BosonVerticle { - public static final int BOOTSTRAP_MIN_INTERVAL = 4 * 60 * 1000; // 4 minutes - public static final int SELF_LOOKUP_INTERVAL = 30 * 60 * 1000; // 30 minutes - public static final int ROUTING_TABLE_PERSIST_INTERVAL = 10 * 60 * 1000; // 10 minutes - public static final int ROUTING_TABLE_MAINTENANCE_INTERVAL = 4 * 60 * 1000; // 4 minutes - public static final int RANDOM_LOOKUP_INTERVAL = 10 * 60 * 1000; // 10 minutes - public static final int RANDOM_PING_INTERVAL = 10 * 1000; // 10 seconds - public static final int BOOTSTRAP_IF_LESS_THAN_X_ENTRIES = 30; + public static final int BOOTSTRAP_MIN_INTERVAL = 4 * 60 * 1000; // 4 minutes + public static final int SELF_LOOKUP_INTERVAL = 30 * 60 * 1000; // 30 minutes + public static final int ROUTING_TABLE_PERSIST_INTERVAL = 10 * 60 * 1000; // 10 minutes + public static final int ROUTING_TABLE_MAINTENANCE_INTERVAL = 4 * 60 * 1000; // 4 minutes + public static final int RANDOM_LOOKUP_INTERVAL = 10 * 60 * 1000; // 10 minutes + public static final int RANDOM_PING_INTERVAL = 10 * 1000; // 10 seconds + public static final int BOOTSTRAP_IF_LESS_THAN_X_ENTRIES = 30; public static final int USE_BOOTSTRAP_NODES_IF_LESS_THAN_X_ENTRIES = 8; private final Identity identity; @@ -371,8 +374,8 @@ private void routingTableMaintenance() { lastMaintenance = now; routingTable.maintenance(bootstrapIds, bucket -> - tryPingMaintenance(bucket, false, false, true, - "RoutingTable maintenance: refreshing bucket - " + bucket.prefix()) + tryPingMaintenance(bucket, false, false, true, + "RoutingTable maintenance: refreshing bucket - " + bucket.prefix()) ); } @@ -675,7 +678,7 @@ private void onRequest(Message message) { case FIND_NODE -> onFindNode((Message) message); case FIND_VALUE -> onFindValue((Message) message); case STORE_VALUE -> onStoreValue((Message) message); - case FIND_PEER -> onFindPeers((Message) message); + case FIND_PEER -> onFindPeer((Message) message); case ANNOUNCE_PEER -> onAnnouncePeer((Message) message); default -> onUnknownMethod(message); } @@ -683,7 +686,7 @@ private void onRequest(Message message) { private void onPing(Message request) { Message response = Message.pingResponse(request.getTxid()) - .setRemote(request.getRemoteId(), request.getRemoteAddress()); + .setRemote(request.getRemoteId(), request.getRemoteAddress()); rpcServer.sendMessage(response); } @@ -740,7 +743,37 @@ private void onStoreValue(Message request) { throw new InvalidValue("Invalid value for STORE VALUE request"); return value; - }).compose(storage::putValue).transform(ar -> { + }).compose(value -> { + return storage.getValue(value.getId()).compose(existing -> { + if (existing != null) { + // Immutable check + if (existing.isMutable() != value.isMutable()) { + log.warn("Rejecting value {}: cannot replace mismatched mutable/immutable", value.getId()); + return Future.failedFuture(new ImmutableSubstitutionFail("Cannot replace mismatched mutable/immutable value")); + } + + if (value.getSequenceNumber() < existing.getSequenceNumber()) { + log.warn("Rejecting value {}: sequence number not monotonic", value.getId()); + return Future.failedFuture(new SequenceNotMonotonic("Sequence number less than current")); + } + + int expectedSequenceNumber = request.getBody().getExpectedSequenceNumber(); + if (expectedSequenceNumber >= 0 && existing.getSequenceNumber() > expectedSequenceNumber) { + log.warn("Rejecting value {}: sequence number not expected", value.getId()); + return Future.failedFuture(new SequenceNotExpected("Sequence number not expected")); + } + + if (existing.hasPrivateKey() && !value.hasPrivateKey()) { + // Skip update if the existing value is owned by this node and the new value is not. + // Should not throw NotOwnerException, just silently ignore to avoid disrupting valid operations. + log.info("Skipping to update value for id {}: owned by this node", value.getId()); + return Future.succeededFuture(existing); + } + } + + return storage.putValue(value); + }); + }).transform(ar -> { Message response = ar.succeeded() ? Message.storeValueResponse(request.getTxid()) : exceptionToError(request.getMethod(), request.getTxid(), ar.cause()); response.setRemote(request.getId(), request.getRemoteAddress()); @@ -748,16 +781,14 @@ private void onStoreValue(Message request) { }); } - private void onFindPeers(Message request) { + private void onFindPeer(Message request) { Id target = request.getBody().getTarget(); - storage.getPeers(target).map(peers -> { + int expectedSequenceNumber = request.getBody().getExpectedSequenceNumber(); + int expectedCount = request.getBody().getExpectedCount() > 0 ? request.getBody().getExpectedCount() : 16; + storage.getPeers(target, expectedSequenceNumber, expectedCount).map(peers -> { Message response; if (!peers.isEmpty()) { - if (peers.size() > 8) { - Collections.shuffle(peers); - peers = peers.subList(0, 8); - } response = Message.findPeerResponse(request.getTxid(), peers); } else { int want4 = request.getBody().doesWant4() ? KBucket.MAX_ENTRIES : 0; @@ -798,7 +829,31 @@ private void onAnnouncePeer(Message request) { throw new InvalidPeer("Invalid value for ANNOUNCE PEER request"); return peer; - }).compose(storage::putPeer).transform(ar -> { + }).compose(peer -> { + return storage.getPeer(peer.getId(), peer.getFingerprint()).compose(existing -> { + if (existing != null) { + if (peer.getSequenceNumber() < existing.getSequenceNumber()) { + log.warn("Rejecting peer {}: sequence number not monotonic", peer.getId()); + return Future.failedFuture(new SequenceNotMonotonic("Sequence number less than current")); + } + + int expectedSequenceNumber = request.getBody().getExpectedSequenceNumber(); + if (expectedSequenceNumber >= 0 && existing.getSequenceNumber() > expectedSequenceNumber) { + log.warn("Rejecting peer {}: sequence number not expected", peer.getId()); + return Future.failedFuture(new SequenceNotExpected("Sequence number not expected")); + } + + if (existing.hasPrivateKey() && !peer.hasPrivateKey()) { + // Skip update if the existing peer is owned by this node and the new peer is not. + // Should not throw NotOwnerException, just silently ignore to avoid disrupting valid operations. + log.info("Skipping to update peer for id {}: owned by this node", peer.getId()); + return Future.succeededFuture(existing); + } + } + + return storage.putPeer(peer); + }); + }).transform(ar -> { Message response = ar.succeeded() ? Message.announcePeerResponse(request.getTxid()) : exceptionToError(request.getMethod(), request.getTxid(), ar.cause()); response.setRemote(request.getId(), request.getRemoteAddress()); @@ -978,7 +1033,7 @@ private Result> populateClosestNodes(Id target, int v4, .nodes(); // Add self to the list if needed if (nodes6.size() < v6) - nodes6.add(nodeInfo); + nodes6.add(nodeInfo); } } @@ -1083,14 +1138,14 @@ public Future storeValue(Value value, int expectedSequenceNumber) { } @SuppressWarnings("unused") - public Future> findPeer(Id id, int expected, LookupOption option) { + public Future> findPeer(Id id, int expectedSequenceNumber, int expectedCount, LookupOption option) { Promise> promise = Promise.promise(); runOnContext(v -> { - PeerLookupTask task = new PeerLookupTask(kadContext, id) + PeerLookupTask task = new PeerLookupTask(kadContext, id, expectedSequenceNumber, expectedCount) .setName("Lookup peer: " + id) .setResultFilter((previous, next) -> { - if (expected >= 0 && next.size() >= expected) + if (expectedCount >= 0 && next.size() >= expectedCount) return ResultFilter.Action.ACCEPT_DONE; else return ResultFilter.Action.ACCEPT_CONTINUE; @@ -1104,11 +1159,11 @@ public Future> findPeer(Id id, int expected, LookupOption option) return promise.future(); } - public Future announcePeer(PeerInfo peer) { + public Future announcePeer(PeerInfo peer, int expectedSequenceNumber) { Promise promise = Promise.promise(); runOnContext(v -> { - PeerAnnounceTask announceTask = new PeerAnnounceTask(kadContext, peer) + PeerAnnounceTask announceTask = new PeerAnnounceTask(kadContext, peer, expectedSequenceNumber) .setName("Announce peer: " + peer.getId()) .addListener(t -> promise.complete()); diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/impl/SimpleNodeConfiguration.java b/dht/src/main/java/io/bosonnetwork/kademlia/impl/SimpleNodeConfiguration.java index 6dd6544..8457952 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/impl/SimpleNodeConfiguration.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/impl/SimpleNodeConfiguration.java @@ -14,7 +14,6 @@ import io.bosonnetwork.NodeConfiguration; import io.bosonnetwork.NodeInfo; import io.bosonnetwork.crypto.Signature; -import io.bosonnetwork.kademlia.storage.InMemoryStorage; public class SimpleNodeConfiguration implements NodeConfiguration { private final Vertx vertx; @@ -39,7 +38,7 @@ public SimpleNodeConfiguration(NodeConfiguration config) { this.privateKey = config.privateKey(); this.dataDir = config.dataDir() != null ? config.dataDir().toAbsolutePath() : Path.of(System.getProperty("user.dir")).resolve("node"); - this.databaseUri = config.databaseUri() != null ? config.databaseUri() : InMemoryStorage.STORAGE_URI; + this.databaseUri = config.databaseUri(); this.databasePoolSize = config.databasePoolSize(); this.databaseSchemaName = config.databaseSchemaName(); this.bootstrapNodes = new ArrayList<>(config.bootstrapNodes() != null ? config.bootstrapNodes() : Collections.emptyList()); diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/protocol/AnnouncePeerRequest.java b/dht/src/main/java/io/bosonnetwork/kademlia/protocol/AnnouncePeerRequest.java index e34538d..82ab46c 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/protocol/AnnouncePeerRequest.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/protocol/AnnouncePeerRequest.java @@ -39,16 +39,18 @@ import io.bosonnetwork.Id; import io.bosonnetwork.PeerInfo; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.internal.DataFormat; @JsonSerialize(using = AnnouncePeerRequest.Serializer.class) @JsonDeserialize(using = AnnouncePeerRequest.Deserializer.class) public class AnnouncePeerRequest implements Request { private final int token; + private final int expectedSequenceNumber; private final PeerInfo peer; - public AnnouncePeerRequest(PeerInfo peer, int token) { + public AnnouncePeerRequest(PeerInfo peer, int token, int expectedSequenceNumber) { this.token = token; + this.expectedSequenceNumber = expectedSequenceNumber; this.peer = peer; } @@ -56,6 +58,10 @@ public int getToken() { return token; } + public int getExpectedSequenceNumber() { + return expectedSequenceNumber; + } + public PeerInfo getPeer() { return peer; } @@ -89,36 +95,56 @@ public Serializer(Class t) { @Override public void serialize(AnnouncePeerRequest value, JsonGenerator gen, SerializerProvider provider) throws IOException { - boolean binaryFormat = Json.isBinaryFormat(gen); + boolean binaryFormat = DataFormat.isBinary(gen); gen.writeStartObject(); gen.writeNumberField("tok", value.token); + if (value.expectedSequenceNumber >= 0) + gen.writeNumberField("cas", value.expectedSequenceNumber); + + PeerInfo peer = value.peer; + if (binaryFormat) { gen.writeFieldName("t"); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.peer.getId().bytes(), 0, Id.BYTES); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, peer.getId().bytes(), 0, Id.BYTES); } else { - gen.writeStringField("t", value.peer.getId().toBase58String()); + gen.writeStringField("t", peer.getId().toBase58String()); } - if (value.peer.isDelegated()) { + byte[] nonce = peer.getNonce(); + gen.writeFieldName("n"); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, nonce, 0, nonce.length); + + if (peer.getSequenceNumber() > 0) + gen.writeNumberField("seq", peer.getSequenceNumber()); + + if (peer.isAuthenticated()) { if (binaryFormat) { gen.writeFieldName("o"); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.peer.getOrigin().bytes(), 0, Id.BYTES); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, peer.getNodeId().bytes(), 0, Id.BYTES); } else { - gen.writeStringField("o", value.peer.getOrigin().toBase58String()); + gen.writeStringField("o", value.peer.getNodeId().toBase58String()); } - } - - gen.writeNumberField("p", value.peer.getPort()); - if (value.peer.getAlternativeURI() != null) - gen.writeStringField("alt", value.peer.getAlternativeURI()); + byte[] sig = peer.getNodeSignature(); + gen.writeFieldName("os"); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, sig, 0, sig.length); + } - byte[] sig = value.peer.getSignature(); + byte[] sig = peer.getSignature(); gen.writeFieldName("sig"); gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, sig, 0, sig.length); + gen.writeNumberField("f", peer.getFingerprint()); + gen.writeStringField("e", peer.getEndpoint()); + + if (peer.hasExtra()) { + byte[] extra = peer.getExtraData(); + gen.writeFieldName("ex"); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, extra, 0, extra.length); + } + gen.writeEndObject(); } } @@ -140,18 +166,19 @@ public AnnouncePeerRequest deserialize(JsonParser p, DeserializationContext ctxt throw ctxt.wrongTokenException(p, AnnouncePeerRequest.class, JsonToken.START_OBJECT, "Invalid AnnouncePeerRequest: should be an object"); - final boolean binaryFormat = Json.isBinaryFormat(p); + final boolean binaryFormat = DataFormat.isBinary(p); int tok = 0; + int cas = -1; Id peerId = null; - Id origin = null; - int port = 0; - String alternativeURI = null; + byte[] nonce = null; + int sequenceNumber = 0; + Id nodeId = null; + byte[] nodeSig = null; byte[] signature = null; - - Id nodeId = (Id) ctxt.getAttribute(Message.ATTR_NODE_ID); - if (nodeId == null) - ctxt.reportInputMismatch(AnnouncePeerRequest.class, "Missing nodeId attribute in the deserialization context"); + long fingerprint = 0; + String endpoint = null; + byte[] extraData = null; while (p.nextToken() != JsonToken.END_OBJECT) { final String fieldName = p.currentName(); @@ -160,29 +187,45 @@ public AnnouncePeerRequest deserialize(JsonParser p, DeserializationContext ctxt case "tok": tok = p.getIntValue(); break; + case "cas": + cas = p.getIntValue(); + break; case "t": peerId = binaryFormat ? Id.of(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : Id.of(p.getText()); break; + case "n": + nonce = p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL); + break; + case "seq": + sequenceNumber = p.getIntValue(); + break; case "o": if (token != JsonToken.VALUE_NULL) - origin = binaryFormat ? Id.of(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : Id.of(p.getText()); + nodeId = binaryFormat ? Id.of(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : Id.of(p.getText()); break; - case "p": - port = p.getIntValue(); - break; - case "alt": + case "os": if (token != JsonToken.VALUE_NULL) - alternativeURI = p.getText(); + nodeSig = p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL); break; case "sig": signature = p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL); break; + case "f": + fingerprint = p.getLongValue(); + break; + case "e": + endpoint = p.getText(); + break; + case "ex": + if (token != JsonToken.VALUE_NULL) + extraData = p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL); + break; default: p.skipChildren(); } } - return new AnnouncePeerRequest(PeerInfo.of(peerId, nodeId, origin, port, alternativeURI, signature), tok); + return new AnnouncePeerRequest(PeerInfo.of(peerId, nonce, sequenceNumber, nodeId, nodeSig, signature, fingerprint, endpoint, extraData), tok, cas); } } } \ No newline at end of file diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/protocol/FindPeerRequest.java b/dht/src/main/java/io/bosonnetwork/kademlia/protocol/FindPeerRequest.java index d4588f5..fbe6d3d 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/protocol/FindPeerRequest.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/protocol/FindPeerRequest.java @@ -23,29 +23,68 @@ package io.bosonnetwork.kademlia.protocol; import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; import io.bosonnetwork.Id; -// @JsonDeserialize(using = FindPeerRequest.Deserializer.class) +@JsonPropertyOrder({"t", "w", "cas", "e"}) public class FindPeerRequest extends LookupRequest { + private final int expectedSequenceNumber; + private final int expectedCount; + @JsonCreator protected FindPeerRequest(@JsonProperty(value = "t", required = true) Id target, - @JsonProperty(value = "w", required = true) int want) { + @JsonProperty(value = "w", required = true) int want, + @JsonProperty(value = "cas") Integer expectedSequenceNumber, + @JsonProperty(value = "e") Integer expectedCount) { super(target, want); + this.expectedSequenceNumber = expectedSequenceNumber != null ? expectedSequenceNumber : -1; + this.expectedCount = expectedCount != null ? expectedCount : 0; } - public FindPeerRequest(Id target, boolean want4, boolean want6) { + public FindPeerRequest(Id target, boolean want4, boolean want6, int expectedSequenceNumber, int expectedCount) { super(target, want4, want6, false); + this.expectedSequenceNumber = expectedSequenceNumber; + this.expectedCount = expectedCount; + } + + public int getExpectedSequenceNumber() { + return expectedSequenceNumber; + } + + @JsonProperty("cas") + @JsonInclude(JsonInclude.Include.NON_NULL) + private Integer getCas() { + return expectedSequenceNumber >= 0 ? expectedSequenceNumber : null; + } + + public int getExpectedCount() { + return expectedCount; + } + + @JsonProperty("e") + @JsonInclude(JsonInclude.Include.NON_NULL) + private Integer getExceptedPeers() { + return expectedCount > 0 ? expectedCount : null; } @Override public int hashCode() { - return 0xF1AD9EE2 + super.hashCode(); + return 0xF1AD9EE2 + super.hashCode() + Integer.hashCode(expectedSequenceNumber) + Integer.hashCode(expectedCount); } @Override public boolean equals(Object obj) { - return obj instanceof FindPeerRequest && super.equals(obj); + if (this == obj) + return true; + + if (obj instanceof FindPeerRequest that) + return expectedSequenceNumber == that.expectedSequenceNumber && + expectedCount == that.expectedCount && + super.equals(obj); + + return false; } } \ No newline at end of file diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/protocol/FindPeerResponse.java b/dht/src/main/java/io/bosonnetwork/kademlia/protocol/FindPeerResponse.java index 8547776..e6dbe7a 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/protocol/FindPeerResponse.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/protocol/FindPeerResponse.java @@ -22,8 +22,6 @@ package io.bosonnetwork.kademlia.protocol; -import java.io.IOException; -import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; @@ -32,16 +30,6 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonToken; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JavaType; -import com.fasterxml.jackson.databind.SerializerProvider; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import com.fasterxml.jackson.databind.deser.std.StdDeserializer; -import com.fasterxml.jackson.databind.ser.std.StdSerializer; import io.bosonnetwork.NodeInfo; import io.bosonnetwork.PeerInfo; @@ -50,8 +38,6 @@ public class FindPeerResponse extends LookupResponse { @JsonProperty("p") @JsonInclude(JsonInclude.Include.NON_EMPTY) - @JsonSerialize(using = PeersSerializer.class) - @JsonDeserialize(using = PeersDeserializer.class) private final List peers; @JsonCreator @@ -94,73 +80,4 @@ public boolean equals(Object obj) { return false; } - - private static class PeersSerializer extends StdSerializer> { - private static final long serialVersionUID = -8878162342914711108L; - - public PeersSerializer() { - super((Class>) null); - } - - public PeersSerializer(Class> t) { - super(t); - } - - @Override - public void serialize(List value, JsonGenerator gen, SerializerProvider provider) throws IOException { - final int size = value.size(); - - gen.writeStartArray(value, size); - - boolean leadingPeer = true; - - for (int i = 0; i < size; i++) { - provider.defaultSerializeValue(value.get(i), gen); - if (leadingPeer && size > 1) { - leadingPeer = false; - provider.setAttribute(PeerInfo.ATTRIBUTE_OMIT_PEER_ID, true); - } - } - - gen.writeEndArray(); - } - - @Override - public boolean isEmpty(SerializerProvider provider, List value) { - return value == null || value.isEmpty(); - } - } - - public static class PeersDeserializer extends StdDeserializer> { - private static final long serialVersionUID = -6827233304562559223L; - - public PeersDeserializer() { - super((Class) null); - } - - public PeersDeserializer(Class vc) { - super(vc); - } - - @Override - public List deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { - if (p.currentToken() != JsonToken.START_ARRAY) - throw ctxt.wrongTokenException(p, List.class, JsonToken.START_ARRAY, "Invalid peers list: should be an array"); - - ArrayList peers = new ArrayList<>(); - boolean leadingPeer = true; - - final JavaType type = ctxt.constructType(PeerInfo.class); - while (p.nextToken() != JsonToken.END_ARRAY) { - final PeerInfo peer = ctxt.readValue(p, type); - if (leadingPeer) { - leadingPeer = false; - ctxt.setAttribute(PeerInfo.ATTRIBUTE_PEER_ID, peer.getId()); - } - peers.add(peer); - } - - return peers; - } - } } \ No newline at end of file diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/protocol/FindValueRequest.java b/dht/src/main/java/io/bosonnetwork/kademlia/protocol/FindValueRequest.java index 657a19e..fc674b1 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/protocol/FindValueRequest.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/protocol/FindValueRequest.java @@ -25,9 +25,11 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; import io.bosonnetwork.Id; +@JsonPropertyOrder({"t", "w", "cas"}) public class FindValueRequest extends LookupRequest { // Only send the value if the real sequence number greater than this. private final int expectedSequenceNumber; diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/protocol/FindValueResponse.java b/dht/src/main/java/io/bosonnetwork/kademlia/protocol/FindValueResponse.java index b9113b6..fe58ee8 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/protocol/FindValueResponse.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/protocol/FindValueResponse.java @@ -42,7 +42,8 @@ import io.bosonnetwork.Id; import io.bosonnetwork.NodeInfo; import io.bosonnetwork.Value; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; +import io.bosonnetwork.json.internal.DataFormat; @JsonPropertyOrder({"n4", "n6", "k", "rec", "n", "seq", "sig", "v"}) @JsonDeserialize(using = FindValueResponse.Deserializer.class) @@ -144,7 +145,7 @@ public FindValueResponse deserialize(JsonParser p, DeserializationContext ctxt) throw ctxt.wrongTokenException(p, FindValueResponse.class, JsonToken.START_OBJECT, "Invalid FindValueResponse: should be an object"); - final boolean binaryFormat = Json.isBinaryFormat(p); + final boolean binaryFormat = DataFormat.isBinary(p); List nodes4 = null; List nodes6 = null; diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/protocol/Message.java b/dht/src/main/java/io/bosonnetwork/kademlia/protocol/Message.java index 375bbc8..1ba77e1 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/protocol/Message.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/protocol/Message.java @@ -51,9 +51,10 @@ import io.bosonnetwork.PeerInfo; import io.bosonnetwork.Value; import io.bosonnetwork.Version; +import io.bosonnetwork.json.JsonContext; import io.bosonnetwork.kademlia.KadNode; import io.bosonnetwork.kademlia.rpc.RpcCall; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; @JsonDeserialize(using = Message.Deserializer.class) @JsonSerialize(using = Message.Serializer.class) @@ -305,14 +306,9 @@ public boolean equals(Object obj) { // ndodeId -Source node ID, required to correctly deserialize inbound messages public static Message parse(byte[] bytes, Id nodeId) { try { - /* - return nodeId == null ? - Json.cborMapper().readValue(bytes, Message2.class) : - Json.cborMapper().reader(Json.JsonContext.shared(ATTR_NODE_ID, nodeId)).readValue(bytes, Message2.class); - */ return nodeId == null ? cborReader.readValue(bytes) : - cborReader.with(Json.JsonContext.perCall(ATTR_NODE_ID, nodeId)).readValue(bytes); + cborReader.with(JsonContext.perCall(ATTR_NODE_ID, nodeId)).readValue(bytes); } catch (IOException e) { throw new IllegalArgumentException("Invalid CBOR data for Message", e); } @@ -325,14 +321,9 @@ public static Message parse(String json) { // nodeId -Source node ID, required to correctly deserialize inbound messages public static Message parse(String json, Id nodeId) { try { - /* - return nodeId == null ? - Json.objectMapper().readValue(json, Message2.class) : - Json.objectMapper().reader(Json.JsonContext.shared(ATTR_NODE_ID, nodeId)).readValue(json, Message2.class); - */ return nodeId == null ? jsonReader.readValue(json) : - jsonReader.with(Json.JsonContext.perCall(ATTR_NODE_ID, nodeId)).readValue(json); + jsonReader.with(JsonContext.perCall(ATTR_NODE_ID, nodeId)).readValue(json); } catch (IOException e) { throw new IllegalArgumentException("Invalid JSON data for Message", e); } @@ -414,8 +405,8 @@ public static Message findNodeResponse(long txid, List(Type.RESPONSE, Method.FIND_NODE, txid, new FindNodeResponse(n4, n6, token)); } - public static Message findPeerRequest(Id target, boolean want4, boolean want6) { - return new Message<>(Type.REQUEST, Method.FIND_PEER, nextTxid(), new FindPeerRequest(target, want4, want6)); + public static Message findPeerRequest(Id target, boolean want4, boolean want6, int expectedSequenceNumber, int expectedCount) { + return new Message<>(Type.REQUEST, Method.FIND_PEER, nextTxid(), new FindPeerRequest(target, want4, want6, expectedSequenceNumber, expectedCount)); } public static Message findPeerResponse(long txid, List n4, List n6) { @@ -430,8 +421,8 @@ protected static Message findPeerResponse(long txid, List(Type.RESPONSE, Method.FIND_PEER, txid, new FindPeerResponse(n4, n6, peers)); } - public static Message announcePeerRequest(PeerInfo peer, int token) { - return new Message<>(Type.REQUEST, Method.ANNOUNCE_PEER, nextTxid(), new AnnouncePeerRequest(peer, token)); + public static Message announcePeerRequest(PeerInfo peer, int token, int expectedSequenceNumber) { + return new Message<>(Type.REQUEST, Method.ANNOUNCE_PEER, nextTxid(), new AnnouncePeerRequest(peer, token, expectedSequenceNumber)); } public static Message announcePeerResponse(long txid) { diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/protocol/StoreValueRequest.java b/dht/src/main/java/io/bosonnetwork/kademlia/protocol/StoreValueRequest.java index 8ea13fc..5af5f13 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/protocol/StoreValueRequest.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/protocol/StoreValueRequest.java @@ -37,7 +37,7 @@ import io.bosonnetwork.Id; import io.bosonnetwork.Value; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.internal.DataFormat; @JsonPropertyOrder({"tok", "cas", "k", "rec", "n", "seq", "sig", "v"}) @JsonDeserialize(using = StoreValueRequest.Deserializer.class) @@ -52,12 +52,6 @@ protected StoreValueRequest(Value value, int token, int expectedSequenceNumber) this.value = value; } - public StoreValueRequest(int token, int expectedSequenceNumber, Value value) { - this.token = token; - this.expectedSequenceNumber = expectedSequenceNumber; - this.value = value; - } - @JsonProperty("tok") public int getToken() { return token; @@ -148,7 +142,7 @@ public StoreValueRequest deserialize(JsonParser p, DeserializationContext ctxt) throw ctxt.wrongTokenException(p, StoreValueRequest.class, JsonToken.START_OBJECT, "Invalid StoreValueRequest: should be an object"); - final boolean binaryFormat = Json.isBinaryFormat(p); + final boolean binaryFormat = DataFormat.isBinary(p); int tok = 0; int cas = -1; diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/routing/RoutingTable.java b/dht/src/main/java/io/bosonnetwork/kademlia/routing/RoutingTable.java index 02d8322..e2ed945 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/routing/RoutingTable.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/routing/RoutingTable.java @@ -47,7 +47,7 @@ import io.bosonnetwork.Id; import io.bosonnetwork.crypto.Random; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; /** * Represents a lock-free, non-thread-safe routing table used in the Kademlia Distributed Hash Table (DHT) implementation. diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/security/FileBlacklist.java b/dht/src/main/java/io/bosonnetwork/kademlia/security/FileBlacklist.java index 759b19b..4613729 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/security/FileBlacklist.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/security/FileBlacklist.java @@ -44,7 +44,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.bosonnetwork.Id; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; /** * A thread-safe file based blacklist for managing banned hosts and IDs using a copy-on-write strategy. diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/storage/DataStorage.java b/dht/src/main/java/io/bosonnetwork/kademlia/storage/DataStorage.java index 61fe856..5996778 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/storage/DataStorage.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/storage/DataStorage.java @@ -87,17 +87,7 @@ public interface DataStorage { Future putValue(Value value, boolean persistent); /** - * Stores a value in the storage with the specified identifier, sequence number, and persistence flag. - * - * @param value the value to store - * @param persistent true if the value should be stored persistently, false otherwise - * @param expectedSequenceNumber the expected sequence number for the value, -1 disable the check - * @return a {@link Future} containing the stored {@link Value} - */ - Future putValue(Value value, boolean persistent, int expectedSequenceNumber); - - /** - * Retrieves a value from the DHT by its identifier. + * Retrieves a value from the local storage by its identifier. * * @param id the identifier of the value * @return a {@link Future} containing the {@link Value} or null if not found @@ -151,10 +141,10 @@ public interface DataStorage { Future updateValueAnnouncedTime(Id id); /** - * Removes a value from the DHT by its identifier. + * Removes a value from the storage by its identifier. * * @param id the identifier of the value to remove - * @return a {@link Future Future} that completes with {@code true} if the value was successfully removed, + * @return a {@link Future}{@code } that completes with {@code true} if the value was successfully removed, * or {@code false} if no matching value was found */ Future removeValue(Id id); @@ -184,14 +174,15 @@ public interface DataStorage { */ Future> putPeers(List peerInfos); - /** - * Retrieves peer information by peer and node identifiers. - * @param id the peer identifier - * @param nodeId the node identifier - * @return a {@link Future} containing the {@link PeerInfo} or null if not found + /** + * Retrieves information about a peer based on the provided identifier and serial number. + * + * @param id the unique identifier of the peer + * @param fingerprint the serial number associated with the peer + * @return a Future containing the peer information as a PeerInfo object */ - Future getPeer(Id id, Id nodeId); + Future getPeer(Id id, long fingerprint); /** * Retrieves all peer information associated with a peer identifier. @@ -201,6 +192,26 @@ public interface DataStorage { */ Future> getPeers(Id id); + /** + * Retrieves peer information associated with a peer identifier, filtered by sequence number. + * + * @param id the peer identifier + * @param expectedSequenceNumber the minimum sequence number to include + * @param limit the maximum number of results to return (positive) + * @return a {@link Future} containing a list of matching {@link PeerInfo}s + * @throws IllegalArgumentException if limit is non-positive + */ + Future> getPeers(Id id, int expectedSequenceNumber, int limit); + + /** + * Retrieves peer information by peer and node identifiers. + * + * @param id the peer identifier + * @param nodeId the node identifier + * @return a {@link Future} containing a list of matching {@link PeerInfo}s + */ + Future> getPeers(Id id, Id nodeId); + /** * Retrieves all peer information stored in the storage. * @@ -214,6 +225,7 @@ public interface DataStorage { * @param offset the starting index (non-negative) * @param limit the maximum number of peers to return (positive) * @return a {@link Future} containing a list of {@link PeerInfo}s + * @throws IllegalArgumentException if offset is negative or limit is non-positive */ Future> getPeers(int offset, int limit); @@ -242,46 +254,58 @@ public interface DataStorage { * Updates the announcement timestamp for a peer. * * @param id the peer identifier - * @param nodeId the node identifier + * @param fingerprint the serial number associated with the peer * @return a {@link Future} containing the updated timestamp (in milliseconds) */ - Future updatePeerAnnouncedTime(Id id, Id nodeId); + Future updatePeerAnnouncedTime(Id id, long fingerprint); /** * Removes peer information by peer and node identifiers. * * @param id the peer identifier - * @param nodeId the node identifier - * @return a {@link Future Future} that completes with {@code true} if the peer was successfully removed, + * @param fingerprint the serial number associated with the peer + * @return a {@link Future}{@code } that completes with {@code true} if the peer was successfully removed, * or {@code false} if no matching peer was found */ - Future removePeer(Id id, Id nodeId); + Future removePeer(Id id, long fingerprint); /** * Removes all peer information associated with a peer identifier. * * @param id the peer identifier - * @return a {@link Future Future} that completes with {@code true} if the peers was successfully removed, + * @return a {@link Future}{@code } that completes with {@code true} if the peers was successfully removed, * or {@code false} if no matching peer was found */ Future removePeers(Id id); + /** + * Checks if the provided storage URI is supported by this implementation. + * + * @param uri the storage URI to check + * @return true if the URI is supported, false otherwise + */ static boolean supports(String uri) { - // now only support in-memory, sqlite and postgres - return uri.equals(InMemoryStorage.STORAGE_URI) || uri.startsWith(SQLiteStorage.STORAGE_URI_PREFIX) || - uri.startsWith(PostgresStorage.STORAGE_URI_PREFIX); + // now only support sqlite and postgres + return uri.startsWith(SQLiteStorage.STORAGE_URI_PREFIX) || uri.startsWith(PostgresStorage.STORAGE_URI_PREFIX); } + /** + * Creates a new DataStorage instance based on the provided URI. + * + * @param uri the storage connection URI + * @param poolSize the connection pool size + * @param schema the database schema name (if applicable, e.g., for PostgreSQL) + * @return a new DataStorage instance + * @throws IllegalArgumentException if the URI is unsupported + */ static DataStorage create(String uri, int poolSize, String schema) { Objects.requireNonNull(uri, "url"); - if (uri.equals(InMemoryStorage.STORAGE_URI)) - return new InMemoryStorage(); if (uri.startsWith(SQLiteStorage.STORAGE_URI_PREFIX)) return new SQLiteStorage(uri, poolSize); - if (uri.startsWith(PostgresStorage.STORAGE_URI_PREFIX)) + else if (uri.startsWith(PostgresStorage.STORAGE_URI_PREFIX)) return new PostgresStorage(uri, poolSize, schema); - - throw new IllegalArgumentException("Unsupported storage: " + uri); + else + throw new IllegalArgumentException("Unsupported storage: " + uri); } } \ No newline at end of file diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/storage/DatabaseStorage.java b/dht/src/main/java/io/bosonnetwork/kademlia/storage/DatabaseStorage.java index 914d870..0a14df1 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/storage/DatabaseStorage.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/storage/DatabaseStorage.java @@ -39,9 +39,6 @@ import io.bosonnetwork.Value; import io.bosonnetwork.database.VersionedSchema; import io.bosonnetwork.database.VertxDatabase; -import io.bosonnetwork.kademlia.exceptions.ImmutableSubstitutionFail; -import io.bosonnetwork.kademlia.exceptions.SequenceNotExpected; -import io.bosonnetwork.kademlia.exceptions.SequenceNotMonotonic; public abstract class DatabaseStorage implements DataStorage, VertxDatabase { protected long valueExpiration; @@ -79,7 +76,7 @@ public Future initialize(Vertx vertx, long valueExpiration, long peerIn } }).map(v -> schema.getCurrentVersion().version()) .recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + Future.failedFuture(new DataStorageException("Database initialize failed", cause)) ); } @@ -101,68 +98,25 @@ public Future purge() { .execute(Map.of("updatedBefore", now - peerInfoExpiration)) .map((Void) null) ) - ).andThen(ar -> { - if (ar.succeeded()) - getLogger().info("Purge completed successfully"); - else - getLogger().error("Failed to purge expired values and peers", ar.cause()); - }).recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + ).recover(cause -> + Future.failedFuture(new DataStorageException("purge database failed", cause)) ).mapEmpty(); } @Override public Future putValue(Value value) { - return putValue(value, false, -1); + return putValue(value, false); } @Override public Future putValue(Value value, boolean persistent) { - return putValue(value, persistent, -1); - } - - @Override - public Future putValue(Value value, boolean persistent, int expectedSequenceNumber) { - getLogger().debug("Putting value with id: {}, persistent: {}, expectedSequenceNumber: {}", - value.getId(), persistent, expectedSequenceNumber); - getLogger().debug("Trying to check the existing value with id: {}", value.getId()); - return getValue(value.getId()).compose(existing -> { - if (existing != null) { - // Immutable check - if (existing.isMutable() != value.isMutable()) { - getLogger().warn("Rejecting value {}: cannot replace mismatched mutable/immutable", value.getId()); - return Future.failedFuture(new ImmutableSubstitutionFail("Cannot replace mismatched mutable/immutable value")); - } - - if (value.getSequenceNumber() < existing.getSequenceNumber()) { - getLogger().warn("Rejecting value {}: sequence number not monotonic", value.getId()); - return Future.failedFuture(new SequenceNotMonotonic("Sequence number less than current")); - } - - if (expectedSequenceNumber >= 0 && existing.getSequenceNumber() > expectedSequenceNumber) { - getLogger().warn("Rejecting value {}: sequence number not expected", value.getId()); - return Future.failedFuture(new SequenceNotExpected("Sequence number not expected")); - } - - if (existing.hasPrivateKey() && !value.hasPrivateKey()) { - // Skip update if the existing value is owned by this node and the new value is not. - // Should not throw NotOwnerException, just silently ignore to avoid disrupting valid operations. - getLogger().info("Skipping to update value for id {}: owned by this node", value.getId()); - return Future.succeededFuture(existing); - } - } - - return withTransaction(c -> + getLogger().debug("Putting value with id: {}, persistent: {}", value.getId(), persistent); + return withTransaction(c -> SqlTemplate.forUpdate(c, getDialect().upsertValue()) .execute(valueToMap(value, persistent)) - .map(v -> value)); - }).andThen(ar -> { - if (ar.succeeded()) - getLogger().debug("Put value with id: {} successfully", value.getId()); - else - getLogger().error("Failed to put value with id: {}", value.getId(), ar.cause()); - }).recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + .map(v -> value) + ).recover(cause -> + Future.failedFuture(new DataStorageException("putValue failed", cause)) ); } @@ -170,22 +124,11 @@ public Future putValue(Value value, boolean persistent, int expectedSeque public Future getValue(Id id) { getLogger().debug("Getting value with id: {}", id); return withConnection(c -> - SqlTemplate.forQuery(c, getDialect().selectValueById()) + SqlTemplate.forQuery(c, getDialect().selectValue()) .execute(Map.of("id", id.bytes())) .map(rows -> findUnique(rows, DatabaseStorage::rowToValue)) - .andThen(ar -> { - if (ar.succeeded()) { - if (ar.result() != null) - getLogger().debug("Got value with id: {}", id); - else - //noinspection LoggingSimilarMessage - getLogger().debug("No value found with id: {}", id); - } else { - getLogger().error("Failed to get value with id: {}", id, ar.cause()); - } - }) ).recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + Future.failedFuture(new DataStorageException("getValue failed", cause)) ); } @@ -196,7 +139,7 @@ public Future> getValues() { .execute() .map(rows -> findMany(rows, DatabaseStorage::rowToValue)) ).recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + Future.failedFuture(new DataStorageException("getValues/all failed", cause)) ); } @@ -207,7 +150,7 @@ public Future> getValues(int offset, int limit) { .execute(Map.of("limit", limit, "offset", offset)) .map(rows -> findMany(rows, DatabaseStorage::rowToValue)) ).recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + Future.failedFuture(new DataStorageException("getValues/all/paginated failed", cause)) ); } @@ -218,7 +161,7 @@ public Future> getValues(boolean persistent, long announcedBefore) { .execute(Map.of("persistent", persistent, "updatedBefore", announcedBefore)) .map(rows -> findMany(rows, DatabaseStorage::rowToValue)) ).recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + Future.failedFuture(new DataStorageException("getValues/announcedBefore failed", cause)) ); } @@ -233,7 +176,7 @@ public Future> getValues(boolean persistent, long announcedBefore, i "offset", offset)) .map(rows -> findMany(rows, DatabaseStorage::rowToValue)) ).recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + Future.failedFuture(new DataStorageException("getValues/announcedBefore/paginated failed", cause)) ); } @@ -242,21 +185,11 @@ public Future updateValueAnnouncedTime(Id id) { getLogger().debug("Updating value announced time with id: {}", id); long now = System.currentTimeMillis(); return withTransaction(c -> - SqlTemplate.forUpdate(c, getDialect().updateValueAnnouncedById()) + SqlTemplate.forUpdate(c, getDialect().updateValueAnnounced()) .execute(Map.of("id", id.bytes(), "updated", now)) .map(r -> r.rowCount() > 0 ? now : 0L) - ).andThen(ar -> { - if (ar.succeeded()) { - if (ar.result() != 0) - getLogger().debug("Updated value announced time with id: {}", id); - else - //noinspection LoggingSimilarMessage - getLogger().debug("No value found with id: {}", id); - } else { - getLogger().error("Failed to update value announced time with id: {}", id, ar.cause()); - } - }).recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + ).recover(cause -> + Future.failedFuture(new DataStorageException("updateValueAnnouncedTime failed", cause)) ); } @@ -264,20 +197,11 @@ public Future updateValueAnnouncedTime(Id id) { public Future removeValue(Id id) { getLogger().debug("Removing value with id: {}", id); return withTransaction(c -> - SqlTemplate.forUpdate(c, getDialect().deleteValueById()) + SqlTemplate.forUpdate(c, getDialect().deleteValue()) .execute(Map.of("id", id.bytes())) .map(this::hasAffectedRows) - ).andThen(ar -> { - if (ar.succeeded()) { - if (ar.result()) - getLogger().debug("Removed value with id: {}", id); - else - getLogger().debug("No value found with id: {}", id); - } else { - getLogger().error("Failed to remove value with id: {}", id, ar.cause()); - } - }).recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + ).recover(cause -> + Future.failedFuture(new DataStorageException("removeValue failed", cause)) ); } @@ -289,26 +213,12 @@ public Future putPeer(PeerInfo peerInfo) { @Override public Future putPeer(PeerInfo peerInfo, boolean persistent) { getLogger().debug("Putting peer with id: {} @ {}, persistent: {}", peerInfo.getId(), peerInfo.getNodeId(), persistent); - getLogger().debug("Trying to check the existing peer with id: {} @ {}", peerInfo.getId(), peerInfo.getNodeId()); - return getPeer(peerInfo.getId(), peerInfo.getNodeId()).compose(existing -> { - if (existing != null && existing.hasPrivateKey() && !peerInfo.hasPrivateKey()) { - // Skip update if the existing peer info is owned by this node and the new peer info is not. - // Should not throw NotOwnerException, just silently ignore to avoid disrupting valid operations. - getLogger().info("Skipping to update peer for id {} @ {}: owned by this node", peerInfo.getId(), peerInfo.getNodeId()); - return Future.succeededFuture(peerInfo); - } - - return withTransaction(c -> + return withTransaction(c -> SqlTemplate.forUpdate(c, getDialect().upsertPeer()) .execute(peerToMap(peerInfo, persistent)) - .map(v -> peerInfo)); - }).andThen(ar -> { - if (ar.succeeded()) - getLogger().debug("Put peer with id: {} @ {} successfully", peerInfo.getId(), peerInfo.getNodeId()); - else - getLogger().error("Failed to put peer with id: {} @ {}", peerInfo.getId(), peerInfo.getNodeId(), ar.cause()); - }).recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + .map(v -> peerInfo) + ).recover(cause -> + Future.failedFuture(new DataStorageException("putPeer failed", cause)) ); } @@ -323,36 +233,20 @@ public Future> putPeers(List peerInfos) { SqlTemplate.forUpdate(c, getDialect().upsertPeer()) .executeBatch(params) .map(v -> peerInfos) - ).andThen(ar -> { - if (ar.succeeded()) - getLogger().debug("Put {} peers successfully", peerInfos.size()); - else - getLogger().error("Failed to put peers", ar.cause()); - }).recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + ).recover(cause -> + Future.failedFuture(new DataStorageException("putPeers failed", cause)) ); } @Override - public Future getPeer(Id id, Id nodeId) { + public Future> getPeers(Id id, Id nodeId) { getLogger().debug("Getting peer with id: {} @ {}", id, nodeId); return withConnection(c -> - SqlTemplate.forQuery(c, getDialect().selectPeerByIdAndNodeId()) + SqlTemplate.forQuery(c, getDialect().selectPeersByIdAndNodeId()) .execute(Map.of("id", id.bytes(), "nodeId", nodeId.bytes())) - .map(rows -> findUnique(rows, DatabaseStorage::rowToPeer)) - .andThen(ar -> { - if (ar.succeeded()) { - if (ar.result() != null) - getLogger().debug("Got peer with id: {} @ {}", id, nodeId); - else - //noinspection LoggingSimilarMessage - getLogger().debug("No peer found with id: {} @ {}", id, nodeId); - } else { - getLogger().error("Failed to get peer with id: {} @ {}", id, nodeId, ar.cause()); - } - }) + .map(rows -> findMany(rows, DatabaseStorage::rowToPeer)) ).recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + Future.failedFuture(new DataStorageException("getPeers/id&nodeId failed", cause)) ); } @@ -363,19 +257,22 @@ public Future> getPeers(Id id) { SqlTemplate.forQuery(c, getDialect().selectPeersById()) .execute(Map.of("id", id.bytes())) .map(rows -> findMany(rows, DatabaseStorage::rowToPeer)) - .andThen(ar -> { - if (ar.succeeded()) { - if (!ar.result().isEmpty()) - getLogger().debug("Got peers with id: {}", id); - else - //noinspection LoggingSimilarMessage - getLogger().debug("No peers found with id: {}", id); - } else { - getLogger().error("Failed to get peers with id: {}", id, ar.cause()); - } - }) ).recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + Future.failedFuture(new DataStorageException("getPeers/id failed", cause)) + ); + } + + @Override + public Future> getPeers(Id id, int expectedSequenceNumber, int limit) { + getLogger().debug("Getting peers with id: {}, expectedSequenceNumber: {}, limit{}", id, expectedSequenceNumber, limit); + return withConnection(c -> + SqlTemplate.forQuery(c, getDialect().selectPeersByIdAndSequenceNumberWithLimit()) + .execute(Map.of("id", id.bytes(), + "expectedSequenceNumber", expectedSequenceNumber, + "limit", limit)) + .map(rows -> findMany(rows, DatabaseStorage::rowToPeer)) + ).recover(cause -> + Future.failedFuture(new DataStorageException("getPeers/id&expectedSequenceNumber failed", cause)) ); } @@ -386,7 +283,7 @@ public Future> getPeers() { .execute() .map(rows -> findMany(rows, DatabaseStorage::rowToPeer)) ).recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + Future.failedFuture(new DataStorageException("getPeers/all failed", cause)) ); } @@ -397,7 +294,7 @@ public Future> getPeers(int offset, int limit) { .execute(Map.of("limit", limit, "offset", offset)) .map(rows -> findMany(rows, DatabaseStorage::rowToPeer)) ).recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + Future.failedFuture(new DataStorageException("getPeers/all/paginated failed", cause)) ); } @@ -408,7 +305,7 @@ public Future> getPeers(boolean persistent, long announcedBefore) .execute(Map.of("persistent", persistent, "updatedBefore", announcedBefore)) .map(rows -> findMany(rows, DatabaseStorage::rowToPeer)) ).recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + Future.failedFuture(new DataStorageException("getPeers/announcedBefore failed", cause)) ); } @@ -423,51 +320,43 @@ public Future> getPeers(boolean persistent, long announcedBefore, "offset", offset)) .map(rows -> findMany(rows, DatabaseStorage::rowToPeer)) ).recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + Future.failedFuture(new DataStorageException("getPeers/announcedBefore/paginated failed", cause)) ); } @Override - public Future updatePeerAnnouncedTime(Id id, Id nodeId) { - getLogger().debug("Updating peer announced time with id: {} @ {}", id, nodeId); + public Future updatePeerAnnouncedTime(Id id, long fingerprint) { + getLogger().debug("Updating peer announced time with id: {}:{}", id, fingerprint); long now = System.currentTimeMillis(); return withTransaction(c -> - SqlTemplate.forUpdate(c, getDialect().updatePeerAnnouncedByIdAndNodeId()) - .execute(Map.of("id", id.bytes(), "nodeId", nodeId.bytes(), "updated", now)) + SqlTemplate.forUpdate(c, getDialect().updatePeerAnnounced()) + .execute(Map.of("id", id.bytes(), "fingerprint", fingerprint, "updated", now)) .map(r -> r.rowCount() > 0 ? now : 0L) - ).andThen(ar -> { - if (ar.succeeded()) { - if (ar.result() != 0) - getLogger().debug("Updated peer announced time with id: {} @ {}", id, nodeId); - else - getLogger().debug("No peer found with id: {} @ {}", id, nodeId); - } else { - getLogger().error("Failed to update peer announced time with id: {} @ {}", id, nodeId, ar.cause()); - } - }).recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + ).recover(cause -> + Future.failedFuture(new DataStorageException("updatePeerAnnouncedTime failed", cause)) + ); + } + + @Override + public Future getPeer(Id id, long fingerprint) { + return withConnection(c -> + SqlTemplate.forQuery(c, getDialect().selectPeer()) + .execute(Map.of("id", id.bytes(), "fingerprint", fingerprint)) + .map(rows -> findUnique(rows, DatabaseStorage::rowToPeer)) + ).recover(cause -> + Future.failedFuture(new DataStorageException("getPeer failed", cause)) ); } @Override - public Future removePeer(Id id, Id nodeId) { - getLogger().debug("Removing peer with id: {} @ {}", id, nodeId); + public Future removePeer(Id id, long fingerprint) { + getLogger().debug("Removing peer with id: {}:{}", id, fingerprint); return withTransaction(c -> - SqlTemplate.forUpdate(c, getDialect().deletePeerByIdAndNodeId()) - .execute(Map.of("id", id.bytes(), "nodeId", nodeId.bytes())) + SqlTemplate.forUpdate(c, getDialect().deletePeer()) + .execute(Map.of("id", id.bytes(), "fingerprint", fingerprint)) .map(this::hasAffectedRows) - ).andThen(ar -> { - if (ar.succeeded()) { - if (ar.result()) - getLogger().debug("Removed peer with id: {} @ {}", id, nodeId); - else - //noinspection LoggingSimilarMessage - getLogger().debug("No peer found with id: {} @ {}", id, nodeId); - } else { - getLogger().error("Failed to remove peer with id: {} @ {}", id, nodeId, ar.cause()); - } - }).recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + ).recover(cause -> + Future.failedFuture(new DataStorageException("removePeer failed", cause)) ); } @@ -478,17 +367,8 @@ public Future removePeers(Id id) { SqlTemplate.forUpdate(c, getDialect().deletePeersById()) .execute(Map.of("id", id.bytes())) .map(this::hasAffectedRows) - ).andThen(ar -> { - if (ar.succeeded()) { - if (ar.result()) - getLogger().debug("Removed peers with id: {}", id); - else - getLogger().debug("No peers found with id: {}", id); - } else { - getLogger().error("Failed to remove peers with id: {}", id, ar.cause()); - } - }).recover(cause -> - Future.failedFuture(new DataStorageException("Database operation failed", cause)) + ).recover(cause -> + Future.failedFuture(new DataStorageException("removePeers/id failed", cause)) ); } @@ -499,8 +379,8 @@ protected static Map valueToMap(Value value, boolean persistent) map.put("privateKey", value.getPrivateKey()); map.put("recipient", value.getRecipient() != null ? value.getRecipient().bytes() : null); map.put("nonce", value.getNonce()); - map.put("signature", value.getSignature()); map.put("sequenceNumber", value.getSequenceNumber()); + map.put("signature", value.getSignature()); map.put("data", value.getData()); map.put("persistent", persistent); long now = System.currentTimeMillis(); @@ -511,16 +391,12 @@ protected static Map valueToMap(Value value, boolean persistent) protected static Value rowToValue(Row row) { Id publicKey = getId(row, "public_key"); - Buffer buffer = row.getBuffer("private_key"); - byte[] privateKey = buffer == null ? null : buffer.getBytes(); + byte[] privateKey = getBytes(row, "private_key"); Id recipient = getId(row, "recipient"); - buffer = row.getBuffer("nonce"); - byte[] nonce = buffer == null ? null : buffer.getBytes(); - buffer = row.getBuffer("signature"); - byte[] signature = buffer == null ? null : buffer.getBytes(); + byte[] nonce = getBytes(row, "nonce"); int sequenceNumber = row.getInteger("sequence_number"); // NOT NULL - buffer = row.getBuffer("data"); - byte[] data = buffer == null ? null : buffer.getBytes(); + byte[] signature = getBytes(row, "signature"); + byte[] data = getBytes(row, "data"); return Value.of(publicKey, privateKey, recipient, nonce, sequenceNumber, signature, data); } @@ -528,12 +404,20 @@ protected static Value rowToValue(Row row) { protected static Map peerToMap(PeerInfo peerInfo, boolean persistent) { Map map = new HashMap<>(); map.put("id", peerInfo.getId().bytes()); - map.put("nodeId", peerInfo.getNodeId().bytes()); + map.put("fingerprint", peerInfo.getFingerprint()); map.put("privateKey", peerInfo.getPrivateKey()); - map.put("origin", peerInfo.getOrigin() != null ? peerInfo.getOrigin().bytes() : null); - map.put("port", peerInfo.getPort()); - map.put("alternativeUri", peerInfo.getAlternativeURI()); + map.put("nonce", peerInfo.getNonce()); + map.put("sequenceNumber", peerInfo.getSequenceNumber()); + if (peerInfo.isAuthenticated()) { + map.put("nodeId", peerInfo.getNodeId().bytes()); + map.put("nodeSignature", peerInfo.getNodeSignature()); + } else { + map.put("nodeId", null); + map.put("nodeSignature", null); + } map.put("signature", peerInfo.getSignature()); + map.put("endpoint", peerInfo.getEndpoint()); + map.put("extra", peerInfo.hasExtra() ? peerInfo.getExtraData() : null); map.put("persistent", persistent); long now = System.currentTimeMillis(); map.put("created", now); @@ -543,16 +427,17 @@ protected static Map peerToMap(PeerInfo peerInfo, boolean persis protected static PeerInfo rowToPeer(Row row) { Id id = getId(row, "id"); + long fingerprint = row.getLong("fingerprint"); + byte[] privateKey = getBytes(row, "private_key"); + byte[] nonce = getBytes(row, "nonce"); + int sequenceNumber = row.getInteger("sequence_number"); Id nodeId = getId(row, "node_id"); - Buffer buffer = row.getBuffer("private_key"); - byte[] privateKey = buffer == null ? null : buffer.getBytes(); - Id origin = getId(row, "origin"); - int port = row.getInteger("port"); - String alternativeURI = row.getString("alternative_uri"); - buffer = row.getBuffer("signature"); - byte[] signature = buffer == null ? null : buffer.getBytes(); + byte[] nodeSignature = getBytes(row, "node_signature"); + byte[] signature = getBytes(row, "signature"); + String endpoint = row.getString("endpoint"); + byte[] extra = getBytes(row, "extra"); - return PeerInfo.of(id, privateKey, nodeId, origin, port, alternativeURI, signature); + return PeerInfo.of(id, privateKey, nonce, sequenceNumber, nodeId, nodeSignature, signature, fingerprint, endpoint, extra); } private static Id getId(Row row, String column) { @@ -560,6 +445,11 @@ private static Id getId(Row row, String column) { return buf == null ? null : Id.of(buf.getBytes()); } + private static byte[] getBytes(Row row, String column) { + Buffer buf = row.getBuffer(column); + return buf == null ? null : buf.getBytes(); + } + @Override public Future close() { return getClient().close(); diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/storage/InMemoryStorage.java b/dht/src/main/java/io/bosonnetwork/kademlia/storage/InMemoryStorage.java deleted file mode 100644 index 77c4370..0000000 --- a/dht/src/main/java/io/bosonnetwork/kademlia/storage/InMemoryStorage.java +++ /dev/null @@ -1,458 +0,0 @@ -/* - * Copyright (c) 2023 - bosonnetwork.io - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package io.bosonnetwork.kademlia.storage; - -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Predicate; -import java.util.stream.Stream; - -import io.vertx.core.Future; -import io.vertx.core.Vertx; -import org.jetbrains.annotations.NotNull; - -import io.bosonnetwork.Id; -import io.bosonnetwork.PeerInfo; -import io.bosonnetwork.Value; -import io.bosonnetwork.kademlia.exceptions.ImmutableSubstitutionFail; -import io.bosonnetwork.kademlia.exceptions.KadException; -import io.bosonnetwork.kademlia.exceptions.SequenceNotExpected; -import io.bosonnetwork.kademlia.exceptions.SequenceNotMonotonic; - -public class InMemoryStorage implements DataStorage { - public static final String STORAGE_URI = "inmemory"; - - private static final int SCHEMA_VERSION = 1; - - private static final int DEFAULT_MAP_CAPACITY = 32; - - private final Map> values; - private final Map> peers; - - private long valueExpiration; - private long peerInfoExpiration; - private boolean initialized; - - protected InMemoryStorage() { - values = new ConcurrentHashMap<>(DEFAULT_MAP_CAPACITY); - peers = new ConcurrentHashMap<>(DEFAULT_MAP_CAPACITY); - } - - @Override - public Future initialize(Vertx vertx, long valueExpiration, long peerInfoExpiration) { - if (initialized) - return Future.failedFuture(new DataStorageException("Storage already initialized")); - - this.valueExpiration = valueExpiration; - this.peerInfoExpiration = peerInfoExpiration; - initialized = true; - return Future.succeededFuture(SCHEMA_VERSION); - } - - @Override - public Future close() { - values.clear(); - peers.clear(); - return Future.succeededFuture(); - } - - @Override - public int getSchemaVersion() { - return SCHEMA_VERSION; - } - - @Override - public Future purge() { - values.entrySet().removeIf(entry -> entry.getValue().isExpired(valueExpiration)); - peers.entrySet().removeIf(entry -> entry.getValue().isExpired(valueExpiration)); - - return Future.succeededFuture(); - } - - @Override - public Future putValue(Value value) { - return putValue(value, false, -1); - } - - @Override - public Future putValue(Value value, boolean persistent) { - return putValue(value, persistent, -1); - } - - private void updateValueEntry(StorageEntry entry, Value value, boolean persistent, - int expectedSequenceNumber) throws KadException { - Value existing = entry.getObject(); - - // Immutable value handling - if (existing.isMutable() != value.isMutable()) - throw new ImmutableSubstitutionFail("Cannot replace mismatched mutable/immutable value"); - - if (value.getSequenceNumber() < existing.getSequenceNumber()) - throw new SequenceNotMonotonic("Sequence number less than current"); - - if (expectedSequenceNumber >= 0 && existing.getSequenceNumber() > expectedSequenceNumber) - throw new SequenceNotExpected("Sequence number not expected"); - - if (existing.hasPrivateKey() && !value.hasPrivateKey()) { - // Skip update if the existing value is owned by this node and the new value is not. - // Should not throw NotOwnerException, just silently ignores to avoid disrupting valid operations. - return; - } - - entry.update(value, persistent); - } - - @Override - public Future putValue(Value value, boolean persistent, int expectedSequenceNumber) { - try { - StorageEntry updated = values.compute(value.getId(), (id, entry) -> { - if (entry == null) - return new StorageEntry<>(value, persistent); - - try { - updateValueEntry(entry, value, persistent, expectedSequenceNumber); - return entry; - } catch (KadException e) { - throw new UncheckedStorageException(e); - } - }); - - return Future.succeededFuture(updated.getObject()); - } catch (UncheckedStorageException e) { - return Future.failedFuture(new DataStorageException("InMemoryStorage error", e.getCause())); - } - } - - @Override - public Future getValue(Id id) { - StorageEntry entry = values.get(id); - // Returns succeeded Future with null if entry is missing or expired - return entry == null || entry.isExpired(valueExpiration) ? - Future.succeededFuture() : Future.succeededFuture(entry.getObject()); - } - - private List getValues(Predicate> predicate, int offset, int limit) { - Stream> stream = values.values().stream() - .filter(predicate) - .sorted(StorageEntry::compareTo); - - if (offset > 0) - stream = stream.skip(offset); - - if (limit > 0) - stream = stream.limit(limit); - - return stream.map(StorageEntry::getObject).toList(); - } - - @Override - public Future> getValues() { - List result = getValues(entry -> !entry.isExpired(valueExpiration), -1, -1); - return Future.succeededFuture(result); - } - - @Override - public Future> getValues(int offset, int limit) { - List result = getValues(entry -> !entry.isExpired(valueExpiration), offset, limit); - return Future.succeededFuture(result); - } - - @Override - public Future> getValues(boolean persistent, long announcedBefore) { - List result = getValues(entry -> !entry.isExpired(valueExpiration) && - entry.isPersistent() == persistent && entry.getAnnounced() <= announcedBefore, -1, -1); - return Future.succeededFuture(result); - } - - @Override - public Future> getValues(boolean persistent, long announcedBefore, int offset, int limit) { - List result = getValues(entry -> !entry.isExpired(valueExpiration) && - entry.isPersistent() == persistent && entry.getAnnounced() <= announcedBefore, offset, limit); - return Future.succeededFuture(result); - } - - @Override - public Future updateValueAnnouncedTime(Id id) { - return updateAnnouncementTime(values, id); - } - - @Override - public Future removeValue(Id id) { - StorageEntry entry = values.remove(id); - return Future.succeededFuture(entry != null); - } - - @Override - public Future putPeer(PeerInfo peerInfo) { - return putPeer(peerInfo, false); - } - - private void updatePeerEntry(StorageEntry entry, PeerInfo peerInfo, boolean persistent) throws KadException { - PeerInfo existing = entry.getObject(); - - if (existing.hasPrivateKey() && !peerInfo.hasPrivateKey()) { - // Skip update if the existing peer info is owned by this node and the new peer info is not. - // Should not throw NotOwnerException, just silently ignores to avoid disrupting valid operations. - return; - } - - entry.update(peerInfo, persistent); - } - - private StorageEntry putPeerEntry(PeerInfo peerInfo, boolean persistent) throws UncheckedStorageException { - return peers.compute(CompositeId.of(peerInfo), (unused, entry) -> { - if (entry == null) - return new StorageEntry<>(peerInfo, persistent); - - try { - updatePeerEntry(entry, peerInfo, persistent); - return entry; - } catch (KadException e) { - throw new UncheckedStorageException(e); - } - }); - } - - @Override - public Future putPeer(PeerInfo peerInfo, boolean persistent) { - try { - StorageEntry entry = putPeerEntry(peerInfo, persistent); - return Future.succeededFuture(entry.getObject()); - } catch (UncheckedStorageException e) { - return Future.failedFuture(new DataStorageException("InMemoryStorage error", e.getCause())); - } - } - - @Override - public Future> putPeers(List peerInfos) { - try { - peerInfos.forEach(peerInfo -> putPeerEntry(peerInfo, false)); - return Future.succeededFuture(peerInfos); - } catch (UncheckedStorageException e) { - return Future.failedFuture(new DataStorageException("InMemoryStorage error", e.getCause())); - } - } - - @Override - public Future getPeer(Id id, Id nodeId) { - StorageEntry entry = peers.get(CompositeId.of(id, nodeId)); - // Returns succeeded Future with null if entry is missing or expired - return entry == null || entry.isExpired(peerInfoExpiration) ? - Future.succeededFuture() : Future.succeededFuture(entry.getObject()); - } - - @Override - public Future> getPeers(Id id) { - List result = peers.values().stream() - .filter(entry -> !entry.isExpired(peerInfoExpiration) && entry.getObject().getId().equals(id)) - .sorted(StorageEntry::compareTo) - .map(StorageEntry::getObject) - .toList(); - - return Future.succeededFuture(result); - } - - private List getPeers(Predicate> predicate, int offset, int limit) { - Stream> stream = peers.values().stream() - .filter(predicate) - .sorted(StorageEntry::compareTo); - - if (offset > 0) - stream = stream.skip(offset); - - if (limit > 0) - stream = stream.limit(limit); - - return stream.map(StorageEntry::getObject).toList(); - } - - @Override - public Future> getPeers() { - List result = getPeers(entry -> !entry.isExpired(peerInfoExpiration), -1, -1); - return Future.succeededFuture(result); - } - - @Override - public Future> getPeers(int offset, int limit) { - List result = getPeers(entry -> !entry.isExpired(peerInfoExpiration), offset, limit); - return Future.succeededFuture(result); - } - - @Override - public Future> getPeers(boolean persistent, long announcedBefore) { - List result = getPeers(entry -> !entry.isExpired(peerInfoExpiration) && - entry.isPersistent() == persistent && entry.getAnnounced() <= announcedBefore, -1, -1); - return Future.succeededFuture(result); - } - - @Override - public Future> getPeers(boolean persistent, long announcedBefore, int offset, int limit) { - List result = getPeers(entry -> !entry.isExpired(peerInfoExpiration) && - entry.isPersistent() == persistent && entry.getAnnounced() <= announcedBefore, offset, limit); - return Future.succeededFuture(result); - } - - @Override - public Future updatePeerAnnouncedTime(Id id, Id nodeId) { - return updateAnnouncementTime(peers, CompositeId.of(id, nodeId)); - } - - @Override - public Future removePeer(Id id, Id nodeId) { - StorageEntry entry = peers.remove(CompositeId.of(id, nodeId)); - return Future.succeededFuture(entry != null); - } - - @Override - public Future removePeers(Id id) { - boolean removed = peers.entrySet().removeIf(entry -> entry.getKey().getPeerId().equals(id)); - return Future.succeededFuture(removed); - } - - private Future updateAnnouncementTime(Map> map, K key) { - StorageEntry entry = map.get(key); - if (entry != null) { - entry.setAnnounced(System.currentTimeMillis()); - return Future.succeededFuture(entry.getAnnounced()); - } - return Future.succeededFuture(0L); - } - - static class CompositeId { - private final Id peerId; - private final Id nodeId; - - private CompositeId(Id peerId, Id nodeId) { - this.peerId = peerId; - this.nodeId = nodeId; - } - - public static CompositeId of(Id peerId, Id nodeId) { - return new CompositeId(peerId, nodeId); - } - - public static CompositeId of(PeerInfo peerInfo) { - return new CompositeId(peerInfo.getId(), peerInfo.getNodeId()); - } - - public Id getPeerId() { - return peerId; - } - - public Id getNodeId() { - return nodeId; - } - - @Override - public boolean equals(Object o) { - if (this == o) - return true; - - if (o instanceof CompositeId that) - return peerId.equals(that.peerId) && nodeId.equals(that.nodeId); - - return false; - } - - @Override - public int hashCode() { - return Objects.hash(peerId, nodeId); - } - } - - static class StorageEntry implements Comparable> { - private T object; - private boolean persistent; - private long updated; - private long announced; - - public StorageEntry(T object, boolean persistent, long updated, long announced) { - this.object = object; - this.persistent = persistent; - this.updated = updated; - this.announced = announced; - } - - public StorageEntry(T object, boolean persistent) { - this(object, persistent, System.currentTimeMillis(), 0); - } - - void update(T object, boolean persistent) { - this.object = object; - this.persistent = persistent; - this.updated = System.currentTimeMillis(); - } - - public T getObject() { - return object; - } - - public boolean isPersistent() { - return persistent; - } - - public long getUpdated() { - return updated; - } - - public long getAnnounced() { - return announced; - } - - void setAnnounced(long announced) { - this.announced = announced; - } - - public boolean isExpired(long expiration) { - if (persistent) - return false; - - // Expiration is based on the last announced timestamp, as per Kademlia protocol requirements - // for tracking when values or peers were last advertised to the network. - return System.currentTimeMillis() - (announced != 0 ? announced : updated) > expiration; - } - - @Override - public int compareTo(@NotNull StorageEntry o) { - if (o == this) - return 0; - - int rc = Long.compare(announced, o.announced); - if (rc != 0) - return rc; - - return Long.compare(updated, o.updated); - } - } - - // Custom unchecked exception for wrapping checked exceptions in compute lambdas - static class UncheckedStorageException extends RuntimeException { - private static final long serialVersionUID = 8595433903736762221L; - - public UncheckedStorageException(Throwable cause) { - super(cause); - } - } -} \ No newline at end of file diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/storage/SQLiteStorage.java b/dht/src/main/java/io/bosonnetwork/kademlia/storage/SQLiteStorage.java index 55e6313..32b22eb 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/storage/SQLiteStorage.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/storage/SQLiteStorage.java @@ -56,6 +56,7 @@ protected SQLiteStorage(String connectionUri) { @Override protected void init(Vertx vertx) { // Vert.x 5.x style + // noinspection DuplicatedCode SQLiteDataSource dataSource = new SQLiteDataSource(); dataSource.setUrl(connectionUri); dataSource.setJournalMode("WAL"); diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/storage/SqlDialect.java b/dht/src/main/java/io/bosonnetwork/kademlia/storage/SqlDialect.java index d0a82a1..a00e65b 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/storage/SqlDialect.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/storage/SqlDialect.java @@ -4,11 +4,11 @@ public interface SqlDialect { default String upsertValue() { return """ INSERT INTO valores ( - id, public_key, private_key, recipient, nonce, signature, - sequence_number, data, persistent, created, updated + id, public_key, private_key, recipient, nonce, sequence_number, + signature, data, persistent, created, updated ) VALUES ( - #{id}, #{publicKey}, #{privateKey}, #{recipient}, #{nonce}, #{signature}, - #{sequenceNumber}, #{data}, #{persistent}, #{created}, #{updated} + #{id}, #{publicKey}, #{privateKey}, #{recipient}, #{nonce}, #{sequenceNumber}, + #{signature}, #{data}, #{persistent}, #{created}, #{updated} ) ON CONFLICT(id) DO UPDATE SET public_key = excluded.public_key, private_key = CASE @@ -18,15 +18,16 @@ INSERT INTO valores ( END, recipient = excluded.recipient, nonce = excluded.nonce, - signature = excluded.signature, sequence_number = excluded.sequence_number, + signature = excluded.signature, data = excluded.data, persistent = excluded.persistent, updated = excluded.updated + WHERE valores.sequence_number < excluded.sequence_number """; } - default String selectValueById() { + default String selectValue() { return "SELECT * FROM valores WHERE id = #{id}"; } @@ -56,11 +57,11 @@ default String selectAllValuesPaginated() { return "SELECT * FROM valores ORDER BY updated DESC, id LIMIT #{limit} OFFSET #{offset}"; } - default String updateValueAnnouncedById() { + default String updateValueAnnounced() { return "UPDATE valores SET updated = #{updated} WHERE id = #{id}"; } - default String deleteValueById() { + default String deleteValue() { return "DELETE FROM valores WHERE id = #{id}"; } @@ -71,39 +72,62 @@ default String deleteNonPersistentValuesAnnouncedBefore() { default String upsertPeer() { return """ INSERT INTO peers ( - id, node_id, private_key, origin, port, alternative_uri, signature, - persistent, created, updated + id, fingerprint, private_key, nonce, sequence_number, node_id, node_signature, + signature, endpoint, extra, persistent, created, updated ) VALUES ( - #{id}, #{nodeId}, #{privateKey}, #{origin}, #{port}, #{alternativeUri}, #{signature}, - #{persistent}, #{created}, #{updated} - ) ON CONFLICT(id, node_id) DO UPDATE SET + #{id}, #{fingerprint}, #{privateKey}, #{nonce}, #{sequenceNumber}, #{nodeId}, #{nodeSignature}, + #{signature}, #{endpoint}, #{extra}, #{persistent}, #{created}, #{updated} + ) ON CONFLICT(id, fingerprint) DO UPDATE SET private_key = CASE WHEN excluded.private_key IS NOT NULL THEN excluded.private_key ELSE peers.private_key END, - origin = excluded.origin, - port = excluded.port, - alternative_uri = excluded.alternative_uri, + nonce = excluded.nonce, + sequence_number = excluded.sequence_number, + node_id = excluded.node_id, + node_signature = excluded.node_signature, signature = excluded.signature, + endpoint = excluded.endpoint, + extra = excluded.extra, persistent = excluded.persistent, updated = excluded.updated + WHERE peers.sequence_number < excluded.sequence_number """; } - default String selectPeerByIdAndNodeId() { - return "SELECT * FROM peers WHERE id = #{id} AND node_id = #{nodeId}"; + default String selectPeer() { + return "SELECT * FROM peers WHERE id = #{id} AND fingerprint = #{fingerprint}"; } default String selectPeersById() { - return "SELECT * FROM peers WHERE id = #{id} ORDER BY updated DESC, node_id"; + return "SELECT * FROM peers WHERE id = #{id} ORDER BY updated DESC, fingerprint"; + } + + default String selectPeersByIdAndSequenceNumberWithLimit() { + return """ + SELECT * + FROM peers + WHERE id = #{id} and sequence_number >= #{expectedSequenceNumber} + ORDER BY updated DESC, fingerprint + LIMIT #{limit} + """; + } + + default String selectPeersByIdAndNodeId() { + return """ + SELECT * + FROM peers + WHERE id = #{id} AND node_id = #{nodeId} + ORDER BY updated DESC, fingerprint + """; } default String selectPeersByPersistentAndAnnouncedBefore() { return """ SELECT * FROM peers WHERE persistent = #{persistent} AND updated <= #{updatedBefore} - ORDER BY updated DESC, id, node_id + ORDER BY updated DESC, id, fingerprint """; } @@ -111,25 +135,25 @@ default String selectPeersByPersistentAndAnnouncedBeforePaginated() { return """ SELECT * FROM peers WHERE persistent = #{persistent} AND updated <= #{updatedBefore} - ORDER BY updated DESC, id, node_id + ORDER BY updated DESC, id, fingerprint LIMIT #{limit} OFFSET #{offset} """; } default String selectAllPeers() { - return "SELECT * FROM peers ORDER BY updated DESC, id, node_id"; + return "SELECT * FROM peers ORDER BY updated DESC, id, fingerprint"; } default String selectAllPeersPaginated() { return "SELECT * FROM peers ORDER BY updated DESC, id, node_id LIMIT #{limit} OFFSET #{offset}"; } - default String updatePeerAnnouncedByIdAndNodeId() { - return "UPDATE peers SET updated = #{updated} WHERE id = #{id} AND node_id = #{nodeId}"; + default String updatePeerAnnounced() { + return "UPDATE peers SET updated = #{updated} WHERE id = #{id} AND fingerprint = #{fingerprint}"; } - default String deletePeerByIdAndNodeId() { - return "DELETE FROM peers WHERE id = #{id} AND node_id = #{nodeId}"; + default String deletePeer() { + return "DELETE FROM peers WHERE id = #{id} AND fingerprint = #{fingerprint}"; } default String deletePeersById() { diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/tasks/PeerAnnounceTask.java b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/PeerAnnounceTask.java index cb01f16..a22fdcf 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/tasks/PeerAnnounceTask.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/PeerAnnounceTask.java @@ -48,6 +48,8 @@ public class PeerAnnounceTask extends Task { private final Deque todo; /** The peer information to announce. */ private final PeerInfo peer; + /** The expected sequence number for peer; -1 disables the check. */ + private final int expectedSequenceNumber; private static final Logger log = LoggerFactory.getLogger(PeerAnnounceTask.class); @@ -56,11 +58,13 @@ public class PeerAnnounceTask extends Task { * * @param context the Kademlia context, must not be null * @param peer the peer information to announce, must be valid + * @param expectedSequenceNumber the expected sequence number for the peer; -1 to disable * @throws IllegalArgumentException if the peer is invalid */ - public PeerAnnounceTask(KadContext context, PeerInfo peer) { + public PeerAnnounceTask(KadContext context, PeerInfo peer, int expectedSequenceNumber) { super(context); this.peer = peer; + this.expectedSequenceNumber = expectedSequenceNumber; this.todo = new ArrayDeque<>(); } @@ -97,7 +101,7 @@ protected void iterate() { } log.debug("{}#{} sending ANNOUNCE_PEER RPC to {}", getName(), getId(), cn.getId()); - Message request = Message.announcePeerRequest(peer, cn.getToken()); + Message request = Message.announcePeerRequest(peer, cn.getToken(), expectedSequenceNumber); sendCall(cn, request, c -> todo.remove(cn)); } } diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/tasks/PeerLookupTask.java b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/PeerLookupTask.java index 91c640c..eb481f8 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/tasks/PeerLookupTask.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/PeerLookupTask.java @@ -55,14 +55,23 @@ public class PeerLookupTask extends LookupTask, PeerLookupTask> { private static final Logger log = LoggerFactory.getLogger(PeerLookupTask.class); + /** The expected sequence number for filtering outdated peers; -1 disables the check. */ + private final int expectedSequenceNumber; + /** The expected number of peers; 0 to disable filtering. */ + private final int expectedCount; + /** * Constructs a new peer lookup task for the given target ID, initializing an empty result list. * * @param context the Kademlia context, must not be null * @param target the target ID (e.g., content hash) to look up + * @param expectedSequenceNumber the minimum sequence number for valid peers; -1 to disable + * @param expectedCount the expected number of peers; 0 to disable filtering */ - public PeerLookupTask(KadContext context, Id target) { + public PeerLookupTask(KadContext context, Id target, int expectedSequenceNumber, int expectedCount) { super(context, target); + this.expectedSequenceNumber = expectedSequenceNumber; + this.expectedCount = expectedCount; setResult(Collections.emptyList()); } @@ -97,7 +106,7 @@ protected void iterate() { log.debug("{}#{} sending FIND_PEER RPC to {}", getName(), getId(), cn.getId()); Network network = getContext().getNetwork(); - Message request = Message.findPeerRequest(getTarget(), network.isIPv4(), network.isIPv6()); + Message request = Message.findPeerRequest(getTarget(), network.isIPv4(), network.isIPv6(), expectedSequenceNumber, expectedCount); sendCall(cn, request, c -> cn.setSent()); } } diff --git a/dht/src/main/resources/db/kadnode/postgres/001_initial_schema.sql b/dht/src/main/resources/db/kadnode/postgres/001_initial_schema.sql index b0d2a03..71f638c 100644 --- a/dht/src/main/resources/db/kadnode/postgres/001_initial_schema.sql +++ b/dht/src/main/resources/db/kadnode/postgres/001_initial_schema.sql @@ -6,8 +6,8 @@ CREATE TABLE IF NOT EXISTS valores ( private_key BYTEA DEFAULT NULL, recipient BYTEA DEFAULT NULL, nonce BYTEA DEFAULT NULL, - signature BYTEA DEFAULT NULL, sequence_number INTEGER NOT NULL DEFAULT 0, + signature BYTEA DEFAULT NULL, data BYTEA NOT NULL, persistent BOOLEAN NOT NULL DEFAULT FALSE, created BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000, @@ -23,18 +23,23 @@ CREATE INDEX IF NOT EXISTS idx_valores_updated ON valores (updated DESC); CREATE TABLE IF NOT EXISTS peers ( id BYTEA NOT NULL, - node_id BYTEA NOT NULL, + fingerprint BIGINT NOT NULL default 0, private_key BYTEA DEFAULT NULL, - origin BYTEA DEFAULT NULL, - port INTEGER NOT NULL, - alternative_uri VARCHAR(512) DEFAULT NULL, + nonce BYTEA NOT NULL, + sequence_number INTEGER NOT NULL DEFAULT 0, + node_id BYTEA DEFAULT NULL, + node_signature BYTEA DEFAULT NULL, signature BYTEA NOT NULL, + endpoint VARCHAR(512) NOT NULL, + extra BYTEA DEFAULT NULL, persistent BOOLEAN NOT NULL DEFAULT FALSE, created BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000, updated BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000, - PRIMARY KEY (id, node_id) + PRIMARY KEY (id, fingerprint) ); +CREATE INDEX IF NOT EXISTS idx_peers_id ON peers (id); + -- Partial index for persistent + announced queries CREATE INDEX IF NOT EXISTS idx_peers_persistent_true_updated ON peers (updated DESC) WHERE persistent = TRUE; -- Partial index for non-persistent + updated queries diff --git a/dht/src/main/resources/db/kadnode/sqlite/001_initial_schema.sql b/dht/src/main/resources/db/kadnode/sqlite/001_initial_schema.sql index abe81bc..22f00cc 100644 --- a/dht/src/main/resources/db/kadnode/sqlite/001_initial_schema.sql +++ b/dht/src/main/resources/db/kadnode/sqlite/001_initial_schema.sql @@ -6,8 +6,8 @@ CREATE TABLE IF NOT EXISTS valores ( private_key BLOB DEFAULT NULL, recipient BLOB DEFAULT NULL, nonce BLOB DEFAULT NULL, - signature BLOB DEFAULT NULL, sequence_number INTEGER NOT NULL DEFAULT 0, + signature BLOB DEFAULT NULL, data BLOB NOT NULL, persistent BOOLEAN NOT NULL DEFAULT FALSE, created INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)), @@ -23,18 +23,23 @@ CREATE INDEX IF NOT EXISTS idx_valores_updated ON valores (updated DESC); CREATE TABLE IF NOT EXISTS peers ( id BLOB NOT NULL, - node_id BLOB NOT NULL, + fingerprint INTEGER NOT NULL default 0, private_key BLOB DEFAULT NULL, - origin BLOB DEFAULT NULL, - port INTEGER NOT NULL, - alternative_uri VARCHAR(512) DEFAULT NULL, - signature BLOB NOT NULL, + nonce BLOB NOT NULL, + sequence_number INTEGER NOT NULL DEFAULT 0, + node_id BLOB DEFAULT NULL, + node_signature BLOB DEFAULT NULL, + signature BLOB DEFAULT NULL, + endpoint VARCHAR(512) NOT NULL, + extra BLOB DEFAULT NULL, persistent BOOLEAN NOT NULL DEFAULT FALSE, created INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)), updated INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)), - PRIMARY KEY (id, node_id) + PRIMARY KEY (id, fingerprint) ) WITHOUT ROWID; +CREATE INDEX IF NOT EXISTS idx_peers_id ON peers (id); + -- Partial index for persistent + announced queries CREATE INDEX IF NOT EXISTS idx_peers_persistent_true_updated ON peers (updated DESC) WHERE persistent = TRUE; -- Partial index for non-persistent + updated queries diff --git a/dht/src/test/java/io/bosonnetwork/kademlia/NodeAsyncTests.java b/dht/src/test/java/io/bosonnetwork/kademlia/NodeAsyncTests.java index 18ae575..5f69453 100644 --- a/dht/src/test/java/io/bosonnetwork/kademlia/NodeAsyncTests.java +++ b/dht/src/test/java/io/bosonnetwork/kademlia/NodeAsyncTests.java @@ -40,7 +40,7 @@ import io.bosonnetwork.PeerInfo; import io.bosonnetwork.Result; import io.bosonnetwork.Value; -import io.bosonnetwork.crypto.CryptoBox.Nonce; +import io.bosonnetwork.crypto.Random; import io.bosonnetwork.crypto.Signature; import io.bosonnetwork.crypto.Signature.KeyPair; import io.bosonnetwork.utils.AddressUtils; @@ -283,7 +283,11 @@ void testFindNode(VertxTestContext context) { @Timeout(value = TEST_NODES, timeUnit = TimeUnit.MINUTES) void testAnnounceAndFindPeer(VertxTestContext context) { executeSequentially(testNodes, announcer -> { - var p = PeerInfo.create(announcer.getId(), 8888); + var p = PeerInfo.builder() + .node(announcer) + .fingerprint(Random.random().nextLong()) + .endpoint("tcp://" + localAddr.getHostAddress() + ":8888") + .build(); System.out.format("\n\n\007🟢 %s announce peer %s ...\n", announcer.getId(), p.getId()); return ((VertxFuture)announcer.announcePeer(p)).thenCompose(v -> { @@ -291,14 +295,12 @@ void testAnnounceAndFindPeer(VertxTestContext context) { return executeSequentially(testNodes, node -> { System.out.format("\n\n\007⌛ %s looking up peer %s ...\n", node.getId(), p.getId()); - var future = (VertxFuture>) node.findPeer(p.getId(), 0); + var future = (VertxFuture) node.findPeer(p.getId()); return future.thenAccept(result -> { System.out.format("\007🟢 %s lookup peer %s finished\n", node.getId(), p.getId()); context.verify(() -> { assertNotNull(result); - assertFalse(result.isEmpty()); - assertEquals(1, result.size()); - assertEquals(p, result.get(0)); + assertEquals(p, result); }); }); }); @@ -310,7 +312,7 @@ void testAnnounceAndFindPeer(VertxTestContext context) { @Timeout(value = TEST_NODES, timeUnit = TimeUnit.MINUTES) void testStoreAndFindValue(VertxTestContext context) { executeSequentially(testNodes, announcer -> { - var v = Value.createValue(("Hello from " + announcer.getId()).getBytes()); + var v = Value.builder().data(("Hello from " + announcer.getId()).getBytes()).build(); System.out.format("\n\n\007🟢 %s store value %s ...\n", announcer.getId(), v.getId()); @@ -338,16 +340,9 @@ void testUpdateAndFindSignedValue(VertxTestContext context) { // initial announcement executeSequentially(testNodes, announcer -> { - var peerKeyPair = KeyPair.random(); - var nonce = Nonce.random(); - final Value v; - try { - v = Value.createSignedValue(peerKeyPair, nonce, ("Hello from " + announcer.getId()).getBytes()); - values.add(v); - } catch (Exception e) { - context.failNow(e); - return VertxFuture.failedFuture(e); // make compiler happy - } + var keyPair = KeyPair.random(); + final Value v = Value.builder().key(keyPair).data(("Hello from " + announcer.getId()).getBytes()).buildSigned(); + values.add(v); System.out.format("\n\n\007🟢 %s store value %s ...\n", announcer.getId(), v.getId()); return ((VertxFuture)announcer.storeValue(v)).thenCompose(na -> { @@ -359,8 +354,7 @@ void testUpdateAndFindSignedValue(VertxTestContext context) { System.out.format("\007🟢 %s lookup value %s finished\n", node.getId(), v.getId()); context.verify(() -> { assertNotNull(result); - assertArrayEquals(nonce.bytes(), v.getNonce()); - assertArrayEquals(peerKeyPair.publicKey().bytes(), v.getPublicKey().bytes()); + assertArrayEquals(keyPair.publicKey().bytes(), v.getPublicKey().bytes()); assertTrue(v.isMutable()); assertTrue(v.isValid()); assertEquals(v, result); @@ -412,17 +406,10 @@ void testUpdateAndFindEncryptedValue(VertxTestContext context) { var recipient = KeyPair.random(); recipients.add(recipient); - var peerKeyPair = KeyPair.random(); - var nonce = Nonce.random(); + var keyPair = KeyPair.random(); var data = ("Hello from " + announcer.getId()).getBytes(); - final Value v; - try { - v = Value.createEncryptedValue(peerKeyPair, Id.of(recipient.publicKey().bytes()), nonce, data); - values.add(v); - } catch (Exception e) { - context.failNow(e); - return VertxFuture.failedFuture(e); - } + final Value v = Value.builder().key(keyPair).recipient(Id.of(recipient.publicKey().bytes())).data(data).buildEncrypted(); + values.add(v); System.out.format("\n\n\007🟢 %s store value %s ...\n", announcer.getId(), v.getId()); return ((VertxFuture) announcer.storeValue(v)).thenCompose(unused -> { @@ -433,8 +420,7 @@ void testUpdateAndFindEncryptedValue(VertxTestContext context) { System.out.format("\007🟢 %s lookup value %s finished\n", node.getId(), v.getId()); context.verify(() -> { assertNotNull(result); - assertArrayEquals(nonce.bytes(), v.getNonce()); - assertArrayEquals(peerKeyPair.publicKey().bytes(), v.getPublicKey().bytes()); + assertArrayEquals(keyPair.publicKey().bytes(), v.getPublicKey().bytes()); assertTrue(v.isMutable()); assertTrue(v.isEncrypted()); assertTrue(v.isValid()); diff --git a/dht/src/test/java/io/bosonnetwork/kademlia/NodeSyncTests.java b/dht/src/test/java/io/bosonnetwork/kademlia/NodeSyncTests.java index bbdcc48..4dec570 100644 --- a/dht/src/test/java/io/bosonnetwork/kademlia/NodeSyncTests.java +++ b/dht/src/test/java/io/bosonnetwork/kademlia/NodeSyncTests.java @@ -28,11 +28,12 @@ import io.bosonnetwork.ConnectionStatusListener; import io.bosonnetwork.Id; +import io.bosonnetwork.LookupOption; import io.bosonnetwork.Network; import io.bosonnetwork.NodeConfiguration; import io.bosonnetwork.PeerInfo; import io.bosonnetwork.Value; -import io.bosonnetwork.crypto.CryptoBox.Nonce; +import io.bosonnetwork.crypto.Random; import io.bosonnetwork.crypto.Signature; import io.bosonnetwork.crypto.Signature.KeyPair; import io.bosonnetwork.utils.AddressUtils; @@ -94,6 +95,10 @@ public void connected(Network network) { future.complete(null); } }); + // NOTE: We intentionally use the CONSERVATIVE lookup mode here. + // NodeAsyncTests use the default lookup mode instead, so keeping this different + // helps improve overall test coverage across lookup strategies. + node.setDefaultLookupOption(LookupOption.CONSERVATIVE); node.start().get(); testNodes.add(node); @@ -217,7 +222,11 @@ void testAnnounceAndFindPeer() throws Exception { for (int i = 0; i < TEST_NODES; i++) { Thread.sleep(1000); var announcer = testNodes.get(i); - var p = PeerInfo.create(announcer.getId(), 8888); + var p = PeerInfo.builder() + .node(announcer) + .fingerprint(Random.random().nextLong()) + .endpoint("tcp://" + localAddr.getHostAddress() + ":8888") + .build(); System.out.format("\n\n\007🟢 %s announce peer %s ...\n", announcer.getId(), p.getId()); announcer.announcePeer(p).get(); @@ -230,9 +239,7 @@ void testAnnounceAndFindPeer() throws Exception { System.out.format("\007🟢 %s lookup peer %s finished\n", node.getId(), p.getId()); assertNotNull(result); - assertFalse(result.isEmpty()); - assertEquals(1, result.size()); - assertEquals(p, result.get(0)); + assertEquals(p, result); } } } @@ -242,7 +249,7 @@ void testAnnounceAndFindPeer() throws Exception { void testStoreAndFindValue() throws Exception { for (int i = 0; i < TEST_NODES; i++) { var announcer = testNodes.get(i); - var v = Value.createValue(("Hello from " + announcer.getId()).getBytes()); + var v = Value.builder().data(("Hello from " + announcer.getId()).getBytes()).build(); System.out.format("\n\n\007🟢 %s store value %s ...\n", announcer.getId(), v.getId()); announcer.storeValue(v).get(); @@ -268,9 +275,8 @@ void testUpdateAndFindSignedValue() throws Exception { // initial announcement for (int i = 0; i < TEST_NODES; i++) { var announcer = testNodes.get(i); - var peerKeyPair = KeyPair.random(); - var nonce = Nonce.random(); - var v = Value.createSignedValue(peerKeyPair, nonce, ("Hello from " + announcer.getId()).getBytes()); + var keyPair = KeyPair.random(); + var v = Value.builder().key(keyPair).data(("Hello from " + announcer.getId()).getBytes()).buildSigned(); values.add(v); System.out.format("\n\n\007🟢 %s store value %s ...\n", announcer.getId(), v.getId()); @@ -284,8 +290,7 @@ void testUpdateAndFindSignedValue() throws Exception { System.out.format("\007🟢 %s lookup value %s finished\n", node.getId(), v.getId()); assertNotNull(result); - assertArrayEquals(nonce.bytes(), v.getNonce()); - assertArrayEquals(peerKeyPair.publicKey().bytes(), v.getPublicKey().bytes()); + assertArrayEquals(keyPair.publicKey().bytes(), v.getPublicKey().bytes()); assertTrue(v.isMutable()); assertTrue(v.isValid()); assertEquals(v, result); @@ -329,10 +334,9 @@ void testUpdateAndFindEncryptedValue() throws Exception { var recipient = KeyPair.random(); recipients.add(recipient); - var peerKeyPair = KeyPair.random(); - var nonce = Nonce.random(); + var keyPair = KeyPair.random(); var data = ("Hello from " + announcer.getId()).getBytes(); - var v = Value.createEncryptedValue(peerKeyPair, Id.of(recipient.publicKey().bytes()), nonce, data); + var v = Value.builder().key(keyPair).recipient(Id.of(recipient.publicKey().bytes())).data(data).buildEncrypted(); values.add(v); System.out.format("\n\n\007🟢 %s store value %s ...\n", announcer.getId(), v.getId()); @@ -346,8 +350,7 @@ void testUpdateAndFindEncryptedValue() throws Exception { System.out.format("\007🟢 %s lookup value %s finished\n", node.getId(), v.getId()); assertNotNull(result); - assertArrayEquals(nonce.bytes(), v.getNonce()); - assertArrayEquals(peerKeyPair.publicKey().bytes(), v.getPublicKey().bytes()); + assertArrayEquals(keyPair.publicKey().bytes(), v.getPublicKey().bytes()); assertTrue(v.isMutable()); assertTrue(v.isEncrypted()); assertTrue(v.isValid()); diff --git a/dht/src/test/java/io/bosonnetwork/kademlia/TestNodeLauncher.java b/dht/src/test/java/io/bosonnetwork/kademlia/TestNodeLauncher.java index 2a63e0b..d31cf78 100644 --- a/dht/src/test/java/io/bosonnetwork/kademlia/TestNodeLauncher.java +++ b/dht/src/test/java/io/bosonnetwork/kademlia/TestNodeLauncher.java @@ -16,7 +16,7 @@ import io.bosonnetwork.NodeConfiguration; import io.bosonnetwork.crypto.Signature; import io.bosonnetwork.utils.AddressUtils; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; public class TestNodeLauncher { private static final Path dataPath = Path.of(System.getProperty("java.io.tmpdir"), "boson", "KademliaNode"); diff --git a/dht/src/test/java/io/bosonnetwork/kademlia/protocol/AnnouncePeerTests.java b/dht/src/test/java/io/bosonnetwork/kademlia/protocol/AnnouncePeerTests.java index 435cf0f..0e6d685 100644 --- a/dht/src/test/java/io/bosonnetwork/kademlia/protocol/AnnouncePeerTests.java +++ b/dht/src/test/java/io/bosonnetwork/kademlia/protocol/AnnouncePeerTests.java @@ -27,6 +27,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import java.util.Map; import java.util.stream.Stream; import org.junit.jupiter.api.Test; @@ -36,6 +37,7 @@ import io.bosonnetwork.Id; import io.bosonnetwork.PeerInfo; +import io.bosonnetwork.crypto.CryptoIdentity; import io.bosonnetwork.crypto.Random; public class AnnouncePeerTests extends MessageTests { @@ -44,10 +46,28 @@ private static Stream requestParameters() { int port = 65516; return Stream.of( - Arguments.of("peer", PeerInfo.of(Id.random(), Id.random(), port, sig), 144), - Arguments.of("peerWithAltURL", PeerInfo.of(Id.random(), Id.random(), port, "http://abc.example.com/", sig), 172), - Arguments.of("delegatedPeer", PeerInfo.of(Id.random(), Id.random(), Id.random(), port, sig), 180), - Arguments.of("delegatedPeerWithAltURL", PeerInfo.of(Id.random(), Id.random(), Id.random(), port, "http://abc.example.com/", sig), 208) + Arguments.of("simple", PeerInfo.builder() + .sequenceNumber(6) + .fingerprint(1000) + .endpoint("tcp://203.0.113.10:3456") + .build(), 208), + Arguments.of("simple+extra", PeerInfo.builder() + .sequenceNumber(7) + .endpoint("tcp://203.0.113.10:3456") + .extra(Map.of("foo", "bar", "buz", true)) + .build(), 233), + Arguments.of("authenticated", PeerInfo.builder() + .node(new CryptoIdentity()) + .sequenceNumber(8) + .endpoint("tcp://203.0.113.10:3456") + .build(), 319), + Arguments.of("authenticated+extra", PeerInfo.builder() + .node(new CryptoIdentity()) + .fingerprint(-1234) + .sequenceNumber(9) + .endpoint("tcp://203.0.113.10:3456") + .extra(Map.of("foo", "bar", "buz", true)) + .build(), 332) ); } @@ -55,7 +75,7 @@ private static Stream requestParameters() { @MethodSource("requestParameters") void testRequest(String name, PeerInfo peer, int expectedSize) throws Exception { var token = 0x76543210; - var msg = Message.announcePeerRequest(peer, token); + var msg = Message.announcePeerRequest(peer, token, peer.getSequenceNumber() - 1); msg.setId(peer.getNodeId()); var bin = msg.toBytes(); @@ -101,17 +121,21 @@ void testResponse() throws Exception { @Test void timingRequest() { byte[] sig = Random.randomBytes(64); - PeerInfo peer = PeerInfo.of(Id.random(), Id.random(), 65535, sig); + PeerInfo peer = PeerInfo.builder() + .sequenceNumber(6) + .fingerprint(1000) + .endpoint("tcp://203.0.113.10:3456") + .build(); var token = 0x76543210; - var msg = Message.announcePeerRequest(peer, token); + var msg = Message.announcePeerRequest(peer, token, peer.getSequenceNumber() - 1); msg.setId(peer.getNodeId()); var bin = msg.toBytes(); Message.parse(bin, peer.getNodeId()); var start = System.currentTimeMillis(); for (var i = 0; i < TIMING_ITERATIONS; i++) { - var msg2 = Message.announcePeerRequest(peer, token); + var msg2 = Message.announcePeerRequest(peer, token, peer.getSequenceNumber() - 1); msg2.setId(peer.getNodeId()); var bin2 = msg2.toBytes(); Message.parse(bin2, peer.getNodeId()); diff --git a/dht/src/test/java/io/bosonnetwork/kademlia/protocol/FindPeerTests.java b/dht/src/test/java/io/bosonnetwork/kademlia/protocol/FindPeerTests.java index cb58418..7e4eda4 100644 --- a/dht/src/test/java/io/bosonnetwork/kademlia/protocol/FindPeerTests.java +++ b/dht/src/test/java/io/bosonnetwork/kademlia/protocol/FindPeerTests.java @@ -39,29 +39,39 @@ import io.bosonnetwork.Id; import io.bosonnetwork.NodeInfo; import io.bosonnetwork.PeerInfo; -import io.bosonnetwork.crypto.Random; +import io.bosonnetwork.crypto.CryptoIdentity; +import io.bosonnetwork.crypto.Signature; public class FindPeerTests extends MessageTests { private static Stream requestParameters() { return Stream.of( - Arguments.of("v4", true, false), - Arguments.of("v6", false, true), - Arguments.of("v4+v6", true, true) + Arguments.of("v4-default", true, false, -1, 1, 66), + Arguments.of("v6-default", false, true, -1, 1, 66), + Arguments.of("v4+v6-default", true, true, -1, 1, 66), + Arguments.of("v4-all", true, false, -1, 0, 63), + Arguments.of("v6-all", false, true, -1, 0, 63), + Arguments.of("v4+v6-all", true, true, -1, 0, 63), + Arguments.of("v4-exp", true, false, -1, 2, 66), + Arguments.of("v6-exp", false, true, -1, 3, 66), + Arguments.of("v4+v6-exp", true, true, -1, 4, 66), + Arguments.of("v4-seq-exp", true, false, 8, 5, 71), + Arguments.of("v6-seq-exp", false, true, 9, 6, 71), + Arguments.of("v4+v6-seq-exp", true, true, 10, 7, 71) ); } @ParameterizedTest(name = "{0}") @MethodSource("requestParameters") - void testRequest(String name, boolean want4, boolean want6) throws Exception { + void testRequest(String name, boolean want4, boolean want6, int expectedSequenceNumber, int expectedCount, int size) throws Exception { var nodeId = Id.random(); var target = Id.random(); - var msg = Message.findPeerRequest(target, want4, want6); + var msg = Message.findPeerRequest(target, want4, want6, expectedSequenceNumber, expectedCount); msg.setId(nodeId); byte[] bin = msg.toBytes(); printMessage(msg); - assertEquals(63, bin.length); + assertEquals(size, bin.length); assertEquals(Message.Type.REQUEST, msg.getType()); assertEquals(Message.Method.FIND_PEER, msg.getMethod()); @@ -70,6 +80,8 @@ void testRequest(String name, boolean want4, boolean want6) throws Exception { assertEquals(target, msg.getBody().getTarget()); assertEquals(want4, msg.getBody().doesWant4()); assertEquals(want6, msg.getBody().doesWant6()); + assertEquals(expectedSequenceNumber, msg.getBody().getExpectedSequenceNumber()); + assertEquals(expectedCount, msg.getBody().getExpectedCount()); var msg2 = Message.parse(bin); msg2.setId(nodeId); @@ -103,23 +115,22 @@ private static Stream responseParameters() { nodes6.add(new NodeInfo(Id.random(), ip6, port--)); nodes6.add(new NodeInfo(Id.random(), ip6, port--)); - var peerId = Id.random(); - var sig = Random.randomBytes(64); + var peerKey = Signature.KeyPair.random(); var peers = new ArrayList(); - peers.add(PeerInfo.of(peerId, Id.random(), port--, sig)); - peers.add(PeerInfo.of(peerId, Id.random(), Id.random(), port--, sig)); - peers.add(PeerInfo.of(peerId, Id.random(), Id.random(), port--, "http://abc.example.com/", sig)); - peers.add(PeerInfo.of(peerId, Id.random(), port--, "https://foo.example.com/", sig)); - peers.add(PeerInfo.of(peerId, Id.random(), port, "http://bar.example.com/", sig)); + peers.add(PeerInfo.builder().key(peerKey).fingerprint(0x1234).endpoint("tcp://203.0.113.10:" + port--).build()); + peers.add(PeerInfo.builder().key(peerKey).fingerprint(0x1235).endpoint("tcp://203.0.113.11:" + port--).build()); + peers.add(PeerInfo.builder().key(peerKey).fingerprint(0x1236).endpoint("http://abc.example.com/").build()); + peers.add(PeerInfo.builder().key(peerKey).fingerprint(0x1237).endpoint("http://foo.example.com/").build()); + peers.add(PeerInfo.builder().key(peerKey).fingerprint(0x1238).node(new CryptoIdentity()).endpoint("http://bar.example.com/").build()); return Stream.of( Arguments.of("v4", nodes4, null, null, 380), - Arguments.of("v4+peers", nodes4, null, peers, 1093), + Arguments.of("v4+peers", nodes4, null, peers, 1332), Arguments.of("v6", null, nodes6, null, 476), - Arguments.of("v6+peers", null, nodes6, peers, 1189), + Arguments.of("v6+peers", null, nodes6, peers, 1428), Arguments.of("v4+v6", nodes4, nodes6, null, 832), - Arguments.of("v4+v6+peers", nodes4, nodes6, peers, 1545), - Arguments.of("peers", null, null, peers, 737) + Arguments.of("v4+v6+peers", nodes4, nodes6, peers, 1784), + Arguments.of("peers", null, null, peers, 976) ); } @@ -170,14 +181,14 @@ void timingRequest() { var target = Id.random(); // warmup - var msg = Message.findPeerRequest(target, true, false); + var msg = Message.findPeerRequest(target, true, false, 9, 2); msg.setId(nodeId); var bin = msg.toBytes(); Message.parse(bin); var start = System.currentTimeMillis(); for (var i = 0; i < TIMING_ITERATIONS; i++) { - msg = Message.findPeerRequest(target, true, false); + msg = Message.findPeerRequest(target, true, false, 9, 2); msg.setId(nodeId); bin = msg.toBytes(); Message.parse(bin); @@ -193,14 +204,13 @@ void timingResponse() { int port = 65535; - var peerId = Id.random(); - var sig = Random.randomBytes(64); + var peerKey = Signature.KeyPair.random(); var peers = new ArrayList(); - peers.add(PeerInfo.of(peerId, Id.random(), port--, sig)); - peers.add(PeerInfo.of(peerId, Id.random(), Id.random(), port--, sig)); - peers.add(PeerInfo.of(peerId, Id.random(), Id.random(), port--, "http://abc.example.com/", sig)); - peers.add(PeerInfo.of(peerId, Id.random(), port--, "https://foo.example.com/", sig)); - peers.add(PeerInfo.of(peerId, Id.random(), port, "http://bar.example.com/", sig)); + peers.add(PeerInfo.builder().key(peerKey).endpoint("tcp://203.0.113.10:" + port--).build()); + peers.add(PeerInfo.builder().key(peerKey).endpoint("tcp://203.0.113.11:" + port--).build()); + peers.add(PeerInfo.builder().key(peerKey).endpoint("http://abc.example.com/").build()); + peers.add(PeerInfo.builder().key(peerKey).endpoint("http://foo.example.com/").build()); + peers.add(PeerInfo.builder().key(peerKey).node(new CryptoIdentity()).endpoint("http://bar.example.com/").build()); // warmup var msg = Message.findPeerResponse(txid, null, null, peers); diff --git a/dht/src/test/java/io/bosonnetwork/kademlia/protocol/FindValueTests.java b/dht/src/test/java/io/bosonnetwork/kademlia/protocol/FindValueTests.java index bfc8c43..8a8d0db 100644 --- a/dht/src/test/java/io/bosonnetwork/kademlia/protocol/FindValueTests.java +++ b/dht/src/test/java/io/bosonnetwork/kademlia/protocol/FindValueTests.java @@ -135,10 +135,12 @@ private static Stream responseParameters() throws Exception { nodes6.add(new NodeInfo(Id.random(), ip6, port--)); nodes6.add(new NodeInfo(Id.random(), ip6, port)); - Value immutable = Value.createValue("This is a immutable value".getBytes()); - Value signedValue = Value.createSignedValue("This is a signed value".getBytes()); - Value encryptedValue = Value.createEncryptedValue(Id.of(Signature.KeyPair.random().publicKey().bytes()), - "This is a encrypted value".getBytes()); + Value immutable = Value.builder().data("This is a immutable value".getBytes()).build(); + Value signedValue = Value.builder().data("This is a signed value".getBytes()).buildSigned(); + Value encryptedValue = Value.builder() + .recipient(Id.of(Signature.KeyPair.random().publicKey().bytes())) + .data("This is a encrypted value".getBytes()) + .buildEncrypted(); return Stream.of( Arguments.of("v4", nodes4, null, null, 380), @@ -223,7 +225,10 @@ void timingResponse() throws Exception { var nodeId = Id.random(); var txid = 0x76543210; - Value value = Value.createEncryptedValue(Id.of(Signature.KeyPair.random().publicKey().bytes()), Random.randomBytes(512)); + Value value = Value.builder() + .recipient(Id.of(Signature.KeyPair.random().publicKey().bytes())) + .data(Random.randomBytes(512)) + .buildEncrypted(); // warmup var msg = Message.findValueResponse(txid, null, null, value); diff --git a/dht/src/test/java/io/bosonnetwork/kademlia/protocol/MessageTests.java b/dht/src/test/java/io/bosonnetwork/kademlia/protocol/MessageTests.java index 91c070a..df00a94 100644 --- a/dht/src/test/java/io/bosonnetwork/kademlia/protocol/MessageTests.java +++ b/dht/src/test/java/io/bosonnetwork/kademlia/protocol/MessageTests.java @@ -28,7 +28,7 @@ import org.junit.jupiter.api.BeforeAll; import io.bosonnetwork.utils.Hex; -import io.bosonnetwork.utils.Json; +import io.bosonnetwork.json.Json; public abstract class MessageTests { protected static int TIMING_ITERATIONS = 1_000_000; diff --git a/dht/src/test/java/io/bosonnetwork/kademlia/protocol/StoreValueTests.java b/dht/src/test/java/io/bosonnetwork/kademlia/protocol/StoreValueTests.java index 64e4623..b78947d 100644 --- a/dht/src/test/java/io/bosonnetwork/kademlia/protocol/StoreValueTests.java +++ b/dht/src/test/java/io/bosonnetwork/kademlia/protocol/StoreValueTests.java @@ -37,17 +37,27 @@ import io.bosonnetwork.Id; import io.bosonnetwork.Value; import io.bosonnetwork.crypto.Random; +import io.bosonnetwork.crypto.Signature; public class StoreValueTests extends MessageTests { private static Stream requestParameters() throws Exception { - Value immutable = Value.createValue("This is a immutable value".getBytes()); - Value signedValue = Value.of(Id.random(), Random.randomBytes(24), 3, Random.randomBytes(64), "This is a signed value".getBytes()); - Value encryptedValue = Value.of(Id.random(), Id.random(), Random.randomBytes(24), 9, Random.randomBytes(64), "This is a encrypted value".getBytes()); + Value immutable = Value.builder() + .data("This is a immutable value".getBytes()) + .build(); + Value signedValue = Value.builder() + .sequenceNumber(3) + .data("This is a signed value".getBytes()) + .buildSigned(); + Value encryptedValue = Value.builder() + .recipient(Id.of(Signature.KeyPair.random().publicKey().bytes())) + .sequenceNumber(9) + .data("This is a encrypted value".getBytes()) + .buildEncrypted(); return Stream.of( Arguments.of("immutable", immutable, 62), Arguments.of("signed", signedValue, 202), - Arguments.of("encrypted", encryptedValue, 244) + Arguments.of("encrypted", encryptedValue, 260) ); } diff --git a/dht/src/test/java/io/bosonnetwork/kademlia/rpc/RPCServerTests.java b/dht/src/test/java/io/bosonnetwork/kademlia/rpc/RPCServerTests.java index 10a27e7..bc597c3 100644 --- a/dht/src/test/java/io/bosonnetwork/kademlia/rpc/RPCServerTests.java +++ b/dht/src/test/java/io/bosonnetwork/kademlia/rpc/RPCServerTests.java @@ -295,10 +295,10 @@ protected static List createRandomNodes(int count) { protected static PeerInfo createPeerInfo() { PeerInfo peer = switch (Random.random().nextInt(0, 5)) { - case 1 -> PeerInfo.create(Id.random(), Random.random().nextInt(4096, 65535), faker.internet().url()); - case 2 -> PeerInfo.create(Id.random(), Id.random(), Random.random().nextInt(4096, 65535)); - case 3 -> PeerInfo.create(Id.random(), Id.random(), Random.random().nextInt(4096, 65535), faker.internet().url()); - default -> PeerInfo.create(Id.random(), Random.random().nextInt(4096, 65535)); + case 1 -> PeerInfo.builder().endpoint(faker.internet().url()).build(); + case 2 -> PeerInfo.builder().node(new CryptoIdentity()).endpoint("tcp:///203.0.113.10:" + + faker.internet().port()).build(); + case 3 -> PeerInfo.builder().node(new CryptoIdentity()).endpoint(faker.internet().url()).build(); + default -> PeerInfo.builder().endpoint("tcp:///203.0.113.10:" + faker.internet().port()).build(); }; peers.put(peer.getId(), List.of(peer)); @@ -312,10 +312,10 @@ protected static Id createPeerInfo(int count) { List infos = new ArrayList<>(); for (int i = 0; i < count; i++) { PeerInfo peer = switch (Random.random().nextInt(0, 5)) { - case 1 -> PeerInfo.create(keyPair, Id.random(), Random.random().nextInt(4096, 65535), faker.internet().url()); - case 2 -> PeerInfo.create(keyPair, Id.random(), Id.random(), Random.random().nextInt(4096, 65535)); - case 3 -> PeerInfo.create(keyPair, Id.random(), Id.random(), Random.random().nextInt(4096, 65535), faker.internet().url()); - default -> PeerInfo.create(keyPair, Id.random(), Random.random().nextInt(4096, 65535)); + case 1 -> PeerInfo.builder().key(keyPair).endpoint("http://foo.example.com/").build(); + case 2 -> PeerInfo.builder().key(keyPair).endpoint("tcp://203.0.113.10:1234").build(); + case 3 -> PeerInfo.builder().key(keyPair).node(new CryptoIdentity()).endpoint("http://bar.example.com/").build(); + default -> PeerInfo.builder().key(keyPair).node(new CryptoIdentity()).endpoint("http://abc.example.com/").build(); }; infos.add(peer); @@ -328,10 +328,11 @@ protected static Id createPeerInfo(int count) { protected static Value createValue() { try { Value value = switch (Random.random().nextInt(0, 3)) { - // case 0 -> Value.createValue(faker.lorem().paragraph().getBytes()); - case 1 -> Value.createSignedValue(faker.lorem().paragraph().getBytes()); - case 2 -> Value.createEncryptedValue(Id.of(Signature.KeyPair.random().publicKey().bytes()), faker.lorem().paragraph().getBytes()); - default -> Value.createValue(faker.lorem().paragraph().getBytes()); + case 0 -> Value.builder().data(faker.lorem().paragraph().getBytes()).build(); + case 1 -> Value.builder().data(faker.lorem().paragraph().getBytes()).buildSigned(); + case 2 -> Value.builder().recipient(Id.of(Signature.KeyPair.random().publicKey().bytes())) + .data(faker.lorem().paragraph().getBytes()).buildEncrypted(); + default -> Value.builder().sequenceNumber(2).data(faker.lorem().paragraph().getBytes()).build(); }; values.put(value.getId(), value); @@ -372,8 +373,8 @@ public void testGoodMessages(Vertx vertx, VertxTestContext context) { Message request = switch (i % 7) { case 0, 1 -> Message.pingRequest(); case 2 -> Message.findNodeRequest(Id.random(), true, false, true); - case 3 -> Message.announcePeerRequest(createPeerInfo(), Random.random().nextInt(1, Integer.MAX_VALUE)); - case 4 -> Message.findPeerRequest(createPeerInfo(Random.random().nextInt(2, 8)), true, false); + case 3 -> Message.announcePeerRequest(createPeerInfo(), Random.random().nextInt(1, Integer.MAX_VALUE), -1); + case 4 -> Message.findPeerRequest(createPeerInfo(Random.random().nextInt(2, 8)), true, false, -1, 2); case 5 -> Message.storeValueRequest(createValue(), Random.random().nextInt(1, Integer.MAX_VALUE), Random.random().nextInt(1, 100)); case 6 -> Message.findValueRequest(createValue().getId(), true, false, 0); default -> Message.message(Message.Type.REQUEST, Message.Method.UNKNOWN, 0x7FFF0123, null); @@ -478,8 +479,8 @@ public void testRandomUnknownMessages(Vertx vertx, VertxTestContext context) { Message request = switch (i % 7) { case 1 -> Message.pingRequest(); case 2 -> Message.findNodeRequest(Id.random(), true, false, true); - case 3 -> Message.announcePeerRequest(createPeerInfo(), Random.random().nextInt(1, Integer.MAX_VALUE)); - case 4 -> Message.findPeerRequest(createPeerInfo(Random.random().nextInt(2, 8)), true, false); + case 3 -> Message.announcePeerRequest(createPeerInfo(), Random.random().nextInt(1, Integer.MAX_VALUE), 3); + case 4 -> Message.findPeerRequest(createPeerInfo(Random.random().nextInt(2, 8)), true, false, 2, 1); case 5 -> Message.storeValueRequest(createValue(), Random.random().nextInt(1, Integer.MAX_VALUE), Random.random().nextInt(1, 100)); case 6 -> Message.findValueRequest(createValue().getId(), true, false, 0); default -> Message.message(Message.Type.REQUEST, Message.Method.UNKNOWN, 0x7FFF0123, null); @@ -586,8 +587,8 @@ public void testAbnormalMessages(Vertx vertx, VertxTestContext context) { Message request = switch (i % 7) { case 0, 1 -> Message.pingRequest(); case 2 -> Message.findNodeRequest(Id.random(), true, false, true); - case 3 -> Message.announcePeerRequest(createPeerInfo(), Random.random().nextInt(1, Integer.MAX_VALUE)); - case 4 -> Message.findPeerRequest(createPeerInfo(Random.random().nextInt(2, 8)), true, false); + case 3 -> Message.announcePeerRequest(createPeerInfo(), Random.random().nextInt(1, Integer.MAX_VALUE), -1); + case 4 -> Message.findPeerRequest(createPeerInfo(Random.random().nextInt(2, 8)), true, false, 0, 1); case 5 -> Message.storeValueRequest(createValue(), Random.random().nextInt(1, Integer.MAX_VALUE), Random.random().nextInt(1, 100)); case 6 -> Message.findValueRequest(createValue().getId(), true, false, 0); default -> Message.message(Message.Type.REQUEST, Message.Method.UNKNOWN, 0x7FFF0123, null); @@ -680,8 +681,8 @@ public void testOutboundThrottling(Vertx vertx, VertxTestContext context) { Message request = switch (i % 7) { case 0, 1 -> Message.pingRequest(); case 2 -> Message.findNodeRequest(Id.random(), true, false, true); - case 3 -> Message.announcePeerRequest(createPeerInfo(), Random.random().nextInt(1, Integer.MAX_VALUE)); - case 4 -> Message.findPeerRequest(createPeerInfo(Random.random().nextInt(2, 8)), true, false); + case 3 -> Message.announcePeerRequest(createPeerInfo(), Random.random().nextInt(1, Integer.MAX_VALUE), 2); + case 4 -> Message.findPeerRequest(createPeerInfo(Random.random().nextInt(2, 8)), true, false, 2, 0); case 5 -> Message.storeValueRequest(createValue(), Random.random().nextInt(1, Integer.MAX_VALUE), Random.random().nextInt(1, 100)); case 6 -> Message.findValueRequest(createValue().getId(), true, false, 0); default -> Message.message(Message.Type.REQUEST, Message.Method.UNKNOWN, 0x7FFF0123, null); diff --git a/dht/src/test/java/io/bosonnetwork/kademlia/storage/DataStorageTests.java b/dht/src/test/java/io/bosonnetwork/kademlia/storage/DataStorageTests.java index 36b8b48..599768d 100644 --- a/dht/src/test/java/io/bosonnetwork/kademlia/storage/DataStorageTests.java +++ b/dht/src/test/java/io/bosonnetwork/kademlia/storage/DataStorageTests.java @@ -1,8 +1,8 @@ package io.bosonnetwork.kademlia.storage; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -11,16 +11,18 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.CompletionException; import java.util.concurrent.TimeUnit; -import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import java.util.stream.Stream; import io.vertx.core.Future; +import io.vertx.core.Promise; import io.vertx.core.Vertx; import net.datafaker.Faker; @@ -38,13 +40,12 @@ import io.vertx.junit5.VertxTestContext; import io.bosonnetwork.Id; +import io.bosonnetwork.Identity; import io.bosonnetwork.PeerInfo; import io.bosonnetwork.Value; -import io.bosonnetwork.crypto.CryptoBox; +import io.bosonnetwork.crypto.CryptoIdentity; import io.bosonnetwork.crypto.Random; import io.bosonnetwork.crypto.Signature; -import io.bosonnetwork.kademlia.exceptions.SequenceNotExpected; -import io.bosonnetwork.kademlia.exceptions.SequenceNotMonotonic; import io.bosonnetwork.utils.FileUtils; @ExtendWith(VertxExtension.class) @@ -71,9 +72,10 @@ public class DataStorageTests { private static List peerInfos; private static List persistentPeerInfos; private static Map> multiPeers; + private static Map nodeIdentities; - private static long announced1; - private static long announced2; + private static long announceTime1; + private static long announceTime2; private static PostgresqlServer pgServer; private static final List dataStorages = new ArrayList<>(); @@ -117,15 +119,11 @@ static void setupDataStorage(Vertx vertx, VertxTestContext context) { })); futures.add(future2); - inMemoryStorage = new InMemoryStorage(); - var future3 = inMemoryStorage.initialize(vertx, valueExpiration, peerInfoExpiration).onComplete(context.succeeding(version -> { - context.verify(() -> assertEquals(CURRENT_SCHEMA_VERSION, version)); - dataStorages.add(Arguments.of("InMemoryStorage", inMemoryStorage)); - })); - futures.add(future3); - Future.all(futures).onSuccess(unused -> { try { + nodeIdentities = IntStream.range(0, 32).mapToObj(i -> new CryptoIdentity()) + .collect(Collectors.toMap(Identity::getId, identity -> identity)); + values = generateValues(Random.random().nextInt(32, 64)); persistentValues = generateValues(Random.random().nextInt(32, 64)); @@ -161,18 +159,33 @@ private static List generateValues(int count) throws Exception { for (int i = 0; i < count; i++) { var type = i % 6; var value = switch (type) { - case 0 -> Value.createValue(faker.lorem().paragraph().getBytes()); - case 1 -> Value.createSignedValue(Signature.KeyPair.random(), CryptoBox.Nonce.random(), - faker.number().numberBetween(2, 100), faker.lorem().paragraph().getBytes()); - case 2 -> Value.createEncryptedValue(Signature.KeyPair.random(), - Id.of(Signature.KeyPair.random().publicKey().bytes()), CryptoBox.Nonce.random(), - faker.number().numberBetween(2, 100), faker.lorem().paragraph().getBytes()); - case 3 -> Value.createValue(faker.lorem().paragraph().getBytes()).withoutPrivateKey(); - case 4 -> Value.createSignedValue(Signature.KeyPair.random(), CryptoBox.Nonce.random(), - faker.number().numberBetween(2, 100), faker.lorem().paragraph().getBytes()).withoutPrivateKey(); - case 5 -> Value.createEncryptedValue(Signature.KeyPair.random(), - Id.of(Signature.KeyPair.random().publicKey().bytes()), CryptoBox.Nonce.random(), - faker.number().numberBetween(2, 100), faker.lorem().paragraph().getBytes()).withoutPrivateKey(); + case 0 -> Value.builder() + .data(faker.lorem().paragraph().getBytes()) + .build(); + case 1 -> Value.builder() + .sequenceNumber(faker.number().numberBetween(2, 100)) + .data(faker.lorem().paragraph().getBytes()) + .buildSigned(); + case 2 -> Value.builder() + .recipient(Id.of(Signature.KeyPair.random().publicKey().bytes())) + .sequenceNumber(faker.number().numberBetween(2, 100)) + .data(faker.lorem().paragraph().getBytes()) + .buildEncrypted(); + case 3 -> Value.builder() + .data(faker.lorem().paragraph().getBytes()) + .build() + .withoutPrivateKey(); + case 4 -> Value.builder() + .sequenceNumber(faker.number().numberBetween(2, 100)) + .data(faker.lorem().paragraph().getBytes()) + .buildSigned() + .withoutPrivateKey(); + case 5 -> Value.builder() + .recipient(Id.of(Signature.KeyPair.random().publicKey().bytes())) + .sequenceNumber(faker.number().numberBetween(2, 100)) + .data(faker.lorem().paragraph().getBytes()) + .buildEncrypted() + .withoutPrivateKey(); default -> throw new IllegalStateException(); }; @@ -187,14 +200,42 @@ private static List generatePeerInfos(int count) { for (int i = 0; i < count; i++) { var type = i % 8; var peerInfo = switch (type) { - case 0 -> PeerInfo.create(Id.random(), faker.number().numberBetween(39001, 65535)); - case 1 -> PeerInfo.create(Id.random(), faker.number().numberBetween(39001, 65535), faker.internet().url()); - case 2 -> PeerInfo.create(Id.random(), Id.random(), faker.number().numberBetween(39001, 65535)); - case 3 -> PeerInfo.create(Id.random(), Id.random(), faker.number().numberBetween(39001, 65535), faker.internet().url()); - case 4 -> PeerInfo.create(Id.random(), faker.number().numberBetween(39001, 65535)).withoutPrivateKey(); - case 5 -> PeerInfo.create(Id.random(), faker.number().numberBetween(39001, 65535), faker.internet().url()).withoutPrivateKey(); - case 6 -> PeerInfo.create(Id.random(), Id.random(), faker.number().numberBetween(39001, 65535)).withoutPrivateKey(); - case 7 -> PeerInfo.create(Id.random(), Id.random(), faker.number().numberBetween(39001, 65535), faker.internet().url()).withoutPrivateKey(); + case 0 -> PeerInfo.builder() + .endpoint("tcp://" + faker.internet().ipV4Address() + ":" + faker.internet().port()) + .build(); + case 1 -> PeerInfo.builder() + .endpoint(faker.internet().url()) + .extra(Map.of("foo", 1234, "bar", "baz")) + .build(); + case 2 -> PeerInfo.builder() + .endpoint("tcp://" + faker.internet().ipV4Address() + ":" + faker.internet().port()) + .node(nodeIdentities.values().stream().toList().get(Random.random().nextInt(nodeIdentities.size()))) + .build(); + case 3 -> PeerInfo.builder() + .endpoint(faker.internet().url()) + .extra(Map.of("foo", 1234, "bar", "baz")) + .node(nodeIdentities.values().stream().toList().get(Random.random().nextInt(nodeIdentities.size()))) + .build(); + case 4 -> PeerInfo.builder() + .endpoint("tcp://" + faker.internet().ipV4Address() + ":" + faker.internet().port()) + .build() + .withoutPrivateKey(); + case 5 -> PeerInfo.builder() + .endpoint(faker.internet().url()) + .extra(Map.of("foo", 1234, "bar", "baz")) + .build() + .withoutPrivateKey(); + case 6 -> PeerInfo.builder() + .endpoint("tcp://" + faker.internet().ipV4Address() + ":" + faker.internet().port()) + .node(nodeIdentities.values().stream().toList().get(Random.random().nextInt(nodeIdentities.size()))) + .build() + .withoutPrivateKey(); + case 7 -> PeerInfo.builder() + .endpoint(faker.internet().url()) + .extra(Map.of("foo", 1234, "bar", "baz")) + .node(nodeIdentities.values().stream().toList().get(Random.random().nextInt(nodeIdentities.size()))) + .build() + .withoutPrivateKey(); default -> throw new IllegalStateException(); }; @@ -212,14 +253,58 @@ private static Map> generateMultiPeerInfos(int count, int siz count = Random.random().nextInt(8, 20); for (int i = 0; i < size; i++) { var peerInfo = switch (i % 8) { - case 0 -> PeerInfo.create(keyPair, Id.random(), faker.number().numberBetween(39001, 65535)); - case 1 -> PeerInfo.create(keyPair, Id.random(), faker.number().numberBetween(39001, 65535), faker.internet().url()); - case 2 -> PeerInfo.create(keyPair, Id.random(), Id.random(), faker.number().numberBetween(39001, 65535)); - case 3 -> PeerInfo.create(keyPair, Id.random(), Id.random(), faker.number().numberBetween(39001, 65535), faker.internet().url()); - case 4 -> PeerInfo.create(keyPair, Id.random(), faker.number().numberBetween(39001, 65535)).withoutPrivateKey(); - case 5 -> PeerInfo.create(keyPair, Id.random(), faker.number().numberBetween(39001, 65535), faker.internet().url()).withoutPrivateKey(); - case 6 -> PeerInfo.create(keyPair, Id.random(), Id.random(), faker.number().numberBetween(39001, 65535)).withoutPrivateKey(); - case 7 -> PeerInfo.create(keyPair, Id.random(), Id.random(), faker.number().numberBetween(39001, 65535), faker.internet().url()).withoutPrivateKey(); + case 0 -> PeerInfo.builder() + .fingerprint(Random.random().nextLong()) + .key(keyPair) + .endpoint("tcp://" + faker.internet().ipV4Address() + ":" + faker.internet().port()) + .build(); + case 1 -> PeerInfo.builder() + .key(keyPair) + .fingerprint(Random.random().nextLong()) + .endpoint(faker.internet().url()) + .extra(Map.of("foo", 1234, "bar", "baz")) + .build(); + case 2 -> PeerInfo.builder() + .key(keyPair) + .fingerprint(Random.random().nextLong()) + .endpoint("tcp://" + faker.internet().ipV4Address() + ":" + faker.internet().port()) + .node(nodeIdentities.values().stream().toList().get(Random.random().nextInt(nodeIdentities.size()))) + .build(); + case 3 -> PeerInfo.builder() + .key(keyPair) + .fingerprint(Random.random().nextLong()) + .endpoint(faker.internet().url()) + .extra(Map.of("foo", 1234, "bar", "baz")) + .node(nodeIdentities.values().stream().toList().get(Random.random().nextInt(nodeIdentities.size()))) + .build(); + case 4 -> PeerInfo.builder() + .key(keyPair) + .fingerprint(Random.random().nextLong()) + .endpoint("tcp://" + faker.internet().ipV4Address() + ":" + faker.internet().port()) + .build() + .withoutPrivateKey(); + case 5 -> PeerInfo.builder() + .key(keyPair) + .fingerprint(Random.random().nextLong()) + .endpoint(faker.internet().url()) + .extra(Map.of("foo", 1234, "bar", "baz")) + .build() + .withoutPrivateKey(); + case 6 -> PeerInfo.builder() + .key(keyPair) + .fingerprint(Random.random().nextLong()) + .endpoint("tcp://" + faker.internet().ipV4Address() + ":" + faker.internet().port()) + .node(nodeIdentities.values().stream().toList().get(Random.random().nextInt(nodeIdentities.size()))) + .build() + .withoutPrivateKey(); + case 7 -> PeerInfo.builder() + .key(keyPair) + .fingerprint(Random.random().nextLong()) + .endpoint(faker.internet().url()) + .extra(Map.of("foo", 1234, "bar", "baz")) + .node(nodeIdentities.values().stream().toList().get(Random.random().nextInt(nodeIdentities.size()))) + .build() + .withoutPrivateKey(); default -> throw new IllegalStateException(); }; @@ -243,36 +328,34 @@ void testGetSchemaVersion(String name, DataStorage storage) { assertEquals(CURRENT_SCHEMA_VERSION, storage.getSchemaVersion()); } - private static Future executeSequentially(List inputs, Function> action, int index) { - if (index >= inputs.size()) - return Future.succeededFuture(); - - return action.apply(inputs.get(index)) - .compose(result -> executeSequentially(inputs, action, index + 1)); - } - @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") @Order(1) void testPutValue(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { - executeSequentially(values, value -> { - return storage.putValue(value) - .onComplete(context.succeeding(result -> { - context.verify(() -> assertEquals(value, result)); - })); - }, 0).onComplete(context.succeedingThenComplete()); + Future chain = Future.succeededFuture(); + for (var value : values) { + chain = chain.compose(v -> storage.putValue(value) + .onComplete(context.succeeding(result -> { + context.verify(() -> assertEquals(value, result)); + })) + ); + } + chain.onComplete(context.succeedingThenComplete()); } @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") @Order(2) void testPutPersistentValue(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { - executeSequentially(persistentValues, value -> { - return storage.putValue(value, true) + Future chain = Future.succeededFuture(); + for (var value : persistentValues) { + chain = chain.compose(v -> storage.putValue(value, true) .onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(value, result)); - })); - }, 0).onComplete(context.succeedingThenComplete()); + })) + ); + } + chain.onComplete(context.succeedingThenComplete()); } @ParameterizedTest(name = "{0}") @@ -359,7 +442,7 @@ void testGetValuesPaginated(String name, DataStorage storage, Vertx vertx, Vertx @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") @Order(6) - void testGetPersistentValues(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { + void testGetPersistentValuesUpdatedBefore(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { storage.getValues(true, System.currentTimeMillis()).onComplete(context.succeeding(result -> { context.verify(() -> { var expected = new ArrayList<>(persistentValues); @@ -388,7 +471,7 @@ private static Future> fetchValues(DataStorage storage, boolean pers @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") @Order(7) - void testGetPersistentValuesPaginated(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { + void testGetPersistentValuesUpdatedBeforePaginated(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { List allValues = new ArrayList<>(); fetchValues(storage, true, System.currentTimeMillis(), 0, 8, allValues) .onComplete(context.succeeding(result -> { @@ -407,7 +490,7 @@ void testGetPersistentValuesPaginated(String name, DataStorage storage, Vertx ve @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") @Order(8) - void testGetNonPersistentValues(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { + void testGetNonPersistentValuesUpdatedBefore(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { storage.getValues(false, System.currentTimeMillis()).onComplete(context.succeeding(result -> { context.verify(() -> { var expected = new ArrayList<>(values); @@ -424,7 +507,7 @@ void testGetNonPersistentValues(String name, DataStorage storage, Vertx vertx, V @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") @Order(9) - void testGetNonPersistentValuesPaginated(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { + void testGetNonPersistentValuesUpdatedBeforePaginated(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { List allValues = new ArrayList<>(); fetchValues(storage, false, System.currentTimeMillis(), 0, 8, allValues) .onComplete(context.succeeding(result -> { @@ -460,48 +543,21 @@ void testUpdateValue(String name, DataStorage storage, Vertx vertx, VertxTestCon continue; Value updated = value.update(faker.lorem().paragraph().getBytes()); - var future = storage.putValue(updated).compose(result -> { + var future = storage.putValue(updated).andThen(context.succeeding(result -> { context.verify(() -> assertEquals(updated, result)); - values.set(index, updated); - - return storage.putValue(value, false).andThen(ar -> { + })).compose(v -> { + Value updated2 = updated.withoutPrivateKey(); + return storage.putValue(updated2).onComplete(context.succeeding(result -> { + context.verify(() -> assertEquals(updated, result)); + })); + }).compose(v -> { + return storage.getValue(value.getId()).andThen(context.succeeding(result -> { + // the private key should be kept. context.verify(() -> { - assertFalse(ar.succeeded()); - assertInstanceOf(DataStorageException.class, ar.cause()); - Throwable nested = ar.cause().getCause(); - assertNotNull(nested); - assertInstanceOf(SequenceNotMonotonic.class, nested); + assertTrue(result.hasPrivateKey()); + assertEquals(updated, result); }); - }).otherwise(updated); - }).compose(result -> { - Value updated2; - try { - updated2 = updated.update(faker.lorem().paragraph().getBytes()); - } catch (Exception e) { - throw new CompletionException(e); - } - - return storage.putValue(updated2, false, value.getSequenceNumber() - 1) - .andThen(ar -> { - context.verify(() -> { - assertFalse(ar.succeeded()); - assertInstanceOf(DataStorageException.class, ar.cause()); - Throwable nested = ar.cause().getCause(); - assertNotNull(nested); - assertInstanceOf(SequenceNotExpected.class, nested); - }); - }).otherwise(updated); - }).compose(result -> { - Value updated2; - try { - updated2 = updated.update(faker.lorem().paragraph().getBytes()).withoutPrivateKey(); - } catch (Exception e) { - throw new CompletionException(e); - } - - return storage.putValue(updated2, false).onComplete(context.succeeding(result2 -> { - context.verify(() -> assertEquals(updated, result2)); })); }); @@ -523,48 +579,21 @@ void testUpdateValue(String name, DataStorage storage, Vertx vertx, VertxTestCon continue; Value updated = value.update(faker.lorem().paragraph().getBytes()); - var future = storage.putValue(updated, true).compose(result -> { + var future = storage.putValue(updated, true).andThen(context.succeeding(result -> { context.verify(() -> assertEquals(updated, result)); - persistentValues.set(index, updated); - - return storage.putValue(value, true).andThen(ar -> { + })).compose(v -> { + Value updated2 = updated.withoutPrivateKey(); + return storage.putValue(updated2, true).onComplete(context.succeeding(result -> { + context.verify(() -> assertEquals(updated, result)); + })); + }).compose(v -> { + return storage.getValue(value.getId()).andThen(context.succeeding(result -> { + // the private key should be kept. context.verify(() -> { - assertFalse(ar.succeeded()); - assertInstanceOf(DataStorageException.class, ar.cause()); - Throwable nested = ar.cause().getCause(); - assertNotNull(nested); - assertInstanceOf(SequenceNotMonotonic.class, nested); + assertTrue(result.hasPrivateKey()); + assertEquals(updated, result); }); - }).otherwise(updated); - }).compose(result -> { - Value updated2; - try { - updated2 = updated.update(faker.lorem().paragraph().getBytes()); - } catch (Exception e) { - throw new CompletionException(e); - } - - return storage.putValue(updated2, true, value.getSequenceNumber() - 1) - .andThen(ar -> { - context.verify(() -> { - assertFalse(ar.succeeded()); - assertInstanceOf(DataStorageException.class, ar.cause()); - Throwable nested = ar.cause().getCause(); - assertNotNull(nested); - assertInstanceOf(SequenceNotExpected.class, nested); - }); - }).otherwise(updated); - }).compose(result -> { - Value updated2; - try { - updated2 = updated.update(faker.lorem().paragraph().getBytes()).withoutPrivateKey(); - } catch (Exception e) { - throw new CompletionException(e); - } - - return storage.putValue(updated2, true).onComplete(context.succeeding(result2 -> { - context.verify(() -> assertEquals(updated, result2)); })); }); @@ -610,11 +639,12 @@ void testUpdateValueAnnouncedTime1(String name, DataStorage storage, Vertx vertx futures.add(future); Future.all(futures).onComplete(context.succeeding(unused -> { - announced1 = System.currentTimeMillis(); + announceTime1 = System.currentTimeMillis(); context.completeNow(); })); } + // designed for multiple storages private static boolean firstTestUpdateValueAnnouncedTime2Call = true; @ParameterizedTest(name = "{0}") @@ -659,7 +689,7 @@ void testUpdateValueAnnouncedTime2(String name, DataStorage storage, Vertx vertx futures.add(future); Future.all(futures).onComplete(context.succeeding(unused -> { - announced2 = System.currentTimeMillis(); + announceTime2 = System.currentTimeMillis(); context.completeNow(); })); }); @@ -671,45 +701,49 @@ void testUpdateValueAnnouncedTime2(String name, DataStorage storage, Vertx vertx void testGetValuesAnnouncedBefore(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { var futures = new ArrayList>>(); - var future = storage.getValues(false, announced1).onComplete(context.succeeding(result -> { + // getValues announced before announceTime1 + var future = storage.getValues(false, announceTime1).onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(values.size() / 2, result.size())); })); futures.add(future); - future = storage.getValues(true, announced1).onComplete(context.succeeding(result -> { + future = storage.getValues(true, announceTime1).onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(persistentValues.size() / 2, result.size())); })); futures.add(future); - future = fetchValues(storage, false, announced1, 0, 8, new ArrayList<>()) + // getValues announced before announceTime1 paginated + future = fetchValues(storage, false, announceTime1, 0, 8, new ArrayList<>()) .onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(values.size() / 2, result.size())); })); futures.add(future); - future = fetchValues(storage, true, announced1, 0, 8, new ArrayList<>()) + future = fetchValues(storage, true, announceTime1, 0, 8, new ArrayList<>()) .onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(persistentValues.size() / 2, result.size())); })); futures.add(future); - future = storage.getValues(false, announced2).onComplete(context.succeeding(result -> { + // getValues announced before announceTime2 + future = storage.getValues(false, announceTime2).onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(values.size(), result.size())); })); futures.add(future); - future = storage.getValues(true, announced2).onComplete(context.succeeding(result -> { + future = storage.getValues(true, announceTime2).onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(persistentValues.size(), result.size())); })); futures.add(future); - future = fetchValues(storage, false, announced2, 0, 8, new ArrayList<>()) + // getValues announced before announceTime2 paginated + future = fetchValues(storage, false, announceTime2, 0, 8, new ArrayList<>()) .onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(values.size(), result.size())); })); futures.add(future); - future = fetchValues(storage, true, announced2, 0, 8, new ArrayList<>()) + future = fetchValues(storage, true, announceTime2, 0, 8, new ArrayList<>()) .onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(persistentValues.size(), result.size())); })); @@ -718,6 +752,7 @@ void testGetValuesAnnouncedBefore(String name, DataStorage storage, Vertx vertx, Future.all(futures).onComplete(context.succeedingThenComplete()); } + // designed for multiple storages private static boolean firstTestPurgeCall = true; @ParameterizedTest(name = "{0}") @@ -767,24 +802,30 @@ void testRemoveValue(String name, DataStorage storage, Vertx vertx, VertxTestCon @MethodSource("testStoragesProvider") @Order(101) void testPutPeer(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { - executeSequentially(peerInfos, peerInfo -> { - return storage.putPeer(peerInfo) + Future chain = Future.succeededFuture(); + for (var peerInfo : peerInfos) { + chain = chain.compose(v -> storage.putPeer(peerInfo) .onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(peerInfo, result)); - })); - }, 0).onComplete(context.succeedingThenComplete()); + })) + ); + } + chain.onComplete(context.succeedingThenComplete()); } @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") @Order(102) void testPutPersistentPeer(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { - executeSequentially(persistentPeerInfos, peerInfo -> { - return storage.putPeer(peerInfo, true) + Future chain = Future.succeededFuture(); + for (var peerInfo : persistentPeerInfos) { + chain = chain.compose(v -> storage.putPeer(peerInfo, true) .onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(peerInfo, result)); - })); - }, 0).onComplete(context.succeedingThenComplete()); + })) + ); + } + chain.onComplete(context.succeedingThenComplete()); } @ParameterizedTest(name = "{0}") @@ -794,7 +835,7 @@ void testGetPeer(String name, DataStorage storage, Vertx vertx, VertxTestContext var futures = new ArrayList>(); for (var peerInfo : peerInfos) { - var future = storage.getPeer(peerInfo.getId(), peerInfo.getNodeId()) + var future = storage.getPeer(peerInfo.getId(), peerInfo.getFingerprint()) .onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(peerInfo, result)); })); @@ -802,14 +843,14 @@ void testGetPeer(String name, DataStorage storage, Vertx vertx, VertxTestContext } for (var peerInfo : persistentPeerInfos) { - var future = storage.getPeer(peerInfo.getId(), peerInfo.getNodeId()) + var future = storage.getPeer(peerInfo.getId(), peerInfo.getFingerprint()) .onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(peerInfo, result)); })); futures.add(future); } - var future = storage.getPeer(Id.random(), Id.random()) + var future = storage.getPeer(Id.random(), Random.random().nextLong()) .onComplete(context.succeeding(result -> { context.verify(() -> assertNull(result)); })); @@ -860,6 +901,45 @@ void testGetPeersById1(String name, DataStorage storage, Vertx vertx, VertxTestC @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") @Order(105) + void testGetPeerByIdAndFingerprint1(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { + var futures = new ArrayList>(); + + for (var peerInfo : peerInfos) { + var future = storage.getPeer(peerInfo.getId(), peerInfo.getFingerprint()).onComplete(context.succeeding(result -> { + context.verify(() -> { + assertNotNull(result); + assertEquals(peerInfo, result); + }); + })); + + futures.add(future); + } + + for (var peerInfo : persistentPeerInfos) { + var future = storage.getPeer(peerInfo.getId(), peerInfo.getFingerprint()).onComplete(context.succeeding(result -> { + context.verify(() -> { + assertNotNull(result); + assertEquals(peerInfo, result); + }); + })); + + futures.add(future); + } + + var future = storage.getPeer(Id.random(), Random.random().nextLong()).onComplete(context.succeeding(result -> { + context.verify(() -> { + assertNull(result); + }); + })); + + futures.add(future); + + Future.all(futures).onComplete(context.succeedingThenComplete()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("testStoragesProvider") + @Order(106) void testGetPeers(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { storage.getPeers().onComplete(context.succeeding(result -> { context.verify(() -> { @@ -890,8 +970,8 @@ private static Future> fetchPeers(DataStorage storage, int offset @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") - @Order(106) - void testGetPeerPaginated(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { + @Order(107) + void testGetPeersPaginated(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { fetchPeers(storage, 0, 8, new ArrayList<>()).onComplete(context.succeeding(result -> { context.verify(() -> { List expected = new ArrayList<>(); @@ -910,8 +990,8 @@ void testGetPeerPaginated(String name, DataStorage storage, Vertx vertx, VertxTe @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") - @Order(107) - void testGetPersistentPeers(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { + @Order(108) + void testGetPersistentPeersUpdatedBefore(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { storage.getPeers(true, System.currentTimeMillis()).onComplete(context.succeeding(result -> { context.verify(() -> { var expected = new ArrayList<>(persistentPeerInfos); @@ -940,8 +1020,8 @@ private static Future> fetchPeers(DataStorage storage, boolean pe @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") - @Order(108) - void testGetPersistentPeersPaginated(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { + @Order(109) + void testGetPersistentPeersUpdatedBeforePaginated(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { fetchPeers(storage, true, System.currentTimeMillis(), 0, 8, new ArrayList<>()) .onComplete(context.succeeding(result -> { context.verify(() -> { @@ -959,8 +1039,8 @@ void testGetPersistentPeersPaginated(String name, DataStorage storage, Vertx ver @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") - @Order(109) - void testGetNonPersistentPeers(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { + @Order(110) + void testGetNonPersistentPeersUpdatedBefore(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { storage.getPeers(false, System.currentTimeMillis()).onComplete(context.succeeding(result -> { context.verify(() -> { var expected = new ArrayList<>(peerInfos); @@ -977,8 +1057,8 @@ void testGetNonPersistentPeers(String name, DataStorage storage, Vertx vertx, Ve @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") - @Order(110) - void testGetNonPersistentPeersPaginated(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { + @Order(111) + void testGetNonPersistentPeersUpdatedBeforePaginated(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { fetchPeers(storage, false, System.currentTimeMillis(), 0, 8, new ArrayList<>()) .onComplete(context.succeeding(result -> { context.verify(() -> { @@ -996,7 +1076,7 @@ void testGetNonPersistentPeersPaginated(String name, DataStorage storage, Vertx @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") - @Order(111) + @Order(112) void testPutPeers(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { var futures = new ArrayList>>(); @@ -1014,7 +1094,7 @@ void testPutPeers(String name, DataStorage storage, Vertx vertx, VertxTestContex @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") - @Order(112) + @Order(113) void testGetPeersById2(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { var futures = new ArrayList>>(); @@ -1025,7 +1105,7 @@ void testGetPeersById2(String name, DataStorage storage, Vertx vertx, VertxTestC assertEquals(expected.size(), result.size()); var copy = new ArrayList<>(result); - var comparator = Comparator.comparing(PeerInfo::getId).thenComparing(PeerInfo::getNodeId); + var comparator = Comparator.comparing(PeerInfo::getId).thenComparing(PeerInfo::getFingerprint); copy.sort(comparator); expected.sort(comparator); assertEquals(expected, copy); @@ -1056,7 +1136,28 @@ void testGetPeersById2(String name, DataStorage storage, Vertx vertx, VertxTestC @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") - @Order(113) + @Order(114) + void testGetPeersByIdAndFingerprint2(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { + List all = multiPeers.values().stream().flatMap(List::stream).toList(); + + var futures = new ArrayList>(); + for (var peerInfo : all) { + var future = storage.getPeer(peerInfo.getId(), peerInfo.getFingerprint()).onComplete(context.succeeding(result -> { + context.verify(() -> { + assertNotNull(result); + assertEquals(peerInfo, result); + }); + })); + + futures.add(future); + } + + Future.all(futures).onComplete(context.succeedingThenComplete()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("testStoragesProvider") + @Order(115) void testRemovePeersById(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { var futures = new ArrayList>(); @@ -1085,38 +1186,74 @@ void testRemovePeersById(String name, DataStorage storage, Vertx vertx, VertxTes @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") - @Order(114) - void testUpdatePeer(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { - var futures = new ArrayList>(); + @Order(116) + void testUpdatePeer(String name, DataStorage storage, Vertx vertx, VertxTestContext context) throws Exception { + List> futures = new ArrayList<>(); - for (var peerInfo : peerInfos) { - if (peerInfo.hasPrivateKey()) { - var updated = peerInfo.withoutPrivateKey(); - var future = storage.putPeer(updated).onComplete(context.succeeding(result -> { - context.verify(() -> assertEquals(updated, result)); - })); + for (int i = 0; i < peerInfos.size(); i++) { + final var peerInfo = peerInfos.get(i); + final int index = i; - futures.add(future); - } else { + if (!peerInfo.hasPrivateKey()) { var future = storage.putPeer(peerInfo).onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(peerInfo, result)); })); + futures.add(future); + } else { + CryptoIdentity node = peerInfo.isAuthenticated() ? nodeIdentities.get(peerInfo.getNodeId()) : null; + PeerInfo updated = peerInfo.update(node, faker.internet().url()); + var future = storage.putPeer(updated).andThen(context.succeeding(result -> { + context.verify(() -> assertEquals(updated, result)); + peerInfos.set(index, updated); + })).compose(v -> { + PeerInfo updated2 = updated.withoutPrivateKey(); + return storage.putPeer(updated2).onComplete(context.succeeding(result -> { + context.verify(() -> assertEquals(updated, result)); + })); + }).compose(v -> { + return storage.getPeer(peerInfo.getId(), peerInfo.getFingerprint()).andThen(context.succeeding(result -> { + // the private key should be kept. + context.verify(() -> { + assertTrue(result.hasPrivateKey()); + assertEquals(updated, result); + }); + })); + }); + futures.add(future); } } - for (var peerInfo : persistentPeerInfos) { - if (peerInfo.hasPrivateKey()) { - var updated = peerInfo.withoutPrivateKey(); - var future = storage.putPeer(updated, true).onComplete(context.succeeding(result -> { - context.verify(() -> assertEquals(peerInfo, result)); - })); + for (int i = 0; i < persistentPeerInfos.size(); i++) { + final var peerInfo = persistentPeerInfos.get(i); + final int index = i; - futures.add(future); - } else { + if (!peerInfo.hasPrivateKey()) { var future = storage.putPeer(peerInfo, true).onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(peerInfo, result)); })); + futures.add(future); + } else { + CryptoIdentity node = peerInfo.isAuthenticated() ? nodeIdentities.get(peerInfo.getNodeId()) : null; + PeerInfo updated = peerInfo.update(node, faker.internet().url()); + var future = storage.putPeer(updated, true).andThen(context.succeeding(result -> { + context.verify(() -> assertEquals(updated, result)); + persistentPeerInfos.set(index, updated); + })).compose(v -> { + PeerInfo updated2 = updated.withoutPrivateKey(); + return storage.putPeer(updated2, true).onComplete(context.succeeding(result -> { + context.verify(() -> assertEquals(updated, result)); + })); + }).compose(v -> { + return storage.getPeer(peerInfo.getId(), peerInfo.getFingerprint()).andThen(context.succeeding(result -> { + // the private key should be kept. + context.verify(() -> { + assertTrue(result.hasPrivateKey()); + assertEquals(updated, result); + }); + })); + }); + futures.add(future); } } @@ -1126,14 +1263,14 @@ void testUpdatePeer(String name, DataStorage storage, Vertx vertx, VertxTestCont @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") - @Order(115) + @Order(117) void testUpdatePeerAnnouncedTime1(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { var now = System.currentTimeMillis(); var futures = new ArrayList>(); for (var i = 0; i < peerInfos.size() / 2; i++) { var peerInfo = peerInfos.get(i); - var future = storage.updatePeerAnnouncedTime(peerInfo.getId(), peerInfo.getNodeId()) + var future = storage.updatePeerAnnouncedTime(peerInfo.getId(), peerInfo.getFingerprint()) .onComplete(context.succeeding(result -> { context.verify(() -> assertTrue(result >= now)); })); @@ -1143,7 +1280,7 @@ void testUpdatePeerAnnouncedTime1(String name, DataStorage storage, Vertx vertx, for (var i = 0; i < persistentPeerInfos.size() / 2; i++) { var peerInfo = persistentPeerInfos.get(i); - var future = storage.updatePeerAnnouncedTime(peerInfo.getId(), peerInfo.getNodeId()) + var future = storage.updatePeerAnnouncedTime(peerInfo.getId(), peerInfo.getFingerprint()) .onComplete(context.succeeding(result -> { context.verify(() -> assertTrue(result >= now)); })); @@ -1151,14 +1288,14 @@ void testUpdatePeerAnnouncedTime1(String name, DataStorage storage, Vertx vertx, futures.add(future); } - var future = storage.updatePeerAnnouncedTime(Id.random(), Id.random()) + var future = storage.updatePeerAnnouncedTime(Id.random(), Random.random().nextLong()) .onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(0L, result)); })); futures.add(future); Future.all(futures).onComplete(context.succeeding(unused -> { - announced1 = System.currentTimeMillis(); + announceTime1 = System.currentTimeMillis(); context.completeNow(); })); } @@ -1167,7 +1304,7 @@ void testUpdatePeerAnnouncedTime1(String name, DataStorage storage, Vertx vertx, @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") - @Order(116) + @Order(118) @Timeout(value = 40, timeUnit = TimeUnit.SECONDS) void testUpdatePeerAnnouncedTime2(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { System.out.println("Waiting for 30 seconds to update announced time again..."); @@ -1181,7 +1318,7 @@ void testUpdatePeerAnnouncedTime2(String name, DataStorage storage, Vertx vertx, for (var i = peerInfos.size() / 2; i < peerInfos.size(); i++) { var peerInfo = peerInfos.get(i); - var future = storage.updatePeerAnnouncedTime(peerInfo.getId(), peerInfo.getNodeId()) + var future = storage.updatePeerAnnouncedTime(peerInfo.getId(), peerInfo.getFingerprint()) .onComplete(context.succeeding(result -> { context.verify(() -> assertTrue(result >= now)); })); @@ -1191,7 +1328,7 @@ void testUpdatePeerAnnouncedTime2(String name, DataStorage storage, Vertx vertx, for (var i = persistentPeerInfos.size() / 2; i < persistentPeerInfos.size(); i++) { var peerInfo = persistentPeerInfos.get(i); - var future = storage.updatePeerAnnouncedTime(peerInfo.getId(), peerInfo.getNodeId()) + var future = storage.updatePeerAnnouncedTime(peerInfo.getId(), peerInfo.getFingerprint()) .onComplete(context.succeeding(result -> { context.verify(() -> assertTrue(result >= now)); })); @@ -1199,14 +1336,14 @@ void testUpdatePeerAnnouncedTime2(String name, DataStorage storage, Vertx vertx, futures.add(future); } - var future = storage.updatePeerAnnouncedTime(Id.random(), Id.random()) + var future = storage.updatePeerAnnouncedTime(Id.random(), Random.random().nextLong()) .onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(0L, result)); })); futures.add(future); Future.all(futures).onComplete(context.succeeding(unused -> { - announced2 = System.currentTimeMillis(); + announceTime2 = System.currentTimeMillis(); context.completeNow(); })); }); @@ -1214,49 +1351,49 @@ void testUpdatePeerAnnouncedTime2(String name, DataStorage storage, Vertx vertx, @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") - @Order(117) + @Order(119) void testGetPeersAnnouncedBefore(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { var futures = new ArrayList>>(); - var future = storage.getPeers(false, announced1).onComplete(context.succeeding(result -> { + var future = storage.getPeers(false, announceTime1).onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(peerInfos.size() / 2, result.size())); })); futures.add(future); - future = storage.getPeers(true, announced1).onComplete(context.succeeding(result -> { + future = storage.getPeers(true, announceTime1).onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(persistentPeerInfos.size() / 2, result.size())); })); futures.add(future); - future = fetchPeers(storage, false, announced1, 0, 8, new ArrayList<>()) + future = fetchPeers(storage, false, announceTime1, 0, 8, new ArrayList<>()) .onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(peerInfos.size() / 2, result.size())); })); futures.add(future); - future = fetchPeers(storage, true, announced1, 0, 8, new ArrayList<>()) + future = fetchPeers(storage, true, announceTime1, 0, 8, new ArrayList<>()) .onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(persistentPeerInfos.size() / 2, result.size())); })); futures.add(future); - future = storage.getPeers(false, announced2).onComplete(context.succeeding(result -> { + future = storage.getPeers(false, announceTime2).onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(peerInfos.size(), result.size())); })); futures.add(future); - future = storage.getPeers(true, announced2).onComplete(context.succeeding(result -> { + future = storage.getPeers(true, announceTime2).onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(persistentPeerInfos.size(), result.size())); })); futures.add(future); - future = fetchPeers(storage, false, announced2, 0, 8, new ArrayList<>()) + future = fetchPeers(storage, false, announceTime2, 0, 8, new ArrayList<>()) .onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(peerInfos.size(), result.size())); })); futures.add(future); - future = fetchPeers(storage, true, announced2, 0, 8, new ArrayList<>()) + future = fetchPeers(storage, true, announceTime2, 0, 8, new ArrayList<>()) .onComplete(context.succeeding(result -> { context.verify(() -> assertEquals(persistentPeerInfos.size(), result.size())); })); @@ -1269,7 +1406,7 @@ void testGetPeersAnnouncedBefore(String name, DataStorage storage, Vertx vertx, @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") - @Order(118) + @Order(120) @Timeout(value = 40, timeUnit = TimeUnit.SECONDS) void testPurge2(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { System.out.println("Waiting for 30 seconds to purge..."); @@ -1297,13 +1434,13 @@ void testPurge2(String name, DataStorage storage, Vertx vertx, VertxTestContext @ParameterizedTest(name = "{0}") @MethodSource("testStoragesProvider") - @Order(119) + @Order(121) void testRemovePeer(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { var futures = new ArrayList>(); for (var i = 0; i < persistentPeerInfos.size() / 2; i++) { var peerInfo = persistentPeerInfos.get(i); - var future = storage.removePeer(peerInfo.getId(), peerInfo.getNodeId()) + var future = storage.removePeer(peerInfo.getId(), peerInfo.getFingerprint()) .onComplete(context.succeeding(result -> { context.verify(() -> assertTrue(result)); })); @@ -1317,4 +1454,48 @@ void testRemovePeer(String name, DataStorage storage, Vertx vertx, VertxTestCont })); })).onComplete(context.succeedingThenComplete()); } + + + @ParameterizedTest(name = "{0}") + @MethodSource("testStoragesProvider") + @Timeout(value = 1, timeUnit = TimeUnit.MINUTES) + @Order(122) + void testGetPeersByIdAndExpectedSequenceNumberWithLimit(String name, DataStorage storage, Vertx vertx, VertxTestContext context) { + Map> map = generateMultiPeerInfos(1, 20); + + Id peerId = map.keySet().iterator().next(); + List infos = map.get(peerId); + Future chain = Future.succeededFuture(); + for (PeerInfo peerInfo : infos) { + chain = chain.compose(v -> { + Promise promise = Promise.promise(); + vertx.setTimer(100, (tid) -> { + storage.putPeer(peerInfo).onComplete(context.succeeding(result -> { + context.verify(() -> assertEquals(peerInfo, result)); + promise.complete(peerInfo); + })); + }); + + return promise.future(); + }); + } + + chain.compose(v -> { + return storage.getPeers(peerId, 0, 1).onComplete(context.succeeding(result -> { + context.verify(() -> { + assertEquals(1, result.size()); + assertEquals(infos.get(infos.size() - 1), result.get(0)); + }); + })); + }).compose(v -> { + return storage.getPeers(peerId, 0, 5).onComplete(context.succeeding(result -> { + context.verify(() -> { + assertEquals(5, result.size()); + List expected = infos.subList(infos.size() - 5, infos.size()); + Collections.reverse(expected); + assertArrayEquals(expected.toArray(), result.toArray()); + }); + })); + }).onComplete(context.succeedingThenComplete()); + } } \ No newline at end of file From db36d162e5370ba1a077043eb6451a42229f143c Mon Sep 17 00:00:00 2001 From: Jingyu Date: Mon, 12 Jan 2026 21:07:15 +0800 Subject: [PATCH 2/6] Improve DHT lookup flow and make result harvesting more robust and precise --- .../io/bosonnetwork/json/JsonContext.java | 65 +----- .../io/bosonnetwork/kademlia/KadNode.java | 179 ++++++--------- .../io/bosonnetwork/kademlia/impl/DHT.java | 49 +--- .../kademlia/storage/SqlDialect.java | 2 +- .../kademlia/tasks/ClosestCandidates.java | 17 +- .../kademlia/tasks/EligiblePeers.java | 217 ++++++++++++++++++ .../kademlia/tasks/EligibleValue.java | 113 +++++++++ .../kademlia/tasks/LookupTask.java | 22 +- .../kademlia/tasks/NodeLookupTask.java | 68 +++++- .../kademlia/tasks/PeerLookupTask.java | 57 ++--- .../kademlia/tasks/ResultFilter.java | 96 -------- .../kademlia/tasks/ValueLookupTask.java | 41 ++-- .../kademlia/tasks/LookupTaskTests.java | 2 +- 13 files changed, 533 insertions(+), 395 deletions(-) create mode 100644 dht/src/main/java/io/bosonnetwork/kademlia/tasks/EligiblePeers.java create mode 100644 dht/src/main/java/io/bosonnetwork/kademlia/tasks/EligibleValue.java delete mode 100644 dht/src/main/java/io/bosonnetwork/kademlia/tasks/ResultFilter.java diff --git a/api/src/main/java/io/bosonnetwork/json/JsonContext.java b/api/src/main/java/io/bosonnetwork/json/JsonContext.java index 808b668..f0656b9 100644 --- a/api/src/main/java/io/bosonnetwork/json/JsonContext.java +++ b/api/src/main/java/io/bosonnetwork/json/JsonContext.java @@ -22,7 +22,6 @@ package io.bosonnetwork.json; -import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -34,7 +33,7 @@ * This class extends {@link Impl} and provides a convenient, immutable, * and type-safe way to manage shared (global) and per-call (thread-local) attributes for Jackson operations. *

- * Use static factory methods such as {@link #empty()}, {@link #perCall()}, and {@link #shared(Object, Object)} + * Use static factory methods such as {@link #empty()} and {@link #shared(Object, Object)} * to construct a context instance with the desired attributes. Use instance methods to query or derive * new contexts with added/removed attributes. *

@@ -69,16 +68,7 @@ protected JsonContext(Map shared, Map nonShared) { * @return an empty context */ public static JsonContext empty() { - return new JsonContext(Collections.emptyMap(), Collections.emptyMap()); - } - - /** - * Returns a new {@code JsonContext} with no shared or per-call attributes, for use as a per-call context. - * - * @return a per-call context with no attributes - */ - public static JsonContext perCall() { - return new JsonContext(Collections.emptyMap(), Collections.emptyMap()); + return new JsonContext(Map.of()); } /** @@ -92,7 +82,7 @@ public static JsonContext perCall() { public static JsonContext perCall(Object key, Object value) { Map m = new HashMap<>(); m.put(key, value); - return new JsonContext(Collections.emptyMap(), m); + return new JsonContext(Map.of(), m); } /** @@ -108,7 +98,7 @@ static JsonContext perCall(Object key1, Object value1, Object key2, Object value Map m = new HashMap<>(); m.put(key1, value1); m.put(key2, value2); - return new JsonContext(Collections.emptyMap(), m); + return new JsonContext(Map.of(), m); } /** @@ -127,7 +117,7 @@ static JsonContext perCall(Object key1, Object value1, Object key2, Object value m.put(key1, value1); m.put(key2, value2); m.put(key3, value3); - return new JsonContext(Collections.emptyMap(), m); + return new JsonContext(Map.of(), m); } /** @@ -139,7 +129,7 @@ static JsonContext perCall(Object key1, Object value1, Object key2, Object value * @return a context with the specified shared attribute */ public static JsonContext shared(Object key, Object value) { - return new JsonContext(Map.of(key, value), Collections.emptyMap()); + return new JsonContext(Map.of(key, value)); } /** @@ -152,7 +142,7 @@ public static JsonContext shared(Object key, Object value) { * @return a context with the specified shared attributes */ public static JsonContext shared(Object key1, Object value1, Object key2, Object value2) { - return new JsonContext(Map.of(key1, value1, key2, value2), Collections.emptyMap()); + return new JsonContext(Map.of(key1, value1, key2, value2)); } /** @@ -167,7 +157,7 @@ public static JsonContext shared(Object key1, Object value1, Object key2, Object * @return a context with the specified shared attributes */ public static JsonContext shared(Object key1, Object value1, Object key2, Object value2, Object key3, Object value3) { - return new JsonContext(Map.of(key1, value1, key2, value2, key3, value3), Collections.emptyMap()); + return new JsonContext(Map.of(key1, value1, key2, value2, key3, value3)); } /** @@ -222,7 +212,7 @@ public JsonContext withSharedAttribute(Object key, Object value) { */ @Override public JsonContext withSharedAttributes(Map attributes) { - return new JsonContext(attributes == null || attributes.isEmpty() ? Collections.emptyMap() : attributes); + return new JsonContext(attributes == null || attributes.isEmpty() ? Map.of() : attributes); } /** @@ -244,41 +234,4 @@ public JsonContext withoutSharedAttribute(Object key) { newShared.remove(key); return new JsonContext(newShared); } - - /** - * Returns a new {@code JsonContext} with the given per-call (non-shared) attribute key and value. - * Per-call attributes are visible only to a single serialization/deserialization operation. - *

- * If {@code value} is {@code null}, and the key exists in shared attributes, - * a special null surrogate is used to mask the shared value. If the key does not exist in shared or per-call attributes, - * the context is returned unchanged. - * - * @param key the per-call attribute key - * @param value the per-call attribute value (maybe {@code null}, see behavior above) - * @return a new context with the updated per-call attribute - */ - @Override - public JsonContext withPerCallAttribute(Object key, Object value) { - // First: null value may need masking - if (value == null) { - // need to mask nulls to ensure default values won't be showing - if (_shared.containsKey(key)) { - value = NULL_SURROGATE; - } else if ((_nonShared == null) || !_nonShared.containsKey(key)) { - // except if an immutable shared list has no entry, we don't care - return this; - } else { - //noinspection RedundantCollectionOperation - if (_nonShared.containsKey(key)) // avoid exception on immutable map - _nonShared.remove(key); - return this; - } - } - - if (_nonShared == Collections.emptyMap()) - _nonShared = new HashMap<>(); - - _nonShared.put(key, value); - return this; - } } \ No newline at end of file diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java b/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java index 1771380..610a4ed 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java @@ -5,15 +5,11 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; -import io.vertx.core.CompositeFuture; import io.vertx.core.Context; import io.vertx.core.Future; import io.vertx.core.Promise; @@ -47,6 +43,8 @@ import io.bosonnetwork.kademlia.routing.KBucketEntry; import io.bosonnetwork.kademlia.security.Blacklist; import io.bosonnetwork.kademlia.storage.DataStorage; +import io.bosonnetwork.kademlia.tasks.EligiblePeers; +import io.bosonnetwork.kademlia.tasks.EligibleValue; import io.bosonnetwork.utils.Variable; import io.bosonnetwork.vertx.BosonVerticle; import io.bosonnetwork.vertx.VertxCaffeine; @@ -424,7 +422,7 @@ private Future> doFindNode(Id id, LookupOption option) { Future future4 = dht4.findNode(id, option); Future future6 = dht6.findNode(id, option); - if (option == LookupOption.LOCAL || option == LookupOption.CONSERVATIVE) + if (option == LookupOption.CONSERVATIVE) return Future.all(future4, future6).map(cf -> Result.of(cf.resultAt(0), cf.resultAt(1)) ); @@ -451,76 +449,65 @@ public VertxFuture findValue(Id id, int expectedSequenceNumber, LookupOpt Promise promise = Promise.promise(); runOnContext(v -> { - Variable localValue = Variable.empty(); + EligibleValue eligible = new EligibleValue(id, expectedSequenceNumber); + Variable local = Variable.empty(); - storage.getValue(id).compose(local -> { - if (local != null) { - if (!local.isMutable()) - return Future.succeededFuture(local); + storage.getValue(id).compose(value -> { + if (value != null) { + eligible.update(value); - if (expectedSequenceNumber >= 0 && local.getSequenceNumber() >= expectedSequenceNumber) { - if (lookupOption == LookupOption.LOCAL) - return Future.succeededFuture(local); + if (!value.isMutable()) + return Future.succeededFuture(eligible); - localValue.set(local); - } + if (lookupOption != LookupOption.CONSERVATIVE && !eligible.isEmpty()) + return Future.succeededFuture(eligible); - if (lookupOption != LookupOption.CONSERVATIVE) - return Future.succeededFuture(local); + local.set(value); } - return doFindValue(id, expectedSequenceNumber, lookupOption).map(value -> { - if (value == null && local == null) - return null; - - if (value == null || local == null) - return value == null ? local : value; - - return value.getSequenceNumber() > local.getSequenceNumber() ? value : local; - }); - }).compose(value -> { - if (value != null && value != localValue.orElse(null)) - return storage.putValue(value); + return doFindValue(id, expectedSequenceNumber, lookupOption, eligible).map(eligible); + }).compose(vv -> { + if (eligible.isEmpty() || (local.isPresent() && eligible.getValue().equals(local.get()))) + return Future.succeededFuture(eligible.getValue()); - return Future.succeededFuture(value); + return storage.putValue(eligible.getValue()); }).onComplete(promise); }); return VertxFuture.of(promise.future()); } - private Value valueSelector(CompositeFuture future) { - Value value4 = future.isComplete(0) ? future.resultAt(0) : null; - Value value6 = future.isComplete(1) ? future.resultAt(1) : null; - - if (value4 == null && value6 == null) - return null; - - if (value4 == null || value6 == null) - return value4 == null ? value6 : value4; - - return value4.getSequenceNumber() > value6.getSequenceNumber() ? value4 : value6; - } - - private Future doFindValue(Id id, int expectedSequenceNumber, LookupOption option) { + private Future doFindValue(Id id, int expectedSequenceNumber, LookupOption option, EligibleValue result) { if (dht4 == null || dht6 == null) { DHT dht = dht4 != null ? dht4 : dht6; - return dht.findValue(id, expectedSequenceNumber, option); + return dht.findValue(id, expectedSequenceNumber, option).map(v -> { + if (v != null) + result.update(v); + return null; + }); } else { - Future future4 = dht4.findValue(id, expectedSequenceNumber, option); - Future future6 = dht6.findValue(id, expectedSequenceNumber, option); + Future future4 = dht4.findValue(id, expectedSequenceNumber, option).map(v -> { + if (v != null) + result.update(v); + return null; + }); + Future future6 = dht6.findValue(id, expectedSequenceNumber, option).map(v -> { + if (v != null) + result.update(v); + return null; + }); if (option == LookupOption.CONSERVATIVE) - return Future.all(future4, future6).map(this::valueSelector); + return Future.all(future4, future6).mapEmpty(); return Future.any(future4, future6).compose(cf -> { - if (future4.isComplete() && future4.result() == null) + if (future4.isComplete() && result.isEmpty()) return future6; - if (future6.isComplete() && future6.result() == null) + if (future6.isComplete() && result.isEmpty()) return future4; - return Future.succeededFuture(valueSelector(cf)); + return Future.succeededFuture(); }); } } @@ -598,81 +585,67 @@ public VertxFuture> findPeer(Id id, int expectedSequenceNumber, i Promise> promise = Promise.promise(); runOnContext(v -> { - Variable> localPeers = Variable.empty(); + EligiblePeers eligible = new EligiblePeers(id, expectedSequenceNumber, expectedCount); - storage.getPeers(id).compose(local -> { - if (!local.isEmpty() && expectedSequenceNumber >= 0) - local.removeIf(p -> p.getSequenceNumber() < expectedSequenceNumber); + storage.getPeers(id, expectedSequenceNumber, expectedCount).compose(peers -> { + eligible.add(peers); - if (!local.isEmpty()) { + if (!eligible.isEmpty()) { if (lookupOption == LookupOption.LOCAL) - return Future.succeededFuture(local); + return Future.succeededFuture(eligible); - if (lookupOption != LookupOption.CONSERVATIVE && expectedCount > 0 && local.size() >= expectedCount) - return Future.succeededFuture(local); - - localPeers.set(local); + if (lookupOption != LookupOption.CONSERVATIVE && expectedCount > 0 && eligible.reachedCapacity()) + return Future.succeededFuture(eligible); } - return doFindPeer(id, expectedSequenceNumber, expectedCount, lookupOption).map(peers -> { - if (local.isEmpty() && peers.isEmpty()) - return Collections.emptyList(); - - if (local.isEmpty() || peers.isEmpty()) - return local.isEmpty() ? peers : local; + return doFindPeer(id, expectedSequenceNumber, expectedCount, lookupOption, eligible) + .map(eligible); + }).compose(el -> { + if (eligible.isEmpty()) + return Future.succeededFuture(List.of()); - Map dedup = new HashMap<>(16); - local.forEach(peer -> dedup.put(peer.getNodeId(), peer)); - peers.forEach(peer -> dedup.put(peer.getNodeId(), peer)); - return new ArrayList<>(dedup.values()); + return storage.putPeers(eligible.getPeers()).map(l -> { + eligible.prune(); + return eligible.getPeers(); }); - }).compose(peers -> { - // TODO: - if (!peers.isEmpty() && peers != localPeers.orElse(null)) - return storage.putPeers(peers); - - return Future.succeededFuture(peers.isEmpty() ? Collections.emptyList() : peers); }).onComplete(promise); }); return VertxFuture.of(promise.future()); } - // TODO: - private List mergePeers(CompositeFuture future) { - Map dedup = new HashMap<>(16); - if (future.isComplete(0)) { - List peers = future.resultAt(0); - peers.forEach(peer -> dedup.put(peer.getNodeId(), peer)); - } - - if (future.isComplete(1)) { - List peers = future.resultAt(1); - peers.forEach(peer -> dedup.put(peer.getNodeId(), peer)); - } - - return new ArrayList<>(dedup.values()); - } - - private Future> doFindPeer(Id id, int expectedSequenceNumber, int expectedCount, LookupOption option) { + private Future doFindPeer(Id id, int expectedSequenceNumber, int expectedCount, + LookupOption option, EligiblePeers result) { if (dht4 == null || dht6 == null) { DHT dht = dht4 != null ? dht4 : dht6; - return dht.findPeer(id, expectedSequenceNumber, expectedCount, option); + return dht.findPeer(id, expectedSequenceNumber, expectedCount, option).map(peers -> { + if (!peers.isEmpty()) + result.add(peers); + return null; + }); } else { - Future> future4 = dht4.findPeer(id, expectedSequenceNumber, expectedCount, option); - Future> future6 = dht6.findPeer(id, expectedSequenceNumber, expectedCount, option); + Future future4 = dht4.findPeer(id, expectedSequenceNumber, expectedCount, option).map(peers -> { + if (!peers.isEmpty()) + result.add(peers); + return null; + }); + Future future6 = dht6.findPeer(id, expectedSequenceNumber, expectedCount, option).map(peers -> { + if (!peers.isEmpty()) + result.add(peers); + return null; + }); if (option == LookupOption.CONSERVATIVE) - return Future.all(future4, future6).map(this::mergePeers); + return Future.all(future4, future6).mapEmpty(); return Future.any(future4, future6).compose(cf -> { - if (future4.isComplete() && future4.result().size() < expectedCount) - return Future.all(future4, future6).map(this::mergePeers); + if (future4.isComplete() && !result.reachedCapacity()) + return future6; - if (future6.isComplete() && future6.result().size() < expectedCount) - return Future.all(future4, future6).map(this::mergePeers); + if (future6.isComplete() && !result.reachedCapacity()) + return future4; - return Future.succeededFuture(mergePeers(cf)); + return Future.succeededFuture(); }); } } diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/impl/DHT.java b/dht/src/main/java/io/bosonnetwork/kademlia/impl/DHT.java index e4fee67..91f042a 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/impl/DHT.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/impl/DHT.java @@ -63,7 +63,6 @@ import io.bosonnetwork.kademlia.tasks.PeerAnnounceTask; import io.bosonnetwork.kademlia.tasks.PeerLookupTask; import io.bosonnetwork.kademlia.tasks.PingRefreshTask; -import io.bosonnetwork.kademlia.tasks.ResultFilter; import io.bosonnetwork.kademlia.tasks.Task; import io.bosonnetwork.kademlia.tasks.TaskManager; import io.bosonnetwork.kademlia.tasks.ValueAnnounceTask; @@ -1055,14 +1054,10 @@ public Future findNode(Id id, LookupOption option) { return; } - NodeLookupTask task = new NodeLookupTask(kadContext, id) + NodeLookupTask task = new NodeLookupTask(kadContext, id, option != LookupOption.CONSERVATIVE) .setName("Lookup node: " + id) - .setResultFilter((previous, next) -> { - if (option == LookupOption.CONSERVATIVE) - return ResultFilter.Action.ACCEPT_CONTINUE; - else - return ResultFilter.Action.ACCEPT_DONE; - }).addListener(t -> + .setWantTarget(true) + .addListener(t -> promise.complete(t.getResult()) ); @@ -1076,25 +1071,11 @@ public Future findValue(Id id, int expectedSequenceNumber, LookupOption o Promise promise = Promise.promise(); runOnContext(v -> { - ValueLookupTask task = new ValueLookupTask(kadContext, id, expectedSequenceNumber) + ValueLookupTask task = new ValueLookupTask(kadContext, id, expectedSequenceNumber, + option != LookupOption.CONSERVATIVE) .setName("Lookup value: " + id) - .setResultFilter((previous, next) -> { - if (!next.isMutable()) - return ResultFilter.Action.ACCEPT_DONE; - - if ((expectedSequenceNumber < 0 || next.getSequenceNumber() >= expectedSequenceNumber) && - option != LookupOption.CONSERVATIVE) - return ResultFilter.Action.ACCEPT_DONE; - - if (previous == null) - return ResultFilter.Action.ACCEPT_CONTINUE; - - if (next.getSequenceNumber() > previous.getSequenceNumber()) - return ResultFilter.Action.ACCEPT_CONTINUE; - else - return ResultFilter.Action.REJECT_CONTINUE; - }).addListener(t -> - promise.complete(t.getResult()) + .addListener(t -> + promise.complete(t.getResult().getValue()) ); taskManager.add(task); @@ -1120,7 +1101,7 @@ public Future storeValue(Value value, int expectedSequenceNumber) { return; ClosestSet closest = t.getClosestSet(); - if (closest == null || closest.size() == 0) { + if (closest == null || closest.isEmpty()) { // this should never happen log.error("!!!INTERNAL ERROR: Value announce task not started because the node lookup task got the empty closest nodes."); announceTask.cancel(); @@ -1142,16 +1123,10 @@ public Future> findPeer(Id id, int expectedSequenceNumber, int ex Promise> promise = Promise.promise(); runOnContext(v -> { - PeerLookupTask task = new PeerLookupTask(kadContext, id, expectedSequenceNumber, expectedCount) + PeerLookupTask task = new PeerLookupTask(kadContext, id, expectedSequenceNumber, expectedCount, + option != LookupOption.CONSERVATIVE) .setName("Lookup peer: " + id) - .setResultFilter((previous, next) -> { - if (expectedCount >= 0 && next.size() >= expectedCount) - return ResultFilter.Action.ACCEPT_DONE; - else - return ResultFilter.Action.ACCEPT_CONTINUE; - }).addListener(t -> - promise.complete(t.getResult()) - ); + .addListener(t -> promise.complete(t.getResult().getPeers())); taskManager.add(task); }); @@ -1176,7 +1151,7 @@ public Future announcePeer(PeerInfo peer, int expectedSequenceNumber) { return; ClosestSet closest = t.getClosestSet(); - if (closest == null || closest.size() == 0) { + if (closest == null || closest.isEmpty()) { // this should never happen log.error("!!!INTERNAL ERROR: Peer announce task not started because the node lookup task got the empty closest nodes."); announceTask.cancel(); diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/storage/SqlDialect.java b/dht/src/main/java/io/bosonnetwork/kademlia/storage/SqlDialect.java index a00e65b..dee16c8 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/storage/SqlDialect.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/storage/SqlDialect.java @@ -109,7 +109,7 @@ default String selectPeersByIdAndSequenceNumberWithLimit() { SELECT * FROM peers WHERE id = #{id} and sequence_number >= #{expectedSequenceNumber} - ORDER BY updated DESC, fingerprint + ORDER BY sequence_number DESC, updated DESC, fingerprint LIMIT #{limit} """; } diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/tasks/ClosestCandidates.java b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/ClosestCandidates.java index bae15d8..e0fc8ae 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/tasks/ClosestCandidates.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/ClosestCandidates.java @@ -25,6 +25,7 @@ import java.util.Collection; import java.util.HashSet; +import java.util.List; import java.util.Set; import java.util.SortedMap; import java.util.TreeMap; @@ -153,16 +154,18 @@ public void add(Collection nodes) { } if (reachedCapacity()) { - closest.values().stream() + List toRemove = closest.values().stream() .filter(cn -> !cn.isInFlight()) .sorted(this::candidateOrder) .skip(capacity) - .forEach(cn -> { - closest.remove(cn.getId()); - dedup.remove(cn.getId()); - Object addr = developerMode ? cn.getAddress() : cn.getAddress().getAddress(); - dedup.remove(addr); - }); + .toList(); + + for (CandidateNode cn : toRemove) { + closest.remove(cn.getId()); + dedup.remove(cn.getId()); + Object addr = developerMode ? cn.getAddress() : cn.getAddress().getAddress(); + dedup.remove(addr); + } } } diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/tasks/EligiblePeers.java b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/EligiblePeers.java new file mode 100644 index 0000000..041d4f8 --- /dev/null +++ b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/EligiblePeers.java @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.kademlia.tasks; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.bosonnetwork.Id; +import io.bosonnetwork.PeerInfo; + +/** + * This class maintains a bounded, prioritized set of peers eligible for a Kademlia task. + * Peers are filtered by target ID and minimum sequence number. + * Peers are accumulated via add() and pruned explicitly via prune(). + * Capacity enforcement happens only when prune() is invoked. + * Ordering priority: sequence number (descending), authentication status, + * XOR distance for authenticated peers. + * Unauthenticated peers are intentionally unordered and may be pruned arbitrarily to preserve decentralization. + */ +public class EligiblePeers { + /** + * The lookup target used for XOR distance ordering. + */ + private final Id target; + /** + * The minimum acceptable sequence number. + */ + private final int expectedSequenceNumber; + /** + * The maximum number of peers to retain. + */ + private final int expectedCount; + + /** + * Stores deduplicated eligible peers keyed by peerId:fingerprint. + */ + private final Map eligible; + + /** + * Constructs an EligiblePeers instance with the given target, minimum sequence number, + * and maximum expected count. + * + * @param target the lookup target used for XOR distance ordering + * @param expectedSequenceNumber the minimum acceptable sequence number for peers + * @param expectedCount the maximum number of peers to retain + */ + public EligiblePeers(Id target, int expectedSequenceNumber, int expectedCount) { + this.target = target; + this.expectedSequenceNumber = expectedSequenceNumber; + this.expectedCount = expectedCount; + this.eligible = new HashMap<>(); + } + + /** + * Returns the current number of eligible peers. + * + * @return the number of eligible peers stored + */ + public int size() { + return eligible.size(); + } + + /** + * Checks whether there are no eligible peers. + * + * @return true if no eligible peers are stored, false otherwise + */ + public boolean isEmpty() { + return eligible.isEmpty(); + } + + /** + * Checks whether the number of eligible peers has reached or exceeded + * the maximum expected count. This method does not perform pruning. + * + * @return true if the current size of eligible peers is greater than or + * equal to the expected count, false otherwise + */ + public boolean reachedCapacity() { + return eligible.size() >= expectedCount; + } + + /** + * Adds a collection of peers to the eligible set using an atomic + * pre-validation and merge process. + *

+ * All peers in the collection are validated first. If any peer is invalid + * (target ID mismatch, sequence number below the expected minimum when enabled, + * or {@link PeerInfo#isValid()} returns false), the method returns {@code false} + * and no peers are added. + *

+ * Only if all peers pass validation will they be merged into the eligible set. + *

+ * Deduplication logic: + *

    + *
  • Peers are keyed by their {@code peerId:fingerprint} combination.
  • + *
  • If a peer with the same key already exists, the peer with the higher + * sequence number is retained.
  • + *
+ *

+ * This method does not enforce capacity limits. + * Pruning must be triggered explicitly via {@link #prune()}. + * + * @param peers the collection of peers to add + * @return {@code true} if all peers are valid, and the merge succeeds; + * {@code false} if any peer is invalid and the operation is aborted + */ + public boolean add(Collection peers) { + // check first, should drop the result on any ineligible peer + for (PeerInfo p : peers) { + if (!p.getId().equals(target) || + (expectedSequenceNumber >= 0 && p.getSequenceNumber() < expectedSequenceNumber) || + !p.isValid()) + return false; + } + + peers.forEach(p -> { + if (!p.getId().equals(target) || p.getSequenceNumber() < expectedSequenceNumber) + return; + + String key = p.getId().toString() + ":" + p.getFingerprint(); + eligible.compute(key, (k, v) -> + v == null || v.getSequenceNumber() < p.getSequenceNumber() ? p : v); + }); + + return true; + } + + /** + * Enforces the expectedCount limit by pruning excess peers. + *

+ * Ordering rules for pruning: + * - Peers are ordered by sequence number (descending), authentication status, + * and XOR distance for authenticated peers. + *

+ * Unauthenticated peers may be removed arbitrarily to preserve decentralization, + * as they are intentionally unordered. + */ + public void prune() { + if (reachedCapacity()) { + List toRemove = eligible.values().stream() + .sorted(this::peerOrder) + .skip(expectedCount) + .toList(); + + for (PeerInfo p : toRemove) + eligible.remove(p.getId().toString() + ":" + p.getFingerprint()); + } + } + + /** + * Compares two peers to determine their ordering priority. + * Ordering rules: + * 1. Sequence number in descending order (higher first). + * 2. Authentication status (authenticated peers before unauthenticated). + * 3. For authenticated peers, XOR distance to the target is used as a tiebreaker. + *

+ * Unauthenticated peers compare as equal and are thus unordered relative to each other. + * + * @param p1 the first peer to compare + * @param p2 the second peer to compare + * @return negative if p1 < p2, positive if p1 > p2, zero if equal + */ + private int peerOrder(PeerInfo p1, PeerInfo p2) { + int diff = Integer.compare(p2.getSequenceNumber(), p1.getSequenceNumber()); + if (diff != 0) + return diff; + + diff = Boolean.compare(p2.isAuthenticated(), p1.isAuthenticated()); + if (diff != 0) + return diff; + + // Kademlia XOR distance + if (p1.isAuthenticated() && p2.isAuthenticated()) + return target.threeWayCompare(p1.getNodeId(), p2.getNodeId()); + + return 0; + } + + /** + * Returns a list of eligible peers ordered by sequence number (descending), + * authentication status, and XOR distance for authenticated peers. + * + * @return the ordered list of eligible peers + */ + public List getPeers() { + if (eligible.isEmpty()) + return List.of(); + + List peers = new ArrayList<>(eligible.values()); + peers.sort(this::peerOrder); + return peers; + } +} \ No newline at end of file diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/tasks/EligibleValue.java b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/EligibleValue.java new file mode 100644 index 0000000..5399156 --- /dev/null +++ b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/EligibleValue.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.kademlia.tasks; + +import io.bosonnetwork.Id; +import io.bosonnetwork.Value; + +/** + * This class tracks a single value eligible for a Kademlia value lookup or store task. + * It enforces target ID matching, minimum sequence number, and validity checks. + * It keeps at most one value, preferring the one with the highest sequence number. + */ +public class EligibleValue { + /** + * The lookup target ID this value must match. + */ + private final Id target; + + /** + * The minimum acceptable sequence number; note that a negative value disables the check. + */ + private final int expectedSequenceNumber; + + /** + * The currently selected eligible value, or null if none has been accepted yet. + */ + private Value value; + + /** + * Constructs an EligibleValue tracker for the given target ID and expected sequence number. + * + * @param target the lookup target ID this value must match + * @param expectedSequenceNumber the minimum acceptable sequence number; negative disables the check + */ + public EligibleValue(Id target, int expectedSequenceNumber) { + this.target = target; + this.expectedSequenceNumber = expectedSequenceNumber; + this.value = null; + } + + /** + * Indicates whether an eligible value has been accepted. + * + * @return true if no eligible value has been accepted yet, false otherwise + */ + public boolean isEmpty() { + return value == null; + } + + /** + * Attempts to update the eligible value with the provided value. + *

+ * Validation rules: + *

    + *
  • The value's ID must match the target ID.
  • + *
  • If expectedSequenceNumber is non-negative, the value's sequence number must be at least that number.
  • + *
  • The value must be valid (as per {@link Value#isValid()}).
  • + *
+ *

+ * Update semantics: + *

    + *
  • If the provided value passes all validation checks, it will be accepted.
  • + *
  • If no value has been accepted yet, the provided value is set as the current eligible value.
  • + *
  • If a value is already accepted, the provided value replaces it only if its sequence number is higher.
  • + *
+ * + * @param v the value to consider for acceptance + * @return true if the value was accepted (either set or replaced existing), false otherwise + */ + public boolean update(Value v) { + if (!v.getId().equals(target) || + (expectedSequenceNumber >=0 && v.getSequenceNumber() < expectedSequenceNumber) || + !v.isValid()) + return false; + + if (this.value == null) + this.value = v; + + if (v.getSequenceNumber() > this.value.getSequenceNumber()) + this.value = v; + + return true; + } + + /** + * Returns the current eligible value. + * + * @return the accepted eligible value, or null if none has been accepted yet + */ + public Value getValue() { + return value; + } +} \ No newline at end of file diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/tasks/LookupTask.java b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/LookupTask.java index cbeed2d..4972a93 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/tasks/LookupTask.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/LookupTask.java @@ -63,9 +63,9 @@ public abstract class LookupTask> extends Task private int iterationCount = 0; /** The result of the lookup, set by subclasses. */ - private R result; - /** Filter for validating and processing lookup results, set by subclasses. */ - protected ResultFilter resultFilter; + protected R result; + /** Indicates whether the lookup task should be considered complete when an eligible result is found. */ + protected boolean doneOnEligibleResult; /** Flag indicating if the lookup is complete (e.g., value found). */ protected boolean lookupDone = false; @@ -74,10 +74,12 @@ public abstract class LookupTask> extends Task * * @param context the Kademlia context, must not be null * @param target the target ID to look up + * @param doneOnEligibleResult true if the lookup is complete when a result is eligible, false continue */ - protected LookupTask(KadContext context, Id target) { + protected LookupTask(KadContext context, Id target, boolean doneOnEligibleResult) { super(context); this.target = target; + this.doneOnEligibleResult = doneOnEligibleResult; this.closest = new ClosestSet(target, KBucket.MAX_ENTRIES); this.candidates = new ClosestCandidates(target, KBucket.MAX_ENTRIES * 3, context.isDeveloperMode()); @@ -216,18 +218,6 @@ public R getResult() { return result; } - /** - * Sets the filter for validating and processing lookup results. - * - * @param resultFilter the result filter - * @return this task for method chaining - */ - @SuppressWarnings("unchecked") - public S setResultFilter(ResultFilter resultFilter) { - this.resultFilter = resultFilter; - return (S) this; - } - /** * Performs one iteration of the lookup, sending RPCs to the closest candidates. */ diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/tasks/NodeLookupTask.java b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/NodeLookupTask.java index 9b0ddd1..bfff5d6 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/tasks/NodeLookupTask.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/NodeLookupTask.java @@ -54,16 +54,37 @@ public class NodeLookupTask extends LookupTask { /** Whether to request tokens in FIND_NODE RPCs for subsequent operations. */ private boolean wantToken = false; + /** + * Indicates whether the task should filter the target node during the lookup process. + *

+ * This flag determines if the task explicitly focuses on reaching the target node or resource + * as part of the distributed lookup operation. When set to {@code true}, the task treats the + * target as a priority for the lookup. When set to {@code false}, the lookup may operate + * more generally without a direct focus on the specific target. + */ + private boolean wantTarget = false; + private static final Logger log = LoggerFactory.getLogger(NodeLookupTask.class); /** * Constructs a new node lookup task for the given target ID. * - * @param context the Kademlia context, must not be null - * @param target the target ID to look up + * @param context the Kademlia context providing access to the local node's state and operations, must not be null + * @param target the target ID to look up within the DHT, must not be null + * @param doneOnEligibleResult true if the lookup is complete when a result is eligible, false continue + */ + public NodeLookupTask(KadContext context, Id target, boolean doneOnEligibleResult) { + super(context, target, doneOnEligibleResult); + } + + /** + * Constructs a new node lookup task for the given target ID. + * + * @param context the Kademlia context providing access to the local node's state and operations, must not be null + * @param target the target ID to look up within the DHT, must not be null */ public NodeLookupTask(KadContext context, Id target) { - super(context, target); + super(context, target, false); } /** @@ -106,6 +127,26 @@ public boolean doesWantToken() { return wantToken; } + /** + * Configures the task to determine whether a specific target is desired in the lookup process. + * + * @param wantTarget true if the task should aim for a specific target, false otherwise + * @return this task for method chaining + */ + public NodeLookupTask setWantTarget(boolean wantTarget) { + this.wantTarget = wantTarget; + return this; + } + + /** + * Indicates whether the task aims to target a specific node during the lookup process. + * + * @return true if a specific target is desired, false otherwise + */ + public boolean doesWantTarget() { + return wantTarget; + } + /** * Injects a collection of nodes as initial candidates for the lookup. * Useful for testing or seeding the lookup with known nodes. @@ -196,16 +237,21 @@ protected void callResponded(RpcCall call) { log.debug("{}#{} adding {} candidates from response by {}", getName(), getId(), nodes.size(), call.getTargetId()); addCandidates(nodes); - if (resultFilter != null) { - // Check for nodes matching the target ID + if (wantTarget) { + // Check for nodes matching the target Id for (NodeInfo node : nodes) { if (node.getId().equals(getTarget())) { - ResultFilter.Action action = resultFilter.apply(getResult(), node); - log.debug("{}#{} filtered node {}: action={}", getName(), getId(), node.getId(), action); - if (action.isAccept()) - setResult(node); - if (action.isDone()) - lookupDone = true; + result = node; + break; + } + } + + if (result != null) { + if (doneOnEligibleResult) { + log.debug("{}#{} node info is eligible, done on result", getName(), getId()); + lookupDone = true; + } else { + log.trace("{}#{} continuing iteration for full/deep node lookup", getName(), getId()); } } } diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/tasks/PeerLookupTask.java b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/PeerLookupTask.java index eb481f8..f21a2aa 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/tasks/PeerLookupTask.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/PeerLookupTask.java @@ -23,11 +23,7 @@ package io.bosonnetwork.kademlia.tasks; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,7 +48,7 @@ * It extends {@link LookupTask} to leverage its candidate management and RPC handling * in a single-threaded Vert.x event loop. */ -public class PeerLookupTask extends LookupTask, PeerLookupTask> { +public class PeerLookupTask extends LookupTask { private static final Logger log = LoggerFactory.getLogger(PeerLookupTask.class); /** The expected sequence number for filtering outdated peers; -1 disables the check. */ @@ -67,12 +63,13 @@ public class PeerLookupTask extends LookupTask, PeerLookupTask> { * @param target the target ID (e.g., content hash) to look up * @param expectedSequenceNumber the minimum sequence number for valid peers; -1 to disable * @param expectedCount the expected number of peers; 0 to disable filtering + * @param doneOnEligibleResult true if the lookup is complete when a result is eligible, false continue */ - public PeerLookupTask(KadContext context, Id target, int expectedSequenceNumber, int expectedCount) { - super(context, target); + public PeerLookupTask(KadContext context, Id target, int expectedSequenceNumber, int expectedCount, boolean doneOnEligibleResult) { + super(context, target, doneOnEligibleResult); this.expectedSequenceNumber = expectedSequenceNumber; this.expectedCount = expectedCount; - setResult(Collections.emptyList()); + setResult(new EligiblePeers(getTarget(), expectedSequenceNumber, expectedCount)); } /** @@ -111,22 +108,6 @@ protected void iterate() { } } - /** - * Merges two lists of peers, deduplicating by node ID to ensure uniqueness. - * Peers share the same peer ID (e.g., content hash) but differ in node ID. - * - * @param existing the existing peer list, or null if none - * @param next the new peer list to merge - * @return a deduplicated list of peers - */ - private List mergeList(List existing, List next) { - Map dedup = new HashMap<>(next.size() + (existing != null ? existing.size() : 0)); - if (existing != null && !existing.isEmpty()) - existing.forEach(p -> dedup.put(p.getNodeId(), p)); - next.forEach(p -> dedup.put(p.getNodeId(), p)); - return new ArrayList<>(dedup.values()); - } - /** * Handles a FIND_PEER response, processing peers or nodes and updating the result. * Assumes the RPC server provides a valid response. Drops the entire response if any peer is invalid, @@ -146,25 +127,21 @@ protected void callResponded(RpcCall call) { Message response = call.getResponse(); if (response.getBody().hasPeers()) { List peers = response.getBody().getPeers(); - for (PeerInfo peer : peers) { - if (!peer.isValid()) { - log.warn("{}#{} Dropping response from {} due to invalid peer (signature mismatch): {}", - getName(), getId(), call.getTargetId(), peer.getNodeId()); - return; - } + if (!result.add(peers)) { + log.warn("{}#{} Dropping response from {} due to ineligible peer(id | sequenceNumber | signature mismatch)", + getName(), getId(), call.getTargetId()); + return; } - List merged = mergeList(getResult(), peers); - log.debug("{}#{} merged {} peers from response by {}", getName(), getId(), peers.size(), call.getTargetId()); - if (resultFilter != null) { - ResultFilter.Action action = resultFilter.apply(getResult(), merged); - log.debug("{}#{} filtered peer list: action={}", getName(), getId(), action); - if (action.isAccept()) - setResult(merged); - if (action.isDone()) + if (result.reachedCapacity()) { + if (doneOnEligibleResult) { + log.debug("{}#{} peer list is eligible, done on result", getName(), getId()); lookupDone = true; - } else { - setResult(merged); + } else { + log.trace("{}#{} peer list is eligible, continuing iteration for precise result", getName(), getId()); + } + + result.prune(); } } else { List nodes = response.getBody().getNodes(getContext().getNetwork()); diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/tasks/ResultFilter.java b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/ResultFilter.java deleted file mode 100644 index 2ed7f03..0000000 --- a/dht/src/main/java/io/bosonnetwork/kademlia/tasks/ResultFilter.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (c) 2023 - bosonnetwork.io - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package io.bosonnetwork.kademlia.tasks; - -/** - * A functional interface for filtering and validating results in Kademlia lookup tasks. - * Implementations determine whether a new result should be accepted or rejected, and whether - * the lookup process should continue or terminate. Used by {@link LookupTask} subclasses to - * process results (e.g., comparing sequence numbers in value lookups). - * - * @param the type of the result being filtered - */ -@FunctionalInterface -public interface ResultFilter { - /** - * Possible actions returned by the {@link #apply} method, indicating whether to accept - * or reject a new result and whether to continue or terminate the lookup. - */ - enum Action { - /** Accept the new result and continue the lookup. */ - ACCEPT_CONTINUE, - /** Reject the new result and continue the lookup. */ - REJECT_CONTINUE, - /** Accept the new result and terminate the lookup. */ - ACCEPT_DONE, - /** Reject the new result and terminate the lookup. */ - REJECT_DONE; - - /** - * Checks if the action allows the lookup to continue. - * - * @return true if the action is {@code ACCEPT_CONTINUE} or {@code REJECT_CONTINUE} - */ - public boolean isContinue() { - return this == ACCEPT_CONTINUE || this == REJECT_CONTINUE; - } - - /** - * Checks if the action terminates the lookup. - * - * @return true if the action is {@code ACCEPT_DONE} or {@code REJECT_DONE} - */ - public boolean isDone() { - return this == ACCEPT_DONE || this == REJECT_DONE; - } - - /** - * Checks if the action accepts the new result. - * - * @return true if the action is {@code ACCEPT_CONTINUE} or {@code ACCEPT_DONE} - */ - public boolean isAccept() { - return this == ACCEPT_CONTINUE || this == ACCEPT_DONE; - } - - /** - * Checks if the action rejects the new result. - * - * @return true if the action is {@code REJECT_CONTINUE} or {@code REJECT_DONE} - */ - public boolean isReject() { - return this == REJECT_CONTINUE || this == REJECT_DONE; - } - } - - /** - * Filters a new result by comparing it to the previous result, determining whether to - * accept or reject it and whether to continue or terminate the lookup. - * For example, in a value lookup, this might compare sequence numbers to accept a newer value. - * - * @param previous the previous result, or null if none - * @param next the new result to evaluate - * @return an {@link Action} indicating whether to accept/reject the result and continue/terminate the lookup - */ - Action apply(T previous, T next); -} \ No newline at end of file diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/tasks/ValueLookupTask.java b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/ValueLookupTask.java index ae37838..8b877da 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/tasks/ValueLookupTask.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/ValueLookupTask.java @@ -48,7 +48,7 @@ * It extends {@link LookupTask} to leverage its candidate management and RPC handling * in a single-threaded Vert.x event loop. */ -public class ValueLookupTask extends LookupTask { +public class ValueLookupTask extends LookupTask { /** The expected sequence number for filtering outdated values; -1 disables the check. */ private final int expectedSequenceNumber; @@ -60,10 +60,12 @@ public class ValueLookupTask extends LookupTask { * @param context the Kademlia context, must not be null * @param target the target ID (e.g., key hash) to look up * @param expectedSequenceNumber the minimum sequence number for valid values; -1 to disable + * @param doneOnEligibleResult true if the lookup is complete when a result is eligible, false continue */ - public ValueLookupTask(KadContext context, Id target, int expectedSequenceNumber) { - super(context, target); + public ValueLookupTask(KadContext context, Id target, int expectedSequenceNumber, boolean doneOnEligibleResult) { + super(context, target, doneOnEligibleResult); this.expectedSequenceNumber = expectedSequenceNumber; + setResult(new EligibleValue(target, expectedSequenceNumber)); } /** @@ -122,34 +124,19 @@ protected void callResponded(RpcCall call) { Message response = call.getResponse(); if (response.getBody().hasValue()) { Value value = response.getBody().getValue(); - if (!value.getId().equals(getTarget())) { - log.warn("{}#{} dropping response from {} due to value ID mismatch: got {}, expected {}", - getName(), getId(), call.getTargetId(), value.getId(), getTarget()); + if (!result.update(value)) { + log.warn("{}#{} dropping response from {} due to ineligible value(id | sequenceNumber | signature mismatch)", + getName(), getId(), call.getTargetId()); return; } - if (!value.isValid()) { - log.warn("{}#{} dropping response from {} due to invalid value (signature mismatch): {}", - getName(), getId(), call.getTargetId(), value.getId()); - return; - } - - if (expectedSequenceNumber >= 0 && value.getSequenceNumber() < expectedSequenceNumber) { - log.warn("{}#{} dropping response from {} due to outdated value: sequence {}, expected {}", - getName(), getId(), call.getTargetId(), value.getSequenceNumber(), expectedSequenceNumber); - return; - } - - if (resultFilter != null) { - ResultFilter.Action action = resultFilter.apply(getResult(), value); - log.debug("{}#{} filtered value {}: action={}", getName(), getId(), value.getId(), action); - if (action.isAccept()) - setResult(value); - if (action.isDone()) + if (!result.isEmpty()) { + if (doneOnEligibleResult) { + log.debug("{}#{} value is eligible, done on result", getName(), getId()); lookupDone = true; - } else { - if (getResult() == null || value.getSequenceNumber() > getResult().getSequenceNumber()) - setResult(value); + } else { + log.trace("{}#{} value is eligible, continuing iteration for precise result", getName(), getId()); + } } } else { List nodes = response.getBody().getNodes(getContext().getNetwork()); diff --git a/dht/src/test/java/io/bosonnetwork/kademlia/tasks/LookupTaskTests.java b/dht/src/test/java/io/bosonnetwork/kademlia/tasks/LookupTaskTests.java index cb78db2..f821bdb 100644 --- a/dht/src/test/java/io/bosonnetwork/kademlia/tasks/LookupTaskTests.java +++ b/dht/src/test/java/io/bosonnetwork/kademlia/tasks/LookupTaskTests.java @@ -39,7 +39,7 @@ static class TestLookupTask extends LookupTask { private static final Logger log = LoggerFactory.getLogger(TestLookupTask.class); public TestLookupTask(KadContext context, Id target) { - super(context, target); + super(context, target, false); } @Override From 7c55180c72640295d3d7cc23def5afe83ce285ac Mon Sep 17 00:00:00 2001 From: Jingyu Date: Mon, 12 Jan 2026 21:30:22 +0800 Subject: [PATCH 3/6] Add new test case for peer update and lookup --- .../bosonnetwork/kademlia/NodeAsyncTests.java | 30 ++++++++++++++++++- .../bosonnetwork/kademlia/NodeSyncTests.java | 29 +++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/dht/src/test/java/io/bosonnetwork/kademlia/NodeAsyncTests.java b/dht/src/test/java/io/bosonnetwork/kademlia/NodeAsyncTests.java index 5f69453..c27e00b 100644 --- a/dht/src/test/java/io/bosonnetwork/kademlia/NodeAsyncTests.java +++ b/dht/src/test/java/io/bosonnetwork/kademlia/NodeAsyncTests.java @@ -22,6 +22,7 @@ import io.vertx.core.Promise; import io.vertx.core.Vertx; import io.vertx.core.VertxOptions; +import net.datafaker.Faker; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -281,13 +282,17 @@ void testFindNode(VertxTestContext context) { @Test @Timeout(value = TEST_NODES, timeUnit = TimeUnit.MINUTES) - void testAnnounceAndFindPeer(VertxTestContext context) { + void testUpdateAndFindPeer(VertxTestContext context) { + var peers = new ArrayList(TEST_NODES); + + // initial announcement executeSequentially(testNodes, announcer -> { var p = PeerInfo.builder() .node(announcer) .fingerprint(Random.random().nextLong()) .endpoint("tcp://" + localAddr.getHostAddress() + ":8888") .build(); + peers.add(p); System.out.format("\n\n\007🟢 %s announce peer %s ...\n", announcer.getId(), p.getId()); return ((VertxFuture)announcer.announcePeer(p)).thenCompose(v -> { @@ -305,6 +310,29 @@ void testAnnounceAndFindPeer(VertxTestContext context) { }); }); }); + }).thenCompose(unused -> { + Faker faker = new Faker(); + return executeSequentially(testNodes.size(), index -> { + KadNode announcer = testNodes.get(index); + final PeerInfo p = peers.get(index).update(announcer, faker.internet().url()); + + System.out.format("\n\n\007🟢 %s announce peer %s ...\n", announcer.getId(), p.getId()); + return ((VertxFuture) announcer.announcePeer(p)).thenCompose(v -> { + System.out.format("\n\n\007🟢 Looking up peer %s ...\n", p.getId()); + + return executeSequentially(testNodes, node -> { + System.out.format("\n\n\007⌛ %s looking up peer %s ...\n", node.getId(), p.getId()); + var future = (VertxFuture) node.findPeer(p.getId()); + return future.thenAccept(result -> { + System.out.format("\007🟢 %s lookup peer %s finished\n", node.getId(), p.getId()); + context.verify(() -> { + assertNotNull(result); + assertEquals(p, result); + }); + }); + }); + }); + }); }).toVertxFuture().onComplete(context.succeedingThenComplete()); } diff --git a/dht/src/test/java/io/bosonnetwork/kademlia/NodeSyncTests.java b/dht/src/test/java/io/bosonnetwork/kademlia/NodeSyncTests.java index 4dec570..080760d 100644 --- a/dht/src/test/java/io/bosonnetwork/kademlia/NodeSyncTests.java +++ b/dht/src/test/java/io/bosonnetwork/kademlia/NodeSyncTests.java @@ -20,6 +20,7 @@ import io.vertx.core.Vertx; import io.vertx.core.VertxOptions; +import net.datafaker.Faker; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -218,7 +219,10 @@ void testFindNode() throws Exception { @Test @Timeout(value = TEST_NODES, unit = TimeUnit.MINUTES) - void testAnnounceAndFindPeer() throws Exception { + void testUpdateAndFindPeer() throws Exception { + var peers = new ArrayList(TEST_NODES); + + // initial announcement for (int i = 0; i < TEST_NODES; i++) { Thread.sleep(1000); var announcer = testNodes.get(i); @@ -227,6 +231,7 @@ void testAnnounceAndFindPeer() throws Exception { .fingerprint(Random.random().nextLong()) .endpoint("tcp://" + localAddr.getHostAddress() + ":8888") .build(); + peers.add(p); System.out.format("\n\n\007🟢 %s announce peer %s ...\n", announcer.getId(), p.getId()); announcer.announcePeer(p).get(); @@ -242,6 +247,28 @@ void testAnnounceAndFindPeer() throws Exception { assertEquals(p, result); } } + + // update announcement + Faker faker = new Faker(); + for (int i = 0; i < TEST_NODES; i++) { + var announcer = testNodes.get(i); + var p = peers.get(i); + p = p.update(announcer, faker.internet().url()); + + System.out.format("\n\n\007🟢 %s update peer %s ...\n", announcer.getId(), p.getId()); + announcer.announcePeer(p).get(); + + System.out.format("\n\n\007🟢 Looking up peer %s ...\n", p.getId()); + for (int j = 0; j < TEST_NODES; j++) { + var node = testNodes.get(j); + System.out.format("\n\n\007⌛ %s looking up peer %s ...\n", node.getId(), p.getId()); + var result = node.findPeer(p.getId()).get(); + System.out.format("\007🟢 %s lookup value %s finished\n", node.getId(), p.getId()); + + assertNotNull(result); + assertEquals(p, result); + } + } } @Test From c8b24a90589234760b2d87a9c91ee4588db0473d Mon Sep 17 00:00:00 2001 From: Jingyu Date: Tue, 13 Jan 2026 10:18:20 +0800 Subject: [PATCH 4/6] Add document for boson DHT RPC protocol --- dht/docs/protocol.md | 189 ++++++++++++++++++ .../protocol/AnnouncePeerRequest.java | 6 +- 2 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 dht/docs/protocol.md diff --git a/dht/docs/protocol.md b/dht/docs/protocol.md new file mode 100644 index 0000000..92a25c8 --- /dev/null +++ b/dht/docs/protocol.md @@ -0,0 +1,189 @@ +# Boson Kademlia Protocol + +This document describes the RPC protocol used in the Boson Kademlia DHT network. The protocol is designed for security, efficiency and interoperability, supporting both **JSON** (text) and **CBOR** (binary) serialization formats. + +--- + +## Message Envelope + +Every message in the Boson DHT follows a common envelope structure. + +### Fields + +| Key | Name | JSON Type | CBOR Type | Description | +| :--- | :--- | :--- | :--- | :--- | +| **`y`** | Type & Method | `Number` | `Integer` | Composite field encoding message type and RPC method. | +| **`t`** | Transaction ID | `Number` | `Integer` | A non-zero unsigned integer used to match requests and responses. | +| **`q`** | Request Body | `Object` | `Map` | Payload for **Request** messages. | +| **`r`** | Response Body | `Object` | `Map` | Payload for **Response** messages. | +| **`e`** | Error Body | `Object` | `Map` | Payload for **Error** messages. | +| **`v`** | Version | `Number` | `Integer` | (Optional) Node software version. | + +### Message Type & Method Encoding (`y`) + +The `y` field is a bitmask that combines the message type and the method identifier. + +| Bits | Mask | Field | Values | +| :--- | :--- | :--- | :--- | +| 0-4 | `0x1F` | **Method** | PING(1), FIND_NODE(2), ANNOUNCE_PEER(3), FIND_PEER(4), STORE_VALUE(5), FIND_VALUE(6) | +| 5-7 | `0xE0` | **Type** | ERROR(0x00), REQUEST(0x20), RESPONSE(0x40) | + +**Example Computation:** +- `FIND_NODE` (2) + `REQUEST` (0x20) = `0x22` (34) +- `FIND_NODE` (2) + `RESPONSE` (0x40) = `0x42` (66) + +--- + +## Data Representations + +### Binary Data +- **JSON**: Encoded as **Base64 URL-safe** strings without padding. +- **CBOR**: Encoded as **Byte Strings**. + +### Identifiers (`Id`) +- **JSON**: Base58 encoded string (default) or W3C DID. +- **CBOR**: **32 bytes** of raw binary data. + +--- + +## RPC Methods + +### PING (1) +Verifies node liveness. + +- **Request (`q`)**: *Empty object* +- **Response (`r`)**: *Empty object* + +--- + +### FIND_NODE (2) +Iterative lookup for the closest nodes to a target. + +- **Request (`q`)**: + + | Key | Name | Type | Description | + | :--- | :--- | :--- | :--- | + | **`t`** | Target | `Id` | The 256-bit identifier to look up. | + | **`w`** | Want | `Integer` | Bitmask: `1`=IPv4, `2`=IPv6, `4`=Return token. | + +- **Response (`r`)**: + + | Key | Name | Type | Description | + | :--- | :--- | :--- | :--- | + | **`n4`** | Nodes IPv4 | `List`| Closest IPv4 nodes. | + | **`n6`** | Nodes IPv6 | `List`| Closest IPv6 nodes. | + | **`tok`**| Token | `Integer` | (Optional) Opaque token for storage writes. | + +--- + +### FIND_VALUE (6) +Retrieves a stored value associated with an ID. + +- **Request (`q`)**: + + | Key | Name | Type | Description | + | :--- | :--- | :--- | :--- | + | **`t`** | Target | `Id` | Identifier of the value. | + | **`w`** | Want | `Integer` | Same as `FIND_NODE`. | + | **`cas`**| CAS | `Integer` | (Optional) Only return value if `seq` > `cas`. | + +- **Response (`r`)**: + + | Key | Name | Type | Description | + | :--- | :--- | :--- | :--- | + | **`n4`** / **`n6`** | Nodes | `List`| Closest nodes if value is not found. | + | **`k`** | Public Key | `Id` | (Mutable) Public key of the owner. | + | **`rec`**| Recipient | `Id` | (Encrypted) Public key of the recipient. | + | **`n`** | Nonce | `Binary` | Nonce used for the value. | + | **`seq`**| Sequence | `Integer` | Version number of the value. | + | **`sig`**| Signature | `Binary` | Signature of the value. | + | **`v`** | Value | `Binary` | The value data. | + +--- + +### STORE_VALUE (5) +Publishes a value to the network. Requires a valid token. + +- **Request (`q`)**: + + | Key | Name | Type | Description | + | :--- | :--- | :--- | :--- | + | **`tok`**| Token | `Integer` | Token from a previous `FIND_VALUE` or `FIND_NODE`. | + | **`cas`**| CAS | `Integer` | (Optional) Atomic update: only store if `seq` matches `cas`. | + | **`k`** | Public Key | `Id` | (Mutable) Public key of the owner. | + | **`rec`**| Recipient | `Id` | (Encrypted) Public key of the recipient. | + | **`n`** | Nonce | `Binary` | Nonce used for the value. | + | **`seq`**| Sequence | `Integer` | Version number of the value. | + | **`sig`**| Signature | `Binary` | Signature of the value. | + | **`v`** | Value | `Binary` | The value data. | +- **Response (`r`)**: *Empty object* + +--- + +### FIND_PEER (4) +Discovers service endpoints for a service ID. + +- **Request (`q`)**: + + | Key | Name | Type | Description | + | :--- | :--- | :--- | :--- | + | **`t`** | Target | `Id` | Service identifier. | + | **`w`** | Want | `Integer` | Same as `FIND_NODE`. | + | **`cas`**| Sequence | `Integer` | (Optional) Only return results if `seq` > `cas`. | + | **`e`** | Count | `Integer` | (Optional) Desired number of peers to return. | + +- **Response (`r`)**: + + | Key | Name | Type | Description | + | :--- | :--- | :--- | :--- | + | **`n4`** / **`n6`** | Nodes | `List`| Closest nodes. | + | **`p`** | Peers | `List`| List of matching service peers. | + +--- + +### ANNOUNCE_PEER (3) +Registers a service endpoint. Requires a valid token. + +- **Request (`q`)**: + + | Key | Name | Type | Description | + | :--- | :--- | :--- | :--- | + | **`tok`**| Token | `Integer` | Valid token from `FIND_PEER` or `FIND_NODE`. | + | **`cas`**| CAS | `Integer` | (Optional) Atomic update: only store if `seq` matches `cas`. | + | **`k`** | Peer ID | `Id` | Public key of the service peer. | + | **`n`** | Nonce | `Binary` | 24-byte nonce. | + | **`seq`**| Sequence | `Integer` | Current sequence number. | + | **`sig`**| Signature | `Binary` | Signature from the peer owner. | + | **`f`** | Fingerprint| `Long` | Unique fingerprint for the peer. | + | **`e`** | Endpoint | `String` | Service URI (e.g., `https://...`). | + | **`o`** | Node ID | `Id` | (Authenticated) ID of the hosting node. | + | **`os`**| Node Sig | `Binary` | (Authenticated) Signature from the hosting node. | + | **`ex`**| Extra | `Binary` | (Optional) Opaque extension data. | + +- **Response (`r`)**: *Empty object* + +--- + +## Errors + +Errors are communicated using the `e` field in the envelope. + +### Error Body (`e`) + +| Key | Name | Type | Description | +| :--- | :--- | :--- | :--- | +| **`c`** | Code | `Integer` | Error code identifying the failure. | +| **`m`** | Message | `String` | Human-readable explanation. | + +### Standard Error Codes + +| Code | Label | Description | +| :--- | :--- | :--- | +| **201** | Generic Error | General failure. | +| **203** | Protocol Error | Malformed packet, invalid arguments, or bad token. | +| **204** | Method Unknown | RPC method not supported. | +| **205** | Message Too Big | Packet exceeds MTU or internal limits. | +| **206** | Invalid Signature | Cryptographic verification failed. | +| **301** | CAS Fail | Sequence number mismatch for atomic update. | +| **302** | Sequence Not Monotonic | New sequence number is not greater than current. | +| **400** | Invalid Token | The provided write token is incorrect or expired. | \ No newline at end of file diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/protocol/AnnouncePeerRequest.java b/dht/src/main/java/io/bosonnetwork/kademlia/protocol/AnnouncePeerRequest.java index 82ab46c..b498a43 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/protocol/AnnouncePeerRequest.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/protocol/AnnouncePeerRequest.java @@ -106,10 +106,10 @@ public void serialize(AnnouncePeerRequest value, JsonGenerator gen, SerializerPr PeerInfo peer = value.peer; if (binaryFormat) { - gen.writeFieldName("t"); + gen.writeFieldName("k"); gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, peer.getId().bytes(), 0, Id.BYTES); } else { - gen.writeStringField("t", peer.getId().toBase58String()); + gen.writeStringField("k", peer.getId().toBase58String()); } byte[] nonce = peer.getNonce(); @@ -190,7 +190,7 @@ public AnnouncePeerRequest deserialize(JsonParser p, DeserializationContext ctxt case "cas": cas = p.getIntValue(); break; - case "t": + case "k": peerId = binaryFormat ? Id.of(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : Id.of(p.getText()); break; case "n": From 28244716391b9d1c13a2a638b3ff6685519b9e4b Mon Sep 17 00:00:00 2001 From: Jingyu Date: Tue, 13 Jan 2026 11:16:43 +0800 Subject: [PATCH 5/6] Improve the document for the basic datatypes --- api/docs/datatypes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/docs/datatypes.md b/api/docs/datatypes.md index eae662b..fedb462 100644 --- a/api/docs/datatypes.md +++ b/api/docs/datatypes.md @@ -42,7 +42,7 @@ The `Id` is a 256-bit identifier used to uniquely identify nodes, values, peers, ## PeerInfo -`PeerInfo` describes a service published over the Boson DHT. It can be **Authenticated** (includes node signature) or **Regular**. +`PeerInfo` describes a service published over the Boson DHT. It can be **Authenticated** (includes node signature) or **Regular**. A `PeerInfo` record is uniquely identified by the combination of its Peer ID (`id`) and Fingerprint (`f`). ### Serialization Format From 79c4362a157444e244a47c8b353f7df3a7b85745 Mon Sep 17 00:00:00 2001 From: Jingyu Date: Wed, 14 Jan 2026 09:53:12 +0800 Subject: [PATCH 6/6] Refine the service interfaces --- .../io/bosonnetwork/service/BosonService.java | 17 +++++++--- .../io/bosonnetwork/service/Federation.java | 5 +-- .../io/bosonnetwork/service/ServiceInfo.java | 33 ++++++++++++------- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/api/src/main/java/io/bosonnetwork/service/BosonService.java b/api/src/main/java/io/bosonnetwork/service/BosonService.java index 1072ed6..7e07daf 100644 --- a/api/src/main/java/io/bosonnetwork/service/BosonService.java +++ b/api/src/main/java/io/bosonnetwork/service/BosonService.java @@ -26,6 +26,7 @@ import java.util.concurrent.CompletableFuture; import io.bosonnetwork.Id; +import io.bosonnetwork.PeerInfo; /** * Interface BosonService is the basic abstraction for the extensible service on top of @@ -70,13 +71,19 @@ public interface BosonService { int getPort(); /** - * Retrieves an alternative endpoint for the service. + * Retrieves the endpoint URL associated with the service. * - * @return a string representing the alternative endpoint. + * @return a string representing the service endpoint. */ - default String getAlternativeEndpoint() { - return null; - } + String getEndpoint(); + + /** + * Retrieves detailed information about the peer associated with the service. + * + * @return a {@link PeerInfo} object containing peer-related information, such as + * host, port, and other metadata. + */ + PeerInfo getPeerInfo(); /** * Checks whether the federation feature is enabled for the service. diff --git a/api/src/main/java/io/bosonnetwork/service/Federation.java b/api/src/main/java/io/bosonnetwork/service/Federation.java index 8cda9e1..cfddd7e 100644 --- a/api/src/main/java/io/bosonnetwork/service/Federation.java +++ b/api/src/main/java/io/bosonnetwork/service/Federation.java @@ -22,6 +22,7 @@ package io.bosonnetwork.service; +import java.util.List; import java.util.concurrent.CompletableFuture; import io.bosonnetwork.Id; @@ -71,8 +72,8 @@ default CompletableFuture getNode(Id nodeId) { * * @param nodeId the unique identifier of the node hosting the service * @param peerId the unique identifier of the service peer - * @return a {@link CompletableFuture} that completes with the {@link ServiceInfo} object if found, + * @return a {@link CompletableFuture} that completes with the list of {@link ServiceInfo} if found, * or completes exceptionally/with null if the service cannot be located */ - public CompletableFuture getService(Id nodeId, Id peerId); + public CompletableFuture> getServices(Id peerId, Id nodeId); } \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/service/ServiceInfo.java b/api/src/main/java/io/bosonnetwork/service/ServiceInfo.java index 1152028..27353a6 100644 --- a/api/src/main/java/io/bosonnetwork/service/ServiceInfo.java +++ b/api/src/main/java/io/bosonnetwork/service/ServiceInfo.java @@ -22,6 +22,8 @@ package io.bosonnetwork.service; +import java.util.Map; + import io.bosonnetwork.Id; /** @@ -38,6 +40,13 @@ public interface ServiceInfo { */ Id getPeerId(); + /** + * Retrieves a unique fingerprint representing the service instance. + * + * @return a long value uniquely identifying the service instance + */ + long getFingerprint(); + /** * Gets the unique identifier of the node hosting the service. * @@ -46,32 +55,32 @@ public interface ServiceInfo { Id getNodeId(); /** - * Gets the origin identifier, typically representing the entity responsible for the service. + * Gets the endpoint URL or URI for accessing the service. * - * @return the origin {@link Id} + * @return the endpoint string, never be {@code null} */ - Id getOriginId(); + String getEndpoint(); /** - * Gets the hostname or IP address where the service is accessible. + * Checks if the extra data is present. * - * @return the host string + * @return {@code true} if the extra data is present, {@code false} otherwise. */ - String getHost(); + boolean hasExtra(); /** - * Gets the network port number where the service is listening. + * Gets the extra data. * - * @return the port number + * @return the extra data */ - int getPort(); + byte[] getExtraData(); /** - * Gets an alternative endpoint URL or URI for accessing the service. + * Gets the extra data as a map. * - * @return the alternative endpoint string, or {@code null} if not available + * @return the extra data map */ - String getAlternativeEndpoint(); + Map getExtra(); /** * Gets the unique identifier string for the specific service type.