Skip to content

proven on the hosted testnet instance · 2026-09-13 · QA-14

Upload media in parts, read by range

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.

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.

20-multipart.sh
# 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.bin
XML="<CompleteMultipartUpload>"
i=1
while [ "$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))
done
XML="$XML</CompleteMultipartUpload>"
# Completing is the slow call: the server assembles and registers the object.
printf '%s' "$XML" > complete.xml
curl -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"

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.

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.

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.

Permafrost runs on Sui testnet and Walrus testnet. Everything here describes a shipped testnet instance, not a production service.