What's needed
Honor conditional-write preconditions (If-Match / If-None-Match, and the x-amz-copy-source-if-* variants) on PutObject so a write with a stale/wrong ETag fails with 412 Precondition Failed instead of silently succeeding.
Motivation
Reported by a Source Coop contributor writing an Icechunk Zarr store natively through the proxy. Icechunk uses conditional writes as the compare-and-swap that protects refs from concurrent writers. Today a PUT with a deliberately wrong ETag succeeds where S3 returns 412:
If-Match preconditions are silently ignored. A PUT with a deliberately wrong ETag succeeds where S3 returns 412 Precondition Failed. Without enforcement, concurrent commits can silently clobber each other.
Current behavior
Conditional headers are forwarded on reads but dropped on writes:
- GET forwards
if-match/if-none-match/if-modified-since/if-unmodified-since: crates/core/src/proxy.rs:769-775.
- HEAD forwards the same set:
crates/core/src/proxy.rs:789-795.
- PutObject forward whitelist (
crates/core/src/proxy.rs:818-827) is only content-*, cache-control, expires — if-match/if-none-match are absent, so the precondition is silently dropped and the write always succeeds.
The 412 machinery already exists but is only wired for the read/backend-executed paths:
ProxyError::PreconditionFailed → S3 code PreconditionFailed / HTTP 412: crates/core/src/error.rs:79,112,131.
- Produced from
object_store::Error::Precondition in from_object_store_error(): crates/core/src/error.rs:162.
ETags themselves are never computed/stored by the proxy — they originate from the backend and are only passed through (crates/core/src/api/response.rs:100-101,128-130,228-230; crates/core/src/api/list.rs:200).
Why it isn't a trivial header pass-through
Same constraint as CopyObject: PUT forwards via a presigned URL carrying only signed, whitelisted headers (crates/core/src/proxy.rs:810-817). if-match/if-none-match must be part of the signature for the backend to evaluate them — they can't be added unsigned. So enforcement means including the precondition headers in the presign/signing so the backend (S3) returns 412 directly, then mapping that response through cleanly.
Scope / proposed approach
- Add
if-match / if-none-match (and x-amz-copy-source-if-* for the copy case) to the PutObject signed-forward set so the backend evaluates the precondition.
- Confirm the backend's 412 is surfaced to the client unchanged (presigned passthrough) and, for proxy-executed paths, maps via
from_object_store_error() (error.rs:162).
- Failing-test-first (per CLAUDE.md): a PUT with a wrong
If-Match must return 412; If-None-Match: * must fail when the object exists.
- Update
docs/ for the newly honored preconditions.
Pairs with CopyObject support (#119) — Icechunk needs both for native transactional commits.
What's needed
Honor conditional-write preconditions (
If-Match/If-None-Match, and thex-amz-copy-source-if-*variants) onPutObjectso a write with a stale/wrong ETag fails with 412 Precondition Failed instead of silently succeeding.Motivation
Reported by a Source Coop contributor writing an Icechunk Zarr store natively through the proxy. Icechunk uses conditional writes as the compare-and-swap that protects refs from concurrent writers. Today a PUT with a deliberately wrong ETag succeeds where S3 returns
412:Current behavior
Conditional headers are forwarded on reads but dropped on writes:
if-match/if-none-match/if-modified-since/if-unmodified-since:crates/core/src/proxy.rs:769-775.crates/core/src/proxy.rs:789-795.crates/core/src/proxy.rs:818-827) is onlycontent-*,cache-control,expires—if-match/if-none-matchare absent, so the precondition is silently dropped and the write always succeeds.The 412 machinery already exists but is only wired for the read/backend-executed paths:
ProxyError::PreconditionFailed→ S3 codePreconditionFailed/ HTTP 412:crates/core/src/error.rs:79,112,131.object_store::Error::Preconditioninfrom_object_store_error():crates/core/src/error.rs:162.ETags themselves are never computed/stored by the proxy — they originate from the backend and are only passed through (
crates/core/src/api/response.rs:100-101,128-130,228-230;crates/core/src/api/list.rs:200).Why it isn't a trivial header pass-through
Same constraint as CopyObject: PUT forwards via a presigned URL carrying only signed, whitelisted headers (
crates/core/src/proxy.rs:810-817).if-match/if-none-matchmust be part of the signature for the backend to evaluate them — they can't be added unsigned. So enforcement means including the precondition headers in the presign/signing so the backend (S3) returns 412 directly, then mapping that response through cleanly.Scope / proposed approach
if-match/if-none-match(andx-amz-copy-source-if-*for the copy case) to the PutObject signed-forward set so the backend evaluates the precondition.from_object_store_error()(error.rs:162).If-Matchmust return 412;If-None-Match: *must fail when the object exists.docs/for the newly honored preconditions.Pairs with CopyObject support (#119) — Icechunk needs both for native transactional commits.