Skip to content

Commit b625d0e

Browse files
committed
[test] introduce SSLSocketTest (unit test)
- writeNonblockDataIntegrity: approximates the gem push scenario (#242) large payload via write_nonblock loop, then read server's byte count response, assert data integrity (no bytes lost) - writeNonblockNetWriteDataState: saturates TCP buffer, then accesses the package-private netWriteData field directly to verify buffer consistency after the compact() fix
1 parent 5a75fdb commit b625d0e

File tree

5 files changed

+226
-4
lines changed

5 files changed

+226
-4
lines changed

Mavenfile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,12 @@ plugin :clean do
8282
'failOnError' => 'false' )
8383
end
8484

85-
jar 'org.jruby:jruby-core', '9.2.0.0', :scope => :provided
85+
jruby_compile_compat = '9.2.0.0'
86+
jar 'org.jruby:jruby-core', jruby_compile_compat, :scope => :provided
8687
# for invoker generated classes we need to add javax.annotation when on Java > 8
8788
jar 'javax.annotation:javax.annotation-api', '1.3.1', :scope => :compile
89+
# a test dependency to provide digest and other stdlib bits, needed when loading OpenSSL in Java unit tests
90+
jar 'org.jruby:jruby-stdlib', jruby_compile_compat, :scope => :test
8891
jar 'junit:junit', '[4.13.1,)', :scope => :test
8992

9093
# NOTE: to build on Java 11 - installing gems fails (due old jossl) with:

pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ DO NOT MODIFY - GENERATED CODE
107107
<version>1.3.1</version>
108108
<scope>compile</scope>
109109
</dependency>
110+
<dependency>
111+
<groupId>org.jruby</groupId>
112+
<artifactId>jruby-stdlib</artifactId>
113+
<version>9.2.0.0</version>
114+
<scope>test</scope>
115+
</dependency>
110116
<dependency>
111117
<groupId>junit</groupId>
112118
<artifactId>junit</artifactId>

src/main/java/org/jruby/ext/openssl/SSLSocket.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,9 @@ private static CallSite callSite(final CallSite[] sites, final CallSiteIndex ind
145145
private SSLEngine engine;
146146
private RubyIO io;
147147

148-
private ByteBuffer appReadData;
149-
private ByteBuffer netReadData;
150-
private ByteBuffer netWriteData;
148+
ByteBuffer appReadData;
149+
ByteBuffer netReadData;
150+
ByteBuffer netWriteData;
151151
private final ByteBuffer dummy = ByteBuffer.allocate(0); // could be static
152152

153153
private boolean initialHandshake = false;
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package org.jruby.ext.openssl;
2+
3+
import java.nio.ByteBuffer;
4+
5+
import org.jruby.RubyArray;
6+
import org.jruby.RubyFixnum;
7+
import org.jruby.RubyInteger;
8+
import org.jruby.RubyString;
9+
import org.jruby.exceptions.RaiseException;
10+
import org.jruby.runtime.builtin.IRubyObject;
11+
12+
import org.junit.After;
13+
import org.junit.Before;
14+
import org.junit.Test;
15+
import static org.junit.Assert.*;
16+
17+
public class SSLSocketTest extends OpenSSLHelper {
18+
19+
/** Loads the ssl_pair.rb script that creates a connected SSL socket pair. */
20+
private String start_ssl_server_rb() { return readResource("/start_ssl_server.rb"); }
21+
22+
@Before
23+
public void setUp() throws Exception {
24+
setUpRuntime();
25+
}
26+
27+
@After
28+
public void tearDown() {
29+
tearDownRuntime();
30+
}
31+
32+
/**
33+
* Real-world scenario: {@code gem push} sends a large POST body via {@code syswrite_nonblock},
34+
* then reads the HTTP response via {@code sysread}.
35+
*
36+
* Approximates the {@code gem push} scenario:
37+
* <ol>
38+
* <li>Write 256KB via {@code syswrite_nonblock} in a loop (the net/http POST pattern)</li>
39+
* <li>Server reads via {@code sysread} and counts bytes</li>
40+
* <li>Assert: server received exactly what client sent</li>
41+
* </ol>
42+
*
43+
* With the old {@code clear()} bug, encrypted bytes were silently
44+
* discarded during partial non-blocking writes, so the server would
45+
* receive fewer bytes than sent.
46+
*/
47+
@Test
48+
public void syswriteNonblockDataIntegrity() throws Exception {
49+
final RubyArray pair = (RubyArray) runtime.evalScriptlet(start_ssl_server_rb());
50+
SSLSocket client = (SSLSocket) pair.entry(0).toJava(SSLSocket.class);
51+
SSLSocket server = (SSLSocket) pair.entry(1).toJava(SSLSocket.class);
52+
53+
try {
54+
// Server: read all data in a background thread, counting bytes
55+
final long[] serverReceived = { 0 };
56+
Thread serverReader = startServerReader(server, serverReceived);
57+
58+
// Client: write 256KB in 4KB chunks via syswrite_nonblock
59+
byte[] chunk = new byte[4096];
60+
java.util.Arrays.fill(chunk, (byte) 'P'); // P for POST body
61+
RubyString payload = RubyString.newString(runtime, chunk);
62+
63+
long totalSent = 0;
64+
for (int i = 0; i < 64; i++) { // 64 * 4KB = 256KB
65+
try {
66+
IRubyObject written = client.syswrite_nonblock(currentContext(), payload);
67+
totalSent += ((RubyInteger) written).getLongValue();
68+
} catch (RaiseException e) {
69+
if ("OpenSSL::SSL::SSLErrorWaitWritable".equals(e.getException().getMetaClass().getName())) {
70+
System.out.println("syswrite_nonblock expected: " + e.getMessage());
71+
// Expected: non-blocking write would block — retry as blocking
72+
IRubyObject written = client.syswrite(currentContext(), payload);
73+
totalSent += ((RubyInteger) written).getLongValue();
74+
} else {
75+
System.err.println("syswrite_nonblock unexpected: " + e.getMessage());
76+
throw e;
77+
}
78+
}
79+
}
80+
assertTrue("should have sent data", totalSent > 0);
81+
82+
// Close client to signal EOF, let server finish reading
83+
client.callMethod(currentContext(), "close");
84+
serverReader.join(10_000);
85+
86+
assertEquals(
87+
"server must receive exactly what client sent — mismatch means encrypted bytes were lost!",
88+
totalSent, serverReceived[0]
89+
);
90+
} finally {
91+
closeQuietly(pair);
92+
}
93+
}
94+
95+
private Thread startServerReader(final SSLSocket server, final long[] serverReceived) {
96+
Thread serverReader = new Thread(() -> {
97+
try {
98+
RubyFixnum len = RubyFixnum.newFixnum(runtime, 8192);
99+
while (true) {
100+
IRubyObject data = server.sysread(currentContext(), len);
101+
serverReceived[0] += ((RubyString) data).getByteList().getRealSize();
102+
}
103+
} catch (RaiseException e) {
104+
String errorName = e.getException().getMetaClass().getName();
105+
if ("EOFError".equals(errorName) || "IOError".equals(errorName)) { // client closes connection
106+
System.out.println("server-reader expected: " + e.getMessage());
107+
} else {
108+
System.err.println("server-reader unexpected: " + e.getMessage());
109+
e.printStackTrace(System.err);
110+
throw e;
111+
}
112+
}
113+
});
114+
serverReader.start();
115+
return serverReader;
116+
}
117+
118+
/**
119+
* After saturating the TCP send buffer with {@code syswrite_nonblock},
120+
* inspect {@code netWriteData} to verify the buffer is consistent.
121+
*/
122+
@Test
123+
public void syswriteNonblockNetWriteDataConsistency() {
124+
final RubyArray pair = (RubyArray) runtime.evalScriptlet(start_ssl_server_rb());
125+
SSLSocket client = (SSLSocket) pair.entry(0).toJava(SSLSocket.class);
126+
127+
try {
128+
assertNotNull("netWriteData initialized after handshake", client.netWriteData);
129+
130+
// Saturate: server is not reading yet, so backpressure builds
131+
byte[] chunk = new byte[16384];
132+
java.util.Arrays.fill(chunk, (byte) 'S');
133+
RubyString payload = RubyString.newString(runtime, chunk);
134+
135+
int successWrites = 0;
136+
for (int i = 0; i < 200; i++) {
137+
try {
138+
client.syswrite_nonblock(currentContext(), payload);
139+
successWrites++;
140+
} catch (RaiseException e) {
141+
if ("OpenSSL::SSL::SSLErrorWaitWritable".equals(e.getException().getMetaClass().getName())) {
142+
System.out.println("saturate-loop expected: " + e.getMessage());
143+
break; // buffer saturated — expected
144+
}
145+
System.err.println("saturate-loop unexpected: " + e.getMessage());
146+
throw e;
147+
}
148+
}
149+
assertTrue("at least one write should succeed", successWrites > 0);
150+
151+
ByteBuffer netWriteData = client.netWriteData;
152+
assertTrue("position <= limit", netWriteData.position() <= netWriteData.limit());
153+
assertTrue("limit <= capacity", netWriteData.limit() <= netWriteData.capacity());
154+
155+
// If there are unflushed bytes, compact() preserved them
156+
if (netWriteData.remaining() > 0) {
157+
// The bytes should be valid TLS record data, not zeroed memory
158+
byte b = netWriteData.get(netWriteData.position());
159+
assertNotEquals("preserved bytes should be TLS data, not zeroed", 0, b);
160+
}
161+
162+
} finally {
163+
closeQuietly(pair);
164+
}
165+
}
166+
167+
private void closeQuietly(final RubyArray sslPair) {
168+
for (int i = 0; i < sslPair.getLength(); i++) {
169+
final IRubyObject elem = sslPair.entry(i);
170+
try { elem.callMethod(currentContext(), "close"); }
171+
catch (RaiseException e) { // already closed?
172+
System.err.println("close raised (" + elem.inspect() + ") : " + e.getMessage());
173+
}
174+
}
175+
}
176+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Creates a connected SSL socket pair for Java unit tests.
2+
# Returns [client_ssl, server_ssl]
3+
#
4+
# OpenSSL extension is loaded by SSLSocketTest.setUp via OpenSSL.load(runtime).
5+
6+
require 'socket'
7+
8+
key = OpenSSL::PKey::RSA.new(2048)
9+
cert = OpenSSL::X509::Certificate.new
10+
cert.version = 2
11+
cert.serial = 1
12+
cert.subject = cert.issuer = OpenSSL::X509::Name.parse('/CN=Test')
13+
cert.public_key = key.public_key
14+
cert.not_before = Time.now
15+
cert.not_after = Time.now + 3600
16+
cert.sign(key, OpenSSL::Digest::SHA256.new)
17+
18+
tcp_server = TCPServer.new('127.0.0.1', 0)
19+
port = tcp_server.local_address.ip_port
20+
ctx = OpenSSL::SSL::SSLContext.new
21+
ctx.cert = cert
22+
ctx.key = key
23+
ssl_server = OpenSSL::SSL::SSLServer.new(tcp_server, ctx)
24+
ssl_server.start_immediately = true
25+
26+
server_ssl = nil
27+
server_thread = Thread.new { server_ssl = ssl_server.accept }
28+
29+
sock = TCPSocket.new('127.0.0.1', port)
30+
sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF, 4096)
31+
sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF, 4096)
32+
client_ssl = OpenSSL::SSL::SSLSocket.new(sock)
33+
client_ssl.sync_close = true
34+
client_ssl.connect
35+
server_thread.join(5)
36+
37+
[client_ssl, server_ssl]

0 commit comments

Comments
 (0)