use std::time::SystemTime;
use vantage_core::{Result, error};
use crate::account::AwsAccount;
use crate::condition::AwsCondition;
use crate::sign::sign_v4;
const EMPTY_BODY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
#[allow(clippy::type_complexity)]
pub(crate) fn build_request(
target: &str,
resolved: &[AwsCondition],
) -> Result<(String, String, Vec<(String, String)>)> {
let (method, rest) = target.split_once(' ').ok_or_else(|| {
error!(
"REST target must be \"METHOD path[?query]\" — got",
target = target
)
})?;
let method = method.trim().to_ascii_uppercase();
let (path_template, static_query) = match rest.split_once('?') {
Some((p, q)) => (p, q),
None => (rest, ""),
};
let mut params: Vec<(String, String)> = Vec::with_capacity(resolved.len());
for cond in resolved {
match cond {
AwsCondition::Eq { field, value } => {
params.push((field.clone(), cbor_scalar_to_string(value)));
}
AwsCondition::In { field, values } => match values.as_slice() {
[single] => params.push((field.clone(), cbor_scalar_to_string(single))),
[] => {
return Err(error!(
"AwsCondition::In with zero values is not representable",
field = field.as_str()
));
}
_ => {
return Err(error!(
"AWS REST APIs don't accept multi-value filters; \
resolved condition must collapse to one value",
field = field.as_str(),
count = values.len()
));
}
},
AwsCondition::Deferred { field, .. } => {
return Err(error!(
"Internal: Deferred condition reached REST builder unresolved",
field = field.as_str()
));
}
}
}
let mut path = String::with_capacity(path_template.len());
let mut consumed: Vec<String> = Vec::new();
let mut chars = path_template.chars().peekable();
while let Some(c) = chars.next() {
if c == '{' {
let mut name = String::new();
for nc in chars.by_ref() {
if nc == '}' {
break;
}
name.push(nc);
}
let value = params
.iter()
.find(|(k, _)| k == &name)
.map(|(_, v)| v.clone())
.ok_or_else(|| {
error!(
"REST path placeholder has no matching condition",
placeholder = name.as_str()
)
})?;
path.push_str(&path_segment_encode(&value));
consumed.push(name);
} else {
path.push(c);
}
}
let mut query_pairs: Vec<(String, String)> = Vec::new();
if !static_query.is_empty() {
for kv in static_query.split('&') {
let (k, v) = match kv.split_once('=') {
Some((k, v)) => (k.to_string(), v.to_string()),
None => (kv.to_string(), String::new()),
};
query_pairs.push((k, v));
}
}
for (k, v) in params {
if consumed.contains(&k) {
continue;
}
query_pairs.push((k, v));
}
Ok((method, path, query_pairs))
}
pub(crate) async fn restxml_call(
account: &AwsAccount,
service: &str,
method: &str,
path: &str,
query: &[(String, String)],
) -> Result<String> {
let region = account.region();
if region.is_empty() {
return Err(error!(
"AWS region is not configured — pass it to AwsAccount::new \
or set AWS_REGION before issuing a REST-XML request"
));
}
let host = format!("{service}.{region}.amazonaws.com");
let url = build_url(&host, path, query);
let body_bytes: Vec<u8> = Vec::new();
let signing_headers = [
("host".to_string(), host.clone()),
(
"x-amz-content-sha256".to_string(),
EMPTY_BODY_SHA256.to_string(),
),
];
let signed = sign_v4(
account.access_key(),
account.secret_key(),
account.session_token(),
region,
service,
method,
&url,
&signing_headers,
&body_bytes,
SystemTime::now(),
)?;
let req_builder = match method {
"GET" => account.http().get(&url),
"HEAD" => account.http().head(&url),
other => {
return Err(error!(
"REST-XML transport currently only supports read methods",
method = other
));
}
};
let mut req = req_builder.header("x-amz-content-sha256", EMPTY_BODY_SHA256);
for h in &signed {
req = req.header(h.name.as_str(), h.value.as_str());
}
let resp = req.send().await.map_err(|e| {
error!(
"AWS REST-XML request failed",
url = url.as_str(),
method = method,
detail = e
)
})?;
let status = resp.status();
let response_text = resp
.text()
.await
.map_err(|e| error!("Failed to read AWS REST-XML response body", detail = e))?;
if !status.is_success() {
return Err(error!(
"AWS REST-XML request returned error status",
url = url.as_str(),
status = status.as_u16(),
body = response_text
));
}
Ok(response_text)
}
fn build_url(host: &str, path: &str, query: &[(String, String)]) -> String {
let mut url = format!("https://{host}{path}");
if !query.is_empty() {
url.push('?');
for (i, (k, v)) in query.iter().enumerate() {
if i > 0 {
url.push('&');
}
url.push_str(&query_part_encode(k));
url.push('=');
url.push_str(&query_part_encode(v));
}
}
url
}
fn path_segment_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for &b in s.as_bytes() {
let unreserved = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'/');
if unreserved {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
fn query_part_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for &b in s.as_bytes() {
let unreserved = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~');
if unreserved {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
fn cbor_scalar_to_string(v: &ciborium::Value) -> String {
match v {
ciborium::Value::Text(s) => s.clone(),
ciborium::Value::Integer(i) => {
let n: i128 = (*i).into();
n.to_string()
}
ciborium::Value::Float(f) => f.to_string(),
ciborium::Value::Bool(b) => b.to_string(),
ciborium::Value::Null => String::new(),
other => other
.deserialized::<serde_json::Value>()
.map(|v| v.to_string())
.unwrap_or_default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::condition::AwsCondition;
use ciborium::Value as CborValue;
#[test]
fn build_request_substitutes_placeholders_from_conditions() {
let target = "GET /{Bucket}?list-type=2";
let conds = [AwsCondition::Eq {
field: "Bucket".into(),
value: CborValue::from("my-bucket"),
}];
let (m, p, q) = build_request(target, &conds).unwrap();
assert_eq!(m, "GET");
assert_eq!(p, "/my-bucket");
assert_eq!(q, vec![("list-type".to_string(), "2".to_string())]);
}
#[test]
fn build_request_pushes_unmatched_conditions_to_query() {
let target = "GET /{Bucket}?list-type=2";
let conds = [
AwsCondition::Eq {
field: "Bucket".into(),
value: CborValue::from("foo"),
},
AwsCondition::Eq {
field: "prefix".into(),
value: CborValue::from("logs/"),
},
];
let (_m, p, q) = build_request(target, &conds).unwrap();
assert_eq!(p, "/foo");
assert_eq!(
q,
vec![
("list-type".to_string(), "2".to_string()),
("prefix".to_string(), "logs/".to_string()),
]
);
}
#[test]
fn build_request_errors_on_missing_placeholder_value() {
let target = "GET /{Bucket}";
let err = build_request(target, &[]).unwrap_err();
assert!(format!("{err}").contains("placeholder"));
}
#[test]
fn build_request_no_query_section_is_fine() {
let target = "GET /";
let (m, p, q) = build_request(target, &[]).unwrap();
assert_eq!(m, "GET");
assert_eq!(p, "/");
assert!(q.is_empty());
}
}