Why it exists
Two gaps, and the same server fills both.
On the JVM, "an S3 endpoint you can start in a test" is a mock, and a mock answers what it was told to answer — it will accept a signature it never verified and a body framed in a way no real client sends. The alternatives are a container of MinIO, which is a distributed store running in a mode it was not built for, or the real thing over the network, which is not a test.
The other gap is the small deployment: one machine, one disk, objects that have to survive a kill. Everything in that space is either a cluster with a coordinator or a toy that loses data on a power cut.
What you get
- All four ways a client can frame a body — signed payload,
UNSIGNED-PAYLOAD,STREAMING-AWS4-HMAC-SHA256-PAYLOADandSTREAMING-UNSIGNED-PAYLOAD-TRAILER— because the default foraws s3 cpis the third one, and a server that readsContent-Lengthbytes off a socket does not work with it at all. - SigV4 verified against all 34 official AWS vectors, run in the verifying direction, plus presigned URLs and browser
POSTform uploads with both signature versions. - Objects, properly:
Range, metadata, checksums in both forms, server-side copy, conditional reads and writes,GetObjectAttributes,partNumberon a read, multipart upload withUploadPartCopy. - Versioning, object lock and lifecycle rules — and lifecycle rules that are applied: objects and noncurrent versions expire, orphaned delete markers and abandoned uploads go, and
x-amz-expirationsays when. - Durability that was tested by killing it. The write order's worst outcome is an orphaned file, never a key pointing at nothing; the index is a log with a checksum per record; crash tests
SIGKILLa child JVM mid-write and require everything the log admitted to to read back. - A
GETistransferTofrom the file straight into the socket — measured at 7.6–8.0× less processor per byte than reading into the heap, across a real network card. - A published ceiling rather than a surprise. Every key lives in memory, so the number of versions is bounded by the heap: 399 215 on the shipped 512 MiB profile, printed on the first line of the log. Going over it is a refusal to start, not a slide into swap.
What it is not
One process, one node, no replication. No ACLs, bucket policies or IAM — the access keys are a static list, narrowed to a mode and a set of buckets. No TLS inside the process: terminating it here would wrap the socket and silently remove the zero-copy read path. No storage classes, so a lifecycle rule carrying a Transition is refused by name rather than stored and never acted on.
The number
426 of 744 in ceph/s3-tests, revision 5522d1c. The other 318 carry a classified reason each — 316 deliberate scope decisions, two waiting on a source rather than on work, zero known defects — and a failure matching no rule is reported by name as unclassified rather than folded into a category. That count is zero, and the reasons behind it are re-read when a feature lands, because a rule keeps matching long after its reason stops being true.
Run it
docker run -d --name bochka \
-u 1000:1000 \
-v /srv/bochka:/var/lib/bochka \
-p 127.0.0.1:9000:9000 \
-e BOCHKA_KEYS='youraccesskey:yoursecretkey' \
ghcr.io/youndie/bochka:latest
Start it inside a test
repositories { maven("https://reposilite.kotlin.website/snapshots") }
dependencies { testImplementation("io.github.youndie.bochka:bochka-embedded:0.2.0") }
class ReportsTest {
companion object {
@JvmField
@RegisterExtension
val bochka = BochkaExtension()
}
@Test
fun `uploads the report`() {
// bochka.endpoint, bochka.accessKeyId, bochka.secretKey — point any SDK at it
}
}
Same server as the image: same signature verification, same body framings, same storage. It also does the one thing a real store cannot and a test double should — failNext(503) makes the next request fail, so client code whose retries were never exercised gets exercised.