@@ -142,7 +142,14 @@ pub fn canonical_query(query: Option<&str>) -> String {
142142 Some ( pos) => ( & part[ ..pos] , & part[ pos + 1 ..] ) ,
143143 None => ( part, "" ) ,
144144 } ;
145- Some ( ( percent_encode_query ( key) , percent_encode_query ( value) ) )
145+ // Decode any existing percent-encoding first, then re-encode per the
146+ // SigV4 rules. This canonicalizes already-encoded input (e.g.
147+ // `logs%2F` stays `logs%2F` instead of double-encoding to `logs%252F`)
148+ // so the signed query equals what callers transmit.
149+ Some ( (
150+ percent_encode_query ( & percent_decode ( key) ) ,
151+ percent_encode_query ( & percent_decode ( value) ) ,
152+ ) )
146153 } )
147154 . collect ( ) ;
148155
@@ -155,16 +162,25 @@ pub fn canonical_query(query: Option<&str>) -> String {
155162 . join ( "&" )
156163}
157164
165+ /// Canonicalize a header value per SigV4: trim, and collapse sequential
166+ /// internal whitespace to a single space (for non-quoted values).
167+ fn canonical_header_value ( v : & str ) -> String {
168+ v. split_whitespace ( ) . collect :: < Vec < _ > > ( ) . join ( " " )
169+ }
170+
158171/// Compute the SigV4 `Authorization` header and related signed headers.
159172///
160173/// # Panics
161174/// Never panics — HMAC accepts any key length.
162175pub fn sign ( input : & SigningInput , creds : & Credentials , config : & SigningConfig ) -> SignedHeaders {
163176 // BTreeMap guarantees keys are already sorted; keys must be lowercase.
177+ // SigV4 requires header values to be trimmed AND have sequential internal
178+ // whitespace collapsed to a single space; `v.trim()` alone diverges from
179+ // AWS's recomputation for multi-space values.
164180 let canonical_headers_str: String = input
165181 . headers_to_sign
166182 . iter ( )
167- . map ( |( k, v) | format ! ( "{}:{}\n " , k, v . trim ( ) ) )
183+ . map ( |( k, v) | format ! ( "{}:{}\n " , k, canonical_header_value ( v ) ) )
168184 . collect ( ) ;
169185
170186 let signed_headers_str: String = input
@@ -232,6 +248,35 @@ fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
232248
233249/// Percent-encode a query string key or value (SigV4 rules).
234250/// Encodes all bytes except unreserved characters (`A-Z a-z 0-9 - _ . ~`).
251+ /// Decode percent-escapes (`%XX`) in a query component. Invalid escapes are left
252+ /// as-is. `+` is treated literally (RFC 3986 query semantics), matching SigV4.
253+ fn percent_decode ( s : & str ) -> String {
254+ let bytes = s. as_bytes ( ) ;
255+ let mut out: Vec < u8 > = Vec :: with_capacity ( bytes. len ( ) ) ;
256+ let mut i = 0 ;
257+ while i < bytes. len ( ) {
258+ if bytes[ i] == b'%' && i + 3 <= bytes. len ( ) {
259+ if let ( Some ( hi) , Some ( lo) ) = ( hex_val ( bytes[ i + 1 ] ) , hex_val ( bytes[ i + 2 ] ) ) {
260+ out. push ( ( hi << 4 ) | lo) ;
261+ i += 3 ;
262+ continue ;
263+ }
264+ }
265+ out. push ( bytes[ i] ) ;
266+ i += 1 ;
267+ }
268+ String :: from_utf8_lossy ( & out) . into_owned ( )
269+ }
270+
271+ fn hex_val ( c : u8 ) -> Option < u8 > {
272+ match c {
273+ b'0' ..=b'9' => Some ( c - b'0' ) ,
274+ b'a' ..=b'f' => Some ( c - b'a' + 10 ) ,
275+ b'A' ..=b'F' => Some ( c - b'A' + 10 ) ,
276+ _ => None ,
277+ }
278+ }
279+
235280fn percent_encode_query ( s : & str ) -> String {
236281 let mut result = String :: with_capacity ( s. len ( ) ) ;
237282 for byte in s. bytes ( ) {
@@ -367,6 +412,26 @@ mod tests {
367412 ) ;
368413 }
369414
415+ // CR-5: already-encoded input is decoded then re-encoded (not double-encoded),
416+ // so the signed query matches what is transmitted.
417+ #[ test]
418+ fn test_canonical_query_does_not_double_encode ( ) {
419+ assert_eq ! (
420+ canonical_query( Some ( "prefix=logs%2Fdir&list-type=2" ) ) ,
421+ "list-type=2&prefix=logs%2Fdir"
422+ ) ;
423+ // Idempotent: canonicalizing twice yields the same result.
424+ let once = canonical_query ( Some ( "k=a%2Bb" ) ) ;
425+ assert_eq ! ( canonical_query( Some ( & once) ) , once) ;
426+ }
427+
428+ // CR-5: header values are trimmed AND internal whitespace is collapsed.
429+ #[ test]
430+ fn test_canonical_header_value_collapses_whitespace ( ) {
431+ assert_eq ! ( canonical_header_value( " a b\t c " ) , "a b c" ) ;
432+ assert_eq ! ( canonical_header_value( "single" ) , "single" ) ;
433+ }
434+
370435 #[ test]
371436 fn test_canonical_query_no_value ( ) {
372437 // Key without value
0 commit comments