From 06088b9f27df4578a647986b53d6207bf544898a Mon Sep 17 00:00:00 2001 From: gkc Date: Fri, 3 Jul 2026 15:21:37 +0100 Subject: [PATCH] feat(metadata): add AppMetadata value class (pluggable-encryption provider metadata) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the canonical at_commons AppMetadata (at_key.dart): an SDK-owned providerId routing field plus opaque `additional` entries, exposed on Metadata as the appMetadata field. - New AppMetadata value class: providerId + additional Map, with toJson() (flat {providerId, ...additional}), fromJson(Map), encode() (base64(JSON)), and decode() (accepts a base64 String, a flat Map, or an AppMetadata) — mirroring canonical encodeAppMetadata / decodeAppMetadata. - Metadata.appMetadata typed as AppMetadata; toString() appends :appMetadata: LAST after immutable, matching the canonical metadata fragment order. Static Metadata.encodeAppMetadata / decodeAppMetadata delegate for API parity, and Jackson (@JsonValue / @JsonCreator) round-trips the flat-object form used in metadata maps. Groundwork for the Java client's fast-follow adoption of the pluggable encrypt/decrypt provider model (the post-quantum-safety work in flight on the Dart SDK), which routes on appMetadata.providerId. It also lets a Java atServer parse, store, and re-emit appMetadata through llookup:all / sync / notify so the receiver can select the right decryptor after sync or cached delivery. MetadataTest +8 (72 total). --- .../java/org/atsign/client/api/Metadata.java | 131 ++++++++++++++++++ .../org/atsign/client/api/MetadataTest.java | 73 ++++++++++ 2 files changed, 204 insertions(+) diff --git a/at_client/src/main/java/org/atsign/client/api/Metadata.java b/at_client/src/main/java/org/atsign/client/api/Metadata.java index 6012e16e..3d2d0f7a 100644 --- a/at_client/src/main/java/org/atsign/client/api/Metadata.java +++ b/at_client/src/main/java/org/atsign/client/api/Metadata.java @@ -1,8 +1,14 @@ package org.atsign.client.api; +import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; import org.atsign.client.impl.util.JsonUtils; import lombok.Builder; @@ -50,6 +56,14 @@ public class Metadata { String skeEncKeyName; String skeEncAlgo; Boolean immutable; + /** + * Provider-owned crypto metadata for the pluggable encryption/decryption model. Mirrors the + * canonical at_commons {@code AppMetadata}: an SDK-owned {@code providerId} that routes the + * value to the crypto provider able to decrypt it, plus opaque {@code additional} entries the + * SDK preserves but does not interpret. Serialised LAST in the metadata fragment as + * {@code :appMetadata:} and as a flat JSON object in metadata maps. + */ + AppMetadata appMetadata; /** * A builder for instantiating {@link Metadata} instances. Note: Metadata is immutable so if you @@ -90,6 +104,7 @@ public String toString() { .append(skeEncKeyName != null ? ":skeEncKeyName:" + skeEncKeyName : "") .append(skeEncAlgo != null ? ":skeEncAlgo:" + skeEncAlgo : "") .append(immutable != null ? ":immutable:" + immutable : "") + .append(appMetadata != null ? ":appMetadata:" + appMetadata.encode() : "") .toString(); } @@ -458,4 +473,120 @@ public static class PublicKeyHash { String hash; String hashingAlgo; } + + /** + * Encode {@link AppMetadata} to its base64(JSON) wire form. Mirrors canonical + * {@code Metadata.encodeAppMetadata} (at_commons at_key.dart). + * + * @param appMetadata value to encode + * @return base64(JSON) string + */ + public static String encodeAppMetadata(AppMetadata appMetadata) { + return appMetadata.encode(); + } + + /** + * Decode an {@code appMetadata} value that may arrive as a base64(JSON) String (the wire form), + * a flat JSON object ({@code Map}, the metadata-map form), or an already-parsed + * {@link AppMetadata}. Mirrors canonical {@code Metadata.decodeAppMetadata}. + * + * @param value wire value, or null + * @return the parsed value, or null when absent + */ + public static AppMetadata decodeAppMetadata(Object value) { + return AppMetadata.decode(value); + } + + /** + * Provider-owned crypto metadata for the pluggable encryption/decryption model, mirroring the + * canonical Dart {@code AppMetadata} (at_commons). The SDK owns {@code providerId} — the + * routing key that selects the crypto provider able to decrypt the value — and preserves any + * {@code additional} provider-owned entries opaquely. On the wire it is base64(JSON)-encoded + * (see {@link #encode()}); in metadata maps it is the flat JSON object + * {@code {"providerId":…, …additional}} (see {@link #toJson()}). + */ + @Value + @Builder + public static class AppMetadata { + String providerId; + Map additional; + + /** + * @return the flat JSON object form — {@code providerId} plus any {@code additional} entries, + * serialised flat (not nested under an {@code additional} key), matching canonical. + */ + @JsonValue + public Map toJson() { + Map map = new LinkedHashMap<>(); + map.put("providerId", providerId); + if (additional != null) { + map.putAll(additional); + } + return map; + } + + /** + * Build from the flat JSON object form; every key other than {@code providerId} is collected + * into {@code additional}. Used by Jackson when deserialising a metadata map. + * + * @param json flat map with a String {@code providerId} and opaque extras + * @return the parsed value + * @throws IllegalArgumentException if {@code providerId} is missing or blank + */ + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static AppMetadata fromJson(Map json) { + Object providerId = json.get("providerId"); + if (!(providerId instanceof String) || ((String) providerId).trim().isEmpty()) { + throw new IllegalArgumentException("Invalid appMetadata.providerId: " + providerId); + } + Map additional = new LinkedHashMap<>(); + for (Map.Entry entry : json.entrySet()) { + if (!"providerId".equals(entry.getKey())) { + additional.put(entry.getKey(), entry.getValue()); + } + } + return AppMetadata.builder() + .providerId((String) providerId) + .additional(additional.isEmpty() ? null : additional) + .build(); + } + + /** + * @return the base64(JSON) wire encoding of {@link #toJson()}. + */ + public String encode() { + return Base64.getEncoder() + .encodeToString(JsonUtils.writeValueAsString(toJson()).getBytes(StandardCharsets.UTF_8)); + } + + /** + * Decode an {@code appMetadata} value arriving as a base64(JSON) String, a flat JSON + * {@code Map}, or an already-parsed {@link AppMetadata}. Absent (null or the literal + * {@code "null"}) yields null. + * + * @param value wire value + * @return the parsed value, or null when absent + * @throws IllegalArgumentException if the value is a non-decodable shape + */ + @SuppressWarnings("unchecked") + public static AppMetadata decode(Object value) { + if (value == null || "null".equals(value)) { + return null; + } + if (value instanceof AppMetadata) { + return (AppMetadata) value; + } + if (value instanceof Map) { + return fromJson((Map) value); + } + if (value instanceof String && !((String) value).isEmpty()) { + byte[] decoded = Base64.getDecoder().decode((String) value); + Object json = JsonUtils.readValue(new String(decoded, StandardCharsets.UTF_8), Object.class); + if (json instanceof Map) { + return fromJson((Map) json); + } + } + throw new IllegalArgumentException("Invalid appMetadata: " + value); + } + } } diff --git a/at_client/src/test/java/org/atsign/client/api/MetadataTest.java b/at_client/src/test/java/org/atsign/client/api/MetadataTest.java index 2f5b52ca..47baf974 100644 --- a/at_client/src/test/java/org/atsign/client/api/MetadataTest.java +++ b/at_client/src/test/java/org/atsign/client/api/MetadataTest.java @@ -571,4 +571,77 @@ void testSetImmutableIfNotNullReturnsFalseAndDoesNotOverwriteExistingValueWhenNu assertThat(b.build().immutable(), is(true)); } + // ---- AppMetadata (pluggable-encryption provider metadata) ---- + + @Test + void testAppMetadataToJsonIsFlatProviderIdPlusAdditional() { + Metadata.AppMetadata am = Metadata.AppMetadata.builder() + .providerId("legacy") + .additional(java.util.Map.of("v", 2)) + .build(); + java.util.Map json = am.toJson(); + assertThat(json.get("providerId"), equalTo("legacy")); + assertThat(json.get("v"), equalTo(2)); + // additional is flattened, NOT nested under an "additional" key + assertThat(json.containsKey("additional"), is(false)); + } + + @Test + void testAppMetadataFromJsonRoutesNonProviderIdKeysToAdditional() { + Metadata.AppMetadata am = Metadata.AppMetadata + .fromJson(new java.util.LinkedHashMap<>(java.util.Map.of("providerId", "p1", "x", "y"))); + assertThat(am.providerId(), equalTo("p1")); + assertThat(am.additional().get("x"), equalTo("y")); + } + + @Test + void testAppMetadataFromJsonWithOnlyProviderIdHasNullAdditional() { + Metadata.AppMetadata am = Metadata.AppMetadata + .fromJson(new java.util.LinkedHashMap<>(java.util.Map.of("providerId", "p1"))); + assertThat(am.additional(), is(nullValue())); + } + + @Test + void testAppMetadataFromJsonThrowsWhenProviderIdMissingOrBlank() { + assertThrows(IllegalArgumentException.class, + () -> Metadata.AppMetadata.fromJson(new java.util.LinkedHashMap<>())); + assertThrows(IllegalArgumentException.class, () -> Metadata.AppMetadata + .fromJson(new java.util.LinkedHashMap<>(java.util.Map.of("providerId", " ")))); + } + + @Test + void testAppMetadataEncodeDecodeRoundTrips() { + Metadata.AppMetadata am = Metadata.AppMetadata.builder().providerId("prov").build(); + String encoded = am.encode(); + // base64 of a JSON object — decode must reconstruct the same value + assertThat(Metadata.AppMetadata.decode(encoded), equalTo(am)); + // canonical parity: the static Metadata helpers delegate to encode/decode + assertThat(Metadata.encodeAppMetadata(am), equalTo(encoded)); + assertThat(Metadata.decodeAppMetadata(encoded), equalTo(am)); + } + + @Test + void testAppMetadataDecodeAcceptsMapAndAppMetadataAndNull() { + Metadata.AppMetadata am = Metadata.AppMetadata.builder().providerId("prov").build(); + assertThat(Metadata.AppMetadata.decode(am), sameInstance(am)); + assertThat(Metadata.AppMetadata.decode(java.util.Map.of("providerId", "prov")), equalTo(am)); + assertThat(Metadata.AppMetadata.decode(null), is(nullValue())); + assertThat(Metadata.AppMetadata.decode("null"), is(nullValue())); + } + + @Test + void testToStringEncodesAppMetadataAsBase64Fragment() { + Metadata.AppMetadata am = Metadata.AppMetadata.builder().providerId("prov").build(); + Metadata md = Metadata.builder().appMetadata(am).build(); + assertThat(md.toString(), containsString(":appMetadata:" + am.encode())); + } + + @Test + void testMetadataFromJsonDeserialisesNestedAppMetadataObject() { + // The metadata-map form carries appMetadata as a flat JSON object (not base64). + Metadata md = Metadata.fromJson("{\"appMetadata\":{\"providerId\":\"prov\",\"v\":1}}"); + assertThat(md.appMetadata().providerId(), equalTo("prov")); + assertThat(md.appMetadata().additional().get("v"), equalTo(1)); + } + }