Skip to content

Commit 7505146

Browse files
- Introduced ExperimentalKapacityApi annotation to mark experimental Kapacity APIs.
- Added `Kapacity.INVALID` sentinel value (equivalent to `-1L`) to represent invalid, missing, or exhausted capacity, particularly for I/O operations. - Added `InputStream.available` extension property to return available bytes as a `Kapacity` instance. - Added `InputStream.read` and `InputStream.readKapacity` extension functions to safely read data into a `ByteArray` using `Kapacity` to limit length and prevent buffer overflows. - Added `OutputStream.write` extension function to write data from a `ByteArray` to a stream with `Kapacity` bounds checking and clamping. - Included comprehensive unit tests for new `InputStream` and `OutputStream` extensions in `kapacity-io`. - Incremented project version to `0.9.9-beta09`.
1 parent a416e71 commit 7505146

6 files changed

Lines changed: 325 additions & 1 deletion

File tree

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[versions]
2-
kapacity = "0.9.9-beta08"
2+
kapacity = "0.9.9-beta09"
33
agp = "8.11.2"
44
android-compileSdk = "36"
55
android-minSdk = "24"
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package io.github.developrofthings.kapacity.io
2+
3+
import io.github.developrofthings.kapacity.Kapacity
4+
import io.github.developrofthings.kapacity.byte
5+
import java.io.ByteArrayInputStream
6+
import kotlin.test.Test
7+
import kotlin.test.assertEquals
8+
import kotlin.test.assertFailsWith
9+
10+
11+
class InputStreamKapacityTest {
12+
13+
@Test
14+
fun testReadFillsDestinationCorrectlyWithinKapacity() {
15+
val data = byteArrayOf(1, 2, 3, 4, 5)
16+
val stream = ByteArrayInputStream(data)
17+
val destination = ByteArray(5)
18+
19+
val bytesRead = stream.read(destination, kapacity = 3.byte)
20+
21+
assertEquals(3, bytesRead)
22+
assertEquals(1, destination[0])
23+
assertEquals(3, destination[2])
24+
assertEquals(0, destination[3]) // Remaining bytes should be untouched
25+
}
26+
27+
@Test
28+
fun testReadReturnsMinusOneAtEndOfStream() {
29+
val stream = ByteArrayInputStream(byteArrayOf())
30+
val destination = ByteArray(10)
31+
32+
val bytesRead = stream.read(destination, kapacity = 5.byte)
33+
34+
assertEquals(-1, bytesRead)
35+
}
36+
37+
@Test
38+
fun testReadCoercesLengthToAvailableSpaceInDestination() {
39+
val data = byteArrayOf(10, 20, 30, 40, 50)
40+
val stream = ByteArrayInputStream(data)
41+
val destination = ByteArray(3) // Smaller than the data and the requested kapacity
42+
43+
// We request 5 bytes, but the buffer only has 3 slots.
44+
val bytesRead = stream.read(destination, kapacity = 5.byte)
45+
46+
assertEquals(3, bytesRead, "Should have capped the read at the destination size")
47+
assertEquals(10, destination[0])
48+
assertEquals(30, destination[2])
49+
}
50+
51+
@Test
52+
fun testReadRespectsDestinationOffsetAndCapsRemainingSpace() {
53+
val data = byteArrayOf(9, 9, 9, 9, 9)
54+
val stream = ByteArrayInputStream(data)
55+
val destination = ByteArray(10)
56+
57+
// Start at index 8. Only 2 slots (8, 9) are left in the destination.
58+
// We ask for 5 bytes, but should only safely get 2.
59+
val bytesRead = stream.read(destination, destinationOffset = 8, kapacity = 5.byte)
60+
61+
assertEquals(2, bytesRead)
62+
assertEquals(9, destination[8])
63+
assertEquals(9, destination[9])
64+
}
65+
66+
@Test
67+
fun testReadReturnsZeroForNegativeKapacity() {
68+
val stream = ByteArrayInputStream(byteArrayOf(1))
69+
val destination = ByteArray(5)
70+
71+
val readBytes = stream.read(destination, kapacity = (-1).byte)
72+
assertEquals(0, readBytes)
73+
}
74+
75+
@Test
76+
fun testReadThrowsExceptionForOutOfBoundsOffset() {
77+
val stream = ByteArrayInputStream(byteArrayOf(1))
78+
val destination = ByteArray(5)
79+
80+
assertFailsWith<IllegalArgumentException> {
81+
// Offset is larger than the array size
82+
stream.read(destination, destinationOffset = 6, kapacity = 1.byte)
83+
}
84+
85+
assertFailsWith<IllegalArgumentException> {
86+
// Negative offset
87+
stream.read(destination, destinationOffset = -1, kapacity = 1.byte)
88+
}
89+
}
90+
91+
// --- Tests for readKapacity() ---
92+
93+
@Test
94+
fun testReadKapacityReturnsValidKapacityOnSuccess() {
95+
val data = byteArrayOf(100, 101, 102)
96+
val stream = ByteArrayInputStream(data)
97+
val destination = ByteArray(5)
98+
99+
val kapacityRead = stream.readKapacity(destination, kapacity = 2.byte)
100+
101+
assertEquals(2.byte, kapacityRead)
102+
assertEquals(100, destination[0])
103+
assertEquals(101, destination[1])
104+
}
105+
106+
@Test
107+
fun testReadKapacityReturnsInvalidAtEndOfStream() {
108+
val stream = ByteArrayInputStream(byteArrayOf())
109+
val destination = ByteArray(5)
110+
111+
val kapacityRead = stream.readKapacity(destination, kapacity = 5.byte)
112+
113+
assertEquals(Kapacity.INVALID, kapacityRead)
114+
}
115+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package io.github.developrofthings.kapacity.io
2+
3+
import io.github.developrofthings.kapacity.byte
4+
import java.io.ByteArrayOutputStream
5+
import kotlin.test.Test
6+
import kotlin.test.assertContentEquals
7+
import kotlin.test.assertEquals
8+
9+
class OutputStreamKapacityTest {
10+
@Test
11+
fun testOutputStreamWrite_exactFit() {
12+
val stream = ByteArrayOutputStream()
13+
val source = byteArrayOf(1, 2, 3, 4, 5)
14+
15+
stream.write(source = source, kapacity = 5.byte)
16+
17+
assertContentEquals(byteArrayOf(1, 2, 3, 4, 5), stream.toByteArray())
18+
}
19+
20+
@Test
21+
fun testOutputStreamWrite_limitedByKapacity() {
22+
val stream = ByteArrayOutputStream()
23+
val source = byteArrayOf(1, 2, 3, 4, 5)
24+
25+
// Source has 5 bytes, but we strictly limit the write to 3 bytes
26+
stream.write(source = source, kapacity = 3.byte)
27+
28+
assertContentEquals(byteArrayOf(1, 2, 3), stream.toByteArray())
29+
}
30+
31+
@Test
32+
fun testOutputStreamWrite_limitedBySourceSpace() {
33+
val stream = ByteArrayOutputStream()
34+
val source = byteArrayOf(1, 2, 3)
35+
36+
// We ask to write 5 bytes, but the source array only has 3
37+
stream.write(source = source, kapacity = 5.byte)
38+
39+
// Should safely clamp and write only the 3 available bytes without crashing
40+
assertContentEquals(byteArrayOf(1, 2, 3), stream.toByteArray())
41+
}
42+
43+
@Test
44+
fun testOutputStreamWrite_withSourceOffset() {
45+
val stream = ByteArrayOutputStream()
46+
val source = byteArrayOf(9, 8, 7, 6, 5)
47+
48+
// Skip the first 2 bytes. We ask for 10 bytes, but only 3 remain in the array.
49+
stream.write(
50+
source = source,
51+
sourceOffset = 2,
52+
kapacity = 10.byte
53+
)
54+
55+
// Should clamp to the remaining bytes after the offset
56+
assertContentEquals(byteArrayOf(7, 6, 5), stream.toByteArray())
57+
}
58+
59+
@Test
60+
fun testOutputStreamWrite_zeroKapacityEarlyExit() {
61+
val stream = ByteArrayOutputStream()
62+
val source = byteArrayOf(1, 2, 3)
63+
64+
stream.write(source = source, kapacity = 0.byte)
65+
66+
assertEquals(0, stream.toByteArray().size, "Stream should remain completely empty")
67+
}
68+
}

kapacity-io/src/androidMain/kotlin/io/github/developrofthings/kapacity/io/Kapacity.android.kt

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33

44
package io.github.developrofthings.kapacity.io
55

6+
import io.github.developrofthings.kapacity.ExperimentalKapacityApi
67
import io.github.developrofthings.kapacity.InternalKapacityApi
78
import io.github.developrofthings.kapacity.Kapacity
89
import io.github.developrofthings.kapacity.byte
910
import java.io.File
11+
import java.io.InputStream
12+
import java.io.OutputStream
13+
import java.io.OutputStreamWriter
1014
import java.nio.ByteBuffer
1115
import java.nio.file.Path
1216
import kotlin.io.path.fileSize
@@ -123,4 +127,119 @@ fun ByteBuffer.put(
123127
/* offset = */ sourceOffset,
124128
/* length = */ safeLength,
125129
)
130+
}
131+
132+
133+
/**
134+
* Returns an estimate of the number of bytes that can be read (or skipped over) from this
135+
* [InputStream] without blocking, safely wrapped as a [Kapacity] instance.
136+
*
137+
* **Important Note on Java IO:** This property directly delegates to [InputStream.available].
138+
* It represents the number of bytes currently buffered locally or immediately accessible.
139+
* It does **not** represent the total remaining size of the stream or file. You should never
140+
* use this property to allocate a buffer intended to hold the entire contents of a network stream.
141+
*
142+
* @return The estimated number of non-blocking bytes currently available, represented as [Kapacity].
143+
* @throws java.io.IOException If an I/O error occurs while checking the underlying stream.
144+
*/
145+
val InputStream.available: Kapacity get() = available().byte
146+
147+
/**
148+
* Reads up to the specified [kapacity] of bytes from this input stream into the given [destination] array.
149+
*
150+
* This function safely guards against buffer overflows. It automatically calculates the available
151+
* space in the [destination] array starting from the [destinationOffset] and ensures that the number
152+
* of bytes read does not exceed this available space, even if the requested [kapacity] is larger.
153+
*
154+
* @param destination The byte array to which data is written.
155+
* @param destinationOffset The starting offset in the [destination] array where the data will be written. Defaults to 0.
156+
* @param kapacity The maximum number of bytes to read from the stream.
157+
* @return The total number of bytes read into the buffer, or `-1` if there is no more data because the end of the stream has been reached.
158+
* @throws IllegalArgumentException If [destinationOffset] is outside the bounds of the [destination] array,
159+
* or if [kapacity] represents a negative value.
160+
*/
161+
fun InputStream.read(
162+
destination: ByteArray,
163+
destinationOffset: Int = 0,
164+
kapacity: Kapacity,
165+
): Int {
166+
require(destinationOffset in 0..destination.size) {
167+
"destinationOffset ($destinationOffset) must be between 0 and ${destination.size}"
168+
}
169+
require(kapacity.rawBytes >= 0L) {
170+
"Cannot read a negative kapacity: $kapacity"
171+
}
172+
173+
val readLength = kapacity.rawBytesCoercedToIntRange
174+
val availableSpace = (destination.size - destinationOffset)
175+
val safeLength = minOf(
176+
a = readLength,
177+
b = availableSpace,
178+
)
179+
return this.read(
180+
/* b = */ destination,
181+
/* off = */ destinationOffset,
182+
/* len = */ safeLength
183+
)
184+
}
185+
186+
/**
187+
* Reads up to the specified [kapacity] of bytes from this input stream into the given [destination] array,
188+
* returning the result as a [Kapacity] instance.
189+
*
190+
* Like its primitive counterpart, this function safely limits the read length to the available space
191+
* in the [destination] array to prevent buffer overflows.
192+
*
193+
* @param destination The byte array to which data is written.
194+
* @param destinationOffset The starting offset in the [destination] array where the data will be written. Defaults to 0.
195+
* @param kapacity The maximum number of bytes to read from the stream.
196+
* @return The total number of bytes read wrapped in a [Kapacity] instance, or [Kapacity.INVALID] if
197+
* the end of the stream has been reached.
198+
* @throws IllegalArgumentException If [destinationOffset] is outside the bounds of the [destination] array,
199+
* or if [kapacity] represents a negative value.
200+
*/
201+
@ExperimentalKapacityApi
202+
fun InputStream.readKapacity(
203+
destination: ByteArray,
204+
destinationOffset: Int = 0,
205+
kapacity: Kapacity,
206+
): Kapacity = this.read(
207+
destination = destination,
208+
destinationOffset = destinationOffset,
209+
kapacity = kapacity
210+
).takeIf { it >= 0 }?.byte ?: Kapacity.INVALID
211+
212+
/**
213+
* Writes up to the specified [kapacity] of bytes from the [source] array to this output stream.
214+
*
215+
* This function blocks until the bytes are written or an exception is thrown.
216+
* * **Safe Bounds:** The actual number of bytes written is safely clamped to prevent
217+
* `IndexOutOfBoundsException`. The length will be the minimum of: the requested [kapacity] or
218+
* the available data in the [source] array accounting for the [sourceOffset].
219+
*
220+
* @param source The data to write.
221+
* @param sourceOffset The start offset in the [source] array from which to begin reading. Defaults to 0.
222+
* @param kapacity The maximum number of bytes to write to the stream.
223+
* @throws java.io.IOException If an I/O error occurs.
224+
*/
225+
fun OutputStream.write(
226+
source: ByteArray,
227+
sourceOffset: Int = 0,
228+
kapacity: Kapacity,
229+
) {
230+
val writeLength = kapacity.rawBytesCoercedToIntRange
231+
val availableSpace = (source.size - sourceOffset)
232+
val safeLength = minOf(
233+
a = writeLength,
234+
b = availableSpace,
235+
)
236+
237+
// Exit early if capacity is 0 or offsets are out of bounds
238+
if (safeLength <= 0) return
239+
240+
this.write(
241+
/* b = */ source,
242+
/* off = */ sourceOffset,
243+
/* len = */ safeLength
244+
)
126245
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package io.github.developrofthings.kapacity
2+
3+
@RequiresOptIn(
4+
level = RequiresOptIn.Level.WARNING, // Or ERROR to be stricter
5+
message = "This Kapacity API is experimental and subject to change."
6+
)
7+
@Retention(AnnotationRetention.BINARY)
8+
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS, AnnotationTarget.PROPERTY)
9+
annotation class ExperimentalKapacityApi

kapacity/src/commonMain/kotlin/io/github/developrofthings/kapacity/Kapacity.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,19 @@ value class Kapacity private constructor(val rawBytes: Long) : Comparable<Kapaci
225225
override fun compareTo(other: Kapacity): Int = this.rawBytes.compareTo(other.rawBytes)
226226

227227
companion object {
228+
229+
/**
230+
* A sentinel value representing an invalid, missing, or exhausted capacity.
231+
*
232+
* In the context of I/O operations, this is commonly returned to indicate that the
233+
* end of a stream (EOF) has been reached, mirroring the `-1` result returned by
234+
* standard Java API's.
235+
*
236+
* The underlying `rawBytes` value for this instance is `-1L`.
237+
*/
238+
@ExperimentalKapacityApi
239+
val INVALID: Kapacity = Kapacity(rawBytes = -1L)
240+
228241
fun fromBytes(bytes: Long): Kapacity = Kapacity(
229242
rawBytes = bytes.coerceAtLeast(
230243
minimumValue = 0L

0 commit comments

Comments
 (0)