Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package io.a2a.client.transport.jsonrpc;

import static io.a2a.spec.AgentInterface.CURRENT_PROTOCOL_VERSION;

/**
* Request and response messages used by the tests. These have been created following examples from
* the <a href="https://google.github.io/A2A/specification/sample-messages">A2A sample messages</a>.
Expand Down Expand Up @@ -266,7 +264,8 @@ public class JsonMessages {
},
{
"raw":"aGVsbG8=",
"filename":"hello.txt"
"filename":"hello.txt",
"mediaType": "text/plain"
}
],
"messageId":"message-123"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ public class JsonRestMessages {
},
{
"raw": "aGVsbG8=",
"filename":"hello.txt",
"mediaType": "text/plain"
}
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -746,9 +746,11 @@ StreamingEventKind read(JsonReader in) throws java.io.IOException {
*/
static class FileContentTypeAdapter extends TypeAdapter<FileContent> {

// Create separate Gson instance without the FileContent adapter to avoid recursion
// Create separate Gson instance without the FileContent adapter to avoid recursion,
// but with an explicit FileWithBytes adapter to prevent field/path leakage.
private final Gson delegateGson = new GsonBuilder()
.registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeTypeAdapter())
.registerTypeAdapter(FileWithBytes.class, new FileWithBytesTypeAdapter())
.create();

@Override
Expand Down Expand Up @@ -788,6 +790,56 @@ FileContent read(JsonReader in) throws java.io.IOException {
}
}

/**
* Gson TypeAdapter for serializing and deserializing {@link FileWithBytes}.
* <p>
* Explicitly maps only the three protocol fields ({@code mimeType}, {@code name}, {@code bytes})
* to and from JSON. This prevents internal implementation fields (such as the lazy-loading
* {@code source} or the {@code cachedBytes} soft reference) from leaking into serialized output,
* and ensures correct round-trip deserialization via the canonical
* {@link FileWithBytes#FileWithBytes(String, String, String)} constructor.
*/
static class FileWithBytesTypeAdapter extends TypeAdapter<FileWithBytes> {

@Override
public void write(JsonWriter out, FileWithBytes value) throws java.io.IOException {
if (value == null) {
out.nullValue();
return;
}
out.beginObject();
out.name("mimeType").value(value.mimeType());
out.name("name").value(value.name());
out.name("bytes").value(value.bytes());
out.endObject();
}

@Override
public @Nullable FileWithBytes read(JsonReader in) throws java.io.IOException {
if (in.peek() == JsonToken.NULL) {
in.nextNull();
return null;
}
String mimeType = null;
String name = null;
String bytes = null;
in.beginObject();
while (in.hasNext()) {
switch (in.nextName()) {
case "mimeType" -> mimeType = in.nextString();
case "name" -> name = in.nextString();
case "bytes" -> bytes = in.nextString();
default -> in.skipValue();
}
}
in.endObject();
return new FileWithBytes(
mimeType != null ? mimeType : "",
name != null ? name : "",
bytes != null ? bytes : "");
}
}

/**
* Gson TypeAdapter for serializing and deserializing {@link APIKeySecurityScheme.Location} enum.
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@

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;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.OffsetDateTime;
import java.util.Base64;
import java.util.List;
import java.util.Map;

import io.a2a.spec.Artifact;
import io.a2a.spec.DataPart;
import io.a2a.spec.FileContent;
import io.a2a.spec.FilePart;
import io.a2a.spec.FileWithBytes;
import io.a2a.spec.FileWithUri;
Expand All @@ -22,6 +28,7 @@
import io.a2a.spec.TaskStatus;
import io.a2a.spec.TextPart;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

/**
* Tests for Task serialization and deserialization using Gson.
Expand Down Expand Up @@ -706,4 +713,74 @@ void testTaskWithMixedPartTypes() throws JsonProcessingException {
assertTrue(parts.get(2) instanceof DataPart);
assertTrue(parts.get(3) instanceof FilePart);
}

// ========== FileContentTypeAdapter tests ==========

@TempDir
Path tempDir;

@Test
void testFileWithBytesSerializationDoesNotLeakInternalFields() throws Exception {
FileWithBytes fwb = new FileWithBytes("application/pdf", "doc.pdf", "base64data");

String json = JsonUtil.toJson(fwb);

// Must contain the three protocol fields
assertTrue(json.contains("\"mimeType\""), "missing mimeType: " + json);
assertTrue(json.contains("\"name\""), "missing name: " + json);
assertTrue(json.contains("\"bytes\""), "missing bytes: " + json);
// Must NOT contain internal implementation fields
assertFalse(json.contains("\"source\""), "internal source field leaked: " + json);
assertFalse(json.contains("\"cachedBytes\""), "internal cachedBytes field leaked: " + json);
}

@Test
void testFileWithBytesRoundTripViaFileContentTypeAdapter() throws Exception {
FileWithBytes original = new FileWithBytes("image/png", "photo.png", "abc123");

String json = JsonUtil.toJson(original);
FileContent deserialized = JsonUtil.fromJson(json, FileContent.class);

assertInstanceOf(FileWithBytes.class, deserialized);
FileWithBytes result = (FileWithBytes) deserialized;
assertEquals("image/png", result.mimeType());
assertEquals("photo.png", result.name());
assertEquals("abc123", result.bytes());
}

@Test
void testPathBackedFileWithBytesDoesNotLeakFilePath() throws Exception {
byte[] content = "hello".getBytes();
Path file = tempDir.resolve("secret.txt");
Files.write(file, content);

FileWithBytes fwb = new FileWithBytes("text/plain", file);

String json = JsonUtil.toJson(fwb);

// File path must not appear in the serialized JSON
assertFalse(json.contains(file.toString()), "file path leaked in JSON: " + json);
assertFalse(json.contains(tempDir.toString()), "temp dir path leaked in JSON: " + json);
// Must contain the three protocol fields, not internal implementation fields
assertTrue(json.contains("\"bytes\""), "missing bytes field: " + json);
assertFalse(json.contains("\"source\""), "internal source field leaked: " + json);
}

@Test
void testPathBackedFileWithBytesRoundTrip() throws Exception {
byte[] content = "round-trip".getBytes();
Path file = tempDir.resolve("data.bin");
Files.write(file, content);

FileWithBytes original = new FileWithBytes("application/octet-stream", file);

String json = JsonUtil.toJson(original);
FileContent deserialized = JsonUtil.fromJson(json, FileContent.class);

assertInstanceOf(FileWithBytes.class, deserialized);
FileWithBytes result = (FileWithBytes) deserialized;
assertEquals("application/octet-stream", result.mimeType());
assertEquals("data.bin", result.name());
assertEquals(Base64.getEncoder().encodeToString(content), result.bytes());
}
}
4 changes: 2 additions & 2 deletions spec-grpc/src/main/java/io/a2a/grpc/mapper/PartMapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ default Part<?> fromProto(io.a2a.grpc.Part proto) {
} else if (proto.hasRaw()) {
// raw bytes → FilePart(FileWithBytes)
String bytes = Base64.getEncoder().encodeToString(proto.getRaw().toByteArray());
String mimeType = proto.getMediaType().isEmpty() ? null : proto.getMediaType();
String name = proto.getFilename().isEmpty() ? null : proto.getFilename();
String mimeType = proto.getMediaType().isEmpty() ? "" : proto.getMediaType();
String name = proto.getFilename().isEmpty() ? "" : proto.getFilename();
return new FilePart(new FileWithBytes(mimeType, name, bytes), metadata);
} else if (proto.hasUrl()) {
// url → FilePart(FileWithUri)
Expand Down
37 changes: 37 additions & 0 deletions spec-grpc/src/test/java/io/a2a/grpc/utils/PartTypeAdapterTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,19 @@
import io.a2a.spec.FileWithUri;
import io.a2a.spec.Part;
import io.a2a.spec.TextPart;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;


public class PartTypeAdapterTest {

@TempDir
Path tempDir;

// -------------------------------------------------------------------------
// TextPart
// -------------------------------------------------------------------------
Expand Down Expand Up @@ -134,6 +141,36 @@ public void shouldRoundTripFilePartWithBytes() throws JsonProcessingException {
assertEquals("AAEC", bytes.bytes());
}

@Test
public void shouldRoundTripFilePartWithBytesFromRealFile() throws JsonProcessingException, IOException {
// Create a temporary file with some content
Path testFile = tempDir.resolve("test-file.txt");
String fileContent = "This is test content for lazy loading verification";
Files.writeString(testFile, fileContent);

// Create FileWithBytes from the file path (lazy loading)
FileWithBytes fileWithBytes = new FileWithBytes("text/plain", testFile);
FilePart original = new FilePart(fileWithBytes);

// Serialize to JSON (this triggers lazy loading)
String json = JsonUtil.toJson(original);

// Deserialize and verify
Part<?> deserialized = JsonUtil.fromJson(json, Part.class);
assertInstanceOf(FilePart.class, deserialized);
FilePart result = (FilePart) deserialized;
assertInstanceOf(FileWithBytes.class, result.file());
FileWithBytes bytes = (FileWithBytes) result.file();

assertEquals("text/plain", bytes.mimeType());
assertEquals("test-file.txt", bytes.name());

// Verify the content by decoding the base64
byte[] decodedBytes = Base64.getDecoder().decode(bytes.bytes());
String decodedContent = new String(decodedBytes);
assertEquals(fileContent, decodedContent);
}

// -------------------------------------------------------------------------
// FilePart – FileWithUri
// -------------------------------------------------------------------------
Expand Down
Loading