Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions services/aws-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ pub use region::IMDSv2RegionProvider;
pub mod assume_role;
pub use assume_role::AssumeRoleGrant;

mod percent_encoding_mode;
pub use percent_encoding_mode::PercentEncodingMode;

mod credential;
pub use credential::Credential;

Expand Down
40 changes: 40 additions & 0 deletions services/aws-core/src/percent_encoding_mode.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

/// Percent-encoding applied to the URI path in an AWS canonical request.
///
/// Requests must already contain a wire-ready URI. This setting affects only the
/// canonical URI used for signing; it does not rewrite the request URI, normalize
/// path segments, or change query encoding.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum PercentEncodingMode {
/// Keep the existing single-encoding behavior, suitable for Amazon S3.
///
/// Each path segment is percent-decoded as UTF-8, then encoded using AWS's
/// unreserved character set. For example, `/a%20b` stays `/a%20b`, `%2f`
/// becomes `%2F`, and `%7E` becomes `~`. Invalid UTF-8 escapes are rejected.
/// This remains the default for all services for backwards compatibility.
#[default]
Single,
/// Apply one additional encoding pass to the caller's wire path.
///
/// Existing escapes are encoded without decoding them first: `/a%20b`
/// becomes `/a%2520b`, `%2f` becomes `%252f`, and `%FF` becomes `%25FF`.
/// Literal `/` separators remain unchanged; literal `@` and `=` become
/// `%40` and `%3D`. Use this for services that require double URI encoding.
Double,
}
135 changes: 129 additions & 6 deletions services/aws-core/src/signing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,28 +19,44 @@ use std::fmt::Write;
use std::time::Duration;

use http::{HeaderValue, Uri, header};
use percent_encoding::{percent_decode_str, utf8_percent_encode};
use percent_encoding::{AsciiSet, percent_decode_str, utf8_percent_encode};
use reqsign_core::time::Timestamp;
use reqsign_core::{Result, SigningRequest};

use crate::Credential;
use crate::constants::{
AWS_QUERY_ENCODE_SET, AWS_URI_ENCODE_SET, X_AMZ_CONTENT_SHA_256, X_AMZ_DATE,
X_AMZ_S3_SESSION_TOKEN, X_AMZ_SECURITY_TOKEN,
};
use crate::{Credential, PercentEncodingMode};

static AWS_URI_DOUBLE_ENCODE_SET: AsciiSet = AWS_URI_ENCODE_SET.remove(b'/');

/// Build the canonical request shared by AWS SigV4-family algorithms.
pub fn canonical_request_string(
request: &SigningRequest,
canonical_query: &[(String, String)],
) -> Result<String> {
canonical_request_string_with_encoding(request, canonical_query, PercentEncodingMode::Single)
}

/// Build an AWS canonical request with the selected URI percent-encoding mode.
///
/// The mode only affects the canonical URI, not the request's wire URI or query.
pub fn canonical_request_string_with_encoding(
request: &SigningRequest,
canonical_query: &[(String, String)],
percent_encoding_mode: PercentEncodingMode,
) -> Result<String> {
let mut output = String::with_capacity(256);

writeln!(output, "{}", request.method)
.map_err(|e| reqsign_core::Error::unexpected(format!("failed to write method: {e}")))?;
writeln!(output, "{}", canonical_uri(&request.path)?).map_err(|e| {
reqsign_core::Error::unexpected(format!("failed to write encoded path: {e}"))
})?;
writeln!(
output,
"{}",
canonical_uri_with_encoding(&request.path, percent_encoding_mode)?
)
.map_err(|e| reqsign_core::Error::unexpected(format!("failed to write encoded path: {e}")))?;
writeln!(
output,
"{}",
Expand Down Expand Up @@ -194,8 +210,26 @@ pub fn canonicalize_query(
query
}

/// Encode a wire-ready path for use in an AWS canonical request.
/// Encode a wire-ready path with the default single-encoding behavior.
pub fn canonical_uri(path: &str) -> Result<String> {
canonical_uri_with_encoding(path, PercentEncodingMode::Single)
}

/// Encode a wire-ready path for use in an AWS canonical request.
///
/// This does not normalize path segments or modify the original wire path.
pub fn canonical_uri_with_encoding(
path: &str,
percent_encoding_mode: PercentEncodingMode,
) -> Result<String> {
if percent_encoding_mode == PercentEncodingMode::Double {
// The input is already the caller's encoded wire path. Do not decode it:
// doing so would lose percent-escape spelling and reject non-UTF-8 bytes.
return Ok(utf8_percent_encode(path, &AWS_URI_DOUBLE_ENCODE_SET).to_string());
}

// Keep the existing single-encoding behavior, including escape normalization
// and rejection of percent-encoded non-UTF-8 bytes, for S3 compatibility.
path.split('/')
.map(|segment| {
let decoded = percent_decode_str(segment).decode_utf8().map_err(|e| {
Expand Down Expand Up @@ -246,3 +280,92 @@ pub fn append_query_fragment(uri: &Uri, fragment: &str) -> Result<Uri> {
reqsign_core::Error::request_invalid("failed to append signing query").with_source(e)
})
}

#[cfg(test)]
mod tests {
use super::*;
use http::Request;

#[test]
fn canonical_uri_encoding_modes() -> Result<()> {
let cases = [
("/", "/", "/"),
("/a%25b", "/a%25b", "/a%2525b"),
("/a%2Fb", "/a%2Fb", "/a%252Fb"),
("/a%20b", "/a%20b", "/a%2520b"),
("/a%40b", "/a%40b", "/a%2540b"),
("/a%3Db", "/a%3Db", "/a%253Db"),
("/a@b=c", "/a%40b%3Dc", "/a%40b%3Dc"),
("/a%2fb%3d", "/a%2Fb%3D", "/a%252fb%253d"),
("/%7E/%41", "/~/A", "/%257E/%2541"),
(
"/%E4%B8%AD%E6%96%87",
"/%E4%B8%AD%E6%96%87",
"/%25E4%25B8%25AD%25E6%2596%2587",
),
("/a//./b/../c/", "/a//./b/../c/", "/a//./b/../c/"),
("/-._~AZaz09", "/-._~AZaz09", "/-._~AZaz09"),
];

assert_eq!(PercentEncodingMode::default(), PercentEncodingMode::Single);
for (wire_path, single, double) in cases {
assert_eq!(canonical_uri(wire_path)?, single, "default: {wire_path}");
assert_eq!(
canonical_uri_with_encoding(wire_path, PercentEncodingMode::Single)?,
single,
"single: {wire_path}"
);
assert_eq!(
canonical_uri_with_encoding(wire_path, PercentEncodingMode::Double)?,
double,
"double: {wire_path}"
);
}
Ok(())
}

#[test]
fn double_encoding_does_not_decode_non_utf8_escapes() -> Result<()> {
for (wire_path, double) in [("/%FF", "/%25FF"), ("/%C3%28", "/%25C3%2528")] {
assert!(canonical_uri(wire_path).is_err());
assert!(canonical_uri_with_encoding(wire_path, PercentEncodingMode::Single).is_err());
assert_eq!(
canonical_uri_with_encoding(wire_path, PercentEncodingMode::Double)?,
double
);
}
Ok(())
}

#[test]
fn encoding_changes_only_the_canonical_uri() -> Result<()> {
let mut parts = Request::get("https://example.com/a%2Fb?key=%25")
.header("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
.body(())
.expect("request must be valid")
.into_parts()
.0;
let original_uri = parts.uri.clone();
let request = SigningRequest::build(&mut parts)?;
let canonical_query = canonicalize_query(&request, &[]);
let default = canonical_request_string(&request, &canonical_query)?;
let single = canonical_request_string_with_encoding(
&request,
&canonical_query,
PercentEncodingMode::Single,
)?;
let double = canonical_request_string_with_encoding(
&request,
&canonical_query,
PercentEncodingMode::Double,
)?;

assert_eq!(default, single);
assert_eq!(single.lines().nth(1), Some("/a%2Fb"));
assert_eq!(double.lines().nth(1), Some("/a%252Fb"));
assert_eq!(double, single.replacen("/a%2Fb", "/a%252Fb", 1));
assert_eq!(request.path, "/a%2Fb");
assert_eq!(parts.uri, original_uri);
Ok(())
}
}
23 changes: 23 additions & 0 deletions services/aws-v4/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,29 @@ async fn main() -> anyhow::Result<()> {
}
```

## Canonical URI Encoding

The signer defaults to `PercentEncodingMode::Single`, preserving existing S3
behavior. For a service that requires double URI encoding, select it explicitly:

```rust
use reqsign_aws_v4::{PercentEncodingMode, RequestSigner};

let signer = RequestSigner::new("execute-api", "us-east-1")
.with_percent_encoding_mode(PercentEncodingMode::Double);
```

Both header signing and presigning use this setting. Supply the final wire-ready
URI: a path of `/a%20b` is signed as `/a%2520b` in Double mode, while the outgoing
path remains `/a%20b`. Double encodes the original path without decoding it; a
literal `@` becomes `%40`, while an existing `%40` becomes `%2540`. It preserves
literal `/` separators and does not normalize paths or change payload hashing.
The service name and endpoint do not automatically select a mode.

Single retains Reqsign's existing per-segment decode/re-encode behavior, including
normalizing escape spelling and rejecting percent-encoded invalid UTF-8. It is
not a raw-path pass-through for noncanonical wire paths.

## Default Credential Chain

`DefaultCredentialProvider::new()` builds the documented AWS default chain:
Expand Down
1 change: 1 addition & 0 deletions services/aws-v4/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ pub use provide_credential::{
S3ExpressSessionGranter, S3ExpressSessionMode, S3ExpressSessionPartition,
S3ExpressSessionProvider,
};
pub use reqsign_aws_core::PercentEncodingMode;
pub use reqsign_aws_core::constants;
pub use reqsign_aws_core::{
AssumeRoleCredentialProvider, AssumeRoleGrant, AssumeRoleWithWebIdentityCredentialProvider,
Expand Down
Loading
Loading