diff --git a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java index 71fac010e..67ef81346 100644 --- a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java +++ b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java @@ -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 A2A sample messages. @@ -266,7 +264,8 @@ public class JsonMessages { }, { "raw":"aGVsbG8=", - "filename":"hello.txt" + "filename":"hello.txt", + "mediaType": "text/plain" } ], "messageId":"message-123" diff --git a/client/transport/rest/src/test/java/io/a2a/client/transport/rest/JsonRestMessages.java b/client/transport/rest/src/test/java/io/a2a/client/transport/rest/JsonRestMessages.java index 471ac5d90..b9a6527e0 100644 --- a/client/transport/rest/src/test/java/io/a2a/client/transport/rest/JsonRestMessages.java +++ b/client/transport/rest/src/test/java/io/a2a/client/transport/rest/JsonRestMessages.java @@ -91,6 +91,7 @@ public class JsonRestMessages { }, { "raw": "aGVsbG8=", + "filename":"hello.txt", "mediaType": "text/plain" } ], diff --git a/jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/json/JsonUtil.java b/jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/json/JsonUtil.java index 76a23273a..9e8fb057f 100644 --- a/jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/json/JsonUtil.java +++ b/jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/json/JsonUtil.java @@ -746,9 +746,11 @@ StreamingEventKind read(JsonReader in) throws java.io.IOException { */ static class FileContentTypeAdapter extends TypeAdapter { - // 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 @@ -788,6 +790,56 @@ FileContent read(JsonReader in) throws java.io.IOException { } } + /** + * Gson TypeAdapter for serializing and deserializing {@link FileWithBytes}. + *

+ * 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 { + + @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. *

diff --git a/jsonrpc-common/src/test/java/io/a2a/jsonrpc/common/json/TaskSerializationTest.java b/jsonrpc-common/src/test/java/io/a2a/jsonrpc/common/json/TaskSerializationTest.java index ff47e746b..c5199a1e1 100644 --- a/jsonrpc-common/src/test/java/io/a2a/jsonrpc/common/json/TaskSerializationTest.java +++ b/jsonrpc-common/src/test/java/io/a2a/jsonrpc/common/json/TaskSerializationTest.java @@ -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; @@ -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. @@ -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()); + } } diff --git a/spec-grpc/src/main/java/io/a2a/grpc/mapper/PartMapper.java b/spec-grpc/src/main/java/io/a2a/grpc/mapper/PartMapper.java index 8704f4699..79748f3c2 100644 --- a/spec-grpc/src/main/java/io/a2a/grpc/mapper/PartMapper.java +++ b/spec-grpc/src/main/java/io/a2a/grpc/mapper/PartMapper.java @@ -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) diff --git a/spec-grpc/src/test/java/io/a2a/grpc/utils/PartTypeAdapterTest.java b/spec-grpc/src/test/java/io/a2a/grpc/utils/PartTypeAdapterTest.java index 8b7eb741b..3daea3a4a 100644 --- a/spec-grpc/src/test/java/io/a2a/grpc/utils/PartTypeAdapterTest.java +++ b/spec-grpc/src/test/java/io/a2a/grpc/utils/PartTypeAdapterTest.java @@ -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 // ------------------------------------------------------------------------- @@ -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 // ------------------------------------------------------------------------- diff --git a/spec/src/main/java/io/a2a/spec/FileWithBytes.java b/spec/src/main/java/io/a2a/spec/FileWithBytes.java index b5aef3813..53e448c5f 100644 --- a/spec/src/main/java/io/a2a/spec/FileWithBytes.java +++ b/spec/src/main/java/io/a2a/spec/FileWithBytes.java @@ -1,28 +1,341 @@ package io.a2a.spec; +import io.a2a.util.Assert; +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.ref.SoftReference; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.Objects; + +import org.jspecify.annotations.Nullable; + /** * Represents file content embedded directly as base64-encoded bytes. *

* FileWithBytes is used when file content needs to be transmitted inline with the message or * artifact, rather than requiring a separate download. This is appropriate for: *

*

* The bytes field contains the base64-encoded file content. Decoders should handle the base64 * encoding/decoding transparently. *

- * This class is immutable. + * This class uses lazy loading with soft-reference caching to reduce memory pressure: the + * base64-encoded content is computed on-demand and held via a {@link SoftReference}, allowing + * the JVM to reclaim it under memory pressure. If reclaimed, it is recomputed on next access. * - * @param mimeType the MIME type of the file (e.g., "image/png", "application/pdf") (required) - * @param name the file name (e.g., "report.pdf", "diagram.png") (required) - * @param bytes the base64-encoded file content (required) * @see FileContent * @see FilePart * @see FileWithUri */ -public record FileWithBytes(String mimeType, String name, String bytes) implements FileContent { +public final class FileWithBytes implements FileContent { + + /** + * Maximum file size that can be loaded (10 MB). + * Files larger than this will be rejected at construction time. + */ + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB + + private final String mimeType; + private final String name; + + // Source for (re)generating base64 content on-demand + private final ByteSource source; + + // Soft-reference cache: held in memory but reclaimable by GC under memory pressure + @Nullable + private volatile SoftReference cachedBytes; + + /** + * Creates a {@code FileWithBytes} with pre-encoded base64 content. + * This is the canonical constructor used by serialization frameworks. + * + * @param mimeType the MIME type of the file (e.g., "image/png", "application/pdf") + * @param name the file name (e.g., "report.pdf", "diagram.png") + * @param bytes the base64-encoded file content + */ + public FileWithBytes(String mimeType, String name, String bytes) { + this.mimeType = Assert.checkNotNullParam("mimeType", mimeType); + this.name = Assert.checkNotNullParam("name", name); + this.source = new PreEncodedSource(Assert.checkNotNullParam("bytes", bytes)); + this.cachedBytes = new SoftReference<>(bytes); + } + + /** + * Creates a {@code FileWithBytes} by reading the content of the given {@link File}. + * The file name is derived from {@link File#getName()}. + *

+ * The file is validated at construction time to ensure it exists, is readable, is a regular file, + * and does not exceed the maximum size limit ({@value #MAX_FILE_SIZE} bytes). + *

+ * The file content is read and base64-encoded on the first call to {@link #bytes()}, then + * cached via a soft reference. The cache may be cleared by GC under memory pressure, in + * which case the file is re-read on the next access. + * + * @param mimeType the MIME type of the file (e.g., {@code "image/png"}) + * @param file the file whose content will be read and encoded + * @throws IllegalArgumentException if the file does not exist, is not readable, is not a regular file, + * or exceeds the maximum size limit + * @throws RuntimeException if an I/O error occurs while checking the file + */ + public FileWithBytes(String mimeType, File file) { + this(mimeType, file.toPath()); + } + + /** + * Creates a {@code FileWithBytes} by reading the content of the given {@link Path}. + * The file name is derived from {@link Path#getFileName()}. + *

+ * The file is validated at construction time to ensure it exists, is readable, is a regular file, + * and does not exceed the maximum size limit ({@value #MAX_FILE_SIZE} bytes). + *

+ * The file content is read and base64-encoded on the first call to {@link #bytes()}, then + * cached via a soft reference. The cache may be cleared by GC under memory pressure, in + * which case the file is re-read on the next access. + * + * @param mimeType the MIME type of the file (e.g., {@code "image/png"}) + * @param file the path whose content will be read and encoded + * @throws IllegalArgumentException if the file does not exist, is not readable, is not a regular file, + * or exceeds the maximum size limit + * @throws RuntimeException if an I/O error occurs while checking the file + */ + public FileWithBytes(String mimeType, Path file) { + this.mimeType = Assert.checkNotNullParam("mimeType", mimeType); + validateFile(file); + this.name = file.getFileName().toString(); + this.source = new PathSource(file); + } + + /** + * Creates a {@code FileWithBytes} by base64-encoding the given raw byte array. + *

+ * A defensive copy of {@code content} is made at construction time, so subsequent mutations + * to the caller's array have no effect. The copy is base64-encoded on the first call to + * {@link #bytes()}, then cached via a soft reference. The cache may be cleared by GC under + * memory pressure, in which case the encoding is recomputed from the retained copy. + * + * @param mimeType the MIME type of the file (e.g., {@code "application/pdf"}) + * @param name the file name (e.g., {@code "report.pdf"}) + * @param content the raw file content to be base64-encoded + * @throws NullPointerException if {@code content} is null + */ + public FileWithBytes(String mimeType, String name, byte[] content) { + this.mimeType = Assert.checkNotNullParam("mimeType", mimeType); + this.name = Assert.checkNotNullParam("name", name); + this.source = new ByteArraySource(content); + } + + @Override + public String mimeType() { + return mimeType; + } + + @Override + public String name() { + return name; + } + + /** + * Returns the base64-encoded file content. + *

+ * The content is computed on the first call and cached via a soft reference. Subsequent calls + * return the cached value. If the JVM reclaims the cache under memory pressure, the content is + * recomputed transparently on the next access. + *

+ * For instances created from a {@link File} or {@link Path}, recomputation involves reading + * the file from disk. Callers in performance-sensitive paths should retain the returned value + * rather than calling this method repeatedly. + * + * @return the base64-encoded file content + * @throws RuntimeException if an I/O error occurs while reading a file-backed source + */ + public String bytes() { + // First check: fast path without locking + SoftReference ref = cachedBytes; + if (ref != null) { + String cached = ref.get(); + if (cached != null) { + return cached; + } + } + // Second check: slow path, synchronized to prevent redundant computation + // (especially costly for file-backed sources, which would re-read from disk) + synchronized (this) { + ref = cachedBytes; + if (ref != null) { + String cached = ref.get(); + if (cached != null) { + return cached; + } + } + try { + String computed = source.getBase64(); + cachedBytes = new SoftReference<>(computed); + return computed; + } catch (IOException e) { + throw new RuntimeException("Failed to load file content", e); + } + } + } + + /** + * Compares this FileWithBytes to another object for equality. + *

+ * Important: This method uses identity-based comparison to avoid triggering + * potentially expensive I/O operations. Two FileWithBytes instances are considered equal only + * if they are the same object (reference equality). + *

+ * This design choice prevents: + *

+ *

+ * If you need to compare the actual content of two FileWithBytes instances, use a separate + * method or compare the results of {@link #bytes()} explicitly. + * + * @param o the object to compare with + * @return true if this is the same object as o, false otherwise + */ + @Override + public boolean equals(Object o) { + return this == o; + } + + /** + * Returns the identity hash code for this FileWithBytes. + *

+ * This method uses {@link System#identityHashCode(Object)} to avoid triggering I/O operations + * that would be required to compute a content-based hash code. This ensures that using + * FileWithBytes instances as keys in HashMap or elements in HashSet remains safe and efficient. + * + * @return the identity hash code + */ + @Override + public int hashCode() { + return System.identityHashCode(this); + } + + @Override + public String toString() { + return "FileWithBytes[mimeType=" + mimeType + ", name=" + name + "]"; + } + + /** + * Validates that a file exists, is readable, is a regular file, and does not exceed the maximum size. + * + * @param file the file to validate + * @throws IllegalArgumentException if validation fails + * @throws RuntimeException if an I/O error occurs during validation + */ + private static void validateFile(Path file) { + if (!Files.exists(file)) { + throw new IllegalArgumentException("File does not exist: " + file); + } + if (!Files.isReadable(file)) { + throw new IllegalArgumentException("File is not readable: " + file); + } + if (!Files.isRegularFile(file)) { + throw new IllegalArgumentException("Not a regular file: " + file); + } + try { + long size = Files.size(file); + if (size > MAX_FILE_SIZE) { + throw new IllegalArgumentException( + String.format("File too large: %d bytes (maximum: %d bytes)", size, MAX_FILE_SIZE) + ); + } + } catch (IOException e) { + throw new RuntimeException("Failed to check file size: " + file, e); + } + } + + /** + * Internal interface for different byte sources. + */ + private interface ByteSource { + String getBase64() throws IOException; + } + + /** + * Source for pre-encoded base64 content. + */ + private static final class PreEncodedSource implements ByteSource { + private final String base64; + + PreEncodedSource(String base64) { + this.base64 = base64; + } + + @Override + public String getBase64() { + return base64; + } + } + + /** + * Source for file path that needs to be read and encoded. + */ + private static final class PathSource implements ByteSource { + private final Path path; + + PathSource(Path path) { + this.path = path; + } + + @Override + public String getBase64() throws IOException { + return encodeFileToBase64(path); + } + } + + /** + * Source for byte array that needs to be encoded. + */ + private static final class ByteArraySource implements ByteSource { + private final byte[] content; + + ByteArraySource(byte[] content) { + this.content = Objects.requireNonNull(content, "content must not be null").clone(); + } + + @Override + public String getBase64() { + return Base64.getEncoder().encodeToString(content); + } + } + + /** + * Encodes a file to base64 by streaming its content in chunks. + * This avoids loading the entire file into memory at once by using + * a wrapping output stream that encodes data as it's written. + * + * @param path the path to the file to encode + * @return the base64-encoded content + * @throws IOException if an I/O error occurs reading the file + */ + private static String encodeFileToBase64(Path path) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + try (InputStream inputStream = new BufferedInputStream(Files.newInputStream(path)); + OutputStream base64OutputStream = Base64.getEncoder().wrap(outputStream)) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + base64OutputStream.write(buffer, 0, bytesRead); + } + } + return outputStream.toString(StandardCharsets.UTF_8); + } } diff --git a/spec/src/test/java/io/a2a/spec/FileWithBytesTest.java b/spec/src/test/java/io/a2a/spec/FileWithBytesTest.java new file mode 100644 index 000000000..5cf556198 --- /dev/null +++ b/spec/src/test/java/io/a2a/spec/FileWithBytesTest.java @@ -0,0 +1,273 @@ +package io.a2a.spec; + +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.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +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; + +/** + * Unit tests for the convenience constructors added to {@link FileWithBytes}. + *

+ * The canonical {@code FileWithBytes(String, String, String)} constructor expects the bytes field + * to already be base64-encoded. The constructors under test accept raw sources ({@link java.io.File}, + * {@link java.nio.file.Path}, or {@code byte[]}) and handle the base64 encoding internally. + *

+ * Each group of tests verifies: + *

+ * The cross-constructor consistency tests confirm that all three convenience constructors and the + * canonical constructor produce equivalent {@link FileWithBytes} instances when given the same data. + */ +class FileWithBytesTest { + + private static final String SVG_MIME_TYPE = "image/svg+xml"; + private static final String SVG_RESOURCE = "/a2a-logo-white.svg"; + + @TempDir + Path tempDir; + + private Path svgPath() throws URISyntaxException { + return Path.of(getClass().getResource(SVG_RESOURCE).toURI()); + } + + private String base64(byte[] content) { + return Base64.getEncoder().encodeToString(content); + } + + private Path writeTempFile(String name, byte[] content) throws IOException { + Path path = tempDir.resolve(name); + Files.write(path, content); + return path; + } + + // ========== File constructor ========== + + @Test + void testFileConstructor_encodesContentAsBase64() throws IOException { + byte[] content = "hello world".getBytes(); + File file = writeTempFile("test.txt", content).toFile(); + + FileWithBytes fwb = new FileWithBytes("text/plain", file); + + assertEquals("text/plain", fwb.mimeType()); + assertEquals("test.txt", fwb.name()); + assertEquals(base64(content), fwb.bytes()); + } + + @Test + void testFileConstructor_useFileNameFromPath() throws IOException, URISyntaxException { + File svgFile = svgPath().toFile(); + + FileWithBytes fwb = new FileWithBytes(SVG_MIME_TYPE, svgFile); + + assertEquals(SVG_MIME_TYPE, fwb.mimeType()); + assertEquals("a2a-logo-white.svg", fwb.name()); + assertEquals(base64(Files.readAllBytes(svgFile.toPath())), fwb.bytes()); + } + + @Test + void testFileConstructor_emptyFile() throws IOException { + File file = writeTempFile("empty.bin", new byte[0]).toFile(); + + FileWithBytes fwb = new FileWithBytes("application/octet-stream", file); + + assertEquals("application/octet-stream", fwb.mimeType()); + assertEquals("empty.bin", fwb.name()); + assertEquals("", fwb.bytes()); + } + + // ========== Path constructor ========== + + @Test + void testPathConstructor_encodesContentAsBase64() throws IOException { + byte[] content = "path content".getBytes(); + Path path = writeTempFile("data.txt", content); + + FileWithBytes fwb = new FileWithBytes("text/plain", path); + + assertEquals("text/plain", fwb.mimeType()); + assertEquals("data.txt", fwb.name()); + assertEquals(base64(content), fwb.bytes()); + } + + @Test + void testPathConstructor_usesFileNameFromPath() throws IOException, URISyntaxException { + Path path = svgPath(); + + FileWithBytes fwb = new FileWithBytes(SVG_MIME_TYPE, path); + + assertEquals(SVG_MIME_TYPE, fwb.mimeType()); + assertEquals("a2a-logo-white.svg", fwb.name()); + assertEquals(base64(Files.readAllBytes(path)), fwb.bytes()); + } + + @Test + void testPathConstructor_emptyFile() throws IOException { + Path path = writeTempFile("empty.txt", new byte[0]); + + FileWithBytes fwb = new FileWithBytes("text/plain", path); + + assertEquals("text/plain", fwb.mimeType()); + assertEquals("empty.txt", fwb.name()); + assertEquals("", fwb.bytes()); + } + + // ========== byte[] constructor ========== + + @Test + void testByteArrayConstructor_encodesContentAsBase64() throws IOException { + byte[] content = "binary data".getBytes(); + + FileWithBytes fwb = new FileWithBytes("application/octet-stream", "data.bin", content); + + assertEquals("application/octet-stream", fwb.mimeType()); + assertEquals("data.bin", fwb.name()); + assertEquals(base64(content), fwb.bytes()); + } + + @Test + void testByteArrayConstructor_emptyArray() throws IOException { + FileWithBytes fwb = new FileWithBytes("text/plain", "empty.txt", new byte[0]); + + assertEquals("text/plain", fwb.mimeType()); + assertEquals("empty.txt", fwb.name()); + assertEquals("", fwb.bytes()); + } + + @Test + void testByteArrayConstructor_binaryContent() throws IOException { + byte[] content = new byte[]{0, 1, 2, (byte) 0xFF, (byte) 0xFE}; + + FileWithBytes fwb = new FileWithBytes("application/octet-stream", "bin.dat", content); + + byte[] decoded = Base64.getDecoder().decode(fwb.bytes()); + assertArrayEquals(content, decoded); + } + + // ========== Consistency across constructors ========== + + @Test + void testFileAndPathConstructorsProduceSameResult() throws IOException { + Path path = writeTempFile("consistent.txt", "consistent content".getBytes()); + + FileWithBytes fromFile = new FileWithBytes("text/plain", path.toFile()); + FileWithBytes fromPath = new FileWithBytes("text/plain", path); + + assertEquals(fromFile.mimeType(), fromPath.mimeType()); + assertEquals(fromFile.name(), fromPath.name()); + assertEquals(fromFile.bytes(), fromPath.bytes()); + } + + @Test + void testByteArrayConstructorMatchesCanonicalConstructor() throws IOException { + byte[] content = "test".getBytes(); + + FileWithBytes fromCanonical = new FileWithBytes("text/plain", "test.txt", base64(content)); + FileWithBytes fromByteArray = new FileWithBytes("text/plain", "test.txt", content); + + assertEquals(fromCanonical.mimeType(), fromByteArray.mimeType()); + assertEquals(fromCanonical.name(), fromByteArray.name()); + assertEquals(fromCanonical.bytes(), fromByteArray.bytes()); + } + + @Test + void testFileConstructorMatchesCanonicalConstructor() throws IOException { + byte[] content = "file content".getBytes(); + Path path = writeTempFile("match.txt", content); + + FileWithBytes fromCanonical = new FileWithBytes("text/plain", "match.txt", base64(content)); + FileWithBytes fromFile = new FileWithBytes("text/plain", path.toFile()); + + assertEquals(fromCanonical.mimeType(), fromFile.mimeType()); + assertEquals(fromCanonical.name(), fromFile.name()); + assertEquals(fromCanonical.bytes(), fromFile.bytes()); + } + + // ========== File validation tests ========== + + @Test + void testPathConstructor_rejectsNonExistentFile() { + Path nonExistent = tempDir.resolve("does-not-exist.txt"); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> new FileWithBytes("text/plain", nonExistent)); + + assertTrue(exception.getMessage().contains("does not exist")); + } + + @Test + void testPathConstructor_rejectsDirectory() throws IOException { + Path directory = tempDir.resolve("subdir"); + Files.createDirectory(directory); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> new FileWithBytes("text/plain", directory)); + + assertTrue(exception.getMessage().contains("Not a regular file")); + } + + @Test + void testPathConstructor_rejectsTooLargeFile() throws IOException { + // Create a file larger than 10MB + Path largeFile = tempDir.resolve("large.bin"); + byte[] chunk = new byte[1024 * 1024]; // 1MB + try (var out = Files.newOutputStream(largeFile)) { + for (int i = 0; i < 11; i++) { // Write 11MB + out.write(chunk); + } + } + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> new FileWithBytes("application/octet-stream", largeFile)); + + assertTrue(exception.getMessage().contains("too large")); + assertTrue(exception.getMessage().contains("maximum")); + } + + // ========== Identity-based equality tests ========== + + @Test + void testEquals_usesIdentityComparison() throws IOException { + byte[] content = "test content".getBytes(); + Path path = writeTempFile("test.txt", content); + + FileWithBytes fwb1 = new FileWithBytes("text/plain", path); + FileWithBytes fwb2 = new FileWithBytes("text/plain", path); + + // Same object should equal itself + assertEquals(fwb1, fwb1); + + // Different objects with same content should NOT be equal (identity-based) + assertNotEquals(fwb1, fwb2); + } + + @Test + void testHashCode_usesIdentityHashCode() throws IOException { + byte[] content = "test content".getBytes(); + Path path = writeTempFile("test.txt", content); + + FileWithBytes fwb1 = new FileWithBytes("text/plain", path); + FileWithBytes fwb2 = new FileWithBytes("text/plain", path); + + // Hash codes should be different for different objects (identity-based) + assertNotEquals(fwb1.hashCode(), fwb2.hashCode()); + + // Hash code should be consistent for same object + assertEquals(fwb1.hashCode(), fwb1.hashCode()); + } +} diff --git a/spec/src/test/resources/a2a-logo-white.svg b/spec/src/test/resources/a2a-logo-white.svg new file mode 100644 index 000000000..0d1a0a67a --- /dev/null +++ b/spec/src/test/resources/a2a-logo-white.svg @@ -0,0 +1,9 @@ + + + + + + + + +