fix(sigv4): collapse interior header-value spaces per SigV4 canonicalization

ober

8d3e16b0dbe4357acc745a9e272bb714d7272bef

diff --git a/lib/jerboa-aws/sigv4.sls b/lib/jerboa-aws/sigv4.sls
index 3f161cc..c4e48e2 100644
--- a/lib/jerboa-aws/sigv4.sls
+++ b/lib/jerboa-aws/sigv4.sls
@@ -31,13 +31,13 @@
               (lambda (a b) (string<? (string-downcase (car a))
                                        (string-downcase (car b))))
               headers)]
-           ;; Build canonical headers
-           [canonical-headers
-            (apply string-append
-              (map (lambda (h)
-                     (string-append (string-downcase (car h)) ":"
-                                    (string-trim (cdr h)) "\n"))
-                   sorted-headers))]
+            ;; Build canonical headers
+            [canonical-headers
+             (apply string-append
+               (map (lambda (h)
+                      (string-append (string-downcase (car h)) ":"
+                                     (canonical-header-value (cdr h)) "\n"))
+                    sorted-headers))]
            ;; Build signed headers list
            [signed-headers
             (string-join
@@ -80,6 +80,26 @@
 
   ;; --- Helpers ---
 
+  ;; Canonical header value per SigV4: trim leading/trailing whitespace AND
+  ;; collapse runs of interior spaces to a single space. Without the collapse,
+  ;; a header value carrying two consecutive spaces is signed differently from
+  ;; the value AWS reconstructs for verification, yielding SignatureDoesNotMatch.
+  (define (canonical-header-value str)
+    (collapse-spaces (string-trim str)))
+
+  (define (collapse-spaces str)
+    (let ([len (string-length str)]
+          [out (open-output-string)])
+      (let loop ([i 0] [prev-space? #f])
+        (cond
+          [(= i len) (get-output-string out)]
+          [(char=? (string-ref str i) #\space)
+           (unless prev-space? (write-char #\space out))
+           (loop (+ i 1) #t)]
+          [else
+           (write-char (string-ref str i) out)
+           (loop (+ i 1) #f)]))))
+
   (define (string-trim str)
     (let* ([len (string-length str)]
            [start (let loop ([i 0])
diff --git a/test/test-all.ss b/test/test-all.ss
index b80274f..0aae18a 100644
--- a/test/test-all.ss
+++ b/test/test-all.ss
@@ -3,6 +3,8 @@
         (jerboa-aws s3 objects)
         (jerboa-aws ec2 params)
         (jerboa-aws json)
+        (jerboa-aws sigv4)
+        (only (jerboa-aws crypto) sha256 hex-encode)
         (only (jerboa-aws pssm)
               parse-pssm-args
               pssm-terminal-sanitize
@@ -90,6 +92,49 @@
           (cons "a" "")))
   "a=&a%20b=x%2Fy&z=last")
 
+;; --- SigV4 signing ----------------------------------------------------
+;; AWS SigV4 test-suite "get-vanilla" known-answer vector: validates the
+;; canonical request, string-to-sign, key derivation and signature end to end.
+(check
+  "sigv4 get-vanilla known vector"
+  (sigv4-sign "GET" "/" ""
+    '(("Host" . "example.amazonaws.com")
+      ("X-Amz-Date" . "20150830T123600Z"))
+    (hex-encode (sha256 ""))
+    "us-east-1" "service"
+    "AKIDEXAMPLE"
+    "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"
+    "20150830T123600Z" "20150830")
+  (string-append
+    "AWS4-HMAC-SHA256 "
+    "Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, "
+    "SignedHeaders=host;x-amz-date, "
+    "Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31"))
+
+;; SigV4 canonical header values collapse runs of interior spaces to a single
+;; space. A header value with two consecutive spaces must therefore sign
+;; identically to the same value with one space; otherwise AWS reconstructs a
+;; different canonical request and rejects the call (SignatureDoesNotMatch).
+(let ([sign (lambda (v)
+              (sigv4-sign "GET" "/" ""
+                `(("Host" . "example.amazonaws.com")
+                  ("X-Amz-Date" . "20150830T123600Z")
+                  ("X-Custom" . ,v))
+                (hex-encode (sha256 ""))
+                "us-east-1" "service"
+                "AKIDEXAMPLE"
+                "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"
+                "20150830T123600Z" "20150830"))])
+  (check "sigv4 collapses interior header spaces"
+    (sign "a  b")
+    (sign "a b"))
+  (check "sigv4 collapses a long run of header spaces"
+    (sign "a     b")
+    (sign "a b"))
+  (check "sigv4 trims leading/trailing header whitespace"
+    (sign "   value   ")
+    (sign "value")))
+
 (let ([target (s3-build-request-target
                 path-client
                 "bucket"