pub(crate) mod filters;
mod handlers;
pub(crate) mod reply;
mod routes;
use std::net::SocketAddr;
use std::path::PathBuf;
use tracing::debug;
use super::provider::Provider;
use crate::signature::KeyRing;
use crate::{search::Search, signature::SecretKeyStorage};
pub(crate) const TOML_MIME_TYPE: &str = "application/toml";
pub(crate) const JSON_MIME_TYPE: &str = "application/json";
pub struct TlsConfig {
pub cert_path: PathBuf,
pub key_path: PathBuf,
}
#[allow(clippy::too_many_arguments)]
pub async fn server<P, I, Authn, Authz, S>(
store: P,
index: I,
authn: Authn,
authz: Authz,
addr: impl Into<SocketAddr> + 'static,
tls: Option<TlsConfig>,
keystore: S,
verification_strategy: crate::VerificationStrategy,
keyring: KeyRing,
) -> anyhow::Result<()>
where
P: Provider + Clone + Send + Sync + 'static,
I: Search + Clone + Send + Sync + 'static,
S: SecretKeyStorage + Clone + Send + Sync + 'static,
Authn: crate::authn::Authenticator + Clone + Send + Sync + 'static,
Authz: crate::authz::Authorizer + Clone + Send + Sync + 'static,
{
let api = routes::api(
store,
index,
authn,
authz,
keystore,
verification_strategy,
keyring,
);
let server = warp::serve(api);
match tls {
None => {
debug!("No TLS config found, starting server in HTTP mode");
server
.try_bind_with_graceful_shutdown(addr, shutdown_signal())?
.1
.await
}
Some(config) => {
debug!(
?config.key_path,
?config.cert_path, "Got TLS config, starting server in HTTPS mode",
);
server
.tls()
.key_path(config.key_path)
.cert_path(config.cert_path)
.bind_with_graceful_shutdown(addr, shutdown_signal())
.1
.await
}
};
Ok(())
}
async fn shutdown_signal() {
tokio::signal::ctrl_c()
.await
.expect("failed to setup signal handler");
}
#[cfg(test)]
mod test {
use std::convert::TryInto;
use crate::authn::always::AlwaysAuthenticate;
use crate::authz::always::AlwaysAuthorize;
use crate::invoice::{signature::KeyRing, VerificationStrategy};
use crate::provider::Provider;
use crate::search::StrictEngine;
use crate::testing::{self, MockKeyStore};
use crate::verification::NoopVerified;
use crate::NoopSigned;
use crate::{signature::SecretKeyStorage, SignatureRole};
use rstest::rstest;
use testing::Scaffold;
use tokio_util::codec::{BytesCodec, FramedRead};
#[rstest]
#[tokio::test]
async fn test_successful_workflow<T>(
#[values(testing::setup(), testing::setup_embedded())]
#[future]
provider_setup: (T, StrictEngine, MockKeyStore),
) where
T: Provider + Clone + Send + Sync + 'static,
{
let bindles = testing::load_all_files().await;
let (store, index, ks) = provider_setup.await;
let valid_v1 = bindles.get("valid_v1").expect("Missing scaffold");
let api = super::routes::api(
store,
index,
AlwaysAuthenticate,
AlwaysAuthorize,
ks,
VerificationStrategy::default(),
valid_v1.keyring.clone(),
);
let res = warp::test::request()
.method("POST")
.header("Content-Type", "application/toml")
.path("/v1/_i")
.body(&valid_v1.invoice)
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::ACCEPTED,
"Body: {}",
String::from_utf8_lossy(res.body())
);
let create_res: crate::InvoiceCreateResponse =
toml::from_slice(res.body()).expect("should be valid invoice response TOML");
assert!(
create_res.missing.is_some(),
"Invoice should have missing parcels"
);
for file in valid_v1.parcel_files.values() {
let res = warp::test::request()
.method("POST")
.path(&format!(
"/v1/_i/{}@{}",
create_res.invoice.bindle.id, file.sha
))
.body(file.data.clone())
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::OK,
"Body: {}",
String::from_utf8_lossy(res.body())
);
}
let mut inv = Scaffold::from(valid_v1.to_owned()).invoice;
inv.bindle.id = "another.com/bindle/1.0.0".try_into().unwrap();
inv.signature = None;
inv.sign(
SignatureRole::Creator,
valid_v1
.keys
.get_first_matching(&SignatureRole::Creator, None)
.unwrap(),
)
.unwrap();
let inv = toml::to_vec(&inv).expect("serialization shouldn't fail");
let res = warp::test::request()
.method("POST")
.header("Content-Type", "application/toml")
.path("/v1/_i")
.body(&inv)
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::CREATED,
"Body: {}",
String::from_utf8_lossy(res.body())
);
let create_res: crate::InvoiceCreateResponse =
toml::from_slice(res.body()).expect("should be valid invoice response TOML");
assert!(
create_res.missing.is_none(),
"Invoice should not have missing parcels"
);
let valid_v2 = bindles.get("valid_v2").expect("Missing scaffold");
let res = warp::test::request()
.method("POST")
.header("Content-Type", "application/toml")
.path("/v1/_i")
.body(&valid_v2.invoice)
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::ACCEPTED,
"Body: {}",
String::from_utf8_lossy(res.body())
);
let create_res: crate::InvoiceCreateResponse =
toml::from_slice(res.body()).expect("should be valid invoice response TOML");
assert_eq!(
create_res
.missing
.expect("Should have missing parcels")
.len(),
1,
"Invoice should not have missing parcels"
);
let res = warp::test::request()
.path("/v1/_i/enterprise.com/warpcore/1.0.0")
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::OK,
"Body: {}",
String::from_utf8_lossy(res.body())
);
let inv: crate::Invoice =
toml::from_slice(res.body()).expect("should be valid invoice TOML");
let parcel = &inv.parcel.expect("Should have parcels")[0];
let res = warp::test::request()
.path(&format!(
"/v1/_i/enterprise.com/warpcore/1.0.0@{}",
parcel.label.sha256
))
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::OK,
"Body: {}",
String::from_utf8_lossy(res.body())
);
assert_eq!(
res.body().as_ref(),
valid_v1.parcel_files.get("parcel").unwrap().data.as_slice()
);
assert_eq!(
res.headers()
.get("Content-Type")
.expect("No content type header found")
.to_str()
.unwrap(),
parcel.label.media_type
);
}
#[rstest]
#[tokio::test]
async fn test_yank<T>(
#[values(testing::setup(), testing::setup_embedded())]
#[future]
provider_setup: (T, StrictEngine, MockKeyStore),
) where
T: Provider + Clone + Send + Sync + 'static,
{
let (store, index, ks) = provider_setup.await;
let scaffold = testing::Scaffold::load("incomplete").await;
let api = super::routes::api(
store.clone(),
index,
AlwaysAuthenticate,
AlwaysAuthorize,
ks,
VerificationStrategy::default(),
scaffold.keyring.clone(),
);
store
.create_invoice(NoopSigned(NoopVerified(scaffold.invoice.clone())))
.await
.expect("Should be able to insert invoice");
let inv_path = format!("/v1/_i/{}", scaffold.invoice.name());
let res = warp::test::request()
.method("DELETE")
.path(&inv_path)
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::OK,
"Body: {}",
String::from_utf8_lossy(res.body())
);
let res = warp::test::request().path(&inv_path).reply(&api).await;
assert_eq!(
res.status(),
warp::http::StatusCode::FORBIDDEN,
"Body: {}",
String::from_utf8_lossy(res.body())
);
let res = warp::test::request()
.path(&format!("{}?yanked=true", inv_path))
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::OK,
"Body: {}",
String::from_utf8_lossy(res.body())
);
toml::from_slice::<crate::Invoice>(res.body()).expect("should be valid invoice TOML");
}
#[rstest]
#[tokio::test]
async fn test_invoice_validation<T>(
#[values(testing::setup(), testing::setup_embedded())]
#[future]
provider_setup: (T, StrictEngine, MockKeyStore),
) where
T: Provider + Clone + Send + Sync + 'static,
{
let bindles = testing::load_all_files().await;
let (store, index, ks) = provider_setup.await;
let valid_raw = bindles.get("valid_v1").expect("Missing scaffold");
let valid = testing::Scaffold::from(valid_raw.clone());
let api = super::routes::api(
store.clone(),
index,
AlwaysAuthenticate,
AlwaysAuthorize,
ks,
VerificationStrategy::default(),
valid.keyring.clone(),
);
store
.create_invoice(NoopSigned(NoopVerified(valid.invoice.clone())))
.await
.expect("Invoice create failure");
let res = warp::test::request()
.method("POST")
.header("Content-Type", "application/toml")
.path("/v1/_i")
.body(&valid_raw.invoice)
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::CONFLICT,
"Trying to upload existing invoice should fail"
);
}
#[rstest]
#[tokio::test]
async fn test_parcel_validation<T>(
#[values(testing::setup(), testing::setup_embedded())]
#[future]
provider_setup: (T, StrictEngine, MockKeyStore),
) where
T: Provider + Clone + Send + Sync + 'static,
{
let (store, index, keystore) = provider_setup.await;
let scaffold = testing::Scaffold::load("valid_v1").await;
let api = super::routes::api(
store.clone(),
index,
AlwaysAuthenticate,
AlwaysAuthorize,
keystore.clone(),
VerificationStrategy::default(),
scaffold.keyring.clone(),
);
let parcel = scaffold.parcel_files.get("parcel").expect("Missing parcel");
let data = std::io::Cursor::new(parcel.data.clone());
store
.create_invoice(NoopSigned(NoopVerified(scaffold.invoice.clone())))
.await
.expect("Unable to insert invoice into store");
store
.create_parcel(
&scaffold.invoice.bindle.id,
&parcel.sha,
FramedRead::new(data, BytesCodec::default()),
)
.await
.expect("Unable to create parcel");
let res = warp::test::request()
.method("POST")
.path(&format!(
"/v1/_i/{}@{}",
scaffold.invoice.bindle.id, &parcel.sha
))
.body(parcel.data.clone())
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::CONFLICT,
"Body: {}",
String::from_utf8_lossy(res.body())
);
let scaffold = testing::Scaffold::load("invalid").await;
store
.create_invoice(NoopSigned(NoopVerified(scaffold.invoice.clone())))
.await
.expect("Unable to create invoice");
let parcel = scaffold
.parcel_files
.get("invalid_sha")
.expect("Missing parcel");
let res = warp::test::request()
.method("POST")
.path(&format!(
"/v1/_i/{}@{}",
scaffold.invoice.bindle.id, &parcel.sha
))
.body(parcel.data.clone())
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::BAD_REQUEST,
"Body: {}",
String::from_utf8_lossy(res.body())
);
}
#[rstest]
#[tokio::test]
async fn test_queries<T>(
#[values(testing::setup(), testing::setup_embedded())]
#[future]
provider_setup: (T, StrictEngine, MockKeyStore),
) where
T: Provider + Clone + Send + Sync + 'static,
{
let (store, index, ks) = provider_setup.await;
let api = super::routes::api(
store.clone(),
index,
AlwaysAuthenticate,
AlwaysAuthorize,
ks,
VerificationStrategy::default(),
KeyRing::default(),
);
let bindles_to_insert = vec!["incomplete", "valid_v1", "valid_v2"];
for b in bindles_to_insert.into_iter() {
let current = testing::Scaffold::load(b).await;
store
.create_invoice(NoopSigned(NoopVerified(current.invoice.clone())))
.await
.expect("Unable to create invoice");
}
let res = warp::test::request()
.path("/v1/_q?q=enterprise.com/warpcore")
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::OK,
"Body: {}",
String::from_utf8_lossy(res.body())
);
let matches: crate::Matches =
toml::from_slice(res.body()).expect("Unable to deserialize response");
assert_eq!(
matches.invoices.len(),
2,
"Expected to get multiple invoice matches"
);
assert_eq!(
matches.query, "enterprise.com/warpcore",
"Response did not contain the query data"
);
for inv in matches.invoices.into_iter() {
assert_eq!(
inv.bindle.id.name(),
"enterprise.com/warpcore",
"Didn't get the correct bindle"
);
}
let res = warp::test::request()
.path("/v1/_q?q=non/existent")
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::OK,
"Body: {}",
String::from_utf8_lossy(res.body())
);
let matches: crate::Matches =
toml::from_slice(res.body()).expect("Unable to deserialize response");
assert!(
matches.invoices.is_empty(),
"Expected to get no invoice matches"
);
}
#[rstest]
#[tokio::test]
async fn test_missing<T>(
#[values(testing::setup(), testing::setup_embedded())]
#[future]
provider_setup: (T, StrictEngine, MockKeyStore),
) where
T: Provider + Clone + Send + Sync + 'static,
{
let (store, index, ks) = provider_setup.await;
let scaffold = testing::Scaffold::load("lotsa_parcels").await;
let api = super::routes::api(
store.clone(),
index,
AlwaysAuthenticate,
AlwaysAuthorize,
ks,
VerificationStrategy::default(),
scaffold.keyring.clone(),
);
store
.create_invoice(NoopSigned(NoopVerified(scaffold.invoice.clone())))
.await
.expect("Unable to load in invoice");
let parcel = scaffold
.parcel_files
.get("parcel")
.expect("parcel doesn't exist");
let parcel_data = std::io::Cursor::new(parcel.data.clone());
store
.create_parcel(
&scaffold.invoice.bindle.id,
&parcel.sha,
FramedRead::new(parcel_data, BytesCodec::default()),
)
.await
.expect("Unable to create parcel");
let res = warp::test::request()
.method("GET")
.path(&format!("/v1/_r/missing/{}", scaffold.invoice.bindle.id))
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::OK,
"Body: {}",
String::from_utf8_lossy(res.body())
);
let resp: crate::MissingParcelsResponse =
toml::from_slice(res.body()).expect("should be valid invoice response TOML");
assert_eq!(
resp.missing.len(),
2,
"Expected 2 missing parcels, got {}",
resp.missing.len()
);
assert!(
resp.missing.iter().any(|l| l.name.contains("crate")),
"Missing labels does not contain correct data: {:?}",
resp.missing
);
assert!(
resp.missing.iter().any(|l| l.name.contains("barrel")),
"Missing labels does not contain correct data: {:?}",
resp.missing
);
}
#[rstest]
#[tokio::test]
async fn test_host_signed<T>(
#[values(testing::setup(), testing::setup_embedded())]
#[future]
provider_setup: (T, StrictEngine, MockKeyStore),
) where
T: Provider + Clone + Send + Sync + 'static,
{
let (store, index, ks) = provider_setup.await;
let scaffold = testing::RawScaffold::load("valid_v1").await;
let api = super::routes::api(
store,
index,
AlwaysAuthenticate,
AlwaysAuthorize,
ks,
VerificationStrategy::default(),
scaffold.keyring.clone(),
);
let res = warp::test::request()
.method("POST")
.header("Content-Type", "application/toml")
.path("/v1/_i")
.body(&scaffold.invoice)
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::ACCEPTED,
"Body: {}",
String::from_utf8_lossy(res.body())
);
let create_res: crate::InvoiceCreateResponse =
toml::from_slice(res.body()).expect("should be valid invoice response TOML");
assert!(
create_res
.invoice
.signature
.unwrap_or_default()
.into_iter()
.any(|sig| matches!(sig.role, crate::SignatureRole::Host)),
"Newly created invoice should be signed by the host"
);
}
#[rstest]
#[tokio::test]
async fn test_anonymous_get<T>(
#[values(testing::setup(), testing::setup_embedded())]
#[future]
provider_setup: (T, StrictEngine, MockKeyStore),
) where
T: Provider + Clone + Send + Sync + 'static,
{
let (store, index, ks) = provider_setup.await;
let scaffold = testing::RawScaffold::load("valid_v1").await;
let api = super::routes::api(
store,
index,
crate::authn::http_basic::HttpBasic::from_file("test/data/htpasswd")
.await
.expect("Unable to load htpasswd file"),
crate::authz::anonymous_get::AnonymousGet,
ks,
VerificationStrategy::default(),
scaffold.keyring.clone(),
);
let res = warp::test::request()
.method("POST")
.header("Content-Type", "application/toml")
.path("/v1/_i")
.body(&scaffold.invoice)
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::FORBIDDEN,
"Body: {}",
String::from_utf8_lossy(res.body())
);
let res = warp::test::request()
.method("POST")
.header("Content-Type", "application/toml")
.header(
"Authorization",
format!("Basic {}", base64::encode(b"admin:sw0rdf1sh")),
)
.path("/v1/_i")
.body(&scaffold.invoice)
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::ACCEPTED,
"Body: {}",
String::from_utf8_lossy(res.body())
);
let scaffold: testing::Scaffold = scaffold.into();
let res = warp::test::request()
.method("GET")
.header(
"Authorization",
format!("Basic {}", base64::encode(b"admin:sw0rdf1sh")),
)
.path(&format!("/v1/_i/{}", scaffold.invoice.bindle.id))
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::OK,
"Body: {}",
String::from_utf8_lossy(res.body())
);
let res = warp::test::request()
.method("GET")
.path(&format!("/v1/_i/{}", scaffold.invoice.bindle.id))
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::OK,
"Body: {}",
String::from_utf8_lossy(res.body())
);
}
#[tokio::test]
async fn test_bindle_keys() {
let (store, index, keystore) = testing::setup_embedded().await;
let api = super::routes::api(
store.clone(),
index,
AlwaysAuthenticate,
AlwaysAuthorize,
keystore.clone(),
VerificationStrategy::default(),
KeyRing::default(),
);
let res = warp::test::request()
.method("GET")
.header("Content-Type", "application/toml")
.path("/v1/bindle-keys")
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::OK,
"A get request with no query params should succeed. Body: {}",
String::from_utf8_lossy(res.body())
);
let keyring: crate::invoice::signature::KeyRing =
toml::from_slice(res.body()).expect("should be valid keyring response TOML");
assert_eq!(keyring.key.len(), 1, "Should only return 1 host key");
assert_eq!(
keyring.key[0].roles,
vec![SignatureRole::Host],
"Returned keys should only have host roles"
);
let res = warp::test::request()
.method("GET")
.header("Content-Type", "application/toml")
.path("/v1/bindle-keys?roles=host")
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::OK,
"A get request with query params should succeed. Body: {}",
String::from_utf8_lossy(res.body())
);
let keyring: crate::invoice::signature::KeyRing =
toml::from_slice(res.body()).expect("should be valid keyring response TOML");
assert_eq!(keyring.key.len(), 1, "Should only return 1 host key");
assert_eq!(
keyring.key[0].roles,
vec![SignatureRole::Host],
"Returned keys should only have host roles"
);
let res = warp::test::request()
.method("GET")
.header("Content-Type", "application/toml")
.path("/v1/bindle-keys?roles=host,creator")
.reply(&api)
.await;
assert_eq!(
res.status(),
warp::http::StatusCode::BAD_REQUEST,
"A get request with non host roles should fail. Body: {}",
String::from_utf8_lossy(res.body())
);
}
}