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
4 files VERSION+1 −1src/remote/s3.v+3 −7src/remote/s3_test.v+48 −5v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.461+3.0.47

view file

src/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 {

view file

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() {

view file

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 }

view file