cb84f9ea35c4
send S3 ETags back exactly as the server gave them, so offers keep landing on a server that follows RFC 7232 (3.0.47)
- time
- 2026-09-24 16:09 UTC · 2 weeks ago
- machine
- koh2
- agent
- claude-opus-5-5
- session
- koh 3 reliability campaign
- parent
- e18607779731
↓ koh-cb84f9ea.tar.gzatlas at this save
files changed4 files
VERSION+1 −1src/remote/s3.v+3 −7src/remote/s3_test.v+48 −5v.mod+1 −1src/remote/s3.v+3 −7
@@ -63,7 +63,9 @@6363 return Object{6464 found: true6565 body: response.body.bytes()66- tag: clean_etag(header_of(response, 'ETag'))66+ // Sent back as-is in If-Match: an RFC 7232 server compares the67+ // quoted entity-tag, and a lenient one strips the quotes itself.68+ tag: header_of(response, 'ETag').trim_space()6769 }6870 }6971 else {@@ -198,12 +200,6 @@198200 199201 fn header_of(response http.Response, name string) string {200202 return response.header.get_custom(name, http.HeaderQueryConfig{}) or { '' }201-}202-203-// clean_etag strips the quotes S3 puts around an ETag: the value is sent back204-// verbatim in `If-Match`, and a quoted form would not match.205-pub fn clean_etag(value string) string {206- return value.trim_space().trim('"')207203 }208204 209205 fn header_case(name string) string {src/remote/s3_test.v+48 −5
@@ -1,5 +1,7 @@11 module remote22 3+import net4+import net.http35 import time46 57 // The vectors below come from two independent sources:@@ -101,11 +103,52 @@101103 assert canonical_query('prefix=a b') == 'prefix=a%20b'102104 }103105 104-fn test_etags_lose_their_quotes() {105- assert clean_etag('"3b1f"') == '3b1f'106- assert clean_etag(' "abc123" ') == 'abc123'107- assert clean_etag('abc123') == 'abc123'108- assert clean_etag('') == ''106+// A server that follows RFC 7232 compares If-Match against the entity-tag as107+// it sent it, quotes included. Koh must hand the ETag back untouched, or every108+// head swap after the first is refused and offers never land again.109+struct StrictHead {}110+111+fn (mut h StrictHead) handle(req http.Request) http.Response {112+ mut resp := http.Response{113+ status_code: 200114+ status_msg: 'OK'115+ }116+ if req.method == .put {117+ if_match := req.header.get_custom('If-Match', http.HeaderQueryConfig{}) or { '' }118+ if if_match != '"3b1f"' {119+ resp.status_code = 412120+ resp.status_msg = 'Precondition Failed'121+ }122+ return resp123+ }124+ resp.body = 'head-id'125+ resp.header.add_custom('ETag', '"3b1f"') or {}126+ return resp127+}128+129+fn test_an_etag_goes_back_exactly_as_the_server_sent_it() {130+ mut port := 0131+ mut server := &http.Server{}132+ for candidate in [19311, 19312, 19313, 19314, 19315] {133+ listener := net.listen_tcp(.ip, '127.0.0.1:${candidate}') or { continue }134+ server = &http.Server{135+ listener: listener136+ handler: StrictHead{}137+ show_startup_message: false138+ }139+ port = candidate140+ break141+ }142+ assert port != 0, 'no free port for the test server'143+ spawn server.listen_and_serve()144+ server.wait_till_running()!145+ defer {146+ server.stop()147+ }148+ mut backend := new_s3('http://127.0.0.1:${port}', 'bucket', 'fr-par', 'ak', 'sk')!149+ head := backend.get('repo/head')!150+ assert head.tag == '"3b1f"'151+ assert backend.put_if_match('repo/head', 'next'.bytes(), head.tag)!109152 }110153 111154 fn test_list_objects_responses_are_read() {v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.0.46'3+ version: '3.0.47'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }