diff --git a/app/src/main/java/com/emanuelef/remote_capture/HTTPReassembly.java b/app/src/main/java/com/emanuelef/remote_capture/HTTPReassembly.java index 5d979b22..328eb614 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/HTTPReassembly.java +++ b/app/src/main/java/com/emanuelef/remote_capture/HTTPReassembly.java @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with PCAPdroid. If not, see . * - * Copyright 2022 - Emanuele Faranda + * Copyright 2022-26 - Emanuele Faranda */ package com.emanuelef.remote_capture; @@ -163,6 +163,7 @@ public class HTTPReassembly { if ((first_space > 0) && (second_space > 0)) { mFirstChunk.httpMethod = line.substring(0, first_space).toUpperCase(); + mFirstChunk.httpVersion = line.substring(second_space + 1); String path = line.substring(first_space + 1, second_space); if (!path.startsWith("/")) { @@ -192,6 +193,8 @@ public class HTTPReassembly { } else if (line.startsWith("HTTP/")) { int first_space = line.indexOf(' '); if (first_space > 0) { + mFirstChunk.httpVersion = line.substring(0, first_space); + try { // NOTE: the response status may be missing when the response is reconstructed by the ushark HTTP2 reassembly int second_space = line.indexOf(' ', first_space + 1); @@ -212,6 +215,7 @@ public class HTTPReassembly { if(line.startsWith("content-encoding: ")) { String contentEncoding = line.substring(18); + log_d("Content-Encoding: " + contentEncoding); switch (contentEncoding) { @@ -356,7 +360,6 @@ public class HTTPReassembly { //log_d("mContentLength=" + mContentLength + ", mReassembleChunks=" + mReassembleChunks + ", mChunkedEncoding=" + mChunkedEncoding); - // Decode body if ((body != null) && (mContentEncoding != ContentEncoding.UNKNOWN)) decodeBody(body); @@ -382,6 +385,7 @@ public class HTTPReassembly { to_add.httpHost = mFirstChunk.httpHost; to_add.httpPath = mFirstChunk.httpPath; to_add.httpQuery = mFirstChunk.httpQuery; + to_add.httpVersion = mFirstChunk.httpVersion; to_add.httpBodyLength = mBodySize; if (httpRst) diff --git a/app/src/main/java/com/emanuelef/remote_capture/HarWriter.java b/app/src/main/java/com/emanuelef/remote_capture/HarWriter.java new file mode 100644 index 00000000..4ecc6a63 --- /dev/null +++ b/app/src/main/java/com/emanuelef/remote_capture/HarWriter.java @@ -0,0 +1,620 @@ +/* + * This file is part of PCAPdroid. + * + * PCAPdroid is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * PCAPdroid is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with PCAPdroid. If not, see . + * + * Copyright 2026 - Emanuele Faranda + */ + +package com.emanuelef.remote_capture; + +import android.content.Context; +import android.util.Base64; + +import com.emanuelef.remote_capture.model.ConnectionDescriptor; +import com.emanuelef.remote_capture.model.PayloadChunk; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * Writes HTTP log data to HAR 1.2 format using streaming output. + * See: http://www.softwareishard.com/blog/har-12-spec/ + */ +public class HarWriter { + private static final String TAG = "HarWriter"; + private final Context mContext; + private final HttpLog mHttpLog; + private final HttpLog.HttpRequest mSingleRequest; + + public HarWriter(Context context, HttpLog httpLog) { + mContext = context; + mHttpLog = httpLog; + mSingleRequest = null; + } + + public HarWriter(Context context, HttpLog.HttpRequest request) { + mContext = context; + mHttpLog = null; + mSingleRequest = request; + } + + public void write(OutputStream out) throws IOException { + JsonWriter writer = new JsonWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8)); + writer.setIndent(" "); + + writer.beginObject(); + writer.name("log"); + writeLog(writer); + writer.endObject(); + + writer.flush(); + } + + private void writeLog(JsonWriter writer) throws IOException { + writer.beginObject(); + + writer.name("version").value("1.2"); + + writer.name("creator"); + writeCreator(writer); + + writer.name("entries"); + writeEntries(writer); + + writer.endObject(); + } + + private void writeCreator(JsonWriter writer) throws IOException { + writer.beginObject(); + writer.name("name").value("PCAPdroid"); + writer.name("version").value(Utils.getAppVersion(mContext)); + writer.endObject(); + } + + private void writeEntries(JsonWriter writer) throws IOException { + writer.beginArray(); + + if (mSingleRequest != null) { + try { + writeEntry(writer, mSingleRequest); + } catch (Exception e) { + Log.w(TAG, "Failed to serialize single entry: " + e.getMessage()); + e.printStackTrace(); + } + } else if (mHttpLog != null) { + int size = mHttpLog.getSize(); + + // NOTE: don't synchronize on the HTTP log, to avoid blocking the UI + for (int i = 0; i < size; i++) { + if (Thread.interrupted()) + throw new InterruptedIOException("Export cancelled"); + + HttpLog.HttpRequest req = mHttpLog.getRequest(i); + if (req == null) + continue; + + try { + writeEntry(writer, req); + } catch (Exception e) { + Log.w(TAG, "Failed to serialize entry " + i + ": " + e.getMessage()); + e.printStackTrace(); + } + } + } + + writer.endArray(); + } + + private void writeEntry(JsonWriter writer, HttpLog.HttpRequest req) throws IOException { + ConnectionDescriptor conn = req.conn; + + writer.beginObject(); + writer.name("startedDateTime").value(Utils.formatMillisIso8601(mContext, req.timestamp)); + + // time (total elapsed time in ms) + long time = -1; + if (req.reply != null) { + PayloadChunk replyChunk = conn.getHttpResponseChunk(req.reply.firstChunkPos); + if (replyChunk != null) + time = replyChunk.timestamp - req.timestamp; + } + writer.name("time").value(time); + + writer.name("serverIPAddress").value(conn.dst_ip); + writer.name("connection").value(String.valueOf(conn.incr_id)); + + writer.name("request"); + writeRequest(writer, req); + + writer.name("response"); + writeResponse(writer, req); + + // cache (required, but empty as we don't track caching) + writer.name("cache"); + writer.beginObject(); + writer.endObject(); + + writer.name("timings"); + writeTimings(writer); + + writeWebSocketMessages(writer, conn, req); + + writer.endObject(); + } + + private void writeRequest(JsonWriter writer, HttpLog.HttpRequest req) throws IOException { + ConnectionDescriptor conn = req.conn; + + writer.beginObject(); + writer.name("method").value(req.method != null ? req.method : ""); + writer.name("url").value(req.getUrl()); + + PayloadChunk reqChunk = conn.getHttpRequestChunk(req.firstChunkPos); + String httpVersion = "HTTP/1.1"; + List requestHeaders = new ArrayList<>(); + int headersSize = -1; + + if (reqChunk != null) { + if (!reqChunk.httpVersion.isEmpty()) + httpVersion = reqChunk.httpVersion; + + if (reqChunk.payload != null) { + String httpText = new String(reqChunk.payload, StandardCharsets.UTF_8); + requestHeaders = parseHeaders(httpText); + headersSize = getHeadersSize(reqChunk.payload); + } + } + + writer.name("httpVersion").value(httpVersion); + + writer.name("cookies"); + writeRequestCookies(writer, requestHeaders); + + writer.name("headers"); + writeHeaders(writer, requestHeaders); + + writer.name("queryString"); + writeQueryString(writer, req.query); + + if ((req.method != null) && (req.method.equals("POST") || req.method.equals("PUT") || req.method.equals("PATCH"))) { + writer.name("postData"); + writePostData(writer, reqChunk, requestHeaders); + } + + writer.name("headersSize").value(headersSize); + writer.name("bodySize").value(req.bodyLength); + writer.endObject(); + } + + private void writeResponse(JsonWriter writer, HttpLog.HttpRequest req) throws IOException { + HttpLog.HttpReply reply = req.reply; + ConnectionDescriptor conn = req.conn; + + writer.beginObject(); + + if ((reply == null) || req.httpRst) { + // No response available - return minimal response object + writer.name("status").value(0); + writer.name("statusText").value(""); + writer.name("httpVersion").value(""); + writer.name("cookies"); + writer.beginArray(); + writer.endArray(); + writer.name("headers"); + writer.beginArray(); + writer.endArray(); + writer.name("content"); + writer.beginObject(); + writer.endObject(); + writer.name("redirectURL").value(""); + writer.name("headersSize").value(-1); + writer.name("bodySize").value(-1); + writer.endObject(); + return; + } + + writer.name("status").value(reply.responseCode); + writer.name("statusText").value(reply.responseStatus != null ? reply.responseStatus : ""); + + PayloadChunk respChunk = conn.getHttpResponseChunk(reply.firstChunkPos); + String httpVersion = "HTTP/1.1"; + List responseHeaders = new ArrayList<>(); + int headersSize = -1; + + if (respChunk != null) { + if (!respChunk.httpVersion.isEmpty()) + httpVersion = respChunk.httpVersion; + + if (respChunk.payload != null) { + String httpText = new String(respChunk.payload, StandardCharsets.UTF_8); + responseHeaders = parseHeaders(httpText); + headersSize = getHeadersSize(respChunk.payload); + } + } + + writer.name("httpVersion").value(httpVersion); + + writer.name("cookies"); + writeResponseCookies(writer, responseHeaders); + + writer.name("headers"); + writeHeaders(writer, responseHeaders); + + writer.name("content"); + writeContent(writer, reply, respChunk); + + String redirectURL = getHeaderValue(responseHeaders, "location"); + writer.name("redirectURL").value(redirectURL != null ? redirectURL : ""); + writer.name("headersSize").value(headersSize); + writer.name("bodySize").value(reply.bodyLength); + writer.endObject(); + } + + private void writeContent(JsonWriter writer, HttpLog.HttpReply reply, PayloadChunk respChunk) throws IOException { + writer.beginObject(); + writer.name("size").value(reply.bodyLength); + + String mimeType = reply.contentType != null ? reply.contentType : "application/octet-stream"; + writer.name("mimeType").value(mimeType); + + if ((respChunk != null) && (respChunk.payload != null)) { + byte[] body = extractBody(respChunk.payload); + if ((body != null) && (body.length > 0)) { + if (isTextContent(body, reply.contentType)) + writer.name("text").value(new String(body, StandardCharsets.UTF_8)); + else { + writer.name("text").value(Base64.encodeToString(body, Base64.NO_WRAP)); + writer.name("encoding").value("base64"); + } + } + } + + writer.endObject(); + } + + private void writePostData(JsonWriter writer, PayloadChunk reqChunk, List requestHeaders) throws IOException { + writer.beginObject(); + + String contentType = getHeaderValue(requestHeaders, "content-type"); + writer.name("mimeType").value(contentType != null ? contentType : ""); + + if ((reqChunk != null) && (reqChunk.payload != null)) { + byte[] body = extractBody(reqChunk.payload); + if ((body != null) && (body.length > 0)) + writer.name("text").value(new String(body, StandardCharsets.UTF_8)); + } + + // params empty - we don't parse form data + writer.name("params"); + writer.beginArray(); + writer.endArray(); + + writer.endObject(); + } + + private void writeTimings(JsonWriter writer) throws IOException { + writer.beginObject(); + + writer.name("send").value(-1); + writer.name("wait").value(-1); + writer.name("receive").value(-1); + + // Optional fields + writer.name("blocked").value(-1); + writer.name("dns").value(-1); + writer.name("connect").value(-1); + writer.name("ssl").value(-1); + + writer.endObject(); + } + + private void writeHeaders(JsonWriter writer, List headers) throws IOException { + writer.beginArray(); + for (String[] header : headers) { + writer.beginObject(); + writer.name("name").value(header[0]); + writer.name("value").value(header[1]); + writer.endObject(); + } + writer.endArray(); + } + + private void writeQueryString(JsonWriter writer, String query) throws IOException { + writer.beginArray(); + + if ((query != null) && !query.isEmpty()) { + // Remove leading '?' + if (query.startsWith("?")) + query = query.substring(1); + + String[] pairs = query.split("&"); + for (String pair : pairs) { + int eqPos = pair.indexOf('='); + + // Decode first, then write - to avoid leaving JSON in invalid state on exception + String name, value; + try { + if (eqPos > 0) { + name = URLDecoder.decode(pair.substring(0, eqPos), "UTF-8"); + value = URLDecoder.decode(pair.substring(eqPos + 1), "UTF-8"); + } else { + name = URLDecoder.decode(pair, "UTF-8"); + value = ""; + } + } catch (Exception e) { + // Skip malformed parameter + continue; + } + + writer.beginObject(); + writer.name("name").value(name); + writer.name("value").value(value); + writer.endObject(); + } + } + + writer.endArray(); + } + + private void writeRequestCookies(JsonWriter writer, List headers) throws IOException { + writer.beginArray(); + + for (String[] header : headers) { + if (header[0].equalsIgnoreCase("Cookie")) { + String value = header[1]; + + // Parse: name1=value1; name2=value2 + String[] pairs = value.split(";"); + for (String pair : pairs) { + pair = pair.trim(); + + int eqPos = pair.indexOf('='); + if (eqPos > 0) { + writer.beginObject(); + writer.name("name").value(pair.substring(0, eqPos)); + writer.name("value").value(pair.substring(eqPos + 1)); + writer.endObject(); + } + } + } + } + + writer.endArray(); + } + + private void writeResponseCookies(JsonWriter writer, List headers) throws IOException { + writer.beginArray(); + + for (String[] header : headers) { + if (header[0].equalsIgnoreCase("Set-Cookie")) { + String value = header[1]; + + // Parse: name=value; Path=/; Domain=.example.com; HttpOnly; Secure; SameSite=Lax + String[] parts = value.split(";"); + + // First part is name=value + if (parts.length > 0) { + int eqPos = parts[0].indexOf('='); + if (eqPos > 0) { + writer.beginObject(); + writer.name("name").value(parts[0].substring(0, eqPos).trim()); + writer.name("value").value(parts[0].substring(eqPos + 1).trim()); + + // Parse attributes with defaults + String path = "/"; + String domain = ""; + boolean httpOnly = false; + boolean secure = false; + String sameSite = null; + String expires = null; + + for (int i = 1; i < parts.length; i++) { + String attr = parts[i].trim(); + String attrLower = attr.toLowerCase(); + + if (attrLower.startsWith("path=")) + path = attr.substring(5); + else if (attrLower.startsWith("domain=")) + domain = attr.substring(7); + else if (attrLower.equals("httponly")) + httpOnly = true; + else if (attrLower.equals("secure")) + secure = true; + else if (attrLower.startsWith("samesite=")) + sameSite = attr.substring(9); + else if (attrLower.startsWith("expires=")) + expires = attr.substring(8); + } + + writer.name("path").value(path); + writer.name("domain").value(domain); + writer.name("httpOnly").value(httpOnly); + writer.name("secure").value(secure); + if (sameSite != null) + writer.name("sameSite").value(sameSite); + if (expires != null) { + String isoExpires = Utils.httpDateToIso8601(expires); + if (isoExpires != null) + writer.name("expires").value(isoExpires); + } + + writer.endObject(); + } + } + } + } + + writer.endArray(); + } + + private List parseHeaders(String httpText) { + List headers = new ArrayList<>(); + int headerEnd = Utils.getEndOfHTTPHeaders(httpText.getBytes(StandardCharsets.UTF_8)); + if (headerEnd == 0) headerEnd = httpText.length(); + + String headerSection = httpText.substring(0, Math.min(headerEnd, httpText.length())); + String[] lines = headerSection.split("\r\n"); + + // Skip first line (request line or status line) + for (int i = 1; i < lines.length; i++) { + int colonPos = lines[i].indexOf(':'); + if (colonPos > 0) { + headers.add(new String[]{ + lines[i].substring(0, colonPos), + lines[i].substring(colonPos + 1).trim() + }); + } + } + + return headers; + } + + private byte[] extractBody(byte[] payload) { + int headerEnd = Utils.getEndOfHTTPHeaders(payload); + if ((headerEnd <= 0) || (headerEnd >= payload.length)) + return null; + + byte[] body = new byte[payload.length - headerEnd]; + System.arraycopy(payload, headerEnd, body, 0, body.length); + return body; + } + + private int getHeadersSize(byte[] payload) { + int headerEnd = Utils.getEndOfHTTPHeaders(payload); + return headerEnd > 0 ? headerEnd : -1; + } + + private boolean isTextContent(byte[] body, String contentType) { + if (contentType != null) { + String ct = contentType.toLowerCase(); + if (ct.startsWith("text/") || ct.contains("json") || + ct.contains("xml") || ct.contains("javascript") || ct.contains("html")) + return true; + if (ct.startsWith("image/") || ct.startsWith("audio/") || + ct.startsWith("video/") || ct.equals("application/octet-stream")) + return false; + } + + // Check first bytes using Utils.isPrintable() + int checkLen = Math.min(body.length, 16); + for (int i = 0; i < checkLen; i++) { + if (!Utils.isPrintable(body[i])) + return false; + } + return true; + } + + private String getHeaderValue(List headers, String name) { + for (String[] header : headers) { + if (header[0].equalsIgnoreCase(name)) + return header[1]; + } + return null; + } + + /** + * Add WebSocket messages to entry if this is a WebSocket connection. + * Uses Chrome DevTools extension format. + */ + private void writeWebSocketMessages(JsonWriter writer, ConnectionDescriptor conn, HttpLog.HttpRequest req) throws IOException { + if ((req.reply == null) || !req.hasWebsocketData()) + return; + + ArrayList wsChunks; + + if (CaptureService.isReadingFromPcapFile()) { + // When reading from PCAP, chunks contain raw data that must be processed + // through HTTPReassembly to decode WebSocket frames + wsChunks = new ArrayList<>(); + HTTPReassembly.ReassemblyListener listener = chunk -> { + if ((chunk.type == PayloadChunk.ChunkType.WEBSOCKET) && + !WebSocketDecoder.isControlOpcode(chunk.wsOpcode)) + wsChunks.add(chunk); + }; + HTTPReassembly httpReq = new HTTPReassembly(true, listener); + HTTPReassembly httpRes = new HTTPReassembly(true, listener); + + int startPos = req.firstChunkPos; + synchronized (conn) { + for (int i = startPos; i < conn.getNumPayloadChunks(); i++) { + PayloadChunk chunk = conn.getPayloadChunk(i); + if ((chunk == null) || (chunk.type == PayloadChunk.ChunkType.RAW)) + continue; + + if (chunk.is_sent) + httpReq.handleChunk(chunk); + else + httpRes.handleChunk(chunk); + } + } + } else { + // Live capture: chunks are already decoded by mitmproxy + wsChunks = new ArrayList<>(); + int startPos = req.reply.firstChunkPos + 1; + + synchronized (conn) { + for (int i = startPos; i < conn.getNumPayloadChunks(); i++) { + PayloadChunk chunk = conn.getPayloadChunk(i); + if ((chunk != null) && (chunk.type == PayloadChunk.ChunkType.WEBSOCKET)) + wsChunks.add(chunk); + } + } + } + + if (wsChunks.isEmpty()) + return; + + // Chrome DevTools extension + writer.name("_resourceType").value("websocket"); + writer.name("_webSocketMessages"); + writer.beginArray(); + + for (PayloadChunk chunk : wsChunks) { + writer.beginObject(); + writer.name("type").value(chunk.is_sent ? "send" : "receive"); + writer.name("time").value(chunk.timestamp / 1000.0); + + // Use wsOpcode if available, otherwise guess from content + int opcode; + if (chunk.wsOpcode > 0) + opcode = chunk.wsOpcode; + else { + boolean isText = (chunk.payload == null) || (chunk.payload.length == 0) || + isTextContent(chunk.payload, null); + opcode = isText ? WebSocketDecoder.OPCODE_TEXT : WebSocketDecoder.OPCODE_BINARY; + } + writer.name("opcode").value(opcode); + + if ((chunk.payload != null) && (chunk.payload.length > 0)) { + if (opcode == WebSocketDecoder.OPCODE_TEXT) + writer.name("data").value(new String(chunk.payload, StandardCharsets.UTF_8)); + else + writer.name("data").value(Base64.encodeToString(chunk.payload, Base64.NO_WRAP)); + } else + writer.name("data").value(""); + + writer.endObject(); + } + + writer.endArray(); + } +} diff --git a/app/src/main/java/com/emanuelef/remote_capture/PCAPdroid.java b/app/src/main/java/com/emanuelef/remote_capture/PCAPdroid.java index ed7cc9a4..11f41f5f 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/PCAPdroid.java +++ b/app/src/main/java/com/emanuelef/remote_capture/PCAPdroid.java @@ -62,6 +62,7 @@ public class PCAPdroid extends Application { private Context mLocalizedContext; private boolean mIsDecryptingPcap = false; private boolean mIsUsharkAvailable = false; + private String mLoadedPcapBasename = null; private static WeakReference mInstance; protected static boolean isUnderTest = false; @@ -252,4 +253,12 @@ public class PCAPdroid extends Application { public boolean isUsharkAvailable() { return mIsUsharkAvailable; } + + public void setLoadedPcapBasename(String basename) { + mLoadedPcapBasename = basename; + } + + public String getLoadedPcapBasename() { + return mLoadedPcapBasename; + } } diff --git a/app/src/main/java/com/emanuelef/remote_capture/Utils.java b/app/src/main/java/com/emanuelef/remote_capture/Utils.java index ce31651c..6c47ad7b 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/Utils.java +++ b/app/src/main/java/com/emanuelef/remote_capture/Utils.java @@ -142,6 +142,7 @@ import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.text.DateFormat; +import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Calendar; @@ -152,6 +153,7 @@ import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Random; +import java.util.TimeZone; import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -331,15 +333,44 @@ public class Utils { String rv = fmt.format(new Date(millis)); if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.N) { - // convert RFC 822 (+0100) -> ISO 8601 timezone (+01:00) + // convert RFC 822 (+0100 or -0500) -> ISO 8601 timezone (+01:00 or -05:00) int l = rv.length(); - if ((l > 5) && (rv.charAt(l - 5) == '+')) + if ((l > 5) && ((rv.charAt(l - 5) == '+') || (rv.charAt(l - 5) == '-'))) rv = rv.substring(0, l - 2) + ":" + rv.substring(l - 2); } return rv; } + public static String httpDateToIso8601(String httpDate) { + if (httpDate == null) + return null; + + String[] patterns = { + "EEE, dd-MMM-yyyy HH:mm:ss zzz", // Fri, 05-Feb-2027 15:30:34 GMT + "EEE, dd MMM yyyy HH:mm:ss zzz", // RFC 1123: Fri, 05 Feb 2027 15:30:34 GMT + "EEEE, dd-MMM-yy HH:mm:ss zzz", // RFC 850: Friday, 05-Feb-27 15:30:34 GMT + "EEE MMM d HH:mm:ss yyyy" // ANSI C asctime: Fri Feb 5 15:30:34 2027 + }; + + Date date = null; + for (String pattern : patterns) { + try { + SimpleDateFormat parser = new SimpleDateFormat(pattern, Locale.US); + parser.setTimeZone(TimeZone.getTimeZone("GMT")); + date = parser.parse(httpDate); + break; + } catch (ParseException ignored) {} + } + + if (date == null) + return null; + + SimpleDateFormat iso8601 = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US); + iso8601.setTimeZone(TimeZone.getTimeZone("UTC")); + return iso8601.format(date); + } + public static String formatEpochMillis(Context context, long millis) { Locale locale = getPrimaryLocale(context); DateFormat fmt = new SimpleDateFormat("MM/dd/yy HH:mm:ss.SSS", locale); @@ -732,6 +763,16 @@ public class Utils { return(Utils.getUniqueFileName(context, pcapng_format ? "pcapng" : "pcap")); } + // Returns the export filename with the given extension. + // If a PCAP file was loaded by the user, uses that filename as base. + // Otherwise, generates a unique filename based on date/time. + public static String getExportFileName(Context context, String ext) { + String loadedBasename = PCAPdroid.getInstance().getLoadedPcapBasename(); + if (loadedBasename != null) + return loadedBasename + "." + ext; + return getUniqueFileName(context, ext); + } + public static @Nullable BitmapDrawable scaleDrawable(Resources res, Drawable drawable, int new_x, int new_y) { if((new_x <= 0) || (new_y <= 0)) return null; diff --git a/app/src/main/java/com/emanuelef/remote_capture/activities/HttpDetailsActivity.java b/app/src/main/java/com/emanuelef/remote_capture/activities/HttpDetailsActivity.java index 4fb1e9a8..69ca446d 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/activities/HttpDetailsActivity.java +++ b/app/src/main/java/com/emanuelef/remote_capture/activities/HttpDetailsActivity.java @@ -19,6 +19,9 @@ package com.emanuelef.remote_capture.activities; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Looper; @@ -26,6 +29,10 @@ import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; +import androidx.activity.result.ActivityResult; +import androidx.appcompat.app.AlertDialog; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; @@ -34,6 +41,7 @@ import androidx.viewpager2.widget.ViewPager2; import com.emanuelef.remote_capture.CaptureService; import com.emanuelef.remote_capture.ConnectionsRegister; +import com.emanuelef.remote_capture.HarWriter; import com.emanuelef.remote_capture.HttpLog; import com.emanuelef.remote_capture.Log; import com.emanuelef.remote_capture.R; @@ -47,7 +55,11 @@ import com.emanuelef.remote_capture.model.PayloadChunk; import com.google.android.material.tabs.TabLayout; import com.google.android.material.tabs.TabLayoutMediator; +import java.io.IOException; +import java.io.OutputStream; import java.util.ArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; public class HttpDetailsActivity extends PayloadExportActivity implements ConnectionsListener, PayloadHostActivity { private static final String TAG = "HttpRequestDetailsActivity"; @@ -63,6 +75,11 @@ public class HttpDetailsActivity extends PayloadExportActivity implements Connec private MenuItem mMenuNext; private MenuItem mMenuDisplayAs; private Boolean mDisplayMode; + private Uri mHarFname; + private AlertDialog mAlertDialog; + + private final ActivityResultLauncher harFileLauncher = + registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), this::harFileResult); private static final int POS_REQUEST = 0; private static final int POS_REPLY = 1; @@ -132,6 +149,14 @@ public class HttpDetailsActivity extends PayloadExportActivity implements Connec unregisterConnsListener(); } + @Override + protected void onDestroy() { + if(mAlertDialog != null) + mAlertDialog.dismiss(); + + super.onDestroy(); + } + private void setupTabs() { mPagerAdapter = new StateAdapter(this); mPager.setAdapter(mPagerAdapter); @@ -205,11 +230,10 @@ public class HttpDetailsActivity extends PayloadExportActivity implements Connec payloadFragment.setDisplayMode(mDisplayMode); - if(mDisplayMode) { + if(mDisplayMode) mMenuDisplayAs.setTitle(R.string.display_as_hexdump); - } else { + else mMenuDisplayAs.setTitle(R.string.display_as_text); - } } else if(currentFragment instanceof ConnectionPayload wsFragment) { if(mDisplayMode == null) mDisplayMode = true; @@ -245,6 +269,9 @@ public class HttpDetailsActivity extends PayloadExportActivity implements Connec updateMenuVisibility(); } return true; + } else if(itemId == R.id.save_as_har) { + openHarFileSelector(); + return true; } return super.onOptionsItemSelected(item); @@ -393,6 +420,120 @@ public class HttpDetailsActivity extends PayloadExportActivity implements Connec unregisterConnsListener(); } + private void openHarFileSelector() { + if (mHttpReq == null) + return; + + boolean noFileDialog = false; + String fname = Utils.getExportFileName(this, "har"); + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("*/*"); + intent.putExtra(Intent.EXTRA_TITLE, fname); + + if(Utils.supportsFileDialog(this, intent)) { + try { + harFileLauncher.launch(intent); + } catch (ActivityNotFoundException e) { + noFileDialog = true; + } + } else + noFileDialog = true; + + if(noFileDialog) { + Log.d(TAG, "No app found to handle file selection"); + + Uri uri = Utils.getDownloadsUri(this, fname); + + if(uri != null) { + mHarFname = uri; + exportHar(); + } else + Utils.showToastLong(this, R.string.no_activity_file_selection); + } + } + + private void exportHar() { + if(mHarFname == null || mHttpReq == null) + return; + + Log.d(TAG, "Writing HAR file: " + mHarFname); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + Handler handler = new Handler(Looper.getMainLooper()); + final boolean[] cancelled = {false}; + + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.exporting); + builder.setMessage(R.string.export_in_progress); + builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> { + Log.i(TAG, "Abort HAR export"); + cancelled[0] = true; + executor.shutdownNow(); + }); + + mAlertDialog = builder.create(); + mAlertDialog.setCanceledOnTouchOutside(false); + mAlertDialog.show(); + + mAlertDialog.setOnCancelListener(dialog -> { + Log.i(TAG, "Abort HAR export (back button)"); + cancelled[0] = true; + executor.shutdownNow(); + }); + mAlertDialog.setOnDismissListener(dialog -> mAlertDialog = null); + + final Uri harFname = mHarFname; + final HttpLog.HttpRequest httpReq = mHttpReq; + mHarFname = null; + + executor.execute(() -> { + boolean success = false; + + try { + OutputStream stream = getContentResolver().openOutputStream(harFname, "rwt"); + + if(stream != null) { + HarWriter writer = new HarWriter(HttpDetailsActivity.this, httpReq); + writer.write(stream); + stream.close(); + success = true; + } + } catch (IOException e) { + if(!cancelled[0]) + e.printStackTrace(); + } + + if(cancelled[0]) + return; + + final boolean result = success; + final Utils.UriStat stat = result ? Utils.getUriStat(HttpDetailsActivity.this, harFname) : null; + + handler.post(() -> { + if(mAlertDialog != null) + mAlertDialog.dismiss(); + + if(result) { + if(stat != null) + Utils.showToast(HttpDetailsActivity.this, R.string.file_saved_with_name, stat.name); + else + Utils.showToast(HttpDetailsActivity.this, R.string.save_ok); + } else + Utils.showToast(HttpDetailsActivity.this, R.string.cannot_write_file); + }); + }); + } + + private void harFileResult(final ActivityResult result) { + if((result.getResultCode() == RESULT_OK) && (result.getData() != null)) { + mHarFname = result.getData().getData(); + exportHar(); + } else { + mHarFname = null; + } + } + private class StateAdapter extends FragmentStateAdapter { StateAdapter(final FragmentActivity fa) { super(fa); } diff --git a/app/src/main/java/com/emanuelef/remote_capture/activities/MainActivity.java b/app/src/main/java/com/emanuelef/remote_capture/activities/MainActivity.java index ee346edd..4c73d6da 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/activities/MainActivity.java +++ b/app/src/main/java/com/emanuelef/remote_capture/activities/MainActivity.java @@ -826,6 +826,10 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig appStateStarting(); + // Clear loaded basename if this is a new capture (not from loaded file) + if (input_pcap_path == null) + PCAPdroid.getInstance().setLoadedPcapBasename(null); + PCAPdroid.getInstance().setIsDecryptingPcap(mDecryptPcap); mDecryptPcap = false; @@ -1069,6 +1073,17 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig } private void startOpenPcap(Uri pcap_uri, Uri keylog_uri) { + // Extract and store the base filename (without extension) + Utils.UriStat stat = Utils.getUriStat(this, pcap_uri); + if (stat != null && stat.name != null) { + String name = stat.name; + int dotIndex = name.lastIndexOf('.'); + String basename = (dotIndex > 0) ? name.substring(0, dotIndex) : name; + PCAPdroid.getInstance().setLoadedPcapBasename(basename); + } else { + PCAPdroid.getInstance().setLoadedPcapBasename(null); + } + mPcapExecutor = Executors.newSingleThreadExecutor(); AlertDialog.Builder builder = new AlertDialog.Builder(this); diff --git a/app/src/main/java/com/emanuelef/remote_capture/fragments/ConnectionsFragment.java b/app/src/main/java/com/emanuelef/remote_capture/fragments/ConnectionsFragment.java index 8fed9b9a..a53e328e 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/fragments/ConnectionsFragment.java +++ b/app/src/main/java/com/emanuelef/remote_capture/fragments/ConnectionsFragment.java @@ -1041,7 +1041,7 @@ public class ConnectionsFragment extends Fragment implements ConnectionsListener public void openFileSelector() { boolean noFileDialog = false; - String fname = Utils.getUniqueFileName(requireContext(), "csv"); + String fname = Utils.getExportFileName(requireContext(), "csv"); Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("*/*"); diff --git a/app/src/main/java/com/emanuelef/remote_capture/fragments/HttpLogFragment.java b/app/src/main/java/com/emanuelef/remote_capture/fragments/HttpLogFragment.java index 24576198..b1d102d5 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/fragments/HttpLogFragment.java +++ b/app/src/main/java/com/emanuelef/remote_capture/fragments/HttpLogFragment.java @@ -40,6 +40,7 @@ import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.SearchView; import androidx.core.graphics.Insets; import androidx.core.view.MenuProvider; @@ -52,6 +53,7 @@ import androidx.recyclerview.widget.RecyclerView; import com.emanuelef.remote_capture.AppsResolver; import com.emanuelef.remote_capture.CaptureService; +import com.emanuelef.remote_capture.HarWriter; import com.emanuelef.remote_capture.HttpLog; import com.emanuelef.remote_capture.Log; import com.emanuelef.remote_capture.R; @@ -69,6 +71,8 @@ import com.google.android.material.slider.Slider; import java.io.IOException; import java.io.OutputStream; import java.util.ArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuProvider, SearchView.OnQueryTextListener { private static final String TAG = "HttpLogFragment"; @@ -79,12 +83,15 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP private int mFabDownMargin = 0; private MenuItem mMenuItemSearch; private MenuItem mSave; + private MenuItem mSaveAsHar; private SearchView mSearchView; private Handler mHandler; private ChipGroup mActiveFilter; private Slider mSizeSlider; private boolean mSizeSliderActive = false; private Uri mTxtFname; + private Uri mHarFname; + private AlertDialog mAlertDialog; private String mQueryToApply; private AppsResolver mApps; @@ -95,6 +102,8 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP registerForActivityResult(new StartActivityForResult(), this::filterResult); private final ActivityResultLauncher txtFileLauncher = registerForActivityResult(new StartActivityForResult(), this::txtFileResult); + private final ActivityResultLauncher harFileLauncher = + registerForActivityResult(new StartActivityForResult(), this::harFileResult); @Override public void onResume() { @@ -129,6 +138,14 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP mQueryToApply = mSearchView.getQuery().toString(); } + @Override + public void onDestroyView() { + if(mAlertDialog != null) + mAlertDialog.dismiss(); + + super.onDestroyView(); + } + @Override public void onHiddenChanged(boolean hidden) { super.onHiddenChanged(hidden); @@ -336,6 +353,7 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP menuInflater.inflate(R.menu.http_log_menu, menu); mSave = menu.findItem(R.id.save); + mSaveAsHar = menu.findItem(R.id.save_as_har); mMenuItemSearch = menu.findItem(R.id.search); mSearchView = (SearchView) mMenuItemSearch.getActionView(); @@ -357,6 +375,9 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP if(id == R.id.save) { openFileSelector(); return true; + } else if(id == R.id.save_as_har) { + openHarFileSelector(); + return true; } else if(id == R.id.edit_filter) { Intent intent = new Intent(requireContext(), HttpLogFilterActivity.class); intent.putExtra(HttpLogFilterActivity.FILTER_DESCRIPTOR, mAdapter.mFilter); @@ -542,11 +563,13 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP mMenuItemSearch.setVisible(is_enabled); mSave.setEnabled(is_enabled); + if(mSaveAsHar != null) + mSaveAsHar.setEnabled(is_enabled); } public void openFileSelector() { boolean noFileDialog = false; - String fname = Utils.getUniqueFileName(requireContext(), "txt"); + String fname = Utils.getExportFileName(requireContext(), "txt"); Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("*/*"); @@ -654,6 +677,121 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP } } + public void openHarFileSelector() { + boolean noFileDialog = false; + String fname = Utils.getExportFileName(requireContext(), "har"); + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("*/*"); + intent.putExtra(Intent.EXTRA_TITLE, fname); + + if(Utils.supportsFileDialog(requireContext(), intent)) { + try { + harFileLauncher.launch(intent); + } catch (ActivityNotFoundException e) { + noFileDialog = true; + } + } else + noFileDialog = true; + + if(noFileDialog) { + Log.d(TAG, "No app found to handle file selection"); + + Uri uri = Utils.getDownloadsUri(requireContext(), fname); + + if(uri != null) { + mHarFname = uri; + exportHttpLogHar(); + } else + Utils.showToastLong(requireContext(), R.string.no_activity_file_selection); + } + } + + private void exportHttpLogHar() { + if(mHarFname == null) + return; + + HttpLog httpLog = CaptureService.getHttpLog(); + if(httpLog == null) + return; + + Log.d(TAG, "Writing HAR file: " + mHarFname); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + Handler handler = new Handler(Looper.getMainLooper()); + final boolean[] cancelled = {false}; + + AlertDialog.Builder builder = new AlertDialog.Builder(requireContext()); + builder.setTitle(R.string.exporting); + builder.setMessage(R.string.export_in_progress); + builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> { + Log.i(TAG, "Abort HAR export"); + cancelled[0] = true; + executor.shutdownNow(); + }); + + mAlertDialog = builder.create(); + mAlertDialog.setCanceledOnTouchOutside(false); + mAlertDialog.show(); + + mAlertDialog.setOnCancelListener(dialog -> { + Log.i(TAG, "Abort HAR export (back button)"); + cancelled[0] = true; + executor.shutdownNow(); + }); + mAlertDialog.setOnDismissListener(dialog -> mAlertDialog = null); + + final Uri harFname = mHarFname; + mHarFname = null; + + executor.execute(() -> { + boolean success = false; + + try { + OutputStream stream = requireActivity().getContentResolver().openOutputStream(harFname, "rwt"); + + if(stream != null) { + HarWriter writer = new HarWriter(requireContext(), httpLog); + writer.write(stream); + stream.close(); + success = true; + } + } catch (IOException e) { + if(!cancelled[0]) + e.printStackTrace(); + } + + if(cancelled[0]) + return; + + final boolean result = success; + final Utils.UriStat stat = result ? Utils.getUriStat(requireContext(), harFname) : null; + + handler.post(() -> { + if(mAlertDialog != null) + mAlertDialog.dismiss(); + + if(result) { + if(stat != null) { + String msg = String.format(getString(R.string.file_saved_with_name), stat.name); + Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show(); + } else + Utils.showToast(requireContext(), R.string.save_ok); + } else + Utils.showToast(requireContext(), R.string.cannot_write_file); + }); + }); + } + + private void harFileResult(final ActivityResult result) { + if((result.getResultCode() == Activity.RESULT_OK) && (result.getData() != null)) { + mHarFname = result.getData().getData(); + exportHttpLogHar(); + } else { + mHarFname = null; + } + } + public void clearFilters() { if(mAdapter != null) { mAdapter.mFilter = new HttpLogFilterDescriptor(); diff --git a/app/src/main/java/com/emanuelef/remote_capture/model/PayloadChunk.java b/app/src/main/java/com/emanuelef/remote_capture/model/PayloadChunk.java index 4f8299c6..247b6b41 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/model/PayloadChunk.java +++ b/app/src/main/java/com/emanuelef/remote_capture/model/PayloadChunk.java @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with PCAPdroid. If not, see . * - * Copyright 2020-22 - Emanuele Faranda + * Copyright 2020-26 - Emanuele Faranda */ package com.emanuelef.remote_capture.model; @@ -37,6 +37,7 @@ public class PayloadChunk implements Serializable { public String httpPath = ""; public String httpQuery = ""; public String httpContentType = ""; + public String httpVersion = ""; public int httpBodyLength = 0; private boolean mHttpRst = false; diff --git a/app/src/main/res/menu/http_details_menu.xml b/app/src/main/res/menu/http_details_menu.xml index 6f0d83ab..231e5db0 100644 --- a/app/src/main/res/menu/http_details_menu.xml +++ b/app/src/main/res/menu/http_details_menu.xml @@ -24,4 +24,10 @@ android:icon="@drawable/ic_short_text" app:showAsAction="never" android:visible="false" /> + + diff --git a/app/src/main/res/menu/http_log_menu.xml b/app/src/main/res/menu/http_log_menu.xml index 33aa5fc5..e1a67f02 100644 --- a/app/src/main/res/menu/http_log_menu.xml +++ b/app/src/main/res/menu/http_log_menu.xml @@ -24,11 +24,17 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3cfd9749..b41698a8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -350,6 +350,8 @@ Download in progress, please wait Capture file loading in progress, please wait Download failed + Exporting… + Export in progress, please wait Database not found. Geolocation is disabled Geolocation database missing To use country-based firewall rules, download the geolocation database from the PCAPdroid settings, otherwise country-based rules will be ineffective @@ -587,4 +589,6 @@ Decryption error Previous Next + Save as text + Save as HAR diff --git a/app/src/test/java/com/emanuelef/remote_capture/HarWriterTest.java b/app/src/test/java/com/emanuelef/remote_capture/HarWriterTest.java new file mode 100644 index 00000000..b107cc82 --- /dev/null +++ b/app/src/test/java/com/emanuelef/remote_capture/HarWriterTest.java @@ -0,0 +1,2507 @@ +/* + * This file is part of PCAPdroid. + * + * PCAPdroid is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * PCAPdroid is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with PCAPdroid. If not, see . + * + * Copyright 2026 - Emanuele Faranda + */ + +package com.emanuelef.remote_capture; + +import android.content.Context; +import android.content.SharedPreferences; +import androidx.preference.PreferenceManager; + +import androidx.test.core.app.ApplicationProvider; + +import com.emanuelef.remote_capture.model.CaptureSettings; +import com.emanuelef.remote_capture.model.ConnectionDescriptor; +import com.emanuelef.remote_capture.model.PayloadChunk; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import android.util.Base64; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.zip.GZIPOutputStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@RunWith(RobolectricTestRunner.class) +public class HarWriterTest { + private Context context; + private CaptureService service; + private HttpLog httpLog; + + @Before + public void setup() { + context = ApplicationProvider.getApplicationContext(); + httpLog = new HttpLog(); + + // Mock CaptureService + service = new CaptureService(); + Whitebox.setInternalState(service, "INSTANCE", service); + Whitebox.setInternalState(service, "mHttpLog", httpLog); + + // Create mock settings with full payload mode + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + CaptureSettings settings = new CaptureSettings(context, prefs); + settings.full_payload = true; + Whitebox.setInternalState(service, "mSettings", settings); + } + + @After + public void tearDown() { + Whitebox.setInternalState(service, "INSTANCE", null); + } + + /** + * Create a ConnectionDescriptor for testing + */ + private ConnectionDescriptor createConnection(int incrId, String dstIp, int dstPort) { + return new ConnectionDescriptor(incrId, 4, 6, + "192.168.1.100", dstIp, "US", + 54321, dstPort, 0, 1000, 0, false, System.currentTimeMillis()); + } + + /** + * Create a PayloadChunk with HTTP data + */ + private PayloadChunk createHttpChunk(String httpText, boolean isSent, long timestamp) { + byte[] payload = httpText.getBytes(StandardCharsets.UTF_8); + return new PayloadChunk(payload, PayloadChunk.ChunkType.HTTP, isSent, timestamp, 0); + } + + /** + * Add a payload chunk directly to the connection without triggering HTTP logging. + * This allows us to control test data precisely. + */ + @SuppressWarnings("unchecked") + private void addChunkDirect(ConnectionDescriptor conn, PayloadChunk chunk) { + try { + java.lang.reflect.Field field = ConnectionDescriptor.class.getDeclaredField("payload_chunks"); + field.setAccessible(true); + java.util.ArrayList chunks = (java.util.ArrayList) field.get(conn); + synchronized (conn) { + chunks.add(chunk); + } + + if (chunk.type == PayloadChunk.ChunkType.WEBSOCKET) { + java.lang.reflect.Field wsField = ConnectionDescriptor.class.getDeclaredField("has_websocket_data"); + wsField.setAccessible(true); + wsField.setBoolean(conn, true); + } + } catch (Exception e) { + throw new RuntimeException("Failed to add chunk directly", e); + } + } + + /** + * Write HAR to string using HarWriter + */ + private String writeHarToString() throws IOException { + HarWriter writer = new HarWriter(context, httpLog); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + writer.write(out); + return out.toString(StandardCharsets.UTF_8.name()); + } + + /** + * Parse HAR JSON and return the root object + */ + private JsonObject parseHar(String harJson) { + return JsonParser.parseString(harJson).getAsJsonObject(); + } + + @Test + public void testBasicHarStructure() throws IOException { + // Create a simple HTTP request/response + ConnectionDescriptor conn = createConnection(1, "93.184.216.34", 80); + conn.info = "example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /index.html HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "User-Agent: TestAgent/1.0\r\n" + + "\r\n"; + + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/html\r\n" + + "Content-Length: 13\r\n" + + "\r\n" + + "Hello, World!"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 100; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "example.com"; + httpReq.path = "/index.html"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = "text/html"; + httpReply.bodyLength = 13; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + // Write HAR + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + + // Verify basic structure + assertTrue("HAR should have 'log' object", har.has("log")); + JsonObject log = har.getAsJsonObject("log"); + + // Verify version + assertEquals("1.2", log.get("version").getAsString()); + + // Verify creator + assertTrue("log should have 'creator' object", log.has("creator")); + JsonObject creator = log.getAsJsonObject("creator"); + assertEquals("PCAPdroid", creator.get("name").getAsString()); + assertNotNull("creator should have version", creator.get("version")); + + // Verify entries array exists + assertTrue("log should have 'entries' array", log.has("entries")); + JsonArray entries = log.getAsJsonArray("entries"); + assertEquals(1, entries.size()); + + JsonObject harEntry = entries.get(0).getAsJsonObject(); + + // Verify serverIPAddress + assertEquals("93.184.216.34", harEntry.get("serverIPAddress").getAsString()); + + // Verify connection ID + assertEquals("1", harEntry.get("connection").getAsString()); + + // Verify cache object exists + assertTrue("entry should have 'cache' object", harEntry.has("cache")); + JsonObject cache = harEntry.getAsJsonObject("cache"); + assertNotNull("cache object should not be null", cache); + + // Verify startedDateTime + assertTrue("entry should have 'startedDateTime'", harEntry.has("startedDateTime")); + String startedDateTime = harEntry.get("startedDateTime").getAsString(); + assertNotNull("startedDateTime should not be null", startedDateTime); + assertTrue("startedDateTime should contain 'T'", startedDateTime.contains("T")); + } + + @Test + public void testRequestFields() throws IOException { + ConnectionDescriptor conn = createConnection(2, "10.0.0.1", 8080); + conn.info = "api.example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /api/users?page=1&limit=10 HTTP/1.1\r\n" + + "Host: api.example.com\r\n" + + "Accept: application/json\r\n" + + "Cookie: session=abc123; user=john\r\n" + + "\r\n"; + + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json\r\n" + + "\r\n" + + "[]"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "api.example.com"; + httpReq.path = "/api/users"; + httpReq.query = "?page=1&limit=10"; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = "application/json"; + httpReply.bodyLength = 2; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject request = entry.getAsJsonObject("request"); + + // Verify request method + assertEquals("GET", request.get("method").getAsString()); + + // Verify URL + String url = request.get("url").getAsString(); + assertTrue("URL should contain host", url.contains("api.example.com")); + assertTrue("URL should contain path", url.contains("/api/users")); + + // Verify HTTP version + assertEquals("HTTP/1.1", request.get("httpVersion").getAsString()); + + // Verify headers + JsonArray headers = request.getAsJsonArray("headers"); + assertNotNull("headers should not be null", headers); + assertTrue("should have headers", headers.size() > 0); + + // Check for Host header + boolean hasHost = false; + for (int i = 0; i < headers.size(); i++) { + JsonObject header = headers.get(i).getAsJsonObject(); + if (header.get("name").getAsString().equals("Host")) { + assertEquals("api.example.com", header.get("value").getAsString()); + hasHost = true; + } + } + assertTrue("should have Host header", hasHost); + + // Verify query string + JsonArray queryString = request.getAsJsonArray("queryString"); + assertNotNull("queryString should not be null", queryString); + assertEquals(2, queryString.size()); + + // Verify query parameters + boolean hasPage = false, hasLimit = false; + for (int i = 0; i < queryString.size(); i++) { + JsonObject param = queryString.get(i).getAsJsonObject(); + String name = param.get("name").getAsString(); + String value = param.get("value").getAsString(); + if (name.equals("page") && value.equals("1")) hasPage = true; + if (name.equals("limit") && value.equals("10")) hasLimit = true; + } + assertTrue("should have 'page' query param", hasPage); + assertTrue("should have 'limit' query param", hasLimit); + + // Verify cookies + JsonArray cookies = request.getAsJsonArray("cookies"); + assertNotNull("cookies should not be null", cookies); + assertEquals(2, cookies.size()); + + boolean hasSession = false, hasUser = false; + for (int i = 0; i < cookies.size(); i++) { + JsonObject cookie = cookies.get(i).getAsJsonObject(); + String name = cookie.get("name").getAsString(); + String value = cookie.get("value").getAsString(); + if (name.equals("session") && value.equals("abc123")) hasSession = true; + if (name.equals("user") && value.equals("john")) hasUser = true; + } + assertTrue("should have 'session' cookie", hasSession); + assertTrue("should have 'user' cookie", hasUser); + + // Verify headersSize and bodySize + assertTrue("headersSize should be > 0", request.get("headersSize").getAsInt() > 0); + assertEquals(0, request.get("bodySize").getAsInt()); + } + + @Test + public void testResponseFields() throws IOException { + ConnectionDescriptor conn = createConnection(3, "203.0.113.50", 443); + conn.info = "secure.example.com"; + conn.l7proto = "TLS.HTTP"; + + String httpRequest = "GET /secure HTTP/1.1\r\n" + + "Host: secure.example.com\r\n" + + "\r\n"; + + String httpResponse = "HTTP/1.1 301 Moved Permanently\r\n" + + "Content-Type: text/html\r\n" + + "Location: https://secure.example.com/new-path\r\n" + + "Set-Cookie: tracking=xyz789; Path=/; HttpOnly; Secure\r\n" + + "\r\n" + + "Redirecting..."; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 75; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "secure.example.com"; + httpReq.path = "/secure"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 301; + httpReply.responseStatus = "Moved Permanently"; + httpReply.contentType = "text/html"; + httpReply.bodyLength = 27; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject response = entry.getAsJsonObject("response"); + + // Verify status + assertEquals(301, response.get("status").getAsInt()); + assertEquals("Moved Permanently", response.get("statusText").getAsString()); + + // Verify HTTP version + assertEquals("HTTP/1.1", response.get("httpVersion").getAsString()); + + // Verify redirect URL + assertEquals("https://secure.example.com/new-path", response.get("redirectURL").getAsString()); + + // Verify response headers + JsonArray headers = response.getAsJsonArray("headers"); + assertNotNull("response headers should not be null", headers); + assertTrue("should have response headers", headers.size() > 0); + + // Check Location header + boolean hasLocation = false; + for (int i = 0; i < headers.size(); i++) { + JsonObject header = headers.get(i).getAsJsonObject(); + if (header.get("name").getAsString().equals("Location")) { + assertEquals("https://secure.example.com/new-path", header.get("value").getAsString()); + hasLocation = true; + } + } + assertTrue("should have Location header", hasLocation); + + // Verify response cookies (Set-Cookie parsing) + JsonArray cookies = response.getAsJsonArray("cookies"); + assertNotNull("response cookies should not be null", cookies); + assertEquals(1, cookies.size()); + + JsonObject cookie = cookies.get(0).getAsJsonObject(); + assertEquals("tracking", cookie.get("name").getAsString()); + assertEquals("xyz789", cookie.get("value").getAsString()); + assertEquals("/", cookie.get("path").getAsString()); + assertTrue("cookie should be httpOnly", cookie.get("httpOnly").getAsBoolean()); + assertTrue("cookie should be secure", cookie.get("secure").getAsBoolean()); + + // Verify content + JsonObject content = response.getAsJsonObject("content"); + assertNotNull("content should not be null", content); + assertEquals(27, content.get("size").getAsInt()); + assertEquals("text/html", content.get("mimeType").getAsString()); + + // Verify headersSize and bodySize + assertTrue("response headersSize should be > 0", response.get("headersSize").getAsInt() > 0); + assertEquals(27, response.get("bodySize").getAsInt()); + } + + @Test + public void testPostRequestWithBody() throws IOException { + ConnectionDescriptor conn = createConnection(4, "10.0.0.2", 80); + conn.info = "api.example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "POST /api/login HTTP/1.1\r\n" + + "Host: api.example.com\r\n" + + "Content-Type: application/x-www-form-urlencoded\r\n" + + "Content-Length: 30\r\n" + + "\r\n" + + "username=admin&password=secret"; + + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json\r\n" + + "\r\n" + + "{\"success\":true}"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 100; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "POST"; + httpReq.host = "api.example.com"; + httpReq.path = "/api/login"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 30; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = "application/json"; + httpReply.bodyLength = 16; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject request = entry.getAsJsonObject("request"); + + // Verify method + assertEquals("POST", request.get("method").getAsString()); + + // Verify postData + assertTrue("POST request should have postData", request.has("postData")); + JsonObject postData = request.getAsJsonObject("postData"); + + assertEquals("application/x-www-form-urlencoded", postData.get("mimeType").getAsString()); + String text = postData.get("text").getAsString(); + // Verify the exact POST body is extracted + assertEquals("username=admin&password=secret", text); + + // Verify bodySize + assertEquals(30, request.get("bodySize").getAsInt()); + } + + @Test + public void testNoResponseEntry() throws IOException { + // Test entry where no response was received + ConnectionDescriptor conn = createConnection(8, "10.0.0.5", 80); + conn.info = "timeout.example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /timeout HTTP/1.1\r\n" + + "Host: timeout.example.com\r\n" + + "\r\n"; + + long reqTimestamp = System.currentTimeMillis(); + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + addChunkDirect(conn, reqChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "timeout.example.com"; + httpReq.path = "/timeout"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + // No reply set + + httpLog.addHttpRequest(httpReq); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject response = entry.getAsJsonObject("response"); + + // Verify response with no actual data + assertEquals(0, response.get("status").getAsInt()); + assertEquals("", response.get("statusText").getAsString()); + assertEquals(-1, response.get("headersSize").getAsInt()); + assertEquals(-1, response.get("bodySize").getAsInt()); + assertEquals("", response.get("redirectURL").getAsString()); + } + + @Test + public void testMultipleEntries() throws IOException { + // Test with multiple HTTP requests + for (int i = 0; i < 3; i++) { + ConnectionDescriptor conn = createConnection(10 + i, "10.0.0." + (10 + i), 80); + conn.info = "multi" + i + ".example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /page" + i + " HTTP/1.1\r\n" + + "Host: multi" + i + ".example.com\r\n" + + "\r\n"; + + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "\r\n" + + "page" + i; + + long reqTimestamp = System.currentTimeMillis() + i * 100; + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "multi" + i + ".example.com"; + httpReq.path = "/page" + i; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = "text/plain"; + httpReply.bodyLength = 5; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + } + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonArray entries = har.getAsJsonObject("log").getAsJsonArray("entries"); + + // Verify we have 3 entries + assertEquals(3, entries.size()); + + // Verify each entry has the expected path + for (int i = 0; i < 3; i++) { + JsonObject entry = entries.get(i).getAsJsonObject(); + JsonObject request = entry.getAsJsonObject("request"); + String url = request.get("url").getAsString(); + assertTrue("Entry " + i + " should have correct path", url.contains("/page" + i)); + } + } + + @Test + public void testBinaryContentEncoding() throws IOException { + ConnectionDescriptor conn = createConnection(13, "10.0.0.9", 80); + conn.info = "binary.example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /image.png HTTP/1.1\r\n" + + "Host: binary.example.com\r\n" + + "\r\n"; + + // Create a response with binary content (non-printable bytes) + String httpResponseHeaders = "HTTP/1.1 200 OK\r\n" + + "Content-Type: image/png\r\n" + + "\r\n"; + byte[] headerBytes = httpResponseHeaders.getBytes(StandardCharsets.UTF_8); + byte[] binaryBody = new byte[] {(byte)0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; // PNG signature + byte[] fullResponse = new byte[headerBytes.length + binaryBody.length]; + System.arraycopy(headerBytes, 0, fullResponse, 0, headerBytes.length); + System.arraycopy(binaryBody, 0, fullResponse, headerBytes.length, binaryBody.length); + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 100; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = new PayloadChunk(fullResponse, PayloadChunk.ChunkType.HTTP, false, respTimestamp, 0); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "binary.example.com"; + httpReq.path = "/image.png"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = "image/png"; + httpReply.bodyLength = binaryBody.length; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject response = entry.getAsJsonObject("response"); + JsonObject content = response.getAsJsonObject("content"); + + // Verify binary content is base64 encoded + assertEquals("image/png", content.get("mimeType").getAsString()); + assertTrue("binary content should have encoding field", content.has("encoding")); + assertEquals("base64", content.get("encoding").getAsString()); + + // Verify the actual base64 text matches the binary body + assertTrue("content should have text field", content.has("text")); + String base64Text = content.get("text").getAsString(); + byte[] decoded = Base64.decode(base64Text, Base64.NO_WRAP); + assertEquals("decoded length should match binary body", binaryBody.length, decoded.length); + for (int i = 0; i < binaryBody.length; i++) { + assertEquals("byte " + i + " should match", binaryBody[i], decoded[i]); + } + } + + @Test + public void testTextContentExtraction() throws IOException { + ConnectionDescriptor conn = createConnection(14, "10.0.0.10", 80); + conn.info = "text.example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /data.json HTTP/1.1\r\n" + + "Host: text.example.com\r\n" + + "\r\n"; + + String responseBody = "{\"name\":\"test\",\"value\":123}"; + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: " + responseBody.length() + "\r\n" + + "\r\n" + + responseBody; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "text.example.com"; + httpReq.path = "/data.json"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = "application/json"; + httpReply.bodyLength = responseBody.length(); + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject response = entry.getAsJsonObject("response"); + JsonObject content = response.getAsJsonObject("content"); + + // Verify text content is NOT base64 encoded + assertEquals("application/json", content.get("mimeType").getAsString()); + assertFalse("text content should NOT have encoding field", content.has("encoding")); + + // Verify the actual text content matches the response body + assertTrue("content should have text field", content.has("text")); + String textContent = content.get("text").getAsString(); + assertEquals("text content should match response body", responseBody, textContent); + } + + @Test + public void testGzipCompressedContent() throws IOException { + ConnectionDescriptor conn = createConnection(15, "10.0.0.11", 80); + conn.info = "gzip.example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /app.js HTTP/1.1\r\n" + + "Host: gzip.example.com\r\n" + + "Accept-Encoding: gzip\r\n" + + "\r\n"; + + // Original JavaScript content + String originalJs = "function hello() { console.log('Hello, World!'); }"; + + // Gzip compress the JavaScript + ByteArrayOutputStream gzipOut = new ByteArrayOutputStream(); + try (GZIPOutputStream gzos = new GZIPOutputStream(gzipOut)) { + gzos.write(originalJs.getBytes(StandardCharsets.UTF_8)); + } + byte[] gzippedBody = gzipOut.toByteArray(); + + // Build HTTP response with gzipped content + String httpResponseHeaders = "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/javascript\r\n" + + "Content-Encoding: gzip\r\n" + + "Content-Length: " + gzippedBody.length + "\r\n" + + "\r\n"; + byte[] headerBytes = httpResponseHeaders.getBytes(StandardCharsets.UTF_8); + byte[] fullResponse = new byte[headerBytes.length + gzippedBody.length]; + System.arraycopy(headerBytes, 0, fullResponse, 0, headerBytes.length); + System.arraycopy(gzippedBody, 0, fullResponse, headerBytes.length, gzippedBody.length); + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = new PayloadChunk(fullResponse, PayloadChunk.ChunkType.HTTP, false, respTimestamp, 0); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "gzip.example.com"; + httpReq.path = "/app.js"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = "application/javascript"; + httpReply.bodyLength = gzippedBody.length; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject response = entry.getAsJsonObject("response"); + JsonObject content = response.getAsJsonObject("content"); + + // Verify content type + assertEquals("application/javascript", content.get("mimeType").getAsString()); + + // Since the data is now decompressed before being written to HAR, + // the content should be plain text (not base64 encoded) + assertFalse("decompressed text content should NOT have encoding field", content.has("encoding")); + + // Verify the actual content is the original JavaScript (decompressed) + assertTrue("content should have text field", content.has("text")); + String textContent = content.get("text").getAsString(); + assertEquals("content should match original JavaScript", originalJs, textContent); + } + + @Test + public void testWebSocketMessages() throws IOException { + ConnectionDescriptor conn = createConnection(20, "10.0.0.20", 80); + conn.info = "ws.example.com"; + conn.l7proto = "HTTP"; + + // HTTP upgrade request/response + String httpRequest = "GET /websocket HTTP/1.1\r\n" + + "Host: ws.example.com\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "\r\n"; + + String httpResponse = "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "\r\n"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + // Add WebSocket text message (sent) + byte[] wsTextPayload = "Hello WebSocket".getBytes(StandardCharsets.UTF_8); + PayloadChunk wsSend = new PayloadChunk(wsTextPayload, PayloadChunk.ChunkType.WEBSOCKET, true, respTimestamp + 100, 0); + addChunkDirect(conn, wsSend); + + // Add WebSocket text message (received) + byte[] wsTextPayload2 = "Hello from server".getBytes(StandardCharsets.UTF_8); + PayloadChunk wsRecv = new PayloadChunk(wsTextPayload2, PayloadChunk.ChunkType.WEBSOCKET, false, respTimestamp + 200, 0); + addChunkDirect(conn, wsRecv); + + // Add WebSocket binary message + byte[] wsBinaryPayload = new byte[] {(byte)0x89, 0x50, 0x4E, 0x47}; // Binary data + PayloadChunk wsBinary = new PayloadChunk(wsBinaryPayload, PayloadChunk.ChunkType.WEBSOCKET, false, respTimestamp + 300, 0); + addChunkDirect(conn, wsBinary); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "ws.example.com"; + httpReq.path = "/websocket"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 101; + httpReply.responseStatus = "Switching Protocols"; + httpReply.contentType = ""; + httpReply.bodyLength = 0; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + + // Verify WebSocket resource type + assertTrue("entry should have _resourceType", entry.has("_resourceType")); + assertEquals("websocket", entry.get("_resourceType").getAsString()); + + // Verify WebSocket messages array + assertTrue("entry should have _webSocketMessages", entry.has("_webSocketMessages")); + JsonArray wsMessages = entry.getAsJsonArray("_webSocketMessages"); + assertEquals(3, wsMessages.size()); + + // Verify first message (text, sent) + JsonObject msg1 = wsMessages.get(0).getAsJsonObject(); + assertEquals("send", msg1.get("type").getAsString()); + assertEquals(1, msg1.get("opcode").getAsInt()); // Text + assertEquals("Hello WebSocket", msg1.get("data").getAsString()); + + // Verify second message (text, received) + JsonObject msg2 = wsMessages.get(1).getAsJsonObject(); + assertEquals("receive", msg2.get("type").getAsString()); + assertEquals(1, msg2.get("opcode").getAsInt()); // Text + assertEquals("Hello from server", msg2.get("data").getAsString()); + + // Verify third message (binary, received) + JsonObject msg3 = wsMessages.get(2).getAsJsonObject(); + assertEquals("receive", msg3.get("type").getAsString()); + assertEquals(2, msg3.get("opcode").getAsInt()); // Binary + // Binary data should be base64 encoded + assertNotNull(msg3.get("data").getAsString()); + } + + @Test + public void testWebSocketEmptyPayload() throws IOException { + ConnectionDescriptor conn = createConnection(21, "10.0.0.21", 80); + conn.info = "ws.example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /ws HTTP/1.1\r\n" + + "Host: ws.example.com\r\n" + + "\r\n"; + + String httpResponse = "HTTP/1.1 101 Switching Protocols\r\n" + + "\r\n"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + // Add WebSocket message with empty payload + PayloadChunk wsEmpty = new PayloadChunk(new byte[0], PayloadChunk.ChunkType.WEBSOCKET, true, respTimestamp + 100, 0); + addChunkDirect(conn, wsEmpty); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "ws.example.com"; + httpReq.path = "/ws"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 101; + httpReply.responseStatus = "Switching Protocols"; + httpReply.contentType = ""; + httpReply.bodyLength = 0; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + + JsonArray wsMessages = entry.getAsJsonArray("_webSocketMessages"); + assertEquals(1, wsMessages.size()); + + JsonObject msg = wsMessages.get(0).getAsJsonObject(); + assertEquals("", msg.get("data").getAsString()); // Empty payload + } + + @Test + public void testHttpRstHandling() throws IOException { + ConnectionDescriptor conn = createConnection(22, "10.0.0.22", 80); + conn.info = "rst.example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /reset HTTP/1.1\r\n" + + "Host: rst.example.com\r\n" + + "\r\n"; + + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "\r\n" + + "data"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "rst.example.com"; + httpReq.path = "/reset"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + httpReq.httpRst = true; // HTTP RST flag + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = "text/plain"; + httpReply.bodyLength = 4; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject response = entry.getAsJsonObject("response"); + + // When httpRst is true, response should be empty like no-response case + assertEquals(0, response.get("status").getAsInt()); + assertEquals("", response.get("statusText").getAsString()); + assertEquals(-1, response.get("headersSize").getAsInt()); + assertEquals(-1, response.get("bodySize").getAsInt()); + assertEquals("", response.get("redirectURL").getAsString()); + } + + @Test + public void testPutAndPatchMethodsWithBody() throws IOException { + // Test that PUT and PATCH methods include postData (like POST) + String[] methods = {"PUT", "PATCH"}; + String[] bodies = {"{\"name\":\"updated\"}", "{\"field\":\"new\"}"}; + + for (int i = 0; i < methods.length; i++) { + httpLog = new HttpLog(); // Reset for each iteration + + ConnectionDescriptor conn = createConnection(23 + i, "10.0.0." + (23 + i), 80); + conn.info = "api.example.com"; + conn.l7proto = "HTTP"; + + String body = bodies[i]; + String httpRequest = methods[i] + " /api/resource/1 HTTP/1.1\r\n" + + "Host: api.example.com\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: " + body.length() + "\r\n" + + "\r\n" + + body; + + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json\r\n" + + "\r\n" + + "{}"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 100; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = methods[i]; + httpReq.host = "api.example.com"; + httpReq.path = "/api/resource/1"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = body.length(); + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = "application/json"; + httpReply.bodyLength = 2; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + HarWriter writer = new HarWriter(context, httpLog); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + writer.write(out); + String harJson = out.toString(StandardCharsets.UTF_8.name()); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject request = entry.getAsJsonObject("request"); + + assertEquals(methods[i], request.get("method").getAsString()); + assertTrue(methods[i] + " request should have postData", request.has("postData")); + JsonObject postData = request.getAsJsonObject("postData"); + assertEquals("application/json", postData.get("mimeType").getAsString()); + assertEquals(body, postData.get("text").getAsString()); + } + } + + @Test + public void testNullFieldsFallbacks() throws IOException { + // Test that null fields fall back to appropriate defaults + ConnectionDescriptor conn = createConnection(25, "10.0.0.25", 80); + conn.info = "null.example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /path HTTP/1.1\r\n" + + "Host: null.example.com\r\n" + + "\r\n"; + + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "\r\n" + + "OK"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = null; // Null method + httpReq.host = "null.example.com"; + httpReq.path = "/path"; + httpReq.query = null; // Null query + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = null; // Null response status + httpReply.contentType = null; // Null content type + httpReply.bodyLength = 2; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject request = entry.getAsJsonObject("request"); + JsonObject response = entry.getAsJsonObject("response"); + JsonObject content = response.getAsJsonObject("content"); + + // Verify null fallbacks + assertEquals("method should fallback to empty", "", request.get("method").getAsString()); + assertTrue("null query should result in empty queryString", request.getAsJsonArray("queryString").isEmpty()); + assertEquals("statusText should fallback to empty", "", response.get("statusText").getAsString()); + assertEquals("null contentType should fallback to octet-stream", "application/octet-stream", content.get("mimeType").getAsString()); + } + + @Test + public void testMissingChunks() throws IOException { + // Test missing request chunk + ConnectionDescriptor conn1 = createConnection(26, "10.0.0.26", 80); + conn1.info = "nochunk.example.com"; + conn1.l7proto = "HTTP"; + + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "\r\n" + + "OK"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + addChunkDirect(conn1, respChunk); + + HttpLog.HttpRequest httpReq1 = new HttpLog.HttpRequest(conn1, 0); + httpReq1.method = "GET"; + httpReq1.host = "nochunk.example.com"; + httpReq1.path = "/path"; + httpReq1.query = ""; + httpReq1.timestamp = reqTimestamp; + httpReq1.bodyLength = 0; + + HttpLog.HttpReply httpReply1 = new HttpLog.HttpReply(httpReq1, 0); + httpReply1.responseCode = 200; + httpReply1.responseStatus = "OK"; + httpReply1.contentType = "text/plain"; + httpReply1.bodyLength = 2; + httpReq1.reply = httpReply1; + + httpLog.addHttpRequest(httpReq1); + httpLog.addHttpReply(httpReply1); + + // Test missing response chunk + ConnectionDescriptor conn2 = createConnection(29, "10.0.0.29", 80); + conn2.info = "missing.example.com"; + conn2.l7proto = "HTTP"; + + String httpRequest = "GET /path HTTP/1.1\r\n" + + "Host: missing.example.com\r\n" + + "\r\n"; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + addChunkDirect(conn2, reqChunk); + + HttpLog.HttpRequest httpReq2 = new HttpLog.HttpRequest(conn2, 0); + httpReq2.method = "GET"; + httpReq2.host = "missing.example.com"; + httpReq2.path = "/path"; + httpReq2.query = ""; + httpReq2.timestamp = reqTimestamp; + httpReq2.bodyLength = 0; + + HttpLog.HttpReply httpReply2 = new HttpLog.HttpReply(httpReq2, 5); // Invalid chunk position + httpReply2.responseCode = 200; + httpReply2.responseStatus = "OK"; + httpReply2.contentType = "text/plain"; + httpReply2.bodyLength = 10; + httpReq2.reply = httpReply2; + + httpLog.addHttpRequest(httpReq2); + httpLog.addHttpReply(httpReply2); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonArray entries = har.getAsJsonObject("log").getAsJsonArray("entries"); + + // Verify missing request chunk defaults + JsonObject entry1 = entries.get(0).getAsJsonObject(); + JsonObject request1 = entry1.getAsJsonObject("request"); + assertEquals("HTTP/1.1", request1.get("httpVersion").getAsString()); + assertEquals(-1, request1.get("headersSize").getAsInt()); + assertTrue(request1.getAsJsonArray("headers").isEmpty()); + + // Verify missing response chunk defaults + JsonObject entry2 = entries.get(1).getAsJsonObject(); + JsonObject response2 = entry2.getAsJsonObject("response"); + assertEquals("HTTP/1.1", response2.get("httpVersion").getAsString()); + assertEquals(-1, response2.get("headersSize").getAsInt()); + assertTrue(response2.getAsJsonArray("headers").isEmpty()); + assertFalse("content should NOT have text when chunk is missing", + entry2.getAsJsonObject("response").getAsJsonObject("content").has("text")); + } + + @Test + public void testRedirectURLNotFound() throws IOException { + // Response without Location header should have empty redirectURL + ConnectionDescriptor conn = createConnection(30, "10.0.0.30", 80); + conn.info = "noredirect.example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /path HTTP/1.1\r\n" + + "Host: noredirect.example.com\r\n" + + "\r\n"; + + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "\r\n" + + "OK"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "noredirect.example.com"; + httpReq.path = "/path"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = "text/plain"; + httpReply.bodyLength = 2; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject harEntry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject response = harEntry.getAsJsonObject("response"); + + assertEquals("", response.get("redirectURL").getAsString()); + } + + @Test + public void testEmptyResponseBody() throws IOException { + ConnectionDescriptor conn = createConnection(31, "10.0.0.31", 80); + conn.info = "empty.example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /empty HTTP/1.1\r\n" + + "Host: empty.example.com\r\n" + + "\r\n"; + + // Response with no body (just headers) + String httpResponse = "HTTP/1.1 204 No Content\r\n" + + "Content-Length: 0\r\n" + + "\r\n"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "empty.example.com"; + httpReq.path = "/empty"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 204; + httpReply.responseStatus = "No Content"; + httpReply.contentType = ""; + httpReply.bodyLength = 0; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject response = entry.getAsJsonObject("response"); + JsonObject content = response.getAsJsonObject("content"); + + // When body is empty, no text or encoding fields should be added + assertFalse("content should NOT have text when body is empty", content.has("text")); + assertFalse("content should NOT have encoding when body is empty", content.has("encoding")); + } + + @Test + public void testNullContentTypeInPostData() throws IOException { + ConnectionDescriptor conn = createConnection(32, "10.0.0.32", 80); + conn.info = "post.example.com"; + conn.l7proto = "HTTP"; + + // POST request without Content-Type header + String httpRequest = "POST /submit HTTP/1.1\r\n" + + "Host: post.example.com\r\n" + + "Content-Length: 11\r\n" + + "\r\n" + + "raw content"; + + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "\r\n"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "POST"; + httpReq.host = "post.example.com"; + httpReq.path = "/submit"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 11; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = ""; + httpReply.bodyLength = 0; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject request = entry.getAsJsonObject("request"); + JsonObject postData = request.getAsJsonObject("postData"); + + assertEquals("", postData.get("mimeType").getAsString()); // Fallback to "" + } + + @Test + public void testQueryParamWithoutValue() throws IOException { + ConnectionDescriptor conn = createConnection(34, "10.0.0.34", 80); + conn.info = "query.example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /path?flag&key=value&another HTTP/1.1\r\n" + + "Host: query.example.com\r\n" + + "\r\n"; + + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "\r\n"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "query.example.com"; + httpReq.path = "/path"; + httpReq.query = "?flag&key=value&another"; // Params without '=' + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = ""; + httpReply.bodyLength = 0; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject request = entry.getAsJsonObject("request"); + JsonArray queryString = request.getAsJsonArray("queryString"); + + assertEquals(3, queryString.size()); + + // First param: flag (no value) + JsonObject param1 = queryString.get(0).getAsJsonObject(); + assertEquals("flag", param1.get("name").getAsString()); + assertEquals("", param1.get("value").getAsString()); + + // Second param: key=value + JsonObject param2 = queryString.get(1).getAsJsonObject(); + assertEquals("key", param2.get("name").getAsString()); + assertEquals("value", param2.get("value").getAsString()); + + // Third param: another (no value) + JsonObject param3 = queryString.get(2).getAsJsonObject(); + assertEquals("another", param3.get("name").getAsString()); + assertEquals("", param3.get("value").getAsString()); + } + + @Test + public void testMalformedUrlEncoding() throws IOException { + ConnectionDescriptor conn = createConnection(35, "10.0.0.35", 80); + conn.info = "query.example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /path?valid=ok&bad=%ZZ&good=yes HTTP/1.1\r\n" + + "Host: query.example.com\r\n" + + "\r\n"; + + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "\r\n"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "query.example.com"; + httpReq.path = "/path"; + httpReq.query = "?valid=ok&bad=%ZZ&good=yes"; // Invalid %ZZ encoding + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = ""; + httpReply.bodyLength = 0; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject request = entry.getAsJsonObject("request"); + JsonArray queryString = request.getAsJsonArray("queryString"); + + // Only valid params should be present (malformed one skipped) + assertEquals(2, queryString.size()); + } + + @Test + public void testInvalidRequestCookie() throws IOException { + ConnectionDescriptor conn = createConnection(36, "10.0.0.36", 80); + conn.info = "cookie.example.com"; + conn.l7proto = "HTTP"; + + // Cookie with invalid format (no '=') + String httpRequest = "GET /path HTTP/1.1\r\n" + + "Host: cookie.example.com\r\n" + + "Cookie: valid=value; invalid; another=ok\r\n" + + "\r\n"; + + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "\r\n"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "cookie.example.com"; + httpReq.path = "/path"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = ""; + httpReply.bodyLength = 0; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject request = entry.getAsJsonObject("request"); + JsonArray cookies = request.getAsJsonArray("cookies"); + + // Only valid cookies should be present + assertEquals(2, cookies.size()); + } + + @Test + public void testInvalidSetCookie() throws IOException { + ConnectionDescriptor conn = createConnection(37, "10.0.0.37", 80); + conn.info = "cookie.example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /path HTTP/1.1\r\n" + + "Host: cookie.example.com\r\n" + + "\r\n"; + + // Set-Cookie with invalid format (no '=' in name=value part) + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "Set-Cookie: invalidcookie; Path=/\r\n" + + "Set-Cookie: valid=ok; Path=/\r\n" + + "\r\n"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "cookie.example.com"; + httpReq.path = "/path"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = ""; + httpReply.bodyLength = 0; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject response = entry.getAsJsonObject("response"); + JsonArray cookies = response.getAsJsonArray("cookies"); + + // Only valid cookie should be present + assertEquals(1, cookies.size()); + assertEquals("valid", cookies.get(0).getAsJsonObject().get("name").getAsString()); + } + + @Test + public void testSetCookieAllAttributes() throws IOException { + ConnectionDescriptor conn = createConnection(38, "10.0.0.38", 80); + conn.info = "cookie.example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /path HTTP/1.1\r\n" + + "Host: cookie.example.com\r\n" + + "\r\n"; + + // Set-Cookie with all attributes + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "Set-Cookie: session=abc123; Path=/app; Domain=.example.com; HttpOnly; Secure; SameSite=Lax; Expires=Wed, 09 Jun 2027 10:18:14 GMT\r\n" + + "\r\n"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "cookie.example.com"; + httpReq.path = "/path"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = ""; + httpReply.bodyLength = 0; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject response = entry.getAsJsonObject("response"); + JsonArray cookies = response.getAsJsonArray("cookies"); + + assertEquals(1, cookies.size()); + JsonObject cookie = cookies.get(0).getAsJsonObject(); + assertEquals("session", cookie.get("name").getAsString()); + assertEquals("abc123", cookie.get("value").getAsString()); + assertEquals("/app", cookie.get("path").getAsString()); + assertEquals(".example.com", cookie.get("domain").getAsString()); + assertTrue(cookie.get("httpOnly").getAsBoolean()); + assertTrue(cookie.get("secure").getAsBoolean()); + assertEquals("Lax", cookie.get("sameSite").getAsString()); + assertEquals("2027-06-09T10:18:14Z", cookie.get("expires").getAsString()); + } + + @Test + public void testEmptyHttpText() throws IOException { + ConnectionDescriptor conn = createConnection(39, "10.0.0.39", 80); + conn.info = "empty.example.com"; + conn.l7proto = "HTTP"; + + // Empty request chunk payload + PayloadChunk reqChunk = new PayloadChunk(new byte[0], PayloadChunk.ChunkType.HTTP, true, System.currentTimeMillis(), 0); + PayloadChunk respChunk = new PayloadChunk(new byte[0], PayloadChunk.ChunkType.HTTP, false, System.currentTimeMillis() + 50, 0); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "empty.example.com"; + httpReq.path = "/path"; + httpReq.query = ""; + httpReq.timestamp = System.currentTimeMillis(); + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = ""; + httpReply.bodyLength = 0; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject request = entry.getAsJsonObject("request"); + JsonObject response = entry.getAsJsonObject("response"); + + // With empty payload, defaults should be used + assertEquals("HTTP/1.1", request.get("httpVersion").getAsString()); + assertEquals("HTTP/1.1", response.get("httpVersion").getAsString()); + } + + @Test + public void testMalformedHttpLines() throws IOException { + // Test malformed request line (no space/HTTP version) + ConnectionDescriptor conn1 = createConnection(40, "10.0.0.40", 80); + conn1.info = "malformed.example.com"; + conn1.l7proto = "HTTP"; + + String httpRequest1 = "GET/path\r\n" + + "Host: malformed.example.com\r\n" + + "\r\n"; + String httpResponse1 = "HTTP/1.1 200 OK\r\n\r\n"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + addChunkDirect(conn1, createHttpChunk(httpRequest1, true, reqTimestamp)); + addChunkDirect(conn1, createHttpChunk(httpResponse1, false, respTimestamp)); + + HttpLog.HttpRequest httpReq1 = new HttpLog.HttpRequest(conn1, 0); + httpReq1.method = "GET"; + httpReq1.host = "malformed.example.com"; + httpReq1.path = "/path"; + httpReq1.query = ""; + httpReq1.timestamp = reqTimestamp; + httpReq1.bodyLength = 0; + + HttpLog.HttpReply httpReply1 = new HttpLog.HttpReply(httpReq1, 1); + httpReply1.responseCode = 200; + httpReply1.responseStatus = "OK"; + httpReply1.contentType = ""; + httpReply1.bodyLength = 0; + httpReq1.reply = httpReply1; + + httpLog.addHttpRequest(httpReq1); + httpLog.addHttpReply(httpReply1); + + // Test malformed response line (no space) + ConnectionDescriptor conn2 = createConnection(41, "10.0.0.41", 80); + conn2.info = "malformed.example.com"; + conn2.l7proto = "HTTP"; + + String httpRequest2 = "GET /path HTTP/1.1\r\n" + + "Host: malformed.example.com\r\n" + + "\r\n"; + String httpResponse2 = "200OK\r\n\r\n"; + + addChunkDirect(conn2, createHttpChunk(httpRequest2, true, reqTimestamp)); + addChunkDirect(conn2, createHttpChunk(httpResponse2, false, respTimestamp)); + + HttpLog.HttpRequest httpReq2 = new HttpLog.HttpRequest(conn2, 0); + httpReq2.method = "GET"; + httpReq2.host = "malformed.example.com"; + httpReq2.path = "/path"; + httpReq2.query = ""; + httpReq2.timestamp = reqTimestamp; + httpReq2.bodyLength = 0; + + HttpLog.HttpReply httpReply2 = new HttpLog.HttpReply(httpReq2, 1); + httpReply2.responseCode = 200; + httpReply2.responseStatus = "OK"; + httpReply2.contentType = ""; + httpReply2.bodyLength = 0; + httpReq2.reply = httpReply2; + + httpLog.addHttpRequest(httpReq2); + httpLog.addHttpReply(httpReply2); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonArray entries = har.getAsJsonObject("log").getAsJsonArray("entries"); + + // Both should default to HTTP/1.1 for malformed lines + JsonObject entry1 = entries.get(0).getAsJsonObject(); + assertEquals("HTTP/1.1", entry1.getAsJsonObject("request").get("httpVersion").getAsString()); + + JsonObject entry2 = entries.get(1).getAsJsonObject(); + assertEquals("HTTP/1.1", entry2.getAsJsonObject("response").get("httpVersion").getAsString()); + } + + @Test + public void testInvalidHeaders() throws IOException { + ConnectionDescriptor conn = createConnection(42, "10.0.0.42", 80); + conn.info = "header.example.com"; + conn.l7proto = "HTTP"; + + // Request with invalid headers (missing colon, colon at position 0) + String httpRequest = "GET /path HTTP/1.1\r\n" + + "Valid-Header: value\r\n" + + "InvalidHeaderNoColon\r\n" + + ":InvalidColonAtStart\r\n" + + "Another-Valid: ok\r\n" + + "\r\n"; + + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "\r\n"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "header.example.com"; + httpReq.path = "/path"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = ""; + httpReply.bodyLength = 0; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject request = entry.getAsJsonObject("request"); + JsonArray headers = request.getAsJsonArray("headers"); + + // Only valid headers should be present (invalid ones skipped) + assertEquals(2, headers.size()); + + boolean hasValid = false, hasAnother = false; + for (int i = 0; i < headers.size(); i++) { + String name = headers.get(i).getAsJsonObject().get("name").getAsString(); + if (name.equals("Valid-Header")) hasValid = true; + if (name.equals("Another-Valid")) hasAnother = true; + } + assertTrue("Should have Valid-Header", hasValid); + assertTrue("Should have Another-Valid", hasAnother); + } + + @Test + public void testXmlContentType() throws IOException { + ConnectionDescriptor conn = createConnection(43, "10.0.0.43", 80); + conn.info = "xml.example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /data.xml HTTP/1.1\r\n" + + "Host: xml.example.com\r\n" + + "\r\n"; + + String xmlBody = "value"; + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/xml\r\n" + + "\r\n" + + xmlBody; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "xml.example.com"; + httpReq.path = "/data.xml"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = "application/xml"; + httpReply.bodyLength = xmlBody.length(); + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject response = entry.getAsJsonObject("response"); + JsonObject content = response.getAsJsonObject("content"); + + // XML is text content, should not be base64 encoded + assertFalse("xml content should NOT have encoding", content.has("encoding")); + assertEquals(xmlBody, content.get("text").getAsString()); + } + + @Test + public void testBinaryContentTypesAreBase64Encoded() throws IOException { + // Test that audio, video, and octet-stream content types result in base64 encoding + String[] contentTypes = {"audio/mpeg", "video/mp4", "application/octet-stream"}; + byte[] binaryData = new byte[] {0x01, 0x02, 0x03, 0x04}; + + for (int i = 0; i < contentTypes.length; i++) { + httpLog = new HttpLog(); // Reset for each iteration + + ConnectionDescriptor conn = createConnection(44 + i, "10.0.0." + (44 + i), 80); + conn.info = "binary.example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /file HTTP/1.1\r\n" + + "Host: binary.example.com\r\n" + + "\r\n"; + + String httpResponseHeaders = "HTTP/1.1 200 OK\r\n" + + "Content-Type: " + contentTypes[i] + "\r\n" + + "\r\n"; + byte[] headerBytes = httpResponseHeaders.getBytes(StandardCharsets.UTF_8); + byte[] fullResponse = new byte[headerBytes.length + binaryData.length]; + System.arraycopy(headerBytes, 0, fullResponse, 0, headerBytes.length); + System.arraycopy(binaryData, 0, fullResponse, headerBytes.length, binaryData.length); + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = new PayloadChunk(fullResponse, PayloadChunk.ChunkType.HTTP, false, respTimestamp, 0); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "binary.example.com"; + httpReq.path = "/file"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = contentTypes[i]; + httpReply.bodyLength = binaryData.length; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + HarWriter writer = new HarWriter(context, httpLog); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + writer.write(out); + String harJson = out.toString(StandardCharsets.UTF_8.name()); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject response = entry.getAsJsonObject("response"); + JsonObject content = response.getAsJsonObject("content"); + + assertTrue(contentTypes[i] + " content should have encoding", content.has("encoding")); + assertEquals("base64", content.get("encoding").getAsString()); + } + } + + @Test + public void testByteBasedContentDetection() throws IOException { + // Test text detection: printable bytes with null content type + ConnectionDescriptor conn1 = createConnection(47, "10.0.0.47", 80); + conn1.info = "detect.example.com"; + conn1.l7proto = "HTTP"; + + String textBody = "This is plain text content"; + String httpResponse1 = "HTTP/1.1 200 OK\r\n\r\n" + textBody; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + String httpRequest = "GET /data HTTP/1.1\r\n" + + "Host: detect.example.com\r\n" + + "\r\n"; + + addChunkDirect(conn1, createHttpChunk(httpRequest, true, reqTimestamp)); + addChunkDirect(conn1, createHttpChunk(httpResponse1, false, respTimestamp)); + + HttpLog.HttpRequest httpReq1 = new HttpLog.HttpRequest(conn1, 0); + httpReq1.method = "GET"; + httpReq1.host = "detect.example.com"; + httpReq1.path = "/data"; + httpReq1.query = ""; + httpReq1.timestamp = reqTimestamp; + httpReq1.bodyLength = 0; + + HttpLog.HttpReply httpReply1 = new HttpLog.HttpReply(httpReq1, 1); + httpReply1.responseCode = 200; + httpReply1.responseStatus = "OK"; + httpReply1.contentType = null; // Forces byte-based detection + httpReply1.bodyLength = textBody.length(); + httpReq1.reply = httpReply1; + + httpLog.addHttpRequest(httpReq1); + httpLog.addHttpReply(httpReply1); + + // Test binary detection: non-printable bytes with null content type + ConnectionDescriptor conn2 = createConnection(48, "10.0.0.48", 80); + conn2.info = "detect.example.com"; + conn2.l7proto = "HTTP"; + + byte[] binaryData = new byte[] {(byte)0x00, (byte)0x01, (byte)0xFF, (byte)0xFE}; + String httpResponseHeaders = "HTTP/1.1 200 OK\r\n\r\n"; + byte[] headerBytes = httpResponseHeaders.getBytes(StandardCharsets.UTF_8); + byte[] fullResponse = new byte[headerBytes.length + binaryData.length]; + System.arraycopy(headerBytes, 0, fullResponse, 0, headerBytes.length); + System.arraycopy(binaryData, 0, fullResponse, headerBytes.length, binaryData.length); + + addChunkDirect(conn2, createHttpChunk(httpRequest, true, reqTimestamp)); + addChunkDirect(conn2, new PayloadChunk(fullResponse, PayloadChunk.ChunkType.HTTP, false, respTimestamp, 0)); + + HttpLog.HttpRequest httpReq2 = new HttpLog.HttpRequest(conn2, 0); + httpReq2.method = "GET"; + httpReq2.host = "detect.example.com"; + httpReq2.path = "/data"; + httpReq2.query = ""; + httpReq2.timestamp = reqTimestamp; + httpReq2.bodyLength = 0; + + HttpLog.HttpReply httpReply2 = new HttpLog.HttpReply(httpReq2, 1); + httpReply2.responseCode = 200; + httpReply2.responseStatus = "OK"; + httpReply2.contentType = null; // Forces byte-based detection + httpReply2.bodyLength = binaryData.length; + httpReq2.reply = httpReply2; + + httpLog.addHttpRequest(httpReq2); + httpLog.addHttpReply(httpReply2); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonArray entries = har.getAsJsonObject("log").getAsJsonArray("entries"); + + // Verify text detection (first entry) + JsonObject content1 = entries.get(0).getAsJsonObject().getAsJsonObject("response").getAsJsonObject("content"); + assertFalse("text-detected content should NOT have encoding", content1.has("encoding")); + assertEquals(textBody, content1.get("text").getAsString()); + + // Verify binary detection (second entry) + JsonObject content2 = entries.get(1).getAsJsonObject().getAsJsonObject("response").getAsJsonObject("content"); + assertTrue("binary-detected content should have encoding", content2.has("encoding")); + assertEquals("base64", content2.get("encoding").getAsString()); + } + + @Test + public void testEntryTimeWithMissingReplyChunk() throws IOException { + ConnectionDescriptor conn = createConnection(50, "10.0.0.50", 80); + conn.info = "time.example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /path HTTP/1.1\r\n" + + "Host: time.example.com\r\n" + + "\r\n"; + + long reqTimestamp = System.currentTimeMillis(); + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + addChunkDirect(conn, reqChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "time.example.com"; + httpReq.path = "/path"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 10); // Invalid chunk position + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = ""; + httpReply.bodyLength = 0; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + + // With missing reply chunk, time should be -1 + assertEquals(-1, entry.get("time").getAsInt()); + } + + @Test + public void testPostWithMissingChunk() throws IOException { + ConnectionDescriptor conn = createConnection(51, "10.0.0.51", 80); + conn.info = "post.example.com"; + conn.l7proto = "HTTP"; + + // Don't add request chunk + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "\r\n"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 5); // Invalid chunk position + httpReq.method = "POST"; + httpReq.host = "post.example.com"; + httpReq.path = "/submit"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 100; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 0); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = ""; + httpReply.bodyLength = 0; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject request = entry.getAsJsonObject("request"); + + // POST should have postData even with missing chunk + assertTrue("POST should have postData", request.has("postData")); + JsonObject postData = request.getAsJsonObject("postData"); + assertEquals("", postData.get("mimeType").getAsString()); + // text field should not be present when chunk is missing + assertFalse("postData should NOT have text when chunk is missing", postData.has("text")); + } + + @Test + public void testMimeTypeWithCharset() throws IOException { + ConnectionDescriptor conn = createConnection(53, "10.0.0.53", 80); + conn.info = "charset.example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /page HTTP/1.1\r\n" + + "Host: charset.example.com\r\n" + + "\r\n"; + + String htmlBody = "Hello"; + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + "\r\n" + + htmlBody; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "charset.example.com"; + httpReq.path = "/page"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = "text/html; charset=utf-8"; // With charset + httpReply.bodyLength = htmlBody.length(); + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject response = entry.getAsJsonObject("response"); + JsonObject content = response.getAsJsonObject("content"); + + // MimeType should include the full content-type with charset + assertEquals("text/html; charset=utf-8", content.get("mimeType").getAsString()); + + // Content should be detected as text (not base64) + assertFalse("text/html with charset should NOT have encoding", content.has("encoding")); + assertEquals(htmlBody, content.get("text").getAsString()); + } + + @Test + public void testPostDataMimeTypeWithCharset() throws IOException { + ConnectionDescriptor conn = createConnection(54, "10.0.0.54", 80); + conn.info = "form.example.com"; + conn.l7proto = "HTTP"; + + String postBody = "name=test&value=123"; + String httpRequest = "POST /submit HTTP/1.1\r\n" + + "Host: form.example.com\r\n" + + "Content-Type: application/x-www-form-urlencoded; charset=utf-8\r\n" + + "Content-Length: " + postBody.length() + "\r\n" + + "\r\n" + + postBody; + + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json\r\n" + + "\r\n" + + "{}"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "POST"; + httpReq.host = "form.example.com"; + httpReq.path = "/submit"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = postBody.length(); + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = "application/json"; + httpReply.bodyLength = 2; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject request = entry.getAsJsonObject("request"); + JsonObject postData = request.getAsJsonObject("postData"); + + // PostData mimeType should include the full content-type with charset + assertEquals("application/x-www-form-urlencoded; charset=utf-8", postData.get("mimeType").getAsString()); + assertEquals(postBody, postData.get("text").getAsString()); + } + + @Test + public void testMimeTypeCaseInsensitive() throws IOException { + ConnectionDescriptor conn = createConnection(55, "10.0.0.55", 80); + conn.info = "case.example.com"; + conn.l7proto = "HTTP"; + + String jsonBody = "{\"test\": true}"; + String httpRequest = "POST /api HTTP/1.1\r\n" + + "Host: case.example.com\r\n" + + "CONTENT-TYPE: APPLICATION/JSON\r\n" + // Uppercase header + "Content-Length: " + jsonBody.length() + "\r\n" + + "\r\n" + + jsonBody; + + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "content-type: TEXT/PLAIN\r\n" + // Lowercase header name, uppercase value + "\r\n" + + "OK"; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 50; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "POST"; + httpReq.host = "case.example.com"; + httpReq.path = "/api"; + httpReq.query = ""; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = jsonBody.length(); + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = "TEXT/PLAIN"; // Uppercase + httpReply.bodyLength = 2; + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + JsonObject har = parseHar(harJson); + JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject(); + JsonObject request = entry.getAsJsonObject("request"); + JsonObject response = entry.getAsJsonObject("response"); + + // PostData mimeType should be found with case-insensitive header lookup + JsonObject postData = request.getAsJsonObject("postData"); + assertEquals("APPLICATION/JSON", postData.get("mimeType").getAsString()); + + // Response mimeType + JsonObject content = response.getAsJsonObject("content"); + assertEquals("TEXT/PLAIN", content.get("mimeType").getAsString()); + } + + @Test + public void testHtmlEntitiesNotEscaped() throws IOException { + // Create a connection with HTML content containing special characters + ConnectionDescriptor conn = createConnection(1, "93.184.216.34", 80); + conn.info = "example.com"; + conn.l7proto = "HTTP"; + + String httpRequest = "GET /page?foo=1&bar=2 HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "\r\n"; + + String htmlBody = "Hello & goodbye"; + String httpResponse = "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/html\r\n" + + "Content-Length: " + htmlBody.length() + "\r\n" + + "\r\n" + + htmlBody; + + long reqTimestamp = System.currentTimeMillis(); + long respTimestamp = reqTimestamp + 100; + + PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp); + PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp); + + addChunkDirect(conn, reqChunk); + addChunkDirect(conn, respChunk); + + HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0); + httpReq.method = "GET"; + httpReq.host = "example.com"; + httpReq.path = "/page"; + httpReq.query = "foo=1&bar=2"; + httpReq.timestamp = reqTimestamp; + httpReq.bodyLength = 0; + + HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1); + httpReply.responseCode = 200; + httpReply.responseStatus = "OK"; + httpReply.contentType = "text/html"; + httpReply.bodyLength = htmlBody.length(); + httpReq.reply = httpReply; + + httpLog.addHttpRequest(httpReq); + httpLog.addHttpReply(httpReply); + + String harJson = writeHarToString(); + + // Verify that HTML entities are NOT escaped (disableHtmlEscaping is working) + // Without disableHtmlEscaping, Gson would convert: + // < to \u003c + // > to \u003e + // & to \u0026 + // = to \u003d + assertFalse("HTML entities should not be escaped: \\u003c found", + harJson.contains("\\u003c")); + assertFalse("HTML entities should not be escaped: \\u003e found", + harJson.contains("\\u003e")); + assertFalse("HTML entities should not be escaped: \\u0026 found", + harJson.contains("\\u0026")); + assertFalse("HTML entities should not be escaped: \\u003d found", + harJson.contains("\\u003d")); + + // Verify the actual characters are present + assertTrue("Should contain literal < character", harJson.contains("")); + assertTrue("Should contain literal > character", harJson.contains("")); + assertTrue("Should contain literal & character", harJson.contains("&bar")); + assertTrue("Should contain literal = in query", harJson.contains("foo=1")); + } + + @Test + public void testSingleRequestExport() throws IOException { + // Create two HTTP requests to verify that only the specified one is exported + ConnectionDescriptor conn1 = createConnection(1, "93.184.216.34", 80); + conn1.info = "example1.com"; + conn1.l7proto = "HTTP"; + + ConnectionDescriptor conn2 = createConnection(2, "93.184.216.35", 80); + conn2.info = "example2.com"; + conn2.l7proto = "HTTP"; + + String httpRequest1 = "GET /first HTTP/1.1\r\n" + + "Host: example1.com\r\n" + + "\r\n"; + + String httpResponse1 = "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "\r\n" + + "First response"; + + String httpRequest2 = "GET /second HTTP/1.1\r\n" + + "Host: example2.com\r\n" + + "\r\n"; + + String httpResponse2 = "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "\r\n" + + "Second response"; + + long reqTimestamp1 = System.currentTimeMillis(); + long respTimestamp1 = reqTimestamp1 + 50; + long reqTimestamp2 = reqTimestamp1 + 100; + long respTimestamp2 = reqTimestamp2 + 50; + + // Add chunks for first request + PayloadChunk reqChunk1 = createHttpChunk(httpRequest1, true, reqTimestamp1); + PayloadChunk respChunk1 = createHttpChunk(httpResponse1, false, respTimestamp1); + addChunkDirect(conn1, reqChunk1); + addChunkDirect(conn1, respChunk1); + + // Add chunks for second request + PayloadChunk reqChunk2 = createHttpChunk(httpRequest2, true, reqTimestamp2); + PayloadChunk respChunk2 = createHttpChunk(httpResponse2, false, respTimestamp2); + addChunkDirect(conn2, reqChunk2); + addChunkDirect(conn2, respChunk2); + + // Create first HTTP request + HttpLog.HttpRequest httpReq1 = new HttpLog.HttpRequest(conn1, 0); + httpReq1.method = "GET"; + httpReq1.host = "example1.com"; + httpReq1.path = "/first"; + httpReq1.query = ""; + httpReq1.timestamp = reqTimestamp1; + httpReq1.bodyLength = 0; + + HttpLog.HttpReply httpReply1 = new HttpLog.HttpReply(httpReq1, 1); + httpReply1.responseCode = 200; + httpReply1.responseStatus = "OK"; + httpReply1.contentType = "text/plain"; + httpReply1.bodyLength = 14; + httpReq1.reply = httpReply1; + + // Create second HTTP request + HttpLog.HttpRequest httpReq2 = new HttpLog.HttpRequest(conn2, 0); + httpReq2.method = "GET"; + httpReq2.host = "example2.com"; + httpReq2.path = "/second"; + httpReq2.query = ""; + httpReq2.timestamp = reqTimestamp2; + httpReq2.bodyLength = 0; + + HttpLog.HttpReply httpReply2 = new HttpLog.HttpReply(httpReq2, 1); + httpReply2.responseCode = 200; + httpReply2.responseStatus = "OK"; + httpReply2.contentType = "text/plain"; + httpReply2.bodyLength = 15; + httpReq2.reply = httpReply2; + + // Add both requests to the HTTP log + httpLog.addHttpRequest(httpReq1); + httpLog.addHttpReply(httpReply1); + httpLog.addHttpRequest(httpReq2); + httpLog.addHttpReply(httpReply2); + + // Export only the second request using single request constructor + HarWriter singleWriter = new HarWriter(context, httpReq2); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + singleWriter.write(out); + String harJson = out.toString(StandardCharsets.UTF_8.name()); + JsonObject har = parseHar(harJson); + + // Verify basic structure + assertTrue("HAR should have 'log' object", har.has("log")); + JsonObject log = har.getAsJsonObject("log"); + assertEquals("1.2", log.get("version").getAsString()); + + // Verify only one entry is present + JsonArray entries = log.getAsJsonArray("entries"); + assertEquals("Should have exactly one entry", 1, entries.size()); + + // Verify it's the second request + JsonObject entry = entries.get(0).getAsJsonObject(); + JsonObject request = entry.getAsJsonObject("request"); + String url = request.get("url").getAsString(); + assertTrue("URL should contain example2.com", url.contains("example2.com")); + assertTrue("URL should contain /second", url.contains("/second")); + assertFalse("URL should NOT contain example1.com", url.contains("example1.com")); + assertFalse("URL should NOT contain /first", url.contains("/first")); + + // Verify response content + JsonObject response = entry.getAsJsonObject("response"); + assertEquals(200, response.get("status").getAsInt()); + JsonObject content = response.getAsJsonObject("content"); + assertEquals("text/plain", content.get("mimeType").getAsString()); + assertEquals("Second response", content.get("text").getAsString()); + + // Verify the full log still has both requests + String fullHarJson = writeHarToString(); + JsonObject fullHar = parseHar(fullHarJson); + JsonArray fullEntries = fullHar.getAsJsonObject("log").getAsJsonArray("entries"); + assertEquals("Full log should have 2 entries", 2, fullEntries.size()); + } +} diff --git a/app/src/test/java/com/emanuelef/remote_capture/UtilsTest.java b/app/src/test/java/com/emanuelef/remote_capture/UtilsTest.java index b53d967f..9e571ea8 100644 --- a/app/src/test/java/com/emanuelef/remote_capture/UtilsTest.java +++ b/app/src/test/java/com/emanuelef/remote_capture/UtilsTest.java @@ -23,6 +23,7 @@ import org.junit.Test; import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertEquals; public class UtilsTest { @Test @@ -46,4 +47,38 @@ public class UtilsTest { assertFalse(Utils.subnetContains("2001:0db8:85a3::8a2e:0370:7334", 120, "2001:0db8:85a3::8a2e:0370:0001")); } + + @Test + public void testTimezoneConversionLogic() { + // Test the RFC 822 -> ISO 8601 timezone conversion logic used in formatMillisIso8601 + // This simulates what the code does on Android < N + + // Test positive timezone (was already working) + String positiveInput = "2026-01-16T17:15:15.123+0100"; + String positiveExpected = "2026-01-16T17:15:15.123+01:00"; + assertEquals(positiveExpected, convertTimezone(positiveInput)); + + // Test negative timezone (was broken before the fix) + String negativeInput = "2026-01-16T10:15:15.123-0500"; + String negativeExpected = "2026-01-16T10:15:15.123-05:00"; + assertEquals(negativeExpected, convertTimezone(negativeInput)); + + // Test UTC (edge case with +0000) + String utcInput = "2026-01-16T15:15:15.123+0000"; + String utcExpected = "2026-01-16T15:15:15.123+00:00"; + assertEquals(utcExpected, convertTimezone(utcInput)); + + // Test negative offset at international date line + String idlInput = "2026-01-16T03:15:15.123-1200"; + String idlExpected = "2026-01-16T03:15:15.123-12:00"; + assertEquals(idlExpected, convertTimezone(idlInput)); + } + + // Helper that replicates the conversion logic from Utils.formatMillisIso8601 + private String convertTimezone(String rv) { + int l = rv.length(); + if ((l > 5) && ((rv.charAt(l - 5) == '+') || (rv.charAt(l - 5) == '-'))) + rv = rv.substring(0, l - 2) + ":" + rv.substring(l - 2); + return rv; + } } \ No newline at end of file