I turned out that I mixed multipart/form-data and application/octet-stream approaches.
The correct Kotlin code for Ktor-client to upload to Cloudflare R2 will be:
suspend fun uploadS3File2(
url: String,
file: File
) = client.put(url) {
setBody(file.readChannel())
headers {
append(HttpHeaders.ContentType, ContentType.Application.OctetStream)
append(HttpHeaders.ContentLength, "${file.length()}")
}
}