Skip to content

Commit 788d87e

Browse files
authored
feat(rt): implement aws-chunked content encoding (#731)
1 parent 38b2666 commit 788d87e

File tree

17 files changed

+1630
-3
lines changed

17 files changed

+1630
-3
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"id": "dd18ebc5-bcba-4b38-ba21-75aa4c6a7e24",
3+
"type": "feature",
4+
"description": "Add aws-chunked content encoding",
5+
"issues": [
6+
"awslabs/aws-sdk-kotlin#747"
7+
]
8+
}

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,4 @@ kotlinLoggingVersion=2.1.21
4343
slf4jVersion=1.7.36
4444

4545
# crt
46-
crtKotlinVersion=0.6.5
46+
crtKotlinVersion=0.6.6-SNAPSHOT

runtime/auth/aws-signing-common/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ description = "Common types for AWS signing"
77
extra["displayName"] = "Smithy :: Kotlin :: AWS Signing Common"
88
extra["moduleName"] = "aws.smithy.kotlin.runtime.auth.signing.awssigning"
99

10+
val coroutinesVersion: String by project
11+
1012
kotlin {
1113
sourceSets {
1214
commonMain {
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package aws.smithy.kotlin.runtime.auth.awssigning
7+
8+
import aws.smithy.kotlin.runtime.http.Headers
9+
import aws.smithy.kotlin.runtime.io.SdkByteReadChannel
10+
import aws.smithy.kotlin.runtime.util.InternalApi
11+
12+
public const val CHUNK_SIZE_BYTES: Int = 65536
13+
14+
@InternalApi
15+
public abstract class AbstractAwsChunkedByteReadChannel(
16+
private val chan: SdkByteReadChannel,
17+
private val signer: AwsSigner,
18+
private val signingConfig: AwsSigningConfig,
19+
private var previousSignature: ByteArray,
20+
private val trailingHeaders: Headers = Headers.Empty,
21+
) : SdkByteReadChannel by chan {
22+
override val isClosedForRead: Boolean
23+
get() = chan.isClosedForRead && (chunk == null || chunkOffset >= chunk!!.size) && hasLastChunkBeenSent
24+
25+
internal var chunk: ByteArray? = null
26+
internal var chunkOffset: Int = 0
27+
private var hasLastChunkBeenSent: Boolean = false
28+
29+
/**
30+
* Returns all the bytes remaining in the underlying data source, up to [limit].
31+
* @return a [ByteArray] containing at most [limit] bytes. it may contain fewer if there are less than [limit] bytes
32+
* remaining in the data source.
33+
*/
34+
override suspend fun readRemaining(limit: Int): ByteArray {
35+
if (!ensureValidChunk()) {
36+
return byteArrayOf()
37+
}
38+
39+
var bytesWritten = 0
40+
val bytes = ByteArray(limit)
41+
42+
while (bytesWritten != limit) {
43+
val numBytesToWrite: Int = minOf(limit - bytesWritten, chunk!!.size - chunkOffset)
44+
45+
chunk!!.copyInto(bytes, bytesWritten, chunkOffset, chunkOffset + numBytesToWrite)
46+
47+
bytesWritten += numBytesToWrite
48+
chunkOffset += numBytesToWrite
49+
50+
// read a new chunk. this handles the case where we consumed the whole chunk but still have not sent `limit` bytes
51+
if (!ensureValidChunk()) { break }
52+
}
53+
54+
return bytes.sliceArray(0 until bytesWritten)
55+
}
56+
57+
/**
58+
* Writes [length] bytes to [sink], starting [offset] bytes from the beginning. If [length] bytes are not available in
59+
* the source data, the call will fail with an [IllegalArgumentException].
60+
*
61+
* @param sink the destination [ByteArray] to write to
62+
* @param offset the number of bytes in [sink] to skip before beginning to write
63+
* @param length the number of bytes to write to [sink]
64+
* @throws IllegalArgumentException when illegal [offset] and [length] arguments are passed
65+
* @throws RuntimeException when the source data is exhausted before [length] bytes are written to [sink]
66+
*/
67+
override suspend fun readFully(sink: ByteArray, offset: Int, length: Int) {
68+
require(offset >= 0) { "Invalid read: offset must be positive: $offset" }
69+
require(offset + length <= sink.size) { "Invalid read: offset + length should be less than the destination size: $offset + $length < ${sink.size}" }
70+
if (length == 0) return
71+
72+
var bytesWritten = 0
73+
74+
while (bytesWritten != length) {
75+
if (!ensureValidChunk()) {
76+
throw RuntimeException("Invalid read: unable to fully read $length bytes. missing ${length - bytesWritten} bytes.")
77+
}
78+
79+
val numBytesToWrite: Int = minOf(length, chunk!!.size - chunkOffset)
80+
81+
chunk!!.copyInto(sink, offset + bytesWritten, chunkOffset, chunkOffset + numBytesToWrite)
82+
83+
bytesWritten += numBytesToWrite
84+
chunkOffset += numBytesToWrite
85+
}
86+
}
87+
88+
/**
89+
* Writes up to [length] bytes to [sink], starting [offset] bytes from the beginning.
90+
* Returns when [length] bytes or the number of available bytes have been written, whichever is lower.
91+
*
92+
* This function will read *at most* one chunk of data into the [sink]. Successive calls will be required to read additional chunks.
93+
* This is done because the function promises to not suspend unless there are zero bytes currently available,
94+
* and we are unable to poll the underlying data source to see if there is a whole chunk available.
95+
*
96+
* @param sink the [ByteArray] to write the data to
97+
* @param offset the number of bytes to skip from the beginning of the chunk
98+
* @param length the maximum number of bytes to write to [sink]. the actual number of bytes written may be fewer if
99+
* there are less immediately available.
100+
* @throws IllegalArgumentException when illegal [offset] and [length] arguments are passed
101+
* @return an [Int] representing the number of bytes written
102+
*/
103+
override suspend fun readAvailable(sink: ByteArray, offset: Int, length: Int): Int {
104+
require(offset >= 0) { "Invalid read: offset must be positive: $offset" }
105+
require(offset + length <= sink.size) { "Invalid read: offset + length should be less than the destination size: $offset + $length < ${sink.size}" }
106+
if (length == 0 || !ensureValidChunk()) {
107+
return 0
108+
}
109+
110+
var bytesWritten = 0
111+
112+
while (bytesWritten != length) {
113+
val numBytesToWrite = minOf(length, chunk!!.size - chunkOffset)
114+
115+
chunk!!.copyInto(sink, offset + bytesWritten, chunkOffset, chunkOffset + numBytesToWrite)
116+
117+
bytesWritten += numBytesToWrite
118+
chunkOffset += numBytesToWrite
119+
120+
// if we've exhausted the current chunk, exit without suspending for a new one
121+
if (chunkOffset >= chunk!!.size) { break }
122+
}
123+
124+
return bytesWritten
125+
}
126+
127+
/**
128+
* Ensures that the internal [chunk] is valid for reading. If it's not valid, try to load the next chunk. Note that
129+
* this function will suspend until the whole chunk has been loaded.
130+
*
131+
* @return true if the [chunk] is valid for reading, false if it's invalid (chunk data is exhausted)
132+
*/
133+
internal suspend fun ensureValidChunk(): Boolean {
134+
// check if the current chunk is still valid
135+
if (chunk != null && chunkOffset < chunk!!.size) { return true }
136+
137+
// if not, try to fetch a new chunk
138+
val nextChunk = if (chan.isClosedForRead && hasLastChunkBeenSent) {
139+
null
140+
} else if (chan.isClosedForRead && !hasLastChunkBeenSent) {
141+
hasLastChunkBeenSent = true
142+
getChunk(byteArrayOf()) + if (!trailingHeaders.isEmpty()) { getTrailingHeadersChunk(trailingHeaders) } else byteArrayOf()
143+
} else {
144+
getChunk()
145+
}
146+
147+
chunkOffset = 0
148+
chunk = nextChunk?.plus("\r\n".encodeToByteArray()) // terminating CRLF to signal end of chunk
149+
return (chunk != null)
150+
}
151+
152+
/**
153+
* Get an aws-chunked encoding of [data].
154+
* If [data] is not set, read the next chunk from [chan] and add hex-formatted chunk size and chunk signature to the front.
155+
* Note that this function will suspend until the whole chunk has been read.
156+
* The chunk structure is: `string(IntHexBase(chunk-size)) + ";chunk-signature=" + signature + \r\n + chunk-data + \r\n`
157+
*
158+
* @param data the ByteArray of data which will be encoded to aws-chunked. if not provided, will default to
159+
* reading up to [CHUNK_SIZE_BYTES] from [chan].
160+
* @return a ByteArray containing the chunked data
161+
*/
162+
private suspend fun getChunk(data: ByteArray? = null): ByteArray {
163+
val chunkBody = data ?: chan.readRemaining(CHUNK_SIZE_BYTES)
164+
165+
val chunkSignature = signer.signChunk(chunkBody, previousSignature, signingConfig).signature
166+
previousSignature = chunkSignature
167+
168+
val chunkHeader = buildString {
169+
append(chunkBody.size.toString(16))
170+
append(";")
171+
append("chunk-signature=")
172+
append(chunkSignature.decodeToString())
173+
append("\r\n")
174+
}.encodeToByteArray()
175+
176+
return chunkHeader + chunkBody
177+
}
178+
179+
/**
180+
* Get the trailing headers chunk. The grammar for trailing headers is:
181+
* trailing-header-A:value CRLF
182+
* trailing-header-B:value CRLF
183+
* ...
184+
* x-amz-trailer-signature:signature_value CRLF
185+
*
186+
* @param trailingHeaders a list of [Headers] which will be sent
187+
* @return a [ByteArray] containing the trailing headers in aws-chunked encoding, ready to send on the wire
188+
*/
189+
private suspend fun getTrailingHeadersChunk(trailingHeaders: Headers): ByteArray {
190+
val trailerSignature = signer.signChunkTrailer(trailingHeaders, previousSignature, signingConfig).signature
191+
previousSignature = trailerSignature
192+
193+
val trailerBody = trailingHeaders.entries().sortedBy { entry -> entry.key.lowercase() }.map { entry ->
194+
buildString {
195+
append(entry.key)
196+
append(":")
197+
append(entry.value.joinToString(",") { v -> v.trim() })
198+
append("\r\n")
199+
}.encodeToByteArray()
200+
}.reduce { acc, bytes -> acc + bytes } +
201+
"x-amz-trailer-signature:${trailerSignature.decodeToString()}\r\n".encodeToByteArray()
202+
203+
chunkOffset = 0
204+
return trailerBody
205+
}
206+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package aws.smithy.kotlin.runtime.auth.awssigning
7+
8+
import aws.smithy.kotlin.runtime.http.Headers
9+
import aws.smithy.kotlin.runtime.io.SdkByteReadChannel
10+
import aws.smithy.kotlin.runtime.util.InternalApi
11+
12+
/**
13+
* aws-chunked content encoding. Operations on this class can not be invoked concurrently.
14+
* This class wraps an SdkByteReadChannel. When reads are performed on this class, it will read the wrapped data
15+
* and return it in aws-chunked content encoding.
16+
* @see <a href="https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html">SigV4 Streaming</a>
17+
* @param chan the underlying [SdkByteReadChannel] which will have its data encoded in aws-chunked format
18+
* @param signer the signer to use to sign chunks and (optionally) chunk trailer
19+
* @param signingConfig the config to use for signing
20+
* @param previousSignature the previous signature to use for signing. in most cases, this should be the seed signature
21+
* @param trailingHeaders the optional trailing headers to include in the final chunk
22+
*/
23+
@InternalApi
24+
public expect class AwsChunkedByteReadChannel public constructor(
25+
chan: SdkByteReadChannel,
26+
signer: AwsSigner,
27+
signingConfig: AwsSigningConfig,
28+
previousSignature: ByteArray,
29+
trailingHeaders: Headers = Headers.Empty,
30+
) : AbstractAwsChunkedByteReadChannel

runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/AwsSigner.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55
package aws.smithy.kotlin.runtime.auth.awssigning
66

7+
import aws.smithy.kotlin.runtime.http.Headers
78
import aws.smithy.kotlin.runtime.http.request.HttpRequest
89

910
/**
@@ -31,4 +32,17 @@ public interface AwsSigner {
3132
prevSignature: ByteArray,
3233
config: AwsSigningConfig,
3334
): AwsSigningResult<Unit>
35+
36+
/**
37+
* Signs a chunked payload's trailer according to the supplied signing configuration
38+
* @param trailingHeaders the trailing [Headers] to send
39+
* @param prevSignature The signature of the previous componenet of the request (in most cases, this is the signature of the final chunk)
40+
* @param config The signing configuration
41+
* @return The signing result, which should be appended as a trailing header itself, named `x-amz-trailer-signature`
42+
*/
43+
public suspend fun signChunkTrailer(
44+
trailingHeaders: Headers,
45+
prevSignature: ByteArray,
46+
config: AwsSigningConfig,
47+
): AwsSigningResult<Unit>
3448
}

runtime/auth/aws-signing-common/common/src/aws/smithy/kotlin/runtime/auth/awssigning/AwsSigningConfig.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ public enum class AwsSignatureType {
4545
*/
4646
HTTP_REQUEST_CHUNK,
4747

48+
/**
49+
* Compute a signature for trailing headers
50+
*/
51+
HTTP_REQUEST_TRAILING_HEADERS,
52+
4853
/**
4954
* Compute a signature for an event stream
5055
*/
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package aws.smithy.kotlin.runtime.auth.awssigning
7+
8+
import aws.smithy.kotlin.runtime.http.Headers
9+
import aws.smithy.kotlin.runtime.io.SdkByteReadChannel
10+
import aws.smithy.kotlin.runtime.util.InternalApi
11+
import java.nio.ByteBuffer
12+
13+
@InternalApi
14+
public actual class AwsChunkedByteReadChannel actual constructor(
15+
chan: SdkByteReadChannel,
16+
signer: AwsSigner,
17+
signingConfig: AwsSigningConfig,
18+
previousSignature: ByteArray,
19+
trailingHeaders: Headers,
20+
) : AbstractAwsChunkedByteReadChannel(chan, signer, signingConfig, previousSignature, trailingHeaders) {
21+
22+
/**
23+
* Read all the available bytes into [sink], up to the [sink]'s limit.
24+
* After reading is complete, flips the buffer to ready the content for consumption.
25+
* @param sink the [ByteBuffer] to read the bytes into
26+
* @return an integer representing the number of bytes written to [sink]
27+
*/
28+
override suspend fun readAvailable(sink: ByteBuffer): Int {
29+
if (!ensureValidChunk() || sink.remaining() == 0) {
30+
return -1
31+
}
32+
33+
var bytesWritten = 0
34+
while (chunkOffset < chunk!!.size && sink.remaining() > 0) {
35+
val numBytesToWrite = minOf(sink.remaining(), chunk!!.size - chunkOffset)
36+
val bytes = chunk!!.slice(chunkOffset until chunkOffset + numBytesToWrite).toByteArray()
37+
sink.put(bytes)
38+
39+
bytesWritten += numBytesToWrite
40+
chunkOffset += numBytesToWrite
41+
42+
// if we've exhausted the current chunk, exit without suspending for a new one
43+
if (chunkOffset >= chunk!!.size) { break }
44+
}
45+
46+
return bytesWritten
47+
}
48+
}

runtime/auth/aws-signing-crt/common/src/aws/smithy/kotlin/runtime/auth/awssigning/crt/CrtAwsSigner.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,26 @@ public object CrtAwsSigner : AwsSigner {
5252

5353
return AwsSigningResult(Unit, crtResult.signature)
5454
}
55+
56+
override suspend fun signChunkTrailer(
57+
trailingHeaders: Headers,
58+
prevSignature: ByteArray,
59+
config: AwsSigningConfig,
60+
): AwsSigningResult<Unit> {
61+
val crtConfig = config.toCrtSigningConfig()
62+
val crtTrailingHeaders = trailingHeaders.toCrtHeaders()
63+
64+
val crtResult = CrtSigner.signChunkTrailer(crtTrailingHeaders, prevSignature, crtConfig)
65+
return AwsSigningResult(Unit, crtResult.signature)
66+
}
5567
}
5668

5769
private fun AwsSignatureType.toCrtSignatureType() = when (this) {
5870
AwsSignatureType.HTTP_REQUEST_CHUNK -> CrtSignatureType.HTTP_REQUEST_CHUNK
5971
AwsSignatureType.HTTP_REQUEST_EVENT -> CrtSignatureType.HTTP_REQUEST_EVENT
6072
AwsSignatureType.HTTP_REQUEST_VIA_HEADERS -> CrtSignatureType.HTTP_REQUEST_VIA_HEADERS
6173
AwsSignatureType.HTTP_REQUEST_VIA_QUERY_PARAMS -> CrtSignatureType.HTTP_REQUEST_VIA_QUERY_PARAMS
74+
AwsSignatureType.HTTP_REQUEST_TRAILING_HEADERS -> CrtSignatureType.HTTP_REQUEST_TRAILING_HEADERS
6275
}
6376

6477
private fun AwsSignedBodyHeader.toCrtSignedBodyHeaderType() = when (this) {
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package aws.smithy.kotlin.runtime.auth.awssigning.crt
7+
8+
import aws.smithy.kotlin.runtime.auth.awssigning.AwsSigner
9+
import aws.smithy.kotlin.runtime.auth.awssigning.tests.AwsChunkedByteReadChannelJVMTestBase
10+
import aws.smithy.kotlin.runtime.auth.awssigning.tests.AwsChunkedByteReadChannelTestBase
11+
12+
class CrtAwsChunkedByteReadChannelTest : AwsChunkedByteReadChannelTestBase() {
13+
override val signer: AwsSigner = CrtAwsSigner
14+
}
15+
16+
class CrtAwsChunkedByteReadChannelJVMTest : AwsChunkedByteReadChannelJVMTestBase() {
17+
override val signer: AwsSigner = CrtAwsSigner
18+
}

0 commit comments

Comments
 (0)