diff --git a/ext/src/main/java/zeroecho/ext/integrations/covert/jpeg/JpegExifEmbedder.java b/ext/src/main/java/zeroecho/ext/integrations/covert/jpeg/JpegExifEmbedder.java
index a593644..b5e48ae 100644
--- a/ext/src/main/java/zeroecho/ext/integrations/covert/jpeg/JpegExifEmbedder.java
+++ b/ext/src/main/java/zeroecho/ext/integrations/covert/jpeg/JpegExifEmbedder.java
@@ -33,6 +33,8 @@
******************************************************************************/
package zeroecho.ext.integrations.covert.jpeg;
+import java.io.ByteArrayInputStream;
+import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
@@ -40,10 +42,9 @@ import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Base64;
import java.util.List;
-import java.util.logging.Level;
-import java.util.logging.Logger;
import org.apache.commons.imaging.Imaging;
import org.apache.commons.imaging.ImagingException;
@@ -57,7 +58,6 @@ import org.apache.commons.imaging.formats.tiff.write.TiffOutputField;
import org.apache.commons.imaging.formats.tiff.write.TiffOutputSet;
import zeroecho.core.io.Util;
-import zeroecho.sdk.util.Pack7LStreamWriter;
/**
* Utility class for embedding and extracting binary payloads across multiple
@@ -72,19 +72,43 @@ import zeroecho.sdk.util.Pack7LStreamWriter;
* The original JPEG EXIF metadata is preserved, except for any overwritten
* fields defined via slot configuration.
*
+ *
+ *
+ * This is an incubating integration, not a general-purpose parser for hostile
+ * JPEG input. Structural JPEG and TIFF/EXIF parsing is delegated to Apache
+ * Commons Imaging. This class independently validates its covert Pack7 framing,
+ * configured slot order and capacity, duplicate slots, payload completeness, and
+ * the one-MiB covert-payload limit. Successful parsing by the underlying library
+ * does not guarantee rejection of every malformed JPEG marker or APP1 boundary.
+ * Callers accepting fully untrusted images should perform separate structural
+ * validation or process them in an appropriate sandbox.
+ *
+ *
+ *
+ * No partial covert payload is written when the declared ZeroEcho payload is
+ * incomplete. Covert-payload memory is bounded by configured slot capacity and
+ * one MiB, subject to the independent resource behavior of Apache Commons
+ * Imaging while parsing the carrier. The existing EXIF-slot and Pack7 on-wire
+ * representation is unchanged.
+ *
*/
public final class JpegExifEmbedder {
- private static final Logger LOG = Logger.getLogger(JpegExifEmbedder.class.getName());
+ /* package */ static final int MAXIMUM_PAYLOAD_BYTES = 1024 * 1024;
+ private static final int MAXIMUM_PACKED_PREFIX_BYTES = 10;
private final List slots = new ArrayList<>();
/**
* Configures the list of EXIF slots to be used for payload embedding and
* extraction. The order determines the splitting/joining order of the payload.
+ * Slots must be unique and have positive capacities.
*
- * @param slots List of slot configurations
+ * @param slots nonempty list of positive-capacity slot configurations
+ * @throws IllegalArgumentException if the list is empty or contains a
+ * {@code null} or nonpositive-capacity slot
*/
public void setSlots(List slots) {
+ requireValidSlots(slots);
this.slots.clear();
this.slots.addAll(slots);
}
@@ -100,6 +124,12 @@ public final class JpegExifEmbedder {
* stream using a lossless EXIF update.
*
*
+ *
+ * Apache Commons Imaging owns structural JPEG and TIFF/EXIF parsing. This
+ * method limits the covert payload to one MiB and configured slot capacity,
+ * but it is not an independent validator for arbitrary outer JPEG corruption.
+ *
+ *
* @param jpegPath the path to the input JPEG file; must not be {@code null}
* @param payloadInput the input stream containing the binary payload to embed;
* it must be prefixed using 7-bit length encoding (e.g.
@@ -107,28 +137,38 @@ public final class JpegExifEmbedder {
* @param jpegOutput the output stream where the modified JPEG will be written
* @return the total number of bytes read from {@code payloadInput}, including
* the prefix
- * @throws IOException if an I/O error occurs while reading the payload,
- * processing EXIF metadata, or writing the output JPEG
+ * @throws IOException if an I/O error occurs, the payload exceeds
+ * one MiB or configured slot capacity, or EXIF
+ * processing fails
+ * @throws IllegalStateException if no slots have been configured
*/
public int embed(Path jpegPath, InputStream payloadInput, OutputStream jpegOutput) throws IOException {
+ requireConfiguredSlots();
+ int maximumPayloadBytes = maximumPayloadBytesForSlots();
+ byte[] allBytes = Util.readWithPackedLengthPrefix(payloadInput, maximumPayloadBytes);
+ long totalCapacity = totalRawCapacity();
+ if (allBytes.length > totalCapacity) {
+ throw new IOException("JPEG EXIF payload exceeds configured slot capacity.");
+ }
+
try {
ImageMetadata metadata = Imaging.getMetadata(jpegPath.toFile());
TiffImageMetadata exif = (metadata instanceof JpegImageMetadata jmeta) ? jmeta.getExif() : null;
TiffOutputSet outputSet = (exif != null) ? exif.getOutputSet() : new TiffOutputSet(ByteOrder.BIG_ENDIAN);
- byte[] allBytes = Util.readWithPackedLengthPrefix(payloadInput, 1024 * 1024);
int offset = 0;
for (Slot slot : slots) {
+ if (offset >= allBytes.length) {
+ break;
+ }
boolean useBase64 = slot.tagInfo.isText();
- int chunkLimit = slot.defaultCapacity;
+ int chunkLimit = rawCapacity(slot);
byte[] encoded;
if (useBase64) {
- // base64 expands ~33%, so reduce raw chunk size
- int safeRawSize = chunkLimit * 3 / 4;
- int size = Math.min(safeRawSize, allBytes.length - offset);
+ int size = Math.min(chunkLimit, allBytes.length - offset);
byte[] raw = new byte[size]; // NOPMD
System.arraycopy(allBytes, offset, raw, 0, size);
offset += size;
@@ -160,11 +200,11 @@ public final class JpegExifEmbedder {
directory.removeField(slot.tagInfo); // Remove only if collides
directory.add(field);
- if (offset >= allBytes.length) {
- break;
- }
}
+ if (offset != allBytes.length) {
+ throw new IOException("JPEG EXIF payload exceeds configured slot capacity.");
+ }
new ExifRewriter().updateExifMetadataLossless(jpegPath.toFile(), jpegOutput, outputSet);
return allBytes.length;
@@ -177,51 +217,218 @@ public final class JpegExifEmbedder {
* Extracts a binary payload that was previously embedded into the JPEG via EXIF
* slots.
*
+ *
+ * Apache Commons Imaging parses the carrier structure. ZeroEcho validates the
+ * selected EXIF fields, Pack7 length, configured capacity, and complete
+ * availability of the declared covert payload before writing caller output.
+ * Outer marker and APP1 corruption accepted by the underlying parser is outside
+ * this method's independent guarantees.
+ *
+ *
* @param jpegPath input JPEG path
* @param payloadOutput stream to write the reconstructed binary payload
- * @throws IOException if the extraction fails
+ * @throws EOFException if the packed length prefix or declared
+ * payload is incomplete
+ * @throws IOException if supported carrier parsing fails or the
+ * payload length or configured slot data is
+ * malformed or exceeds one MiB
+ * @throws IllegalStateException if no slots have been configured
*/
public void extract(Path jpegPath, OutputStream payloadOutput) throws IOException {
+ requireConfiguredSlots();
try {
ImageMetadata metadata = Imaging.getMetadata(jpegPath.toFile());
TiffImageMetadata exif = (metadata instanceof JpegImageMetadata jmeta) ? jmeta.getExif() : null;
if (exif == null) {
- LOG.warning("EXIF metadata not found in image.");
- return;
+ throw new EOFException("JPEG EXIF covert payload is missing.");
}
- Pack7LStreamWriter output = new Pack7LStreamWriter(payloadOutput);
+ PackedPayloadCollector collector = new PackedPayloadCollector();
for (Slot slot : slots) {
TiffField field = exif.findField(slot.tagInfo);
if (field == null) {
continue;
}
+ requireBoundedField(field, slot);
Object value = slot.tagInfo.getValue(field);
- byte[] chunk;
-
- if (value instanceof byte[] binary) {
- chunk = slot.tagInfo.isText() ? Base64.getDecoder().decode(binary) : binary;
-
- } else if (value instanceof String str) {
- chunk = slot.tagInfo.isText() ? Base64.getDecoder().decode(str.getBytes(StandardCharsets.US_ASCII))
- : str.getBytes(StandardCharsets.UTF_8);
-
- } else {
- LOG.log(Level.WARNING, "Unsupported EXIF field value type for tag: {0}", slot.tagInfo.name);
- continue;
+ byte[] chunk = decodeChunk(value, slot);
+ if (chunk.length > rawCapacity(slot)) {
+ throw new IOException("JPEG EXIF covert chunk exceeds slot capacity.");
}
-
- if (chunk.length > output.write(chunk)) {
- // all data has been read
+ collector.accept(chunk);
+ if (collector.isComplete()) {
break;
}
}
-
+ byte[] payload = collector.finish();
+ payloadOutput.write(payload);
} catch (ImagingException e) {
throw new IOException("Failed to read EXIF from JPEG", e);
}
}
+
+ private void requireConfiguredSlots() {
+ if (slots.isEmpty()) {
+ throw new IllegalStateException("JPEG EXIF slots are not configured.");
+ }
+ }
+
+ private static void requireValidSlots(List configuredSlots) {
+ if (configuredSlots == null || configuredSlots.isEmpty()) {
+ throw new IllegalArgumentException("JPEG EXIF slots must not be empty.");
+ }
+ for (int index = 0; index < configuredSlots.size(); index++) {
+ Slot slot = configuredSlots.get(index);
+ if (slot == null || slot.defaultCapacity <= 0) {
+ throw new IllegalArgumentException("JPEG EXIF slot capacity must be positive.");
+ }
+ if (configuredSlots.subList(0, index).contains(slot)) {
+ throw new IllegalArgumentException("JPEG EXIF slots must be unique.");
+ }
+ }
+ }
+
+ private int maximumPayloadBytesForSlots() throws IOException {
+ long capacity = totalRawCapacity();
+ int candidate = (int) Math.min(MAXIMUM_PAYLOAD_BYTES, capacity);
+ while (candidate > 0 && candidate + packedLengthBytes(candidate) > capacity) {
+ candidate--;
+ }
+ if (packedLengthBytes(candidate) > capacity) {
+ throw new IOException("JPEG EXIF slot capacity cannot contain a payload prefix.");
+ }
+ return candidate;
+ }
+
+ private long totalRawCapacity() throws IOException {
+ long capacity = 0;
+ try {
+ for (Slot slot : slots) {
+ capacity = Math.addExact(capacity, rawCapacity(slot));
+ }
+ return capacity;
+ } catch (ArithmeticException exception) {
+ throw new IOException("JPEG EXIF slot capacity is invalid.", exception);
+ }
+ }
+
+ private static int rawCapacity(Slot slot) {
+ return slot.tagInfo.isText() ? slot.defaultCapacity / 4 * 3 : slot.defaultCapacity;
+ }
+
+ private static int packedLengthBytes(long value) {
+ int bytes = 1;
+ long remaining = value;
+ while ((remaining & ~0x7fL) != 0) {
+ remaining >>>= 7;
+ bytes++;
+ }
+ return bytes;
+ }
+
+ private static void requireBoundedField(TiffField field, Slot slot) throws IOException {
+ final long encodedLength;
+ try {
+ encodedLength = Math.multiplyExact(field.getCount(), field.getFieldType().getSize());
+ } catch (ArithmeticException exception) {
+ throw new IOException("JPEG EXIF covert field length is invalid.", exception);
+ }
+ long maximumStoredBytes = slot.tagInfo.isText()
+ ? Math.min((long) slot.defaultCapacity + 1, encodedLimitForPayload())
+ : Math.min((long) slot.defaultCapacity, MAXIMUM_PAYLOAD_BYTES + MAXIMUM_PACKED_PREFIX_BYTES);
+ if (encodedLength < 0 || encodedLength > maximumStoredBytes) {
+ throw new IOException("JPEG EXIF covert field exceeds slot capacity.");
+ }
+ }
+
+ private static long encodedLimitForPayload() {
+ long rawBytes = MAXIMUM_PAYLOAD_BYTES + MAXIMUM_PACKED_PREFIX_BYTES;
+ return (rawBytes + 2) / 3 * 4 + 1;
+ }
+
+ private static byte[] decodeChunk(Object value, Slot slot) throws IOException {
+ byte[] chunk = null;
+ try {
+ if (value instanceof byte[] binary) {
+ chunk = slot.tagInfo.isText() ? Base64.getDecoder().decode(binary) : binary;
+ } else if (value instanceof String string) {
+ chunk = slot.tagInfo.isText()
+ ? Base64.getDecoder().decode(string.getBytes(StandardCharsets.US_ASCII))
+ : string.getBytes(StandardCharsets.UTF_8);
+ }
+ } catch (IllegalArgumentException exception) {
+ chunk = null;
+ }
+ if (chunk == null) {
+ throw new IOException("JPEG EXIF covert chunk is malformed.");
+ }
+ return chunk;
+ }
+
+ /* package */ static byte[] decodePayloadChunks(List chunks) throws IOException {
+ PackedPayloadCollector collector = new PackedPayloadCollector();
+ for (byte[] chunk : chunks) {
+ collector.accept(chunk);
+ if (collector.isComplete()) {
+ break;
+ }
+ }
+ return collector.finish();
+ }
+
+ /**
+ * Accumulates one bounded Pack7-framed payload across configured EXIF slots.
+ */
+ private static final class PackedPayloadCollector {
+ private final byte[] prefix = new byte[MAXIMUM_PACKED_PREFIX_BYTES];
+ private int prefixLength;
+ private byte[] payload;
+ private int payloadLength;
+
+ /* package */ void accept(byte[] chunk) throws IOException {
+ int offset = 0;
+ boolean prefixComplete = false;
+ while (payload == null && offset < chunk.length) {
+ if (prefixLength == prefix.length) {
+ throw new IOException("JPEG EXIF packed payload length is malformed.");
+ }
+ byte current = chunk[offset++];
+ prefix[prefixLength++] = current;
+ if ((current & 0x80) != 0) {
+ prefixComplete = true;
+ break;
+ }
+ }
+ if (prefixComplete) {
+ long declaredLength = Util.readPack7L(new ByteArrayInputStream(prefix, 0, prefixLength));
+ if (declaredLength < 0 || declaredLength > MAXIMUM_PAYLOAD_BYTES) {
+ throw new IOException("JPEG EXIF payload length exceeds maximum.");
+ }
+ payload = new byte[(int) declaredLength];
+ }
+ if (payload != null && offset < chunk.length && payloadLength < payload.length) {
+ int remaining = payload.length - payloadLength;
+ int copied = Math.min(remaining, chunk.length - offset);
+ System.arraycopy(chunk, offset, payload, payloadLength, copied);
+ payloadLength += copied;
+ }
+ }
+
+ /* package */ boolean isComplete() {
+ return payload != null && payloadLength == payload.length;
+ }
+
+ /* package */ byte[] finish() throws EOFException {
+ if (payload == null) {
+ throw new EOFException("JPEG EXIF payload length prefix is truncated.");
+ }
+ if (payloadLength != payload.length) {
+ throw new EOFException("JPEG EXIF payload is truncated.");
+ }
+ return Arrays.copyOf(payload, payload.length);
+ }
+ }
}
diff --git a/ext/src/main/java/zeroecho/ext/integrations/stegano/LSBSteganographyMethod.java b/ext/src/main/java/zeroecho/ext/integrations/stegano/LSBSteganographyMethod.java
index 8b1a114..d35facf 100644
--- a/ext/src/main/java/zeroecho/ext/integrations/stegano/LSBSteganographyMethod.java
+++ b/ext/src/main/java/zeroecho/ext/integrations/stegano/LSBSteganographyMethod.java
@@ -36,10 +36,9 @@ package zeroecho.ext.integrations.stegano;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
-import java.io.DataOutputStream;
+import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
-import java.nio.ByteBuffer;
import java.util.Locale;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -99,6 +98,9 @@ import javax.imageio.ImageIO;
* }
*/
public class LSBSteganographyMethod implements SteganographyMethod {
+ private static final int LENGTH_PREFIX_BYTES = Integer.BYTES;
+ private static final int LENGTH_PREFIX_BITS = LENGTH_PREFIX_BYTES * Byte.SIZE;
+ private static final int CARRIER_BITS_PER_PAYLOAD_BYTE = Byte.SIZE;
private static final Logger LOG = Logger.getLogger(LSBSteganographyMethod.class.getName());
/**
@@ -118,9 +120,9 @@ public class LSBSteganographyMethod implements SteganographyMethod {
* @param message the message stream to embed
* @return an {@code InputStream} containing the stego image encoded in
* {@code outputFormat}
- * @throws IOException if an I/O error occurs while reading the
- * image or message, or while writing the
- * output image
+ * @throws IOException if an I/O error occurs, the carrier is too
+ * small, or the message exceeds the carrier's
+ * bounded capacity
* @throws IllegalArgumentException if {@code outputFormat} is unsupported for
* this method (for example, {@code JPEG})
*/
@@ -135,15 +137,14 @@ public class LSBSteganographyMethod implements SteganographyMethod {
throw new IOException("Failed to read input image.");
}
- byte[] messageBytes = message.readAllBytes();
- ByteArrayOutputStream fullData = new ByteArrayOutputStream();
- DataOutputStream dos = new DataOutputStream(fullData);
- dos.writeInt(messageBytes.length);
- dos.write(messageBytes);
- byte[] fullMessage = fullData.toByteArray();
-
final int width = img.getWidth();
final int height = img.getHeight();
+ final int maximumPayloadBytes = maximumPayloadBytes(width, height);
+ byte[] messageBytes = message.readNBytes(maximumPayloadBytes + 1);
+ if (messageBytes.length > maximumPayloadBytes) {
+ throw new IOException("LSB payload exceeds carrier capacity.");
+ }
+ final long requiredBits = requiredCarrierBits(messageBytes.length);
int msgBitIndex = 0;
if (LOG.isLoggable(Level.INFO)) {
@@ -152,13 +153,13 @@ public class LSBSteganographyMethod implements SteganographyMethod {
outer: for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
- if (msgBitIndex >= fullMessage.length * 8) {
+ if (msgBitIndex >= requiredBits) {
break outer;
}
int rgb = img.getRGB(x, y);
int gray = rgb & 0xFF;
- int bit = (fullMessage[msgBitIndex / 8] >> (7 - (msgBitIndex % 8))) & 1;
+ int bit = embeddedBit(messageBytes, msgBitIndex);
gray = (gray & 0xFE) | bit;
int newRgb = (gray << 16) | (gray << 8) | gray;
img.setRGB(x, y, newRgb);
@@ -185,8 +186,10 @@ public class LSBSteganographyMethod implements SteganographyMethod {
*
* @param inputImage the stego image stream that contains an embedded message
* @return an {@code InputStream} of the extracted message bytes
- * @throws IOException if an I/O error occurs while reading the image or if the
- * image cannot be decoded
+ * @throws EOFException if the carrier cannot contain the complete prefix or
+ * declared payload
+ * @throws IOException if the image cannot be decoded or the embedded length
+ * is negative or outside the carrier's bounded capacity
*/
@Override
public InputStream extract(InputStream inputImage) throws IOException {
@@ -197,42 +200,38 @@ public class LSBSteganographyMethod implements SteganographyMethod {
final int width = img.getWidth();
final int height = img.getHeight();
- ByteArrayOutputStream messageBytes = new ByteArrayOutputStream();
+ final long carrierBits = carrierBits(width, height);
+ if (carrierBits < LENGTH_PREFIX_BITS) {
+ throw new EOFException("LSB carrier length prefix is truncated.");
+ }
if (LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO, "extract from picture w={0} x h={1}", new Object[] { width, height });
}
- int byteVal = 0;
- int messageLength = -1;
- int bitsCollected = 0;
-
- outer: for (int y = 0; y < height; y++) {
- for (int x = 0; x < width; x++) {
- int gray = img.getRGB(x, y) & 0xFF;
- int bit = gray & 1;
- byteVal = (byteVal << 1) | bit;
- bitsCollected++;
-
- if (bitsCollected == 8) { // NOPMD
- messageBytes.write(byteVal);
- byteVal = 0;
- bitsCollected = 0;
-
- if (messageLength == -1 && messageBytes.size() == 4) {
- byte[] lenBytes = messageBytes.toByteArray();
- messageBytes.reset();
- messageLength = ByteBuffer.wrap(lenBytes).getInt();
- }
-
- if (messageLength != -1 && messageBytes.size() == messageLength) {
- break outer;
- }
- }
- }
+ int messageLength = 0;
+ for (int bitIndex = 0; bitIndex < LENGTH_PREFIX_BITS; bitIndex++) {
+ messageLength = (messageLength << 1) | carrierBit(img, width, bitIndex);
+ }
+ if (messageLength < 0) {
+ throw new IOException("LSB payload length is invalid.");
+ }
+ long requiredBits = requiredCarrierBits(messageLength);
+ if (requiredBits > carrierBits) {
+ throw new EOFException("LSB carrier payload is truncated.");
}
- return new ByteArrayInputStream(messageBytes.toByteArray());
+ byte[] messageBytes = new byte[messageLength];
+ long carrierBitIndex = LENGTH_PREFIX_BITS;
+ for (int byteIndex = 0; byteIndex < messageLength; byteIndex++) {
+ int value = 0;
+ for (int bitIndex = 0; bitIndex < Byte.SIZE; bitIndex++) {
+ value = (value << 1) | carrierBit(img, width, carrierBitIndex++);
+ }
+ messageBytes[byteIndex] = (byte) value;
+ }
+
+ return new ByteArrayInputStream(messageBytes);
}
/**
@@ -250,4 +249,47 @@ public class LSBSteganographyMethod implements SteganographyMethod {
return new StegoMetadata("LSB", "Least Significant Bit Spatial Domain Image Steganography",
"Embeds message bits into the least significant bits of grayscale pixel values.");
}
+
+ private static int maximumPayloadBytes(int width, int height) throws IOException {
+ long availableBits = carrierBits(width, height) - LENGTH_PREFIX_BITS;
+ if (availableBits < 0) {
+ throw new IOException("LSB carrier is too small.");
+ }
+ long maximumPayloadBytes = availableBits / CARRIER_BITS_PER_PAYLOAD_BYTE;
+ if (maximumPayloadBytes >= Integer.MAX_VALUE) {
+ throw new IOException("LSB carrier capacity exceeds supported range.");
+ }
+ return (int) maximumPayloadBytes;
+ }
+
+ private static long carrierBits(int width, int height) throws IOException {
+ try {
+ return Math.multiplyExact((long) width, (long) height);
+ } catch (ArithmeticException exception) {
+ throw new IOException("LSB carrier capacity is invalid.", exception);
+ }
+ }
+
+ private static long requiredCarrierBits(int payloadBytes) throws IOException {
+ try {
+ return Math.addExact(LENGTH_PREFIX_BITS,
+ Math.multiplyExact((long) payloadBytes, CARRIER_BITS_PER_PAYLOAD_BYTE));
+ } catch (ArithmeticException exception) {
+ throw new IOException("LSB payload capacity is invalid.", exception);
+ }
+ }
+
+ private static int embeddedBit(byte[] message, int bitIndex) {
+ if (bitIndex < LENGTH_PREFIX_BITS) {
+ return (message.length >>> (LENGTH_PREFIX_BITS - 1 - bitIndex)) & 1;
+ }
+ int payloadBit = bitIndex - LENGTH_PREFIX_BITS;
+ return (message[payloadBit / Byte.SIZE] >>> (Byte.SIZE - 1 - payloadBit % Byte.SIZE)) & 1;
+ }
+
+ private static int carrierBit(BufferedImage image, int width, long bitIndex) {
+ int x = (int) (bitIndex % width);
+ int y = (int) (bitIndex / width);
+ return image.getRGB(x, y) & 1;
+ }
}
diff --git a/ext/src/test/java/zeroecho/ext/integrations/covert/jpeg/JpegExifEmbedderLengthTest.java b/ext/src/test/java/zeroecho/ext/integrations/covert/jpeg/JpegExifEmbedderLengthTest.java
new file mode 100644
index 0000000..1b77bab
--- /dev/null
+++ b/ext/src/test/java/zeroecho/ext/integrations/covert/jpeg/JpegExifEmbedderLengthTest.java
@@ -0,0 +1,312 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.ext.integrations.covert.jpeg;
+
+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.assertThrows;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.EOFException;
+import java.io.IOException;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import zeroecho.core.io.Util;
+
+class JpegExifEmbedderLengthTest {
+
+ @TempDir
+ Path temporaryDirectory;
+
+ @Test
+ void validPackedPayloadBoundariesDecodeExactly() throws Exception {
+ System.out.println("validPackedPayloadBoundariesDecodeExactly");
+ int[] lengths = { 0, 1, 127, 128, 16_384, JpegExifEmbedder.MAXIMUM_PAYLOAD_BYTES };
+
+ for (int length : lengths) {
+ byte[] payload = payload(length);
+ byte[] framed = frame(payload);
+ List chunks = split(framed, 1, Math.min(97, framed.length));
+ assertArrayEquals(payload, JpegExifEmbedder.decodePayloadChunks(chunks));
+ }
+
+ System.out.println("...packed boundary lengths: " + lengths.length);
+ System.out.println("...ok");
+ }
+
+ @Test
+ void malformedPackedLengthsAndTruncationFailClosed() throws Exception {
+ System.out.println("malformedPackedLengthsAndTruncationFailClosed");
+
+ for (int prefixBytes = 0; prefixBytes < 10; prefixBytes++) {
+ byte[] incompletePrefix = new byte[prefixBytes];
+ EOFException incomplete = assertThrows(EOFException.class,
+ () -> JpegExifEmbedder.decodePayloadChunks(List.of(incompletePrefix)));
+ assertFalse(incomplete.getMessage().contains("PAYLOAD_SENTINEL"));
+ }
+ assertThrows(IOException.class, () -> JpegExifEmbedder.decodePayloadChunks(List.of(new byte[10])));
+
+ byte[] excessive = packedLength(JpegExifEmbedder.MAXIMUM_PAYLOAD_BYTES + 1L);
+ IOException overLimit = assertThrows(IOException.class,
+ () -> JpegExifEmbedder.decodePayloadChunks(List.of(excessive)));
+ assertEquals("JPEG EXIF payload length exceeds maximum.", overLimit.getMessage());
+
+ byte[] negative = packedLength(-1L);
+ assertThrows(IOException.class, () -> JpegExifEmbedder.decodePayloadChunks(List.of(negative)));
+
+ byte[] illegalHighTerminal = new byte[10];
+ illegalHighTerminal[0] = 0x02;
+ illegalHighTerminal[9] = (byte) 0x80;
+ assertThrows(IOException.class,
+ () -> JpegExifEmbedder.decodePayloadChunks(List.of(illegalHighTerminal)));
+
+ byte[] framed = frame(payload(12));
+ int prefixLength = packedLength(12).length;
+ int[] retainedPayloadBytes = { 0, 6, 11 };
+ for (int retained : retainedPayloadBytes) {
+ byte[] truncated = Arrays.copyOf(framed, prefixLength + retained);
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ EOFException incomplete = assertThrows(EOFException.class,
+ () -> output.write(JpegExifEmbedder.decodePayloadChunks(List.of(truncated))));
+ assertEquals(0, output.size());
+ assertEquals("JPEG EXIF payload is truncated.", incomplete.getMessage());
+ }
+
+ byte[] surplus = Arrays.copyOf(frame(payload(3)), frame(payload(3)).length + 4);
+ assertArrayEquals(payload(3), JpegExifEmbedder.decodePayloadChunks(List.of(surplus)));
+ byte[] firstResult = JpegExifEmbedder.decodePayloadChunks(List.of(frame(payload(3))));
+ firstResult[0] ^= 0x7f;
+ assertArrayEquals(payload(3), JpegExifEmbedder.decodePayloadChunks(List.of(frame(payload(3)))));
+ System.out.println("...hostile packed cases: 18");
+ System.out.println("...ok");
+ }
+
+ @Test
+ void realJpegRoundTripPreservesPayload() throws Exception {
+ System.out.println("realJpegRoundTripPreservesPayload");
+ URL carrierResource = Objects.requireNonNull(
+ getClass().getClassLoader().getResource("test.jpg"));
+ Path carrier = Path.of(carrierResource.toURI());
+ Path encoded = temporaryDirectory.resolve("encoded.jpg");
+ byte[] payload = payload(512);
+
+ JpegExifEmbedder embedder = new JpegExifEmbedder();
+ embedder.setSlots(Slot.defaults());
+ try (ByteArrayOutputStream jpegOutput = new ByteArrayOutputStream()) {
+ int framedBytes = embedder.embed(carrier, new ByteArrayInputStream(payload), jpegOutput);
+ Files.write(encoded, jpegOutput.toByteArray());
+ assertEquals(frame(payload).length, framedBytes);
+ }
+
+ ByteArrayOutputStream extracted = new ByteArrayOutputStream();
+ embedder.extract(encoded, extracted);
+ assertArrayEquals(payload, extracted.toByteArray());
+ System.out.println("...round-trip payload bytes: " + payload.length);
+ System.out.println("...ok");
+ }
+
+ @Test
+ void embeddingRejectsOverCapacityBeforeOutput() throws Exception {
+ System.out.println("embeddingRejectsOverCapacityBeforeOutput");
+ JpegExifEmbedder embedder = new JpegExifEmbedder();
+ embedder.setSlots(Slot.defaults());
+ byte[] payload = payload(20_000);
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+
+ IOException failure = assertThrows(IOException.class,
+ () -> embedder.embed(temporaryDirectory.resolve("unused.jpg"), new ByteArrayInputStream(payload),
+ output));
+
+ assertEquals(0, output.size());
+ assertEquals("Input exceeds maximum allowed size: 10238", failure.getMessage());
+ System.out.println("...rejected payload bytes: " + payload.length);
+ System.out.println("...ok");
+ }
+
+ @Test
+ void allConstructionPathsRequireValidSlots() {
+ System.out.println("allConstructionPathsRequireValidSlots");
+ JpegExifEmbedder embedder = new JpegExifEmbedder();
+ Slot duplicate = customRawSlot(50_100, 64);
+
+ assertThrows(IllegalArgumentException.class, () -> embedder.setSlots(List.of()));
+ assertThrows(IllegalArgumentException.class, () -> embedder.setSlots(List.of(duplicate, duplicate)));
+ assertThrows(IllegalStateException.class,
+ () -> embedder.extract(temporaryDirectory.resolve("unused.jpg"), new ByteArrayOutputStream()));
+ System.out.println("...slot validation paths: 3");
+ System.out.println("...ok");
+ }
+
+ @Test
+ void parsedExifPayloadBoundariesFailWithoutOutput() throws Exception {
+ System.out.println("parsedExifPayloadBoundariesFailWithoutOutput");
+ Slot first = customRawSlot(50_110, 8);
+ Slot second = customRawSlot(50_111, 64);
+ byte[] expected = payload(24);
+ byte[] jpeg = embeddedJpeg(expected, List.of(first, second));
+
+ assertExtractionFails(jpeg, List.of(first));
+ assertArrayEquals(expected, extractPayload(jpeg, List.of(first, second)));
+ System.out.println("...validated EXIF slots: 2");
+ System.out.println("...ok");
+ }
+
+ @Test
+ void hostileExifSlotsFailClosedWithoutPartialOutput() throws Exception {
+ System.out.println("hostileExifSlotsFailClosedWithoutPartialOutput");
+ Slot raw = customRawSlot(50_120, 64);
+ Slot anotherRaw = customRawSlot(50_121, 64);
+ byte[] validRaw = embeddedJpeg(payload(20), List.of(raw));
+
+ assertExtractionFails(validRaw, List.of(anotherRaw));
+ assertExtractionFails(validRaw, List.of(customRawSlot(50_120, 8)));
+
+ Slot first = customRawSlot(50_122, 8);
+ Slot second = customRawSlot(50_123, 64);
+ byte[] splitPayload = embeddedJpeg(payload(24), List.of(first, second));
+ assertExtractionFails(splitPayload, List.of(first));
+ assertExtractionFails(splitPayload, List.of(second, first));
+
+ System.out.println("...bounded EXIF slot cases: 4");
+ System.out.println("...ok");
+ }
+
+ @Test
+ void configuredCapacityAndMaximumAreEnforcedBeforeCallerOutput() throws Exception {
+ System.out.println("configuredCapacityAndMaximumAreEnforcedBeforeCallerOutput");
+ byte[] excessive = packedLength(JpegExifEmbedder.MAXIMUM_PAYLOAD_BYTES + 1L);
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ assertThrows(IOException.class,
+ () -> output.write(JpegExifEmbedder.decodePayloadChunks(List.of(excessive))));
+ assertEquals(0, output.size());
+
+ Slot largeText = customTextSlot(50_130, Integer.MAX_VALUE);
+ JpegExifEmbedder embedder = new JpegExifEmbedder();
+ embedder.setSlots(List.of(largeText));
+ IOException missingCarrier = assertThrows(IOException.class,
+ () -> embedder.embed(temporaryDirectory.resolve("missing.jpg"),
+ new ByteArrayInputStream(payload(1)), new ByteArrayOutputStream()));
+ assertFalse(missingCarrier.getMessage().contains("PAYLOAD_SENTINEL"));
+ System.out.println("...maximum payload bytes: " + JpegExifEmbedder.MAXIMUM_PAYLOAD_BYTES);
+ System.out.println("...ok");
+ }
+
+ private static byte[] payload(int length) {
+ byte[] payload = new byte[length];
+ for (int index = 0; index < payload.length; index++) {
+ payload[index] = (byte) index;
+ }
+ return payload;
+ }
+
+ private static byte[] frame(byte[] payload) throws IOException {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ Util.writePack7L(output, payload.length);
+ output.write(payload);
+ return output.toByteArray();
+ }
+
+ private static byte[] packedLength(long length) throws IOException {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ Util.writePack7L(output, length);
+ return output.toByteArray();
+ }
+
+ private static List split(byte[] value, int firstLength, int nextLength) {
+ if (value.length <= firstLength) {
+ return List.of(value);
+ }
+ byte[] first = Arrays.copyOfRange(value, 0, firstLength);
+ byte[] remaining = Arrays.copyOfRange(value, firstLength, value.length);
+ if (remaining.length <= nextLength) {
+ return List.of(first, remaining);
+ }
+ byte[] second = Arrays.copyOfRange(remaining, 0, nextLength);
+ byte[] third = Arrays.copyOfRange(remaining, nextLength, remaining.length);
+ return List.of(first, second, third);
+ }
+
+ private Path carrierPath() throws Exception {
+ URL carrierResource = Objects.requireNonNull(getClass().getClassLoader().getResource("test.jpg"));
+ return Path.of(carrierResource.toURI());
+ }
+
+ private byte[] embeddedJpeg(byte[] payload, List slots) throws Exception {
+ JpegExifEmbedder embedder = new JpegExifEmbedder();
+ embedder.setSlots(slots);
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ embedder.embed(carrierPath(), new ByteArrayInputStream(payload), output);
+ return output.toByteArray();
+ }
+
+ private void assertExtractionFails(byte[] jpeg, List slots) throws Exception {
+ Path path = temporaryDirectory.resolve("hostile-" + Math.abs(Arrays.hashCode(jpeg)) + ".jpg");
+ Files.write(path, jpeg);
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ JpegExifEmbedder embedder = new JpegExifEmbedder();
+ embedder.setSlots(slots);
+ IOException failure = assertThrows(IOException.class, () -> embedder.extract(path, output));
+ assertEquals(0, output.size());
+ assertFalse(failure.getMessage().contains("PAYLOAD_SENTINEL"));
+ }
+
+ private byte[] extractPayload(byte[] jpeg, List slots) throws Exception {
+ Path path = temporaryDirectory.resolve("valid-" + Math.abs(Arrays.hashCode(jpeg)) + ".jpg");
+ Files.write(path, jpeg);
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ JpegExifEmbedder embedder = new JpegExifEmbedder();
+ embedder.setSlots(slots);
+ embedder.extract(path, output);
+ return output.toByteArray();
+ }
+
+ private static Slot customRawSlot(int tag, int capacity) {
+ return Slot.parse("EXIF.raw/tag=" + tag + ",undefined,1,exif:" + capacity);
+ }
+
+ private static Slot customTextSlot(int tag, int capacity) {
+ return Slot.parse("EXIF.text/tag=" + tag + ",ascii,1,exif:" + capacity);
+ }
+
+}
diff --git a/ext/src/test/java/zeroecho/ext/integrations/stegano/LSBSteganographyMethodLengthTest.java b/ext/src/test/java/zeroecho/ext/integrations/stegano/LSBSteganographyMethodLengthTest.java
new file mode 100644
index 0000000..78612d3
--- /dev/null
+++ b/ext/src/test/java/zeroecho/ext/integrations/stegano/LSBSteganographyMethodLengthTest.java
@@ -0,0 +1,197 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.ext.integrations.stegano;
+
+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.assertThrows;
+
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+
+import javax.imageio.ImageIO;
+
+import org.junit.jupiter.api.Test;
+
+class LSBSteganographyMethodLengthTest {
+ private static final int PREFIX_BITS = Integer.SIZE;
+
+ @Test
+ void validBoundaryPayloadsRoundTrip() throws Exception {
+ System.out.println("validBoundaryPayloadsRoundTrip");
+ int[] lengths = { 0, 1, 127, 128, 256 };
+ LSBSteganographyMethod method = new LSBSteganographyMethod();
+
+ for (int length : lengths) {
+ byte[] payload = payload(length);
+ int carrierBits = PREFIX_BITS + length * Byte.SIZE;
+ InputStream encoded = method.embed(image(carrierBits + Byte.SIZE), ImageFormat.PNG,
+ new ByteArrayInputStream(payload));
+ byte[] decoded = method.extract(encoded).readAllBytes();
+ assertArrayEquals(payload, decoded);
+ }
+
+ System.out.println("...boundary lengths: " + lengths.length);
+ System.out.println("...ok");
+ }
+
+ @Test
+ void exactCapacityAndInsufficientCapacityFailClosed() throws Exception {
+ System.out.println("exactCapacityAndInsufficientCapacityFailClosed");
+ LSBSteganographyMethod method = new LSBSteganographyMethod();
+ byte[] payload = payload(16);
+ int exactBits = PREFIX_BITS + payload.length * Byte.SIZE;
+
+ InputStream encoded = method.embed(image(exactBits), ImageFormat.PNG, new ByteArrayInputStream(payload));
+ assertArrayEquals(payload, method.extract(encoded).readAllBytes());
+
+ IOException failure = assertThrows(IOException.class,
+ () -> method.embed(image(exactBits - 1), ImageFormat.PNG, new ByteArrayInputStream(payload)));
+ assertEquals("LSB payload exceeds carrier capacity.", failure.getMessage());
+ System.out.println("...exact carrier bits: " + exactBits);
+ System.out.println("...ok");
+ }
+
+ @Test
+ void malformedPrefixesAndTruncatedPayloadsFailClosed() throws Exception {
+ System.out.println("malformedPrefixesAndTruncatedPayloadsFailClosed");
+ LSBSteganographyMethod method = new LSBSteganographyMethod();
+
+ assertThrows(IOException.class, () -> method.extract(new ByteArrayInputStream(new byte[0])));
+ assertThrows(EOFException.class, () -> method.extract(imageWithDeclaredLength(31, 0)));
+
+ IOException negative = assertThrows(IOException.class,
+ () -> method.extract(imageWithDeclaredLength(PREFIX_BITS, -1)));
+ assertEquals("LSB payload length is invalid.", negative.getMessage());
+
+ EOFException truncated = assertThrows(EOFException.class,
+ () -> method.extract(imageWithDeclaredLength(PREFIX_BITS + Byte.SIZE, 2)));
+ assertEquals("LSB carrier payload is truncated.", truncated.getMessage());
+ assertFalse(truncated.getMessage().contains("PAYLOAD_SENTINEL"));
+ System.out.println("...hostile prefix cases: 4");
+ System.out.println("...ok");
+ }
+
+ @Test
+ void everyIncompletePrefixAndRepresentativePayloadTruncationFailsClosed() throws Exception {
+ System.out.println("everyIncompletePrefixAndRepresentativePayloadTruncationFailsClosed");
+ LSBSteganographyMethod method = new LSBSteganographyMethod();
+
+ IOException empty = assertThrows(IOException.class,
+ () -> method.extract(new ByteArrayInputStream(new byte[0])));
+ assertFalse(empty.getMessage().contains("PAYLOAD_SENTINEL"));
+ for (int availablePrefixBits = 1; availablePrefixBits < PREFIX_BITS; availablePrefixBits++) {
+ int testedPrefixBits = availablePrefixBits;
+ EOFException failure = assertThrows(EOFException.class,
+ () -> method.extract(imageWithDeclaredLength(testedPrefixBits, 0)));
+ assertEquals("LSB carrier length prefix is truncated.", failure.getMessage());
+ assertFalse(failure.getMessage().contains("PAYLOAD_SENTINEL"));
+ }
+
+ assertArrayEquals(new byte[0],
+ method.extract(imageWithDeclaredLength(PREFIX_BITS, 0)).readAllBytes());
+
+ int declaredPayloadBytes = 4;
+ int[] availablePayloadBits = { 0, 16, 31 };
+ for (int payloadBits : availablePayloadBits) {
+ EOFException failure = assertThrows(EOFException.class,
+ () -> method.extract(
+ imageWithDeclaredLength(PREFIX_BITS + payloadBits, declaredPayloadBytes)));
+ assertEquals("LSB carrier payload is truncated.", failure.getMessage());
+ assertFalse(failure.getMessage().contains("PAYLOAD_SENTINEL"));
+ }
+
+ System.out.println("...incomplete prefix positions: 32");
+ System.out.println("...payload truncation positions: " + availablePayloadBits.length);
+ System.out.println("...ok");
+ }
+
+ @Test
+ void overCapacityMessageReadIsDeterministicallyBounded() throws Exception {
+ System.out.println("overCapacityMessageReadIsDeterministicallyBounded");
+ LSBSteganographyMethod method = new LSBSteganographyMethod();
+ CountingInfiniteInputStream message = new CountingInfiniteInputStream();
+ int payloadCapacity = 8;
+
+ IOException failure = assertThrows(IOException.class,
+ () -> method.embed(image(PREFIX_BITS + payloadCapacity * Byte.SIZE), ImageFormat.PNG, message));
+
+ assertEquals("LSB payload exceeds carrier capacity.", failure.getMessage());
+ assertEquals(payloadCapacity + 1, message.readCount);
+ System.out.println("...bounded message reads: " + message.readCount);
+ System.out.println("...ok");
+ }
+
+ private static byte[] payload(int length) {
+ byte[] payload = new byte[length];
+ for (int index = 0; index < payload.length; index++) {
+ payload[index] = (byte) index;
+ }
+ return payload;
+ }
+
+ private static InputStream image(int pixels) throws IOException {
+ BufferedImage image = new BufferedImage(pixels, 1, BufferedImage.TYPE_INT_RGB);
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ ImageIO.write(image, "png", output);
+ return new ByteArrayInputStream(output.toByteArray());
+ }
+
+ private static InputStream imageWithDeclaredLength(int pixels, int declaredLength) throws IOException {
+ BufferedImage image = new BufferedImage(pixels, 1, BufferedImage.TYPE_INT_RGB);
+ int prefixBits = Math.min(pixels, PREFIX_BITS);
+ for (int bitIndex = 0; bitIndex < prefixBits; bitIndex++) {
+ int bit = (declaredLength >>> (PREFIX_BITS - 1 - bitIndex)) & 1;
+ image.setRGB(bitIndex, 0, bit);
+ }
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ ImageIO.write(image, "png", output);
+ return new ByteArrayInputStream(output.toByteArray());
+ }
+
+ private static final class CountingInfiniteInputStream extends InputStream {
+ private int readCount;
+
+ @Override
+ public int read() {
+ readCount++;
+ return 0x5a;
+ }
+ }
+}