cloudflare-native port of the tangled knot server
22

Configure Feed

Select the types of activity you want to include in your feed.

knotserver: fix SigV4 canonical-path encoding in clone-perf diagnostic S3 probe

The R2 egress probe (r2Get/r2RangeGet) signed the canonical request with Go's
url.EscapedPath(), which leaves ':' literal in non-first path segments. R2, like
S3, percent-encodes the path for the SigV4 canonical request, so did:plc:<id>
repo keys must sign as did%3Aplc%3A<id> — the literal-colon mismatch produced a
SignatureDoesNotMatch 403, leaving container->R2 egress latency unmeasured.

- canonicalPath now AWS-URI-encodes the path (unreserved + '/' pass through,
everything else %XX uppercase), matching what R2 echoes in its 403 body.
- probeS3Get captures a bounded prefix of non-2xx response bodies into the error
field, so future signing/scope failures are self-diagnosing instead of a bare
'http status 403'.
- unit tests lock in the did:plc colon-encoding and the empty-path case.

Verified live against knot.solpbc.org: r2Get 200 (5.18MB, ~22-30 MB/s),
r2RangeGet 206 (~78ms round-trip floor, stable warm/cold).

Patch category: aerie-distribution
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Jer Miller (Jun 15, 2026, 7:22 PM -0600) ce22c818 f0ab5796

+71 -5
+38 -5
knotserver/diagnostics.go
··· 30 30 diagnosticsTokenHeader = "X-Knot-Diagnostics-Token" 31 31 randomReadCount = 16 32 32 randomReadChunkSize = 16 * 1024 33 + maxErrorBodyBytes = 2048 33 34 ) 34 35 35 36 var errNoPackFile = errors.New("no pack file") ··· 157 158 } 158 159 defer resp.Body.Close() 159 160 161 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { 162 + // Capture a bounded prefix of the error body so signing/scope failures 163 + // are self-diagnosing — R2/S3 echo the CanonicalRequest they computed in 164 + // a SignatureDoesNotMatch body, which pinpoints any future divergence 165 + // instead of leaving a bare "http status 403". 166 + errBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes)) 167 + return diagnosticsHTTPProbe{ 168 + DurationMs: elapsedMilliseconds(start), 169 + Status: resp.StatusCode, 170 + Error: strings.TrimSpace(fmt.Sprintf("http status %d: %s", resp.StatusCode, errBody)), 171 + } 172 + } 173 + 160 174 bytesRead, readErr := io.Copy(io.Discard, resp.Body) 161 175 probe := diagnosticsHTTPProbe{ 162 176 DurationMs: elapsedMilliseconds(start), ··· 165 179 } 166 180 if readErr != nil { 167 181 probe.Error = readErr.Error() 168 - } else if resp.StatusCode < 200 || resp.StatusCode >= 300 { 169 - probe.Error = fmt.Sprintf("http status %d", resp.StatusCode) 170 182 } 171 183 return probe 172 184 } ··· 251 263 } 252 264 253 265 func canonicalPath(u *url.URL) string { 254 - path := u.EscapedPath() 255 - if path == "" { 266 + if u.Path == "" { 256 267 return "/" 257 268 } 258 - return path 269 + return awsURIEncodePath(u.Path) 270 + } 271 + 272 + // awsURIEncodePath percent-encodes a path for the SigV4 canonical request per 273 + // AWS/R2 rules: the unreserved set (A-Za-z0-9-._~) and the path separator '/' 274 + // pass through; every other byte is percent-encoded with uppercase hex. R2, 275 + // like S3, re-encodes the path it receives this way before checking the 276 + // signature, so the client must match it — notably did:plc:<id> repo keys must 277 + // sign as did%3Aplc%3A<id> (Go's url.EscapedPath leaves ':' literal, which is 278 + // the SignatureDoesNotMatch root cause this fixes). Operates byte-wise so it is 279 + // correct for UTF-8 keys too. 280 + func awsURIEncodePath(path string) string { 281 + var b strings.Builder 282 + for i := 0; i < len(path); i++ { 283 + c := path[i] 284 + if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || 285 + c == '-' || c == '_' || c == '.' || c == '~' || c == '/' { 286 + b.WriteByte(c) 287 + } else { 288 + fmt.Fprintf(&b, "%%%02X", c) 289 + } 290 + } 291 + return b.String() 259 292 } 260 293 261 294 func deriveSigningKey(secret, dateStamp, region string) []byte {
+33
knotserver/diagnostics_test.go
··· 146 146 } 147 147 } 148 148 149 + func TestAwsURIEncodePath(t *testing.T) { 150 + cases := []struct { 151 + in string 152 + want string 153 + }{ 154 + // The SignatureDoesNotMatch root cause: did:plc:<id> keys must sign with 155 + // ':' percent-encoded as %3A, while '/' separators stay literal. 156 + { 157 + in: "/aerie-knot-solpbc/did:plc:3ou5bpmuu2heyxve56ovnsnp/objects/pack/pack-98a41d2.pack", 158 + want: "/aerie-knot-solpbc/did%3Aplc%3A3ou5bpmuu2heyxve56ovnsnp/objects/pack/pack-98a41d2.pack", 159 + }, 160 + // Unreserved set passes through untouched. 161 + {in: "/a-b_c.d~e/F0", want: "/a-b_c.d~e/F0"}, 162 + // Space and other reserved bytes encode with uppercase hex. 163 + {in: "/a b", want: "/a%20b"}, 164 + } 165 + for _, c := range cases { 166 + if got := awsURIEncodePath(c.in); got != c.want { 167 + t.Errorf("awsURIEncodePath(%q) = %q, want %q", c.in, got, c.want) 168 + } 169 + } 170 + } 171 + 172 + func TestCanonicalPathEmpty(t *testing.T) { 173 + u, err := url.Parse("https://example.com") 174 + if err != nil { 175 + t.Fatalf("Parse: %v", err) 176 + } 177 + if got := canonicalPath(u); got != "/" { 178 + t.Errorf("canonicalPath(empty) = %q, want %q", got, "/") 179 + } 180 + } 181 + 149 182 func newDiagnosticsRequest(repoDid, token string) *http.Request { 150 183 req := httptest.NewRequest(http.MethodGet, "/diagnostics?repoDid="+url.QueryEscape(repoDid), nil) 151 184 if token != "" {