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 Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,19 @@ aws-smithy-http-client = { version = "=1.1.9", default-features = false, feature
url = { version = "=2.5.8" }
bytes = "1"

# Auth tests
# Already in the graph through aws-config; named directly so the auth crate's
# tests can sign requests with the SDK's SigV4 signer and check the server's
# verifier against it.
aws-sigv4 = { version = "=1.3.8", default-features = false }
# Already in the graph through aws-config; named directly because the
# aws-sigv4 signer takes this crate's credentials type and does not re-export
# it.
aws-credential-types = { version = "=1.2.11", default-features = false }
# Already in the graph through the MongoDB backend's tests; named directly so
# the auth tests can generate requests from a fixed seed.
proptest = { version = "1.11", default-features = false }

# Crypto & checksums
crc32fast = "1"
# Pinned to the versions already resolved in Cargo.lock.
Expand Down
5 changes: 5 additions & 0 deletions crates/auth/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,8 @@ zeroize = { workspace = true }
[dev-dependencies]
extenddb-cache = { workspace = true, features = ["test-util"] }
tokio = { workspace = true, features = ["test-util", "macros", "rt-multi-thread"] }
# The SigV4 differential tests (tests/sigv4_differential) sign requests with
# the SDK's signer and generate them from a fixed seed.
aws-credential-types = { workspace = true }
aws-sigv4 = { workspace = true, features = ["sign-http", "http1"] }
proptest = { workspace = true, features = ["std"] }
176 changes: 176 additions & 0 deletions crates/auth/tests/sigv4_differential/accept.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Copyright 2026 ExtendDB contributors
// SPDX-License-Identifier: Apache-2.0

//! Requests `aws-sigv4` signed the way DynamoDB clients send them verify, and
//! the server's canonical request for each is the one `aws-sigv4` signed.

use crate::corpus::{self, ENDPOINTS, REGIONS, SDK_HEADERS};
use crate::harness::{Key, Shape, Tally, check_accepted, sign};
use crate::mutate::{MARGIN, SKEW_WINDOW};

fn accept(shape: &Shape, what: &str, tally: &mut Tally, category: &'static str) {
if let Err(err) = check_accepted(&sign(shape)) {
panic!("{what}: {err}");
}
tally.add(category);
}

/// Each client shape, with and without `x-amz-content-sha256`, for both keys
/// and every small body.
#[test]
fn client_shapes_verify() {
let mut tally = Tally::default();
for key in Key::ALL {
for (body_name, body) in corpus::small_bodies() {
for (shape_name, shape) in corpus::client_shapes(key, &body) {
for content_sha256 in [false, true] {
let mut shape = shape.clone();
shape.content_sha256 = content_sha256;
let what =
format!("{key:?}, {shape_name}, {body_name}, sha256 {content_sha256}");
accept(&shape, &what, &mut tally, "accepted: client shape");
}
}
}
}
tally.report("client_shapes_verify");
}

/// A request signed for each region verifies.
#[test]
fn every_region_verifies() {
let mut tally = Tally::default();
for key in Key::ALL {
for region in REGIONS {
let mut shape = corpus::base(key, corpus::PUT_ITEM.to_vec());
(*region).clone_into(&mut shape.region);
accept(
&shape,
&format!("{key:?} in {region}"),
&mut tally,
"accepted: region",
);
}
}
tally.report("every_region_verifies");
}

/// Each endpoint, with Host taken from the URI and with Host set by the
/// client.
#[test]
fn every_endpoint_verifies() {
let mut tally = Tally::default();
for key in Key::ALL {
for endpoint in ENDPOINTS {
for explicit_host in [false, true] {
let mut shape = corpus::base(key, corpus::PUT_ITEM.to_vec());
shape.endpoint = *endpoint;
if explicit_host {
shape = shape.with_signed("host", endpoint.host);
}
let what = format!("{key:?} to {}, host set {explicit_host}", endpoint.uri);
accept(&shape, &what, &mut tally, "accepted: endpoint");
}
}
}
tally.report("every_endpoint_verifies");
}

/// Rewrites a header value's spacing.
type Respace = fn(&str) -> String;

/// Signed values with leading, trailing and repeated inner spaces.
#[test]
fn header_values_with_spaces_verify() {
let spacings: [(&str, Respace); 4] = [
("leading spaces", |value| format!(" {value}")),
("trailing spaces", |value| format!("{value} ")),
("repeated inner spaces", |value| value.replace(' ', " ")),
("all three", |value| {
format!(" {} ", value.replace(' ', " "))
}),
];
let mut tally = Tally::default();
for key in Key::ALL {
for (spacing, respace) in spacings {
let mut shape = corpus::base(key, corpus::PUT_ITEM.to_vec());
for (name, value) in SDK_HEADERS {
shape = shape.with_signed(name, value);
}
for (name, value) in &mut shape.signed {
if name != "content-length" {
*value = respace(value);
}
}
accept(
&shape,
&format!("{key:?}, {spacing}"),
&mut tally,
"accepted: spaced header values",
);
}
}
tally.report("header_values_with_spaces_verify");
}

/// Header names in Title-Case, upper case and alternating case, signed and
/// unsigned.
#[test]
fn mixed_case_header_names_verify() {
let mut tally = Tally::default();
for key in Key::ALL {
for style in 1..4 {
for (shape_name, shape) in corpus::client_shapes(key, corpus::PUT_ITEM) {
let host = shape.endpoint.host;
let mut shape = shape.with_signed("host", host);
for (name, _) in shape.signed.iter_mut().chain(shape.unsigned.iter_mut()) {
*name = corpus::spell(name, style);
}
accept(
&shape,
&format!("{key:?}, {shape_name}, style {style}"),
&mut tally,
"accepted: mixed-case names",
);
}
}
}
tally.report("mixed_case_header_names_verify");
}

/// Large bodies, with and without `x-amz-content-sha256`.
#[test]
fn large_bodies_verify() {
let mut tally = Tally::default();
for key in Key::ALL {
for (body_name, body) in corpus::large_bodies() {
for content_sha256 in [false, true] {
let mut shape = corpus::base(key, body.clone());
shape.content_sha256 = content_sha256;
let what = format!("{key:?}, {body_name}, sha256 {content_sha256}");
accept(&shape, &what, &mut tally, "accepted: large body");
}
}
}
tally.report("large_bodies_verify");
}

/// A client clock ahead of or behind the server's, inside the skew window.
#[test]
fn clock_offsets_inside_the_window_verify() {
let edge = SKEW_WINDOW - MARGIN;
let mut tally = Tally::default();
for key in Key::ALL {
for offset in [-edge, -60, 0, 60, edge] {
let mut shape = corpus::base(key, corpus::PUT_ITEM.to_vec());
shape.clock_offset = offset;
accept(
&shape,
&format!("{key:?}, clock {offset} s"),
&mut tally,
"accepted: clock offset inside the window",
);
}
}
tally.report("clock_offsets_inside_the_window_verify");
}
77 changes: 77 additions & 0 deletions crates/auth/tests/sigv4_differential/capture.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright 2026 ExtendDB contributors
// SPDX-License-Identifier: Apache-2.0

//! The canonical request `aws-sigv4` builds.
//!
//! `aws-sigv4` keeps its canonical request private and records it in a
//! trace-level event, in the field `canonical_request`. The subscriber here
//! keeps that field's text while a closure runs on the current thread.

use std::fmt;
use std::sync::{Arc, Mutex, PoisonError};

use tracing::field::{Field, Visit};
use tracing::span::{Attributes, Id, Record};
use tracing::{Event, Metadata, Subscriber};

/// Run `sign` and return its result with the canonical request `aws-sigv4`
/// recorded while it ran, if it recorded one.
pub fn canonical_request<T>(sign: impl FnOnce() -> T) -> (T, Option<String>) {
let recorded = Arc::new(Mutex::new(None));
let recorder = Recorder {
canonical_request: Arc::clone(&recorded),
};
let output = tracing::subscriber::with_default(recorder, sign);
let text = recorded
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take();
(output, text)
}

/// Keeps the last `canonical_request` field an `aws-sigv4` event carries.
struct Recorder {
canonical_request: Arc<Mutex<Option<String>>>,
}

impl Subscriber for Recorder {
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
metadata.target().starts_with("aws_sigv4")
}

fn new_span(&self, _span: &Attributes<'_>) -> Id {
Id::from_u64(1)
}

fn record(&self, _span: &Id, _values: &Record<'_>) {}

fn record_follows_from(&self, _span: &Id, _follows: &Id) {}

fn event(&self, event: &Event<'_>) {
let mut field = CanonicalRequestField(None);
event.record(&mut field);
if let Some(text) = field.0 {
*self
.canonical_request
.lock()
.unwrap_or_else(PoisonError::into_inner) = Some(text);
}
}

fn enter(&self, _span: &Id) {}

fn exit(&self, _span: &Id) {}
}

/// The text of an event's `canonical_request` field.
struct CanonicalRequestField(Option<String>);

impl Visit for CanonicalRequestField {
fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
if field.name() == "canonical_request" {
// The field is recorded with `%`, so its Debug output is the
// Display text of the canonical request.
self.0 = Some(format!("{value:?}"));
}
}
}
Loading
Loading