#![forbid(unsafe_code)]
#![forbid(clippy::undocumented_unsafe_blocks)]
mod canonicalize;
mod compare;
pub mod config;
mod errors;
pub mod headers;
mod proof;
mod types;
pub mod binding;
pub mod enriched;
mod validate;
pub mod build;
pub mod testkit;
pub mod verify;
pub use canonicalize::{ash_canonicalize_json, ash_canonicalize_json_value, ash_canonicalize_json_value_with_size_check, ash_canonicalize_query, ash_canonicalize_urlencoded};
pub use compare::{ash_timing_safe_equal, ash_timing_safe_compare};
pub use errors::{AshError, AshErrorCode, InternalReason};
pub use headers::{HeaderMapView, HeaderBundle, ash_extract_headers};
pub use validate::ash_validate_nonce;
pub use proof::{
ash_build_proof,
ash_verify_proof,
ash_verify_proof_with_freshness,
ash_derive_client_secret,
ash_build_proof_scoped,
ash_verify_proof_scoped,
ash_extract_scoped_fields,
ash_extract_scoped_fields_strict,
ash_build_proof_unified,
ash_verify_proof_unified,
UnifiedProofResult,
ash_hash_body,
ash_hash_proof,
ash_hash_scope,
ash_hash_scoped_body,
ash_hash_scoped_body_strict,
ash_generate_nonce,
ash_generate_nonce_or_panic,
ash_generate_context_id,
ash_generate_context_id_256,
ash_validate_timestamp,
ash_validate_timestamp_format,
DEFAULT_MAX_TIMESTAMP_AGE_SECONDS,
DEFAULT_CLOCK_SKEW_SECONDS,
ASH_SDK_VERSION,
ASH_VERSION_PREFIX,
};
pub use types::{AshMode, BuildProofInput, VerifyInput};
pub use binding::{ash_normalize_binding_value, BindingType, NormalizedBindingValue, MAX_BINDING_VALUE_LENGTH};
pub use build::{build_request_proof, BuildRequestInput, BuildRequestResult, BuildMeta};
pub use enriched::{
ash_canonicalize_query_enriched, CanonicalQueryResult,
ash_hash_body_enriched, BodyHashResult,
ash_normalize_binding_enriched, ash_parse_binding, NormalizedBinding,
};
pub use testkit::{load_vectors, load_vectors_from_file, run_vectors, AshAdapter, AdapterResult, TestReport, VectorResult, Vector, VectorFile};
pub use verify::{verify_incoming_request, VerifyRequestInput, VerifyResult, VerifyMeta};
pub fn ash_normalize_binding(method: &str, path: &str, query: &str) -> Result<String, AshError> {
let method = method.trim();
if method.is_empty() {
return Err(AshError::new(
AshErrorCode::ValidationError,
"Method cannot be empty",
));
}
if !method.is_ascii() {
return Err(AshError::new(
AshErrorCode::ValidationError,
"Method must contain only ASCII characters",
));
}
let method = method.to_ascii_uppercase();
let path = path.trim();
if !path.starts_with('/') {
return Err(AshError::new(
AshErrorCode::ValidationError,
"Path must start with /",
));
}
let decoded_path = ash_percent_decode_path(path)?;
if decoded_path.contains('?') {
return Err(AshError::new(
AshErrorCode::ValidationError,
"Path must not contain '?' (including encoded %3F) - use normalize_binding_from_url for combined path+query",
));
}
let normalized_path = ash_normalize_path_segments(&decoded_path);
if normalized_path.is_empty() || !normalized_path.starts_with('/') {
return Err(AshError::new(
AshErrorCode::ValidationError,
"Path normalization resulted in invalid path",
));
}
let encoded_path = ash_percent_encode_path(&normalized_path);
let query = query.trim();
let canonical_query = if query.is_empty() {
String::new()
} else {
canonicalize::ash_canonicalize_query(query)?
};
Ok(format!(
"{}|{}|{}",
method, encoded_path, canonical_query
))
}
fn ash_percent_decode_path(input: &str) -> Result<String, AshError> {
let mut bytes = Vec::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '%' {
let hex: String = chars.by_ref().take(2).collect();
if hex.len() != 2 {
return Err(AshError::new(
AshErrorCode::ValidationError,
"Invalid percent encoding in path",
));
}
let byte = u8::from_str_radix(&hex, 16).map_err(|_| {
AshError::new(
AshErrorCode::ValidationError,
"Invalid percent encoding hex in path",
)
})?;
bytes.push(byte);
} else {
let mut buf = [0u8; 4];
let encoded = ch.encode_utf8(&mut buf);
bytes.extend_from_slice(encoded.as_bytes());
}
}
String::from_utf8(bytes).map_err(|_| {
AshError::new(
AshErrorCode::ValidationError,
"Invalid UTF-8 in percent-decoded path",
)
})
}
fn ash_normalize_path_segments(path: &str) -> String {
let mut segments: Vec<&str> = Vec::new();
for segment in path.split('/') {
match segment {
"" | "." => {
continue;
}
".." => {
segments.pop();
}
s => {
segments.push(s);
}
}
}
if segments.is_empty() {
"/".to_string()
} else {
format!("/{}", segments.join("/"))
}
}
fn ash_percent_encode_path(input: &str) -> String {
let mut result = String::with_capacity(input.len() * 3);
for ch in input.chars() {
match ch {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
result.push(ch);
}
'/' | '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '=' | ':' | '@' => {
result.push(ch);
}
_ => {
let mut buf = [0u8; 4];
let encoded = ch.encode_utf8(&mut buf);
for byte in encoded.as_bytes() {
use std::fmt::Write;
write!(result, "%{:02X}", byte).unwrap();
}
}
}
}
result
}
pub fn ash_normalize_binding_from_url(method: &str, full_path: &str) -> Result<String, AshError> {
let (path, query) = match full_path.find('?') {
Some(pos) => (&full_path[..pos], &full_path[pos + 1..]),
None => (full_path, ""),
};
ash_normalize_binding(method, path, query)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_binding_basic() {
assert_eq!(
ash_normalize_binding("POST", "/api/users", "").unwrap(),
"POST|/api/users|"
);
}
#[test]
fn test_normalize_binding_lowercase_method() {
assert_eq!(
ash_normalize_binding("post", "/api/users", "").unwrap(),
"POST|/api/users|"
);
}
#[test]
fn test_normalize_binding_duplicate_slashes() {
assert_eq!(
ash_normalize_binding("GET", "/api//users///profile", "").unwrap(),
"GET|/api/users/profile|"
);
}
#[test]
fn test_normalize_binding_trailing_slash() {
assert_eq!(
ash_normalize_binding("PUT", "/api/users/", "").unwrap(),
"PUT|/api/users|"
);
}
#[test]
fn test_normalize_binding_root() {
assert_eq!(ash_normalize_binding("GET", "/", "").unwrap(), "GET|/|");
}
#[test]
fn test_normalize_binding_with_query() {
assert_eq!(
ash_normalize_binding("GET", "/api/users", "page=1&sort=name").unwrap(),
"GET|/api/users|page=1&sort=name"
);
}
#[test]
fn test_normalize_binding_query_sorted() {
assert_eq!(
ash_normalize_binding("GET", "/api/users", "z=3&a=1&b=2").unwrap(),
"GET|/api/users|a=1&b=2&z=3"
);
}
#[test]
fn test_normalize_binding_from_url_basic() {
assert_eq!(
ash_normalize_binding_from_url("GET", "/api/users?page=1&sort=name").unwrap(),
"GET|/api/users|page=1&sort=name"
);
}
#[test]
fn test_normalize_binding_from_url_no_query() {
assert_eq!(
ash_normalize_binding_from_url("POST", "/api/users").unwrap(),
"POST|/api/users|"
);
}
#[test]
fn test_normalize_binding_from_url_query_sorted() {
assert_eq!(
ash_normalize_binding_from_url("GET", "/api/search?z=last&a=first").unwrap(),
"GET|/api/search|a=first&z=last"
);
}
#[test]
fn test_normalize_binding_empty_method() {
assert!(ash_normalize_binding("", "/api", "").is_err());
}
#[test]
fn test_normalize_binding_no_leading_slash() {
assert!(ash_normalize_binding("GET", "api/users", "").is_err());
}
#[test]
fn test_version_constants() {
use crate::{ASH_SDK_VERSION, ASH_VERSION_PREFIX};
assert_eq!(ASH_SDK_VERSION, "2.3.5");
assert_eq!(ASH_VERSION_PREFIX, "ASHv2.1");
}
#[test]
fn test_normalize_binding_strips_fragment() {
assert_eq!(
ash_normalize_binding("GET", "/api/search", "q=test#section").unwrap(),
"GET|/api/search|q=test"
);
}
#[test]
fn test_normalize_binding_plus_literal() {
assert_eq!(
ash_normalize_binding("GET", "/api/search", "q=a+b").unwrap(),
"GET|/api/search|q=a%2Bb"
);
}
#[test]
fn test_normalize_binding_encoded_slashes() {
assert_eq!(
ash_normalize_binding("GET", "/api/%2F%2F/users", "").unwrap(),
"GET|/api/users|"
);
}
#[test]
fn test_normalize_binding_encoded_double_slash() {
assert_eq!(
ash_normalize_binding("GET", "/api%2F%2Fusers", "").unwrap(),
"GET|/api/users|"
);
}
#[test]
fn test_normalize_binding_unicode_path() {
let result = ash_normalize_binding("GET", "/api/café", "").unwrap();
assert!(result.starts_with("GET|/api/caf"));
assert!(result.contains("%C3%A9") || result.contains("é"));
}
#[test]
fn test_normalize_binding_mixed_encoding() {
let result1 = ash_normalize_binding("GET", "/api/%2Ftest", "").unwrap();
let result2 = ash_normalize_binding("GET", "/api//test", "").unwrap();
assert_eq!(result1, result2);
}
#[test]
fn test_normalize_binding_encoded_trailing_slash() {
assert_eq!(
ash_normalize_binding("GET", "/api/users%2F", "").unwrap(),
"GET|/api/users|"
);
}
#[test]
fn test_normalize_binding_special_chars_preserved() {
let result = ash_normalize_binding("GET", "/api/users/@me", "").unwrap();
assert_eq!(result, "GET|/api/users/@me|");
}
#[test]
fn test_normalize_binding_rejects_encoded_question_mark() {
let result = ash_normalize_binding("GET", "/api/users%3Fid=5", "");
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("?"));
}
#[test]
fn test_normalize_binding_rejects_doubly_encoded_question_mark() {
let result = ash_normalize_binding("GET", "/api/users%253F", "");
assert!(result.is_ok());
}
#[test]
fn test_normalize_binding_allows_other_encoded_chars() {
let result = ash_normalize_binding("GET", "/api/hello%20world", "").unwrap();
assert!(result.contains("/api/hello%20world"));
}
#[test]
fn test_normalize_binding_dot_segment() {
assert_eq!(
ash_normalize_binding("GET", "/api/./users", "").unwrap(),
"GET|/api/users|"
);
}
#[test]
fn test_normalize_binding_double_dot_segment() {
assert_eq!(
ash_normalize_binding("GET", "/api/v1/../users", "").unwrap(),
"GET|/api/users|"
);
}
#[test]
fn test_normalize_binding_multiple_dots() {
assert_eq!(
ash_normalize_binding("GET", "/api/v1/./users/../admin", "").unwrap(),
"GET|/api/v1/admin|"
);
}
#[test]
fn test_normalize_binding_dots_at_root() {
assert_eq!(
ash_normalize_binding("GET", "/../api", "").unwrap(),
"GET|/api|"
);
}
#[test]
fn test_normalize_binding_only_dots() {
assert_eq!(
ash_normalize_binding("GET", "/./.", "").unwrap(),
"GET|/|"
);
}
#[test]
fn test_normalize_binding_rejects_unicode_method() {
let result = ash_normalize_binding("GËṪ", "/api", "");
assert!(result.is_err());
assert!(result.unwrap_err().message().contains("ASCII"));
}
#[test]
fn test_normalize_binding_ascii_method_uppercased() {
assert_eq!(
ash_normalize_binding("get", "/api", "").unwrap(),
"GET|/api|"
);
assert_eq!(
ash_normalize_binding("Post", "/api", "").unwrap(),
"POST|/api|"
);
}
#[test]
fn test_normalize_binding_whitespace_only_query() {
assert_eq!(
ash_normalize_binding("GET", "/api", " ").unwrap(),
"GET|/api|"
);
assert_eq!(
ash_normalize_binding("GET", "/api", "\t\n").unwrap(),
"GET|/api|"
);
}
#[test]
fn test_normalize_binding_query_with_leading_trailing_whitespace() {
assert_eq!(
ash_normalize_binding("GET", "/api", " a=1 ").unwrap(),
"GET|/api|a=1"
);
}
}