A service on Kotlin/Native has nothing to talk to object storage with. The AWS SDK for Kotlin publishes JVM artefacts only; binding aws-c-s3 through cinterop drags five C libraries and their build onto every target — and you still have to understand SigV4 to sign a URL.
s3kn writes the protocol out instead: the signature, and seven HTTP requests. Most of it went the way the specification said it would. These three did not.
libcurl rewrites it after you sign
S3 is unusual among AWS services: it signs the request path verbatim. No dot segments removed, no second round of percent-encoding. So a key like a/./b is signed exactly as written — which is the whole reason the signer has two modes rather than one.
Getting that right took a day. Discovering it did not matter took a live request.
The key a/./b left as a/b. libcurl normalises the path per RFC 3986 before sending, the Ktor engine never sets CURLOPT_PATH_AS_IS, and the only symptom is SignatureDoesNotMatch — a response that names no header, no character and no field.
The unit test asserting the encoded path was green the entire time. It shows what the URL builder produced, not what reaches the socket, and no amount of staring at it would have helped. What proved it was running the same request through a second engine: on the JVM the path arrives intact and the server complains about the name instead of the signature. Two engines disagreeing is a fact; one engine failing is a hypothesis.
The library now refuses such a key outright. S3 accepts them and the JVM engines deliver them, so this forbids something genuinely possible — but a silent failure on the platform the library is built for is worse than a refusal everywhere, and the error says what is wrong in a sentence.
When there are no vectors, ask somebody else's implementation
AWS publishes 34 test vectors for SigV4, and they are excellent: each case ships the raw request, the canonical request, the string to sign and the resulting header. Four of them are skipped by botocore's own runner — one because a general HTTP parser cannot read a request line containing a space, three because parsing a query into a map loses repeated names. Neither limit belongs to the algorithm, so all 34 run here.
What AWS does not publish is vectors for the two rules S3 adds: the verbatim path, and presigned URLs. Writing those expectations by hand is a trap — you end up testing your own reading of the specification against itself, twice, and a misreading passes both times.
So they are generated from botocore by a script committed next to them. Not official, and only as trustworthy as the reference implementation — but an independent implementation rather than a restatement of my own behaviour. The sharpest of them is a plain table of keys and their encoded forms, produced by Python's quote: agreeing with it checks how I read the rule, not that I can repeat it.
And a green vector suite proves nothing on its own. Switching the signer to the other path mode has to fail 10 of the 34; sorting the presigned query differently has to fail every presign case. Until a test fails when you break the thing it covers, it is decoration.
One protocol, two encodings
A listing asks S3 to encode the keys it returns, because an object key may hold characters XML 1.0 cannot represent at all. Fine. Then a key containing my dir came back as my+dir.
The request path encodes a space as %20, per RFC 3986. The listing response encodes it as +, which is form encoding. Same protocol, same request, two conventions — and botocore decodes those responses with unquote_plus, so the server was behaving exactly as the SDKs expect. The wrong assumption was mine: that one decoder covers both directions.
It is unambiguous, as it happens — + is not an unreserved character, so a literal one arrives as %2B — but that is a sentence I could have written confidently and wrongly. Instead there is a key called a+b sitting next to my dir in the live tests.
The same afternoon produced a smaller one: botocore's comment lists ContinuationToken among the encoded fields, and its code does not decode it. The code is right — the token is opaque and travels back verbatim, so decoding it would send S3 something it never issued. When a reference implementation contradicts itself, the code is the reference.
The rest of it
put,get,delete,head, listing, multipart upload, presigned URLs. Nothing else.- Bodies stream in both directions; a five-gigabyte object is never held whole. The body of a
getis handed to a block rather than returned, because it only lives as long as the connection does. - One encoder for the object key, called by both the signer and the URL builder. Two that differ by a character produce a rejection that explains nothing.
- The credential scope date is a substring of the timestamp, not a second calculation. Computing it separately is correct 86 399 seconds a day.
- Multipart aborts on every path that can fail, including cancellation — the abort runs under
NonCancellable, or it would be cancelled along with everything else and leave precisely the parts it exists to remove. - No cinterop anywhere. The crypto is pure Kotlin, and TLS arrives inside the engine's own klib. Which is why one Linux job publishes every artefact, macOS klibs included.
What a container still needs
TLS is inside ktor-client-curl — it carries its own libcurl and OpenSSL, so nothing has to be installed for it. The root certificates are another matter: they are read from /etc/ssl/certs at runtime, and a slim image has none.
"Works on my machine" proves nothing there, because my machine has certificates. So the check builds the image twice and requires opposite outcomes: with ca-certificates it must reach AWS and get an HTTP status back, without them it must fail. A test that can only pass is not a test.
Source: github.com/youndie/s3kn