This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-3082-e8ec7f8403554496db976700eb993459e0ab7f9b in repository https://gitbox.apache.org/repos/asf/iceberg-rust.git
commit b8467510f8da71b8f180affdef7b1a2d3dd4cfa9 Author: Jiajia Li <[email protected]> AuthorDate: Fri Oct 9 23:17:10 2026 +0000 feat(rest): add a SigV4 request signer (#3082) * feat(rest): add a SigV4 request signer * fix(rest): mute aws-sigv4 tracing and tighten payload hashing The crate traces the request it signs, and its redaction list covers `authorization` but not the `Original-` copy we make, so a delegate's bearer token could reach trace logs. Also reject non-UTF-8 headers rather than leave them unsigned, and hash a present-but-empty body as Java does. * refactor(rest): take credentials per call, as Java's signer does Java holds one `Aws4Signer` across sessions and resolves credentials from an `AwsCredentialsProvider` per request, so the signer itself carries no credential state. Follow that: drop `AwsCredentials`, take the AWS crate's `Credentials` as a `sign` argument, and leave the provider to the auth session. Also sign `HttpRequest` rather than the concrete request type, name the amz headers, split `convert_headers`/`update_request_headers` after their Java counterparts, collapse the one-file `sigv4` module, and put the AWS dependencies behind a `sigv4` feature. * docs(rest): trim the SigV4 signer comments * refactor(rest): break sign_at into named steps * docs(rest): state the redirect and log-always limits of the signer * fix(rest): keep relocation headers out of the signature, and the tracing flag untouched * docs(rest): name the log bridge that stays unmuted * docs(rest): log-always also bypasses the muted subscriber * fix(rest): return an error instead of unwrapping the content hash header * refactor(rest): make PayloadHashMode non_exhaustive and SigV4Signer::new take impl Into<String> * docs(rest): note the base64 header quirk and credential freshness * docs(rest): keep the plus note next to the rewrite it explains * fix(rest): spell out the headers Java leaves unsigned * test(rest): check signatures against Iceberg Java * test(rest): pin the signing tests to a fixed time * test(rest): check standard mode against the AWS test suite * test(rest): drop the helper-only AWS vector and the hmac dev-dependency * fix(rest): decide the tracing mute with public tracing API * docs(rest): tighten the payload hash and credential notes * test(rest): bound the live-clock stamp by readings around the call * chore(rest): update the public API for the signer changes * docs(rest): simplify the SigV4 signer comments * refactor(rest): build SigV4Signer with a typed builder * fix(rest): propagate a failed userinfo strip instead of signing the URL as is * docs(rest): name the flag the tracing mute is gated to avoid * docs(rest): say that as_bytes merges an absent body with an empty one * chore(rest): update the public API for the signer builder * fix(rest): validate requests before signing * fix(rest): address SigV4 review findings --- Cargo.lock | 4 + Cargo.toml | 6 + crates/catalog/rest/Cargo.toml | 9 + crates/catalog/rest/public-api.txt | 22 + crates/catalog/rest/src/auth/mod.rs | 6 + crates/catalog/rest/src/auth/sigv4.rs | 1225 +++++++++++++++++++++++++++++++++ crates/catalog/rest/src/request.rs | 14 + 7 files changed, 1286 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 75694c387..f3694ad2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2843,6 +2843,9 @@ name = "iceberg-catalog-rest" version = "0.10.1" dependencies = [ "async-trait", + "aws-credential-types", + "aws-sigv4", + "base64 0.22.1", "bytes", "chrono", "futures", @@ -2855,6 +2858,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", + "sha2 0.10.9", "tokio", "tracing", "typed-builder", diff --git a/Cargo.toml b/Cargo.toml index 24f881994..bb9770275 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ arrow-string = "59.2" as-any = "0.3.2" async-trait = "0.1.89" aws-config = "1.8.7" +aws-credential-types = "1.2" aws-sdk-glue = { version = "1.85", default-features = false, features = [ "default-https-client", "rt-tokio", @@ -68,6 +69,10 @@ aws-sdk-s3tables = { version = "1.28", default-features = false, features = [ "default-https-client", "rt-tokio", ] } +aws-sigv4 = { version = "1.4", default-features = false, features = [ + "sign-http", + "http1", +] } backon = "1.5.1" base64 = "0.22.1" bimap = "0.6" @@ -124,6 +129,7 @@ serde_derive = "1.0.219" serde_json = "1.0.142" serde_repr = "0.1.16" serde_with = "3.4" +sha2 = "0.10" sqlx = { version = "0.8.1", default-features = false } strum = "0.27.2" syn = "2" diff --git a/crates/catalog/rest/Cargo.toml b/crates/catalog/rest/Cargo.toml index 8dc9a86d7..0a8cf6b6e 100644 --- a/crates/catalog/rest/Cargo.toml +++ b/crates/catalog/rest/Cargo.toml @@ -29,8 +29,16 @@ keywords = ["iceberg", "rest", "catalog"] license = { workspace = true } repository = { workspace = true } +[features] +default = [] +# SigV4 request signing. Off by default to keep the AWS crates out. +sigv4 = ["dep:aws-credential-types", "dep:aws-sigv4", "dep:base64", "dep:sha2"] + [dependencies] async-trait = { workspace = true } +aws-credential-types = { workspace = true, optional = true } +aws-sigv4 = { workspace = true, optional = true } +base64 = { workspace = true, optional = true } chrono = { workspace = true } http = { workspace = true } iceberg = { workspace = true } @@ -39,6 +47,7 @@ reqwest = { workspace = true } serde = { workspace = true } serde_derive = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true, optional = true } tokio = { workspace = true } tracing = { workspace = true } typed-builder = { workspace = true } diff --git a/crates/catalog/rest/public-api.txt b/crates/catalog/rest/public-api.txt index 47393b573..ddfafb11d 100644 --- a/crates/catalog/rest/public-api.txt +++ b/crates/catalog/rest/public-api.txt @@ -1,4 +1,5 @@ pub mod iceberg_catalog_rest +pub use iceberg_catalog_rest::Credentials pub enum iceberg_catalog_rest::HttpRequestBody<'a> pub iceberg_catalog_rest::HttpRequestBody::Buffered(&'a [u8]) pub iceberg_catalog_rest::HttpRequestBody::Empty @@ -14,6 +15,18 @@ impl<'a> core::fmt::Debug for iceberg_catalog_rest::HttpRequestBody<'a> pub fn iceberg_catalog_rest::HttpRequestBody<'a>::fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl<'a> core::marker::Copy for iceberg_catalog_rest::HttpRequestBody<'a> impl<'a> core::marker::StructuralPartialEq for iceberg_catalog_rest::HttpRequestBody<'a> +#[non_exhaustive] pub enum iceberg_catalog_rest::PayloadHashMode +pub iceberg_catalog_rest::PayloadHashMode::IcebergRest +pub iceberg_catalog_rest::PayloadHashMode::StandardAws +impl core::clone::Clone for iceberg_catalog_rest::PayloadHashMode +pub fn iceberg_catalog_rest::PayloadHashMode::clone(&self) -> iceberg_catalog_rest::PayloadHashMode +impl core::cmp::Eq for iceberg_catalog_rest::PayloadHashMode +impl core::cmp::PartialEq for iceberg_catalog_rest::PayloadHashMode +pub fn iceberg_catalog_rest::PayloadHashMode::eq(&self, other: &iceberg_catalog_rest::PayloadHashMode) -> bool +impl core::fmt::Debug for iceberg_catalog_rest::PayloadHashMode +pub fn iceberg_catalog_rest::PayloadHashMode::fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for iceberg_catalog_rest::PayloadHashMode +impl core::marker::StructuralPartialEq for iceberg_catalog_rest::PayloadHashMode pub struct iceberg_catalog_rest::CommitTableRequest pub iceberg_catalog_rest::CommitTableRequest::identifier: core::option::Option<iceberg::catalog::TableIdent> pub iceberg_catalog_rest::CommitTableRequest::requirements: alloc::vec::Vec<iceberg::catalog::TableRequirement> @@ -331,6 +344,15 @@ impl core::default::Default for iceberg_catalog_rest::RestSessionCatalogBuilder pub fn iceberg_catalog_rest::RestSessionCatalogBuilder::default() -> Self impl core::fmt::Debug for iceberg_catalog_rest::RestSessionCatalogBuilder pub fn iceberg_catalog_rest::RestSessionCatalogBuilder::fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct iceberg_catalog_rest::SigV4Signer +impl iceberg_catalog_rest::SigV4Signer +pub fn iceberg_catalog_rest::SigV4Signer::sign(&self, request: &mut iceberg_catalog_rest::HttpRequest, credentials: &aws_credential_types::credentials_impl::Credentials) -> iceberg::error::Result<()> +impl core::clone::Clone for iceberg_catalog_rest::SigV4Signer +pub fn iceberg_catalog_rest::SigV4Signer::clone(&self) -> iceberg_catalog_rest::SigV4Signer +impl core::fmt::Debug for iceberg_catalog_rest::SigV4Signer +pub fn iceberg_catalog_rest::SigV4Signer::fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl iceberg_catalog_rest::SigV4Signer +pub fn iceberg_catalog_rest::SigV4Signer::builder() -> SigV4SignerBuilder<((), (), ())> pub struct iceberg_catalog_rest::StorageCredential pub iceberg_catalog_rest::StorageCredential::config: std::collections::hash::map::HashMap<alloc::string::String, alloc::string::String> pub iceberg_catalog_rest::StorageCredential::prefix: alloc::string::String diff --git a/crates/catalog/rest/src/auth/mod.rs b/crates/catalog/rest/src/auth/mod.rs index b4c0cf0be..a3aab4df9 100644 --- a/crates/catalog/rest/src/auth/mod.rs +++ b/crates/catalog/rest/src/auth/mod.rs @@ -19,14 +19,20 @@ //! `AuthManager`/`AuthSession` API. mod oauth2; +#[cfg(feature = "sigv4")] +mod sigv4; use std::collections::HashMap; use std::fmt::Debug; use std::sync::Arc; use async_trait::async_trait; +#[cfg(feature = "sigv4")] +pub use aws_credential_types::Credentials; use iceberg::{Result, SessionContext}; pub use oauth2::OAuth2Manager; +#[cfg(feature = "sigv4")] +pub use sigv4::{PayloadHashMode, SigV4Signer}; use crate::client::HttpClient; use crate::request::HttpRequest; diff --git a/crates/catalog/rest/src/auth/sigv4.rs b/crates/catalog/rest/src/auth/sigv4.rs new file mode 100644 index 000000000..b7b4ae5bf --- /dev/null +++ b/crates/catalog/rest/src/auth/sigv4.rs @@ -0,0 +1,1225 @@ +// 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. + +//! AWS SigV4 request signing for the REST catalog. + +use chrono::{DateTime, Utc}; +use iceberg::{Error, ErrorKind, Result}; +use sha2::{Digest, Sha256}; +use typed_builder::TypedBuilder; + +use super::Credentials; + +/// How the payload hash is encoded in the `x-amz-content-sha256` header. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum PayloadHashMode { + /// Iceberg Java's style: a base64 header when there is a body, hex when + /// there is none, and hex in the canonical request. The base64 comes from + /// how Java configures the AWS SDK, not from SigV4, so a verifier that + /// trusts the header instead of hashing the body rejects it. + IcebergRest, + /// Standard SigV4: hex everywhere. + StandardAws, +} + +fn encode_hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn base64_encode(bytes: &[u8]) -> String { + base64::engine::Engine::encode(&base64::engine::general_purpose::STANDARD, bytes) +} + +/// The content header and canonical payload hash, computed from one digest. +fn payload_hashes(body: Option<&[u8]>, mode: PayloadHashMode) -> (String, String) { + let digest = Sha256::digest(body.unwrap_or_default()); + let hex = encode_hex(&digest); + let header = match mode { + PayloadHashMode::IcebergRest if body.is_some() => base64_encode(&digest), + _ => hex.clone(), + }; + (header, hex) +} + +/// Signs REST catalog requests the way Iceberg Java's `RESTSigV4AuthSession` +/// does. Carries no credentials, so one signer serves every session. +/// +/// Built with [`SigV4Signer::builder`]. +#[derive(Clone, Debug, TypedBuilder)] +pub struct SigV4Signer { + /// The signing region, e.g. `us-east-1`. + #[builder(setter(into))] + region: String, + /// The signing name, e.g. `execute-api`. + #[builder(setter(into))] + service: String, + /// How the payload hash is encoded. + mode: PayloadHashMode, +} + +impl SigV4Signer { + /// Signs `request` in place. An existing `Authorization` moves to + /// `Original-Authorization`, and a caller's `x-amz-date`, + /// `x-amz-content-sha256` or `x-amz-security-token` that the signer + /// overwrites moves to `Original-<name>` (Java keeps a caller's content + /// hash when there is a body). Userinfo leaves the URL, and a `+` in the + /// query becomes `%20`, so write a literal plus as `%2B`. + /// + /// Uses `credentials` as given and never refreshes them: resolve temporary + /// ones from their provider before each call. + /// + /// On error, including for a streaming body or a non-UTF-8 header, the + /// request is left unchanged. + /// + /// Send the result through a client that does not follow redirects: a + /// redirect replays the signature, and across hosts reqwest drops + /// `Authorization` but keeps `Original-Authorization`. + /// + /// `aws_sigv4` traces requests without redacting `Original-Authorization`. + /// A `tracing` subscriber is muted for the call, but the `log` bridge (no + /// subscriber, or `log-always`) still forwards those events, so keep + /// `aws_sigv4` below trace level there. + pub fn sign(&self, request: &mut crate::HttpRequest, credentials: &Credentials) -> Result<()> { + self.sign_at(request, credentials, Utc::now()) + } + + fn sign_at( + &self, + request: &mut crate::HttpRequest, + credentials: &Credentials, + now: DateTime<Utc>, + ) -> Result<()> { + let headers = request.headers().clone(); + let url = request.url().clone(); + let signed = self.sign_in_place(request, credentials, now); + if signed.is_err() { + *request.headers_mut() = headers; + *request.url_mut() = url; + } + signed + } + + fn sign_in_place( + &self, + request: &mut crate::HttpRequest, + credentials: &Credentials, + now: DateTime<Utc>, + ) -> Result<()> { + use aws_sigv4::http_request::{SignableBody, SignableRequest, sign}; + use aws_sigv4::sign::v4; + use tracing::dispatcher::Dispatch; + use tracing::level_filters::LevelFilter; + + // Before the content hash is displaced, which takes it out of signing. + signable_headers(request).try_for_each(|header| header.map(|_| ()))?; + let (content_header, payload_hash) = payload_hashes(signable_body(request)?, self.mode); + + convert_headers(request); + + // Relocated after signing, so the `Original-` copy is not signed. + let displaced_content_hash: Vec<_> = request + .headers() + .get_all(CONTENT_SHA256) + .iter() + .filter(|v| v.as_bytes() != content_header.as_bytes()) + .cloned() + .collect(); + let content_value = content_header.parse().map_err(|e| { + Error::new(ErrorKind::Unexpected, "invalid x-amz-content-sha256 value").with_source(e) + })?; + request.headers_mut().insert(CONTENT_SHA256, content_value); + + rewrite_url_for_signing(request)?; + + let identity = credentials.clone().into(); + let params = v4::SigningParams::builder() + .identity(&identity) + .region(&self.region) + .name(&self.service) + .time(now.into()) + .settings(signing_settings()) + .build() + .map_err(|e| { + Error::new(ErrorKind::Unexpected, "failed to build SigV4 params").with_source(e) + })? + .into(); + + let headers = signable_headers(request).collect::<Result<Vec<_>>>()?; + let signable = SignableRequest::new( + request.method().as_str(), + request.url_str(), + headers.into_iter(), + SignableBody::Precomputed(payload_hash), + ) + .map_err(|e| { + Error::new(ErrorKind::DataInvalid, "request is not signable").with_source(e) + })?; + + // `aws_sigv4` traces the request, `Original-Authorization` included. + // Mute it only when a subscriber could record that (the max level is + // `OFF` until one is registered): `with_default` also sets + // tracing-core's `EXISTS` flag, which nothing clears, and with the + // `log` feature tracing then stops forwarding events to `log` for good. + let signed = if LevelFilter::current() == LevelFilter::TRACE { + tracing::dispatcher::with_default(&Dispatch::none(), || sign(signable, ¶ms)) + } else { + sign(signable, ¶ms) + }; + let (instructions, _signature) = signed + .map_err(|e| Error::new(ErrorKind::Unexpected, "SigV4 signing failed").with_source(e))? + .into_parts(); + + update_request_headers(request, instructions, displaced_content_hash) + } +} + +/// The body to sign; as in Java, an absent body and an empty one differ. +fn signable_body(request: &crate::HttpRequest) -> Result<Option<&[u8]>> { + match request.body() { + crate::HttpRequestBody::Empty => Ok(None), + crate::HttpRequestBody::Buffered(bytes) => Ok(Some(bytes)), + crate::HttpRequestBody::Streaming => Err(Error::new( + ErrorKind::FeatureUnsupported, + "cannot sign a streaming request body", + )), + } +} + +/// The headers to sign. A non-UTF-8 one is an error: skipping it would send +/// it unsigned. +fn signable_headers(request: &crate::HttpRequest) -> impl Iterator<Item = Result<(&str, &str)>> { + request.headers().iter().map(|(n, v)| { + let v = std::str::from_utf8(v.as_bytes()).map_err(|e| { + Error::new( + ErrorKind::DataInvalid, + format!("cannot sign non-UTF-8 header value for `{n}`"), + ) + .with_source(e) + })?; + Ok((n.as_str(), v)) + }) +} + +/// Drops userinfo, which the wire `Host` never carries, and rewrites `+` in the +/// query as `%20`: a space to AWS and Java either way, but unambiguous to any +/// verifier. +fn rewrite_url_for_signing(request: &mut crate::HttpRequest) -> Result<()> { + if !request.url().username().is_empty() || request.url().password().is_some() { + let url = request.url_mut(); + url.set_username("") + .and_then(|()| url.set_password(None)) + .map_err(|()| { + Error::new( + ErrorKind::DataInvalid, + "cannot strip userinfo from the request URL", + ) + })?; + } + if let Some(query) = request.url().query().filter(|q| q.contains('+')) { + let unambiguous = query.replace('+', "%20"); + request.url_mut().set_query(Some(&unambiguous)); + } + Ok(()) +} + +/// `Aws4Signer`'s settings: normalized double-encoded path, and Java's ignore +/// list. +fn signing_settings() -> aws_sigv4::http_request::SigningSettings { + use aws_sigv4::http_request::{ + PayloadChecksumKind, PercentEncodingMode, SigningSettings, UriPathNormalizationMode, + }; + + let mut settings = SigningSettings::default(); + settings.percent_encoding_mode = PercentEncodingMode::Double; + settings.uri_path_normalization_mode = UriPathNormalizationMode::Enabled; + // We set and relocate the content hash ourselves, outside the instructions. + settings.payload_checksum_kind = PayloadChecksumKind::NoHeader; + let mut excluded = settings.excluded_headers.take().unwrap_or_default(); + excluded.extend([ + // Java's list, spelled out rather than left to the crate's defaults. + "connection".into(), + "expect".into(), + "transfer-encoding".into(), + "user-agent".into(), + "x-amzn-trace-id".into(), + // Not Java's, but proxies append to it too. + "x-forwarded-for".into(), + // Relocation appends to these after signing. + "original-x-amz-date".into(), + "original-x-amz-content-sha256".into(), + "original-x-amz-security-token".into(), + ]); + settings.excluded_headers = Some(excluded); + settings +} + +/// Java's `convertHeaders`: moves `Authorization` aside before signing, so the +/// moved copy is signed. +fn convert_headers(request: &mut crate::HttpRequest) { + let displaced: Vec<_> = request + .headers() + .get_all(reqwest::header::AUTHORIZATION) + .iter() + .cloned() + .collect(); + if displaced.is_empty() { + return; + } + request.headers_mut().remove(reqwest::header::AUTHORIZATION); + for mut value in displaced { + value.set_sensitive(true); + request.headers_mut().append(RELOCATED_AUTHORIZATION, value); + } +} + +/// Java's `updateRequestHeaders`: installs the signed headers, moving +/// conflicting caller values aside. +fn update_request_headers( + request: &mut crate::HttpRequest, + instructions: aws_sigv4::http_request::SigningInstructions, + displaced_content_hash: Vec<reqwest::header::HeaderValue>, +) -> Result<()> { + let (signed_headers, _params) = instructions.into_parts(); + let h = request.headers_mut(); + for mut value in displaced_content_hash { + // The original may carry a credential. + value.set_sensitive(true); + h.append(RELOCATED_CONTENT_SHA256, value); + } + for header in signed_headers { + let name: reqwest::header::HeaderName = header.name().parse().map_err(|e| { + Error::new(ErrorKind::Unexpected, "invalid signed header name").with_source(e) + })?; + if let Some(relocated) = relocated_name(name.as_str()) { + relocate_conflicting(h, name.as_str(), header.value(), relocated); + } + let mut value: reqwest::header::HeaderValue = header.value().parse().map_err(|e| { + Error::new(ErrorKind::Unexpected, "invalid signed header value").with_source(e) + })?; + if name == reqwest::header::AUTHORIZATION || name == SECURITY_TOKEN { + value.set_sensitive(true); + } + h.insert(name, value); + } + Ok(()) +} + +const CONTENT_SHA256: reqwest::header::HeaderName = + reqwest::header::HeaderName::from_static("x-amz-content-sha256"); +const AMZ_DATE: reqwest::header::HeaderName = + reqwest::header::HeaderName::from_static("x-amz-date"); +const SECURITY_TOKEN: reqwest::header::HeaderName = + reqwest::header::HeaderName::from_static("x-amz-security-token"); + +const RELOCATED_AUTHORIZATION: reqwest::header::HeaderName = + reqwest::header::HeaderName::from_static("original-authorization"); +const RELOCATED_AMZ_DATE: reqwest::header::HeaderName = + reqwest::header::HeaderName::from_static("original-x-amz-date"); +const RELOCATED_CONTENT_SHA256: reqwest::header::HeaderName = + reqwest::header::HeaderName::from_static("original-x-amz-content-sha256"); +const RELOCATED_SECURITY_TOKEN: reqwest::header::HeaderName = + reqwest::header::HeaderName::from_static("original-x-amz-security-token"); + +/// The `Original-<name>` counterpart of a header the signer generates. +fn relocated_name(name: &str) -> Option<reqwest::header::HeaderName> { + match name { + n if n == AMZ_DATE => Some(RELOCATED_AMZ_DATE), + n if n == SECURITY_TOKEN => Some(RELOCATED_SECURITY_TOKEN), + _ => None, + } +} + +/// Moves `name`'s values that differ from `signed` to `relocated`. +fn relocate_conflicting( + headers: &mut reqwest::header::HeaderMap, + name: &str, + signed: &str, + relocated: reqwest::header::HeaderName, +) { + let conflicting: Vec<_> = headers + .get_all(name) + .iter() + .filter(|value| value.as_bytes() != signed.as_bytes()) + .cloned() + .collect(); + for mut value in conflicting { + // It may carry a credential, e.g. a session token. + value.set_sensitive(true); + headers.append(relocated.clone(), value); + } +} + +#[cfg(test)] +mod tests { + use chrono::TimeZone; + + use super::*; + use crate::HttpRequest; + + const EMPTY_HEX: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + #[test] + fn signing_rewrites_an_ambiguous_plus_out_of_the_query() { + // reqwest writes a space as `+`; signing makes it `%20`. + let mut request = HttpRequest::new( + reqwest::Client::new() + .get("https://rest.example.com/v1/namespaces") + .query(&[("parent", "my ns")]) + .build() + .unwrap(), + ); + assert!(request.url().query().unwrap().contains("my+ns")); + + let signer = test_signer(PayloadHashMode::StandardAws); + let now = test_time(); + signer + .sign_at(&mut request, &test_credentials(), now) + .unwrap(); + + let query = request.url().query().unwrap(); + assert!(!query.contains('+'), "{query}"); + assert!(query.contains("my%20ns"), "{query}"); + assert_signature_is( + &request, + "b7bb5a323a1ce0ace18454171084deef2dac44c933c3949771fc70179d3cce2b", + ); + } + + #[test] + fn payload_hashes_iceberg_mode() { + let v = payload_hashes(Some(b"hello"), PayloadHashMode::IcebergRest).0; + assert_eq!(v, "LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ="); + let e = payload_hashes(None, PayloadHashMode::IcebergRest).0; + assert_eq!(e, EMPTY_HEX); + } + + /// As in Java, an empty body is hashed, unlike an absent one. + #[test] + fn signing_separates_an_empty_body_from_an_absent_one() { + let signer = test_signer(PayloadHashMode::IcebergRest); + let now = test_time(); + let hash_of = |builder: reqwest::RequestBuilder| { + let mut req = HttpRequest::new(builder.build().unwrap()); + signer.sign_at(&mut req, &test_credentials(), now).unwrap(); + req.headers() + .get("x-amz-content-sha256") + .unwrap() + .to_str() + .unwrap() + .to_string() + }; + + let client = reqwest::Client::new(); + let url = "https://rest.example.com/v1/namespaces"; + assert_eq!( + hash_of(client.post(url).body("")), + "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=" + ); + assert_eq!(hash_of(client.post(url)), EMPTY_HEX); + } + + #[test] + fn payload_hashes_standard_mode() { + let v = payload_hashes(Some(b"hello"), PayloadHashMode::StandardAws).0; + assert_eq!( + v, + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ); + } + + /// Pins a signature this crate produced; `signatures_match_iceberg_java` + /// checks against Java. + fn assert_signature_is(req: &HttpRequest, expected: &str) { + let auth = req + .headers() + .get("authorization") + .unwrap() + .to_str() + .unwrap(); + assert!(auth.ends_with(&format!("Signature={expected}")), "{auth}"); + } + + fn test_signer(mode: PayloadHashMode) -> SigV4Signer { + SigV4Signer::builder() + .region("us-east-1") + .service("execute-api") + .mode(mode) + .build() + } + + fn test_credentials() -> Credentials { + Credentials::new("ak", "sk", None::<String>, None, "test") + } + + fn example_credentials(session_token: Option<&str>) -> Credentials { + Credentials::new( + "AKIDEXAMPLE", + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + session_token.map(str::to_string), + None, + "test", + ) + } + + fn test_time() -> DateTime<Utc> { + Utc.with_ymd_and_hms(2015, 8, 30, 12, 36, 0).unwrap() + } + + fn signed_headers(req: &HttpRequest) -> Vec<String> { + let auth = req + .headers() + .get("authorization") + .unwrap() + .to_str() + .unwrap(); + let signed = auth.split("SignedHeaders=").nth(1).unwrap(); + let signed = signed.split(',').next().unwrap(); + signed.split(';').map(str::to_string).collect() + } + + /// Every header value as `(name, value)`, sorted. + fn header_list(req: &HttpRequest) -> Vec<(String, String)> { + let mut headers: Vec<_> = req + .headers() + .iter() + .map(|(n, v)| (n.to_string(), v.to_str().unwrap().to_string())) + .collect(); + headers.sort(); + headers + } + + fn sorted_pairs(pairs: &[(&str, &str)]) -> Vec<(String, String)> { + let mut pairs: Vec<_> = pairs + .iter() + .map(|(n, v)| (n.to_string(), v.to_string())) + .collect(); + pairs.sort(); + pairs + } + + /// Records every event field. + #[derive(Clone, Default)] + struct CapturedLog(std::sync::Arc<std::sync::Mutex<String>>); + + impl tracing::field::Visit for CapturedLog { + fn record_debug(&mut self, _: &tracing::field::Field, value: &dyn std::fmt::Debug) { + self.0.lock().unwrap().push_str(&format!("{value:?}")); + } + } + + impl tracing::Subscriber for CapturedLog { + fn enabled(&self, _: &tracing::Metadata<'_>) -> bool { + true + } + fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::Id { + tracing::Id::from_u64(1) + } + fn record(&self, _: &tracing::Id, _: &tracing::span::Record<'_>) {} + fn record_follows_from(&self, _: &tracing::Id, _: &tracing::Id) {} + fn event(&self, event: &tracing::Event<'_>) { + event.record(&mut self.clone()); + } + fn enter(&self, _: &tracing::Id) {} + fn exit(&self, _: &tracing::Id) {} + } + + /// `aws_sigv4` does not redact `Original-Authorization` itself. + #[test] + fn signing_does_not_trace_a_relocated_bearer_token() { + const TOKEN: &str = "Bearer topsecretdelegatetoken"; + let signer = test_signer(PayloadHashMode::IcebergRest); + let mut req = HttpRequest::new( + reqwest::Client::new() + .get("https://rest.example.com/v1/config") + .header(reqwest::header::AUTHORIZATION, TOKEN) + .build() + .unwrap(), + ); + + let log = CapturedLog::default(); + tracing::subscriber::with_default(log.clone(), || { + // `sign_at` mutes only at this level, which is what is under test. + assert_eq!( + tracing::level_filters::LevelFilter::current(), + tracing::level_filters::LevelFilter::TRACE + ); + signer + .sign_at(&mut req, &test_credentials(), test_time()) + .unwrap(); + tracing::trace!(canary = "subscriber-is-live"); + }); + + let captured = log.0.lock().unwrap().clone(); + assert!(captured.contains("subscriber-is-live"), "captured nothing"); + assert!(!captured.contains(TOKEN), "{captured}"); + assert_eq!(req.headers().get(RELOCATED_AUTHORIZATION).unwrap(), TOKEN); + } + + #[test] + fn a_non_utf8_header_is_rejected_without_changing_the_request() { + let signer = test_signer(PayloadHashMode::IcebergRest); + for name in ["x-amz-meta-tenant", "authorization", "x-amz-content-sha256"] { + let mut req = HttpRequest::new(reqwest::Request::new( + reqwest::Method::GET, + "https://user:[email protected]/v1/config?warehouse=my+catalog" + .parse() + .unwrap(), + )); + req.headers_mut() + .insert("authorization", "Bearer delegate-token".parse().unwrap()); + req.headers_mut() + .insert("x-amz-content-sha256", "caller-hash".parse().unwrap()); + req.headers_mut().insert( + name, + reqwest::header::HeaderValue::from_bytes(b"acme\xfa").unwrap(), + ); + let headers = req.headers().clone(); + let url = req.url().clone(); + + let err = signer + .sign_at(&mut req, &test_credentials(), test_time()) + .unwrap_err(); + assert_eq!(err.kind(), ErrorKind::DataInvalid); + assert!(err.message().contains(name), "{err}"); + assert_eq!(req.headers(), &headers, "{name}"); + assert_eq!(req.url(), &url, "{name}"); + } + } + + #[test] + fn a_non_ascii_utf8_header_is_signed() { + let signer = test_signer(PayloadHashMode::StandardAws); + let mut req = HttpRequest::new(reqwest::Request::new( + reqwest::Method::GET, + "https://rest.example.com/v1/config".parse().unwrap(), + )); + req.headers_mut().insert( + "x-tenant", + reqwest::header::HeaderValue::from_bytes("Zürich".as_bytes()).unwrap(), + ); + + signer + .sign_at(&mut req, &test_credentials(), test_time()) + .unwrap(); + + assert_eq!(signed_headers(&req), [ + "host", + "x-amz-content-sha256", + "x-amz-date", + "x-tenant" + ]); + } + + #[test] + fn a_signing_failure_after_relocation_restores_the_request() { + let signer = test_signer(PayloadHashMode::IcebergRest); + // Longer than `http::Uri` accepts, so signing fails after the headers + // and query were rewritten. + let url = format!( + "https://rest.example.com/v1/config?warehouse=my+catalog&pad={}", + "a".repeat(70_000) + ); + let mut req = HttpRequest::new( + reqwest::Client::new() + .get(url) + .header("authorization", "Bearer delegate-token") + .header("x-amz-content-sha256", "caller-hash") + .build() + .unwrap(), + ); + let headers = req.headers().clone(); + let url = req.url().clone(); + + let err = signer + .sign_at(&mut req, &test_credentials(), test_time()) + .unwrap_err(); + + assert_eq!(err.kind(), ErrorKind::DataInvalid, "{err}"); + assert_eq!(req.headers(), &headers); + assert_eq!(req.url(), &url); + } + + /// Relocation appends to a caller's `Original-x-amz-*` after signing, so it + /// must stay unsigned. + #[test] + fn a_caller_supplied_relocation_header_is_not_signed() { + let signer = test_signer(PayloadHashMode::StandardAws); + let mut req = HttpRequest::new( + reqwest::Client::new() + .get("https://rest.example.com/v1/config") + .header("x-amz-content-sha256", "caller-hash") + .header("original-x-amz-content-sha256", "previous") + .build() + .unwrap(), + ); + + signer + .sign_at(&mut req, &test_credentials(), test_time()) + .unwrap(); + + assert_eq!(signed_headers(&req), [ + "host", + "x-amz-content-sha256", + "x-amz-date" + ]); + let relocated: Vec<_> = req + .headers() + .get_all("original-x-amz-content-sha256") + .iter() + .map(|v| v.to_str().unwrap()) + .collect(); + assert_eq!(relocated, ["previous", "caller-hash"]); + } + + #[test] + fn userinfo_is_stripped_before_signing() { + // A hand-built request can carry userinfo; the wire `Host` never does. + let signer = test_signer(PayloadHashMode::StandardAws); + let mut req = HttpRequest::new(reqwest::Request::new( + reqwest::Method::GET, + "https://user:[email protected]/v1/config" + .parse() + .unwrap(), + )); + assert_eq!(req.url().username(), "user"); + let now = test_time(); + + signer.sign_at(&mut req, &test_credentials(), now).unwrap(); + + assert_eq!(req.url().username(), ""); + assert_eq!(req.url().password(), None); + let mut plain = HttpRequest::new(reqwest::Request::new( + reqwest::Method::GET, + "https://rest.example.com/v1/config".parse().unwrap(), + )); + signer + .sign_at(&mut plain, &test_credentials(), now) + .unwrap(); + assert_eq!( + req.headers().get("authorization"), + plain.headers().get("authorization"), + ); + } + + #[test] + fn a_doubled_slash_in_the_path_is_normalized() { + // A trailing slash on the catalog URI gives `//v1/...`, which + // `Aws4Signer` collapses. + let signer = test_signer(PayloadHashMode::StandardAws); + let mut req = HttpRequest::new(reqwest::Request::new( + reqwest::Method::GET, + "https://rest.example.com//v1//config".parse().unwrap(), + )); + let now = test_time(); + + signer.sign_at(&mut req, &test_credentials(), now).unwrap(); + + let mut plain = HttpRequest::new(reqwest::Request::new( + reqwest::Method::GET, + "https://rest.example.com/v1/config".parse().unwrap(), + )); + signer + .sign_at(&mut plain, &test_credentials(), now) + .unwrap(); + assert_eq!( + req.headers().get("authorization"), + plain.headers().get("authorization"), + ); + } + + #[test] + fn caller_headers_the_signer_overwrites_are_relocated() { + // As in Java, conflicting caller values move to `Original-<name>`. + let creds = Credentials::new( + "ak".to_string(), + "sk".to_string(), + Some("signer-token".to_string()), + None, + "test", + ); + let signer = test_signer(PayloadHashMode::StandardAws); + let mut req = HttpRequest::new( + reqwest::Client::new() + .get("https://rest.example.com/v1/config") + .header("authorization", "Bearer caller-token") + .header("x-amz-date", "19700101T000000Z") + .header("x-amz-security-token", "caller-session") + .header("x-amz-content-sha256", "caller-hash") + .build() + .unwrap(), + ); + + signer.sign_at(&mut req, &creds, test_time()).unwrap(); + + assert_eq!( + header_list(&req), + sorted_pairs(&[ + ( + "authorization", + "AWS4-HMAC-SHA256 Credential=ak/20150830/us-east-1/execute-api/aws4_request, \ + SignedHeaders=host;original-authorization;x-amz-content-sha256;x-amz-date;\ + x-amz-security-token, \ + Signature=87560cb735277b284a547d63f57cfe3a721f9bce0b08a9402a58d754d2ed707c" + ), + ("original-authorization", "Bearer caller-token"), + ("original-x-amz-content-sha256", "caller-hash"), + ("original-x-amz-date", "19700101T000000Z"), + ("original-x-amz-security-token", "caller-session"), + ("x-amz-content-sha256", EMPTY_HEX), + ("x-amz-date", "20150830T123600Z"), + ("x-amz-security-token", "signer-token"), + ]) + ); + // Relocated originals may be credentials themselves. + for relocated in [RELOCATED_SECURITY_TOKEN, RELOCATED_CONTENT_SHA256] { + assert!(req.headers().get(relocated).unwrap().is_sensitive()); + } + } + + #[test] + fn an_existing_authorization_is_never_signed() { + // The signer replaces `authorization`, and a proxy may rewrite + // `user-agent`, so neither is signed. + let signer = test_signer(PayloadHashMode::StandardAws); + let mut req = HttpRequest::new( + reqwest::Client::new() + .get("https://rest.example.com/v1/config") + .header("authorization", "Bearer caller-token") + .header("user-agent", "example/1.0") + .build() + .unwrap(), + ); + + signer + .sign_at(&mut req, &test_credentials(), test_time()) + .unwrap(); + + assert_eq!(signed_headers(&req), [ + "host", + "original-authorization", + "x-amz-content-sha256", + "x-amz-date" + ]); + } + + /// As in Java, every `Authorization` value is relocated, and stays redacted. + #[test] + fn every_repeated_authorization_is_relocated_and_kept_sensitive() { + let signer = test_signer(PayloadHashMode::StandardAws); + let mut req = HttpRequest::new( + reqwest::Client::new() + .get("https://rest.example.com/v1/config") + .header("authorization", "Bearer first") + .header("authorization", "Bearer second") + .build() + .unwrap(), + ); + + signer + .sign_at(&mut req, &test_credentials(), test_time()) + .unwrap(); + + let relocated: Vec<_> = req + .headers() + .get_all(RELOCATED_AUTHORIZATION) + .iter() + .collect(); + assert_eq!(relocated.len(), 2, "{relocated:?}"); + assert_eq!(relocated[0], "Bearer first"); + assert_eq!(relocated[1], "Bearer second"); + assert!(relocated.iter().all(|v| v.is_sensitive()), "{relocated:?}"); + } + + #[test] + fn hop_by_hop_headers_are_not_signed() { + // A proxy may drop or rewrite these, so they are not signed. + let signer = test_signer(PayloadHashMode::StandardAws); + let mut req = HttpRequest::new( + reqwest::Client::new() + .get("https://rest.example.com/v1/config") + .header("expect", "100-continue") + .header("connection", "keep-alive") + .header("x-forwarded-for", "203.0.113.7") + .header("x-tenant", "acme") + .build() + .unwrap(), + ); + let now = test_time(); + + signer.sign_at(&mut req, &test_credentials(), now).unwrap(); + + assert_eq!(signed_headers(&req), [ + "host", + "x-amz-content-sha256", + "x-amz-date", + "x-tenant" + ]); + assert_signature_is( + &req, + "f938221412ed6b55cf3db380ce6ded476419ad3d7db4c76d932031c98465ce79", + ); + } + + #[test] + fn signed_credentials_are_marked_sensitive() { + // Both carry a credential, so `Debug` must not print them. + let creds = Credentials::new( + "ak".to_string(), + "sk".to_string(), + Some("session-token".to_string()), + None, + "test", + ); + let signer = test_signer(PayloadHashMode::StandardAws); + let mut req = HttpRequest::new( + reqwest::Client::new() + .get("https://rest.example.com/v1/config") + .build() + .unwrap(), + ); + + signer.sign_at(&mut req, &creds, test_time()).unwrap(); + + assert!(req.headers().get("authorization").unwrap().is_sensitive()); + assert!( + req.headers() + .get("x-amz-security-token") + .unwrap() + .is_sensitive() + ); + let debug = format!("{:?}", req.headers()); + assert!(!debug.contains("session-token"), "{debug}"); + } + + #[test] + fn signs_with_a_non_default_service_and_session_token() { + // As a non-AWS catalog might vend: its own signing name, and STS + // credentials. + let creds = Credentials::new( + "STS.EXAMPLEACCESSKEYID", + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + Some("example-session-token".to_string()), + None, + "test", + ); + let signer = SigV4Signer::builder() + .region("us-east-1") + .service("custom-service") + .mode(PayloadHashMode::IcebergRest) + .build(); + let mut req = HttpRequest::new( + reqwest::Client::new() + .get("https://catalog.example.com/v1/config?warehouse=my-catalog") + .build() + .unwrap(), + ); + let now = Utc.with_ymd_and_hms(2026, 8, 26, 12, 0, 0).unwrap(); + + signer.sign_at(&mut req, &creds, now).unwrap(); + + assert_signature_is( + &req, + "6b7065e5f44da4f5c3654126b8d5fe29599905afb6b98f4de65b6ec6e1be783f", + ); + let auth = req + .headers() + .get("authorization") + .unwrap() + .to_str() + .unwrap(); + assert!( + auth.contains("/us-east-1/custom-service/aws4_request"), + "{auth}" + ); + assert_eq!( + req.headers().get("x-amz-security-token").unwrap(), + "example-session-token" + ); + } + + #[test] + fn signs_request_iceberg_mode() { + let creds = example_credentials(Some("SESSIONTOKEN")); + let signer = SigV4Signer::builder() + .region("us-east-1") + .service("glue") + .mode(PayloadHashMode::IcebergRest) + .build(); + let client = reqwest::Client::new(); + let mut req = HttpRequest::new( + client + .post("https://rest.example.com/v1/namespaces") + .body("{}") + .build() + .unwrap(), + ); + + signer.sign_at(&mut req, &creds, test_time()).unwrap(); + + assert_eq!( + header_list(&req), + sorted_pairs(&[ + ( + "authorization", + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/glue/aws4_request, \ + SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, \ + Signature=effad6acde583dd14ba7aff52b2b83776a54421c010fe82067f166819057cb32" + ), + ( + "x-amz-content-sha256", + "RBNvo1WzZ4oRRq0W9+hknpT7T8If536DEMBg9hyq/4o=" + ), + ("x-amz-date", "20150830T123600Z"), + ("x-amz-security-token", "SESSIONTOKEN"), + ]) + ); + } + + /// An empty body hashes to the hex constant, and caller headers are signed + /// (Java's `authenticateWithoutBody`). + #[test] + fn signs_empty_body_and_all_headers() { + let creds = example_credentials(None); + let signer = SigV4Signer::builder() + .region("us-east-1") + .service("glue") + .mode(PayloadHashMode::IcebergRest) + .build(); + let client = reqwest::Client::new(); + let mut req = HttpRequest::new( + client + .get("https://rest.example.com/v1/config") + .header("content-type", "application/json") + .header("content-encoding", "gzip") + .build() + .unwrap(), + ); + + signer.sign_at(&mut req, &creds, test_time()).unwrap(); + + assert_eq!( + header_list(&req), + sorted_pairs(&[ + ( + "authorization", + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/glue/aws4_request, \ + SignedHeaders=content-encoding;content-type;host;x-amz-content-sha256;x-amz-date, \ + Signature=eaef7eb88d9cd810031684748671d8a3c9394ea5168622a212ed041b670b9777" + ), + ("content-encoding", "gzip"), + ("content-type", "application/json"), + ("x-amz-content-sha256", EMPTY_HEX), + ("x-amz-date", "20150830T123600Z"), + ]) + ); + } + + #[test] + fn iceberg_mode_signs_the_hex_payload_hash_not_the_base64_header() { + // Needs a body: without one the header is the hex constant too. + let creds = example_credentials(None); + let signer = test_signer(PayloadHashMode::IcebergRest); + let body = br#"{"namespace":["ns"]}"#; + let mut req = HttpRequest::new( + reqwest::Client::new() + .post("https://rest.example.com/v1/namespaces") + .body(body.to_vec()) + .build() + .unwrap(), + ); + let now = test_time(); + + signer.sign_at(&mut req, &creds, now).unwrap(); + + assert_eq!( + req.headers().get("x-amz-content-sha256").unwrap(), + payload_hashes(Some(body), PayloadHashMode::IcebergRest) + .0 + .as_str() + ); + assert_ne!( + req.headers().get("x-amz-content-sha256").unwrap(), + encode_hex(&Sha256::digest(body)).as_str() + ); + assert_signature_is( + &req, + "c68682c26cab6a781256f83b0076f50014f4922c3907f4ff09c204a74d61fc1d", + ); + } + + /// What Iceberg Java's `RESTSigV4AuthSession` (iceberg-aws 1.10.1) sent for + /// these requests, given these credentials as `rest.*` properties. + #[test] + fn signatures_match_iceberg_java() { + let creds = example_credentials(Some("example-session-token")); + let signer = test_signer(PayloadHashMode::IcebergRest); + let now = Utc.with_ymd_and_hms(2026, 10, 2, 11, 40, 30).unwrap(); + let client = reqwest::Client::new(); + + // A body, so the header carries base64, and a token to relocate. + let mut post = HttpRequest::new( + client + .post("https://rest.example.com/v1/namespaces") + .header("content-type", "application/json") + .header("authorization", "Bearer delegate-token") + .body(r#"{"namespace":["a","b"],"properties":{}}"#) + .build() + .unwrap(), + ); + signer.sign_at(&mut post, &creds, now).unwrap(); + assert_eq!( + post.headers().get("authorization").unwrap(), + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20261002/us-east-1/execute-api/aws4_request, \ + SignedHeaders=content-type;host;original-authorization;\ + x-amz-content-sha256;x-amz-date;x-amz-security-token, \ + Signature=c5d6bfcecd19c5421e8696c67465f6909b5e09398608d8e79a9eb391e04de556" + ); + assert_eq!( + post.headers().get("x-amz-content-sha256").unwrap(), + "n1CB8Yl4L77sBwHTc5qTpUEhlsHHOXFzg0dV0fbOwXs=" + ); + + // No body, a multi-level namespace and an encoded query. + let mut get = HttpRequest::new( + client + .get("https://rest.example.com/v1/namespaces/a%1Fb/tables/x,y?pageToken=a%20b%2Fc") + .build() + .unwrap(), + ); + signer.sign_at(&mut get, &creds, now).unwrap(); + assert_eq!( + get.headers().get("authorization").unwrap(), + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20261002/us-east-1/execute-api/aws4_request, \ + SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, \ + Signature=5f6832ec82fc821b08cd3eac3a86ffc3e0c333571fd0a5796e43f2534f23329f" + ); + } + + /// The signed `host` keeps a non-default port, as on the wire. + #[test] + fn signs_host_with_non_default_port() { + let creds = example_credentials(None); + let signer = SigV4Signer::builder() + .region("us-east-1") + .service("glue") + .mode(PayloadHashMode::IcebergRest) + .build(); + let client = reqwest::Client::new(); + let mut req = HttpRequest::new( + client + .get("https://rest.example.com:8181/v1/config") + .build() + .unwrap(), + ); + let now = test_time(); + + signer.sign_at(&mut req, &creds, now).unwrap(); + assert_signature_is( + &req, + "f7801a4ecac5fe6dcc4ee385223428ec4833f21d0c2fc10d2fb00694b2c0def7", + ); + } + + /// Like `Aws4Signer`, the canonical path is encoded again: `,` becomes `%2C` + /// and `%2C` becomes `%252C`. + #[test] + fn canonical_uri_is_aws_double_encoded() { + let creds = example_credentials(None); + let signer = SigV4Signer::builder() + .region("us-east-1") + .service("glue") + .mode(PayloadHashMode::IcebergRest) + .build(); + let client = reqwest::Client::new(); + let mut req = HttpRequest::new( + client + .get("https://rest.example.com/v1/namespaces/a%2Cb/tables/x,y") + .build() + .unwrap(), + ); + let now = test_time(); + + signer.sign_at(&mut req, &creds, now).unwrap(); + assert_signature_is( + &req, + "4d966b6fc2dfb62be5a603e4e07e5dc85b2af1c7e6a181c5646bfbf38ddfa543", + ); + } + + /// The AWS SigV4 test suite's `post-x-www-form-urlencoded`, which signs a + /// hex `x-amz-content-sha256` like this mode. + #[test] + fn standard_mode_matches_the_aws_test_suite() { + let creds = example_credentials(None); + let signer = SigV4Signer::builder() + .region("us-east-1") + .service("service") + .mode(PayloadHashMode::StandardAws) + .build(); + let mut req = HttpRequest::new( + reqwest::Client::new() + .post("https://example.amazonaws.com/") + .header("content-type", "application/x-www-form-urlencoded") + .header("content-length", "13") + .body("Param1=value1") + .build() + .unwrap(), + ); + + signer.sign_at(&mut req, &creds, test_time()).unwrap(); + + assert_eq!( + req.headers().get("x-amz-content-sha256").unwrap(), + "9095672bbd1f56dfc5b65f3e153adc8731a4a654192329106275f4c7b24d0b6e" + ); + assert_eq!( + req.headers().get("authorization").unwrap(), + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, \ + SignedHeaders=content-length;content-type;host;x-amz-content-sha256;x-amz-date, \ + Signature=d3875051da38690788ef43de4db0d8f280229d82040bfac253562e56c3f20e0b" + ); + } + + /// The one test on the live clock: everything else pins the time. + #[test] + fn sign_stamps_the_current_time() { + use chrono::SubsecRound; + + let signer = test_signer(PayloadHashMode::IcebergRest); + let mut req = HttpRequest::new( + reqwest::Client::new() + .get("https://rest.example.com/v1/config") + .build() + .unwrap(), + ); + let before = Utc::now(); + signer.sign(&mut req, &test_credentials()).unwrap(); + let after = Utc::now(); + + let date = req.headers().get("x-amz-date").unwrap().to_str().unwrap(); + let stamped = chrono::NaiveDateTime::parse_from_str(date, "%Y%m%dT%H%M%SZ") + .unwrap() + .and_utc(); + assert_eq!(date.len(), "YYYYMMDDTHHMMSSZ".len(), "{date}"); + // The header drops sub-seconds, so compare against a truncated start. + assert!( + before.trunc_subsecs(0) <= stamped && stamped <= after, + "{date}" + ); + } +} diff --git a/crates/catalog/rest/src/request.rs b/crates/catalog/rest/src/request.rs index 7a3c8e084..8c6dd7f57 100644 --- a/crates/catalog/rest/src/request.rs +++ b/crates/catalog/rest/src/request.rs @@ -19,6 +19,8 @@ use http::{HeaderMap, Method}; use iceberg::Result; +#[cfg(feature = "sigv4")] +use reqwest::Url; use reqwest::{Request, RequestBuilder}; /// An outgoing REST request being authenticated by an @@ -48,6 +50,18 @@ impl HttpRequest { self.inner } + /// The request URL. + #[cfg(feature = "sigv4")] + pub(crate) fn url(&self) -> &Url { + self.inner.url() + } + + /// The mutable request URL, for signers that rewrite it. + #[cfg(feature = "sigv4")] + pub(crate) fn url_mut(&mut self) -> &mut Url { + self.inner.url_mut() + } + /// The request method. pub fn method(&self) -> &Method { self.inner.method()
