proven on the hosted testnet instance · 2026-09-13 · QA-14
Upload media in parts, read by range
What you will have at the end
Section titled “What you will have at the end”A large object uploaded in parts, completed, and read back byte-identical in
full. Then sixteen bytes of it served on their own, answered 206, without the
rest of the object moving.
That is the path a video player takes. It is also the path with the sharpest caveat on this site, and it is near the bottom of this page rather than in a footnote.
Prerequisites
Section titled “Prerequisites”- A credential — a key from an invite or a token from your own issuer.
curl, an empty working directory with room for the parts, and the instance up (The testnet instance).HOST,AUTHandBUCKETset, and the bucket created — the first two blocks of the Quickstart.
The whole upload
Section titled “The whole upload”One block, because the interesting parts are how they fit together: start the
upload and keep its id, PUT each part and keep the ETag the server answers
with, complete with the list of both, then read it back whole and by range.
# A large object goes up in parts. Every part but the last must be at least# 5 MiB - that is S3's minimum - so the last part is deliberately smaller.
KEY=${KEY:-big.bin}PARTS=${PARTS:-3}PART_MIB=${PART_MIB:-5}
UPLOAD_ID=$(curl -sS -m 60 -X POST -H "$AUTH" "$HOST/$BUCKET/$KEY?uploads" \ | sed -n 's:.*<UploadId>\(.*\)</UploadId>.*:\1:p')echo "upload id: $UPLOAD_ID"
# Upload each part, keeping the ETag the server answers with.: > all.binXML="<CompleteMultipartUpload>"i=1while [ "$i" -le "$PARTS" ]; do if [ "$i" -eq "$PARTS" ]; then MIB=1; else MIB=$PART_MIB; fi head -c "$((MIB * 1048576))" /dev/urandom > "part$i.bin" cat "part$i.bin" >> all.bin
ETAG=$(curl -sS -m 300 -X PUT -H "$AUTH" --data-binary @"part$i.bin" -D - -o /dev/null \ "$HOST/$BUCKET/$KEY?partNumber=$i&uploadId=$UPLOAD_ID" \ | grep -i '^etag:' | cut -d' ' -f2- | tr -d '\r') XML="$XML<Part><PartNumber>$i</PartNumber><ETag>$ETAG</ETag></Part>" echo "part $i/$PARTS ($MIB MiB) $ETAG" i=$((i + 1))doneXML="$XML</CompleteMultipartUpload>"
# Completing is the slow call: the server assembles and registers the object.printf '%s' "$XML" > complete.xmlcurl -sS -m 600 -X POST -H "$AUTH" -H 'Content-Type: application/xml' \ --data-binary @complete.xml \ -o complete-response.xml -w 'complete %{http_code}\n' \ "$HOST/$BUCKET/$KEY?uploadId=$UPLOAD_ID"
curl -sS -m 600 -H "$AUTH" -o whole.bin "$HOST/$BUCKET/$KEY"cmp all.bin whole.bin && echo "byte-equal over $(wc -c < all.bin | tr -d ' ') bytes"
# 16 bytes at the 1 MiB mark. 206 means the range was served, not the object.curl -sS -m 120 -H "$AUTH" -H 'Range: bytes=1048576-1048591' \ -o slice.bin -w 'range %{http_code}\n' "$HOST/$BUCKET/$KEY"
curl -sS -m 120 -X DELETE -H "$AUTH" \ -o /dev/null -w 'delete %{http_code}\n' "$HOST/$BUCKET/$KEY"What each step is doing
Section titled “What each step is doing”Starting the upload gives you an upload id. Every part you send carries it plus its own part number; nothing is visible as an object until you complete.
Every part but the last must meet S3’s minimum part size. That is S3’s rule, not ours, and it is why the block above makes the final part deliberately smaller than the others — an undersized part anywhere else is refused at completion. Size your parts to the minimum or above and let the tail be short.
Keep every ETag. The completion request is a list of part numbers and the
ETag the server returned for each. Lose one and you cannot complete; the upload
sits unreferenced until it is aborted.
Completion is one request, and it is the slow one — the server assembles and registers the object while you wait. The block allows for that. A client that times out aggressively here will report a failure for an upload that succeeded.
A range read answers 206, not 200, and returns only the bytes asked for.
200 to a Range request would mean the range was ignored and the whole object
is on the wire, so check the code, not just the bytes.
What this path does not tell you
Section titled “What this path does not tell you”A multipart object carries neither x-amz-meta-permafrost-sync-state nor
x-amz-meta-permafrost-blob-id. Not “not yet” — never. Those two headers
describe a single row that resolves through one blob, and a multipart object’s
bytes resolve through its upload’s manifest and its part rows instead. Do not
build a wait loop around them here; it will never finish. The single-object path
that does emit them is Store, read back, and know when it is
certified.
A completed multipart object does still get its own receipt on Sui, minted during that slow completion call. It is a different shape from the single-object one — On-chain objects has both.
Multipart parts are not encrypted at rest by us
Section titled “Multipart parts are not encrypted at rest by us”This is the caveat. A single-object PUT is encrypted server-side, per tenant,
by default. The multipart path is not. Part bytes are stored as you supplied
them. The encryption header is validated the same way — a value we do not
recognise is the same 400 — but it is not acted on, so asking for encryption on
a multipart upload changes nothing.
A team that needs media encrypted at rest encrypts it before upload. That is the whole workaround, it is not difficult, and it is the only one: there is no header, tier or request you can send that turns this on. Decide it before you move a library of files, not after.
Encryption at rest is the reference for what is and is not covered.
How you know it worked
Section titled “How you know it worked”Three lines from the block above, in order: complete 200, then byte-equal over <n> bytes, then range 206. The middle one is the real assertion — the object
you read back is the concatenation of the parts you sent, byte for byte.
What to read next
Section titled “What to read next”- The S3 surface — the multipart verbs in full.
- Limits — part sizes and the ceilings around them.
- Headers, status codes, 401 and 429 — every header a read carries, including the ones a browser cares about.
Permafrost runs on Sui testnet and Walrus testnet. Everything here describes a shipped testnet instance, not a production service.