From 0ea576fc4e0cc0b7f7e859658036810322e8b7f3 Mon Sep 17 00:00:00 2001 From: Russell Cohen Date: Mon, 29 Aug 2022 09:05:26 -0700 Subject: [PATCH] Endpoints 2.0 Standard Library functions (#1667) * Endpoints 2.0 Standard Library functions * Endpoints Standard Library Cleanups --- rust-runtime/inlineable/Cargo.toml | 1 + rust-runtime/inlineable/src/endpoint_lib.rs | 10 ++ .../inlineable/src/endpoint_lib/arn.rs | 153 ++++++++++++++++++ .../inlineable/src/endpoint_lib/diagnostic.rs | 45 ++++++ .../inlineable/src/endpoint_lib/host.rs | 76 +++++++++ .../inlineable/src/endpoint_lib/parse_url.rs | 109 +++++++++++++ .../inlineable/src/endpoint_lib/substring.rs | 113 +++++++++++++ rust-runtime/inlineable/src/lib.rs | 2 + 8 files changed, 509 insertions(+) create mode 100644 rust-runtime/inlineable/src/endpoint_lib.rs create mode 100644 rust-runtime/inlineable/src/endpoint_lib/arn.rs create mode 100644 rust-runtime/inlineable/src/endpoint_lib/diagnostic.rs create mode 100644 rust-runtime/inlineable/src/endpoint_lib/host.rs create mode 100644 rust-runtime/inlineable/src/endpoint_lib/parse_url.rs create mode 100644 rust-runtime/inlineable/src/endpoint_lib/substring.rs diff --git a/rust-runtime/inlineable/Cargo.toml b/rust-runtime/inlineable/Cargo.toml index 49c1d3b3e3..280441c8f9 100644 --- a/rust-runtime/inlineable/Cargo.toml +++ b/rust-runtime/inlineable/Cargo.toml @@ -23,6 +23,7 @@ repository = "https://github.com/awslabs/smithy-rs" "pin-project-lite" = "0.2" "tower" = { version = "0.4.11", default_features = false } "async-trait" = "0.1" +"url" = "2.2.2" [dev-dependencies] proptest = "1" diff --git a/rust-runtime/inlineable/src/endpoint_lib.rs b/rust-runtime/inlineable/src/endpoint_lib.rs new file mode 100644 index 0000000000..395027d517 --- /dev/null +++ b/rust-runtime/inlineable/src/endpoint_lib.rs @@ -0,0 +1,10 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +mod arn; +mod diagnostic; +mod host; +mod parse_url; +mod substring; diff --git a/rust-runtime/inlineable/src/endpoint_lib/arn.rs b/rust-runtime/inlineable/src/endpoint_lib/arn.rs new file mode 100644 index 0000000000..03ec56b9ee --- /dev/null +++ b/rust-runtime/inlineable/src/endpoint_lib/arn.rs @@ -0,0 +1,153 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use crate::endpoint_lib::diagnostic::DiagnosticCollector; +use std::borrow::Cow; +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct Arn<'a> { + partition: &'a str, + service: &'a str, + region: &'a str, + account_id: &'a str, + resource_id: Vec<&'a str>, +} + +#[allow(unused)] +impl<'a> Arn<'a> { + pub(crate) fn partition(&self) -> &'a str { + self.partition + } + pub(crate) fn service(&self) -> &'a str { + self.service + } + pub(crate) fn region(&self) -> &'a str { + self.region + } + pub(crate) fn account_id(&self) -> &'a str { + self.account_id + } + pub(crate) fn resource_id(&self) -> &Vec<&'a str> { + &self.resource_id + } +} + +#[derive(Debug, PartialEq)] +pub(crate) struct InvalidArn { + message: Cow<'static, str>, +} + +impl InvalidArn { + fn from_static(message: &'static str) -> InvalidArn { + Self { + message: Cow::Borrowed(message), + } + } +} +impl Display for InvalidArn { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} +impl Error for InvalidArn {} + +impl<'a> Arn<'a> { + pub(crate) fn parse(arn: &'a str) -> Result { + let mut split = arn.splitn(6, ':'); + let invalid_format = + || InvalidArn::from_static("ARN must have 6 components delimited by `:`"); + let arn = split.next().ok_or_else(invalid_format)?; + let partition = split.next().ok_or_else(invalid_format)?; + let service = split.next().ok_or_else(invalid_format)?; + let region = split.next().ok_or_else(invalid_format)?; + let account_id = split.next().ok_or_else(invalid_format)?; + let resource_id = split.next().ok_or_else(invalid_format)?; + + if arn != "arn" { + return Err(InvalidArn::from_static( + "first component of the ARN must be `arn`", + )); + } + if partition.is_empty() || service.is_empty() || resource_id.is_empty() { + return Err(InvalidArn::from_static( + "partition, service, and resource id must all be non-empty", + )); + } + + let resource_id = resource_id.split([':', '/']).collect::>(); + Ok(Self { + partition, + service, + region, + account_id, + resource_id, + }) + } +} + +pub(crate) fn parse_arn<'a, 'b>(input: &'a str, e: &'b mut DiagnosticCollector) -> Option> { + e.capture(Arn::parse(input)) +} + +#[cfg(test)] +mod test { + use super::Arn; + use crate::endpoint_lib::diagnostic::DiagnosticCollector; + + #[test] + fn arn_parser() { + let arn = "arn:aws:s3:us-east-2:012345678:outpost:op-1234"; + let parsed = Arn::parse(arn).expect("valid ARN"); + assert_eq!( + parsed, + Arn { + partition: "aws", + service: "s3", + region: "us-east-2", + account_id: "012345678", + resource_id: vec!["outpost", "op-1234"] + } + ); + } + + #[test] + fn allow_slash_arns() { + let arn = "arn:aws:s3:us-east-2:012345678:outpost/op-1234"; + let parsed = Arn::parse(arn).expect("valid ARN"); + assert_eq!( + parsed, + Arn { + partition: "aws", + service: "s3", + region: "us-east-2", + account_id: "012345678", + resource_id: vec!["outpost", "op-1234"] + } + ); + } + + #[test] + fn resource_id_must_be_nonempty() { + let arn = "arn:aws:s3:us-east-2:012345678:"; + Arn::parse(arn).expect_err("empty resource"); + } + + #[test] + fn arns_with_empty_parts() { + let arn = "arn:aws:s3:::my_corporate_bucket/Development/*"; + assert_eq!( + Arn::parse(arn).expect("valid arn"), + Arn { + partition: "aws", + service: "s3", + region: "", + account_id: "", + resource_id: vec!["my_corporate_bucket", "Development", "*"] + } + ); + } +} diff --git a/rust-runtime/inlineable/src/endpoint_lib/diagnostic.rs b/rust-runtime/inlineable/src/endpoint_lib/diagnostic.rs new file mode 100644 index 0000000000..1b39e19ba8 --- /dev/null +++ b/rust-runtime/inlineable/src/endpoint_lib/diagnostic.rs @@ -0,0 +1,45 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use std::error::Error; + +/// Diagnostic collector for endpoint resolution +/// +/// Endpoint functions return `Option`—to enable diagnostic information to flow, we capture the +/// last error that occurred. +#[derive(Debug, Default)] +pub(crate) struct DiagnosticCollector { + last_error: Option>, +} + +impl DiagnosticCollector { + /// Report an error to the collector + pub(crate) fn report_error(&mut self, err: impl Into>) { + self.last_error = Some(err.into()); + } + + /// Capture a result, returning Some(t) when the input was `Ok` and `None` otherwise + pub(crate) fn capture>>( + &mut self, + err: Result, + ) -> Option { + match err { + Ok(res) => Some(res), + Err(e) => { + self.report_error(e); + None + } + } + } + + pub(crate) fn take_last_error(&mut self) -> Option> { + self.last_error.take() + } + + /// Create a new diagnostic collector + pub(crate) fn new() -> Self { + Self { last_error: None } + } +} diff --git a/rust-runtime/inlineable/src/endpoint_lib/host.rs b/rust-runtime/inlineable/src/endpoint_lib/host.rs new file mode 100644 index 0000000000..389b1da0d2 --- /dev/null +++ b/rust-runtime/inlineable/src/endpoint_lib/host.rs @@ -0,0 +1,76 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use crate::endpoint_lib::diagnostic::DiagnosticCollector; + +pub(crate) fn is_valid_host_label( + label: &str, + allow_dots: bool, + e: &mut DiagnosticCollector, +) -> bool { + if allow_dots { + for part in label.split('.') { + if !is_valid_host_label(part, false, e) { + return false; + } + } + true + } else { + if label.is_empty() || label.len() > 63 { + e.report_error("host was too short or too long"); + return false; + } + label.chars().enumerate().all(|(idx, ch)| match (ch, idx) { + ('-', 0) => { + e.report_error("cannot start with `-`"); + false + } + _ => ch.is_alphanumeric() || ch == '-', + }) + } +} + +#[cfg(test)] +mod test { + use proptest::proptest; + + fn is_valid_host_label(label: &str, allow_dots: bool) -> bool { + super::is_valid_host_label(label, allow_dots, &mut DiagnosticCollector::new()) + } + + #[test] + fn basic_cases() { + assert_eq!(is_valid_host_label("", false), false); + assert_eq!(is_valid_host_label("", true), false); + assert_eq!(is_valid_host_label(".", true), false); + assert_eq!(is_valid_host_label("a.b", true), true); + assert_eq!(is_valid_host_label("a.b", false), false); + assert_eq!(is_valid_host_label("a.b.", true), false); + assert_eq!(is_valid_host_label("a.b.c", true), true); + assert_eq!(is_valid_host_label("a_b", true), false); + assert_eq!(is_valid_host_label(&"a".repeat(64), false), false); + assert_eq!( + is_valid_host_label(&format!("{}.{}", "a".repeat(63), "a".repeat(63)), true), + true + ); + } + + #[test] + fn start_bounds() { + assert_eq!(is_valid_host_label("-foo", false), false); + assert_eq!(is_valid_host_label("-foo", true), false); + assert_eq!(is_valid_host_label(".foo", true), false); + assert_eq!(is_valid_host_label("a-b.foo", true), true); + } + + use crate::endpoint_lib::diagnostic::DiagnosticCollector; + use proptest::prelude::*; + proptest! { + #[test] + fn no_panics(s in any::(), dots in any::()) { + is_valid_host_label(&s, dots); + } + } +} diff --git a/rust-runtime/inlineable/src/endpoint_lib/parse_url.rs b/rust-runtime/inlineable/src/endpoint_lib/parse_url.rs new file mode 100644 index 0000000000..0354ba335c --- /dev/null +++ b/rust-runtime/inlineable/src/endpoint_lib/parse_url.rs @@ -0,0 +1,109 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use crate::endpoint_lib::diagnostic::DiagnosticCollector; +use http::Uri; +use std::error::Error; +use url::{Host, Url as ParsedUrl}; + +#[derive(PartialEq, Debug)] +pub(crate) struct Url<'a> { + uri: Uri, + url: ParsedUrl, + raw: &'a str, +} + +impl<'a> Url<'a> { + pub(crate) fn is_ip(&self) -> bool { + matches!(self.url.host(), Some(Host::Ipv4(_) | Host::Ipv6(_))) + } + pub(crate) fn scheme(&self) -> &str { + self.url.scheme() + } + + pub(crate) fn authority(&self) -> &str { + self.uri.authority().unwrap().as_str() + } + + pub(crate) fn normalized_path(&self) -> &str { + match self.uri.path() { + path if !path.is_empty() => path, + _ => "/", + } + } + + pub(crate) fn path(&self) -> &str { + if self.uri.path() == "/" && !self.raw.ends_with('/') { + "" + } else { + self.uri.path() + } + } +} + +pub(crate) fn parse_url<'a, 'b>(url: &'a str, e: &'b mut DiagnosticCollector) -> Option> { + let raw = url; + let uri: Uri = e.capture(url.parse())?; + let url: ParsedUrl = e.capture(url.parse())?; + if let Some(query) = uri.query() { + e.report_error(format!( + "URL cannot have a query component (found {})", + query + )); + return None; + } + if !["http", "https"].contains(&url.scheme()) { + e.report_error(format!( + "URL scheme must be HTTP or HTTPS (found {})", + url.scheme() + )); + return None; + } + Some(Url { url, uri, raw }) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::endpoint_lib::diagnostic::DiagnosticCollector; + + #[test] + fn parse_simple_url() { + let url = "https://control.vpce-1a2b3c4d-5e6f.s3.us-west-2.vpce.amazonaws.com"; + let url = parse_url(url, &mut DiagnosticCollector::new()).expect("valid url"); + assert_eq!(url.path(), ""); + assert_eq!(url.normalized_path(), "/"); + assert_eq!(url.is_ip(), false); + assert_eq!(url.scheme(), "https"); + assert_eq!( + url.authority(), + "control.vpce-1a2b3c4d-5e6f.s3.us-west-2.vpce.amazonaws.com" + ); + } + + #[test] + fn schemes_are_normalized() { + let url = "HTTPS://control.vpce-1a2b3c4d-5e6f.s3.us-west-2.vpce.amazonaws.com"; + let url = parse_url(url, &mut DiagnosticCollector::new()).expect("valid url"); + assert_eq!(url.scheme(), "https"); + } + + #[test] + fn parse_url_with_port() { + let url = "http://localhost:8000/path"; + let url = parse_url(url, &mut DiagnosticCollector::new()).expect("valid url"); + assert_eq!(url.path(), "/path"); + assert_eq!(url.normalized_path(), "/path"); + assert_eq!(url.is_ip(), false); + assert_eq!(url.scheme(), "http"); + assert_eq!(url.authority(), "localhost:8000"); + } + + #[test] + fn only_http_https_supported() { + let url = "wss://localhost:8443/path"; + assert_eq!(parse_url(url, &mut DiagnosticCollector::new()), None); + } +} diff --git a/rust-runtime/inlineable/src/endpoint_lib/substring.rs b/rust-runtime/inlineable/src/endpoint_lib/substring.rs new file mode 100644 index 0000000000..3e55b08009 --- /dev/null +++ b/rust-runtime/inlineable/src/endpoint_lib/substring.rs @@ -0,0 +1,113 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use crate::endpoint_lib::diagnostic::DiagnosticCollector; + +/// substring of `input` +/// +/// > Note: this function only operates on ASCII input. If the input contains non-ASCII characters, +/// > `None` will be returned. +/// +/// - When `reverse` is false, indexes are evaluated from the beginning of the string +/// - When `reverse` is true, indexes are evaluated from the end of the string (however, the result +/// will still be "forwards" and `start` MUST be less than `end`. +pub(crate) fn substring<'a, 'b>( + input: &'a str, + start: usize, + stop: usize, + reverse: bool, + e: &'b mut DiagnosticCollector, +) -> Option<&'a str> { + if start >= stop { + e.capture(Err("start > stop"))?; + } + if !input.is_ascii() { + e.capture(Err("the input to substring was not ascii"))?; + } + if input.len() < stop { + e.capture(Err("the input was too short"))?; + } + let (effective_start, effective_stop) = if !reverse { + (start, stop) + } else { + (input.len() - stop, input.len() - start) + }; + Some(&input[effective_start..effective_stop]) +} + +#[cfg(test)] +mod test { + use super::*; + use proptest::proptest; + + #[test] + fn substring_forwards() { + assert_eq!( + substring("hello", 0, 2, false, &mut DiagnosticCollector::new()), + Some("he") + ); + assert_eq!( + substring("hello", 0, 0, false, &mut DiagnosticCollector::new()), + None + ); + assert_eq!( + substring("hello", 0, 5, false, &mut DiagnosticCollector::new()), + Some("hello") + ); + assert_eq!( + substring("hello", 0, 6, false, &mut DiagnosticCollector::new()), + None + ); + } + fn substring_backwards() { + assert_eq!( + substring("hello", 0, 2, true, &mut DiagnosticCollector::new()), + Some("lo") + ); + assert_eq!( + substring("hello", 0, 0, true, &mut DiagnosticCollector::new()), + None + ); + assert_eq!( + substring("hello", 0, 5, true, &mut DiagnosticCollector::new()), + Some("hello") + ) + } + + // substring doesn't support unicode, it always returns none + #[test] + fn substring_unicode() { + let mut collector = DiagnosticCollector::new(); + assert_eq!(substring("a🐱b", 0, 2, false, &mut collector), None); + assert_eq!( + format!( + "{}", + collector + .take_last_error() + .expect("last error should be set") + ), + "the input to substring was not ascii" + ); + } + + use proptest::prelude::*; + proptest! { + #[test] + fn substring_no_panics(s in any::(), start in 0..100usize, stop in 0..100usize, reverse in proptest::bool::ANY) { + substring(&s, start, stop, reverse, &mut DiagnosticCollector::new()); + } + + #[test] + fn substring_correct_length(s in r#"[\x00-\xFF]*"#, start in 0..10usize, stop in 0..10usize, reverse in proptest::bool::ANY) { + prop_assume!(start < s.len()); + prop_assume!(stop < s.len()); + prop_assume!(start < stop); + if let Some(result) = substring(&s, start, stop, reverse, &mut DiagnosticCollector::new()) { + assert_eq!(result.len(), stop - start); + } + + } + } +} diff --git a/rust-runtime/inlineable/src/lib.rs b/rust-runtime/inlineable/src/lib.rs index a26d779f30..3cbcd5e5ff 100644 --- a/rust-runtime/inlineable/src/lib.rs +++ b/rust-runtime/inlineable/src/lib.rs @@ -16,6 +16,8 @@ mod rest_xml_wrapped_errors; #[allow(unused)] mod server_operation_handler_trait; +#[allow(unused)] +mod endpoint_lib; // This test is outside of uuid.rs to enable copying the entirety of uuid.rs into the SDK without // requiring a proptest dependency #[cfg(test)]