#![deny(
clippy::all,
clippy::pedantic,
clippy::nursery,
clippy::suspicious,
clippy::complexity,
clippy::perf
)]
#![deny(
clippy::absolute_paths,
clippy::todo,
clippy::unimplemented,
clippy::tests_outside_test_module,
clippy::panic,
clippy::unwrap_used,
clippy::unwrap_in_result,
clippy::unused_trait_names,
clippy::print_stdout,
clippy::print_stderr
)]
#![allow(clippy::doc_markdown)]
#![allow(clippy::duration_suboptimal_units)]
#![deny(missing_docs)]
pub mod cache;
pub mod credentials;
#[cfg(feature = "reqwest")]
mod schema;
#[cfg(feature = "reqwest")]
pub mod reqwest;
use std::collections::HashMap;
use std::error::Error as StdError;
use std::fmt;
use std::future::Future;
use std::sync::Arc;
use std::sync::RwLock;
use crate::cache::TypeErasedCarCache;
use http::HeaderMap;
use nv_redfish_core::query::ExpandQuery;
use nv_redfish_core::Action;
use nv_redfish_core::Bmc;
use nv_redfish_core::BoxTryStream;
use nv_redfish_core::EntityTypeRef;
use nv_redfish_core::Expandable;
use nv_redfish_core::FilterQuery;
use nv_redfish_core::ModificationResponse;
use nv_redfish_core::ODataETag;
use nv_redfish_core::ODataId;
use nv_redfish_core::SessionCreateResponse;
use nv_redfish_core::UploadReader;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use url::Url;
#[doc(inline)]
pub use credentials::BmcCredentials;
#[cfg(feature = "update-service-deprecated")]
#[doc(inline)]
pub use nv_redfish_core::HttpPushUriUpdateRequest;
#[cfg(feature = "update-service-deprecated")]
#[doc(inline)]
pub use nv_redfish_core::UploadStream;
#[doc(inline)]
pub use nv_redfish_core::MultipartUpdateRequest;
pub trait HttpClient: Send + Sync {
type Error: Send + StdError;
fn get<T>(
&self,
url: Url,
credentials: &BmcCredentials,
etag: Option<ODataETag>,
custom_headers: &HeaderMap,
) -> impl Future<Output = Result<T, Self::Error>> + Send
where
T: DeserializeOwned + Send + Sync;
fn post<B, T>(
&self,
url: Url,
body: &B,
credentials: &BmcCredentials,
custom_headers: &HeaderMap,
) -> impl Future<Output = Result<ModificationResponse<T>, Self::Error>> + Send
where
B: Serialize + Send + Sync,
T: DeserializeOwned + Send + Sync;
fn post_session<B, T>(
&self,
url: Url,
body: &B,
custom_headers: &HeaderMap,
) -> impl Future<Output = Result<SessionCreateResponse<T>, Self::Error>> + Send
where
B: Serialize + Send + Sync,
T: DeserializeOwned + Send + Sync;
fn post_multipart_update<U, V, T>(
&self,
url: Url,
request: MultipartUpdateRequest<'_, U, V>,
credentials: &BmcCredentials,
custom_headers: &HeaderMap,
) -> impl Future<Output = Result<ModificationResponse<T>, Self::Error>> + Send
where
U: UploadReader,
T: DeserializeOwned + Send + Sync,
V: Serialize + Send + Sync;
#[cfg(feature = "update-service-deprecated")]
fn post_http_push_uri_update<U, T>(
&self,
url: Url,
request: HttpPushUriUpdateRequest<U>,
credentials: &BmcCredentials,
custom_headers: &HeaderMap,
) -> impl Future<Output = Result<ModificationResponse<T>, Self::Error>> + Send
where
U: UploadReader,
T: DeserializeOwned + Send + Sync;
fn patch<B, T>(
&self,
url: Url,
etag: ODataETag,
body: &B,
credentials: &BmcCredentials,
custom_headers: &HeaderMap,
) -> impl Future<Output = Result<ModificationResponse<T>, Self::Error>> + Send
where
B: Serialize + Send + Sync,
T: DeserializeOwned + Send + Sync;
fn delete<T>(
&self,
url: Url,
credentials: &BmcCredentials,
custom_headers: &HeaderMap,
) -> impl Future<Output = Result<ModificationResponse<T>, Self::Error>> + Send
where
T: DeserializeOwned + Send + Sync;
fn sse<T: Sized + for<'de> Deserialize<'de> + Send>(
&self,
url: Url,
credentials: &BmcCredentials,
custom_headers: &HeaderMap,
) -> impl Future<Output = Result<BoxTryStream<T, Self::Error>, Self::Error>> + Send;
}
pub struct HttpBmc<C: HttpClient> {
client: C,
redfish_endpoint: RedfishEndpoint,
credentials: RwLock<Arc<BmcCredentials>>,
cache: RwLock<TypeErasedCarCache<Url>>,
etags: RwLock<HashMap<Url, ODataETag>>,
custom_headers: HeaderMap,
cache_enabled: bool,
}
impl<C: HttpClient> HttpBmc<C>
where
C::Error: CacheableError,
{
pub fn new(
client: C,
redfish_endpoint: Url,
credentials: BmcCredentials,
cache_settings: CacheSettings,
) -> Self {
Self::with_custom_headers(
client,
redfish_endpoint,
credentials,
cache_settings,
HeaderMap::new(),
)
}
pub fn with_custom_headers(
client: C,
redfish_endpoint: Url,
credentials: BmcCredentials,
cache_settings: CacheSettings,
custom_headers: HeaderMap,
) -> Self {
Self {
client,
redfish_endpoint: RedfishEndpoint::from(redfish_endpoint),
credentials: RwLock::new(Arc::new(credentials)),
cache: RwLock::new(TypeErasedCarCache::new(cache_settings.capacity)),
etags: RwLock::new(HashMap::new()),
custom_headers,
cache_enabled: cache_settings.capacity > 0,
}
}
#[allow(clippy::panic)] pub fn set_credentials(&self, credentials: BmcCredentials) {
*self.credentials.write().expect("poisoned") = Arc::new(credentials);
}
}
#[derive(Debug, Clone)]
pub struct RedfishEndpoint {
base_url: Url,
}
#[derive(Clone, Copy)]
struct UriReference<'a>(&'a str);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RejectedUriReferenceError {
pub reason: String,
}
impl StdError for RejectedUriReferenceError {}
impl fmt::Display for RejectedUriReferenceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.reason.fmt(f)
}
}
impl RedfishEndpoint {
#[must_use]
pub const fn new(base_url: Url) -> Self {
Self { base_url }
}
#[must_use]
pub fn with_path(&self, path: &str) -> Url {
let mut url = self.base_url.clone();
url.set_path(path);
url
}
fn with_odata_id(&self, id: &ODataId) -> Url {
let id = id.to_string();
let (path, query) = id
.split_once('?')
.map_or((id.as_str(), None), |(path, query)| (path, Some(query)));
let mut url = self.with_path(path);
url.set_query(query);
url
}
fn with_odata_id_and_query(&self, id: &ODataId, query: &str) -> Url {
let mut url = self.with_odata_id(id);
match url.query() {
Some(existing) if !existing.is_empty() => {
let combined_query = format!("{existing}&{query}");
url.set_query(Some(&combined_query));
}
_ => url.set_query(Some(query)),
}
url
}
fn with_same_origin_uri_reference(
&self,
uri: UriReference<'_>,
) -> Result<Url, RejectedUriReferenceError> {
let UriReference(uri) = uri;
let resolved = self
.base_url
.join(uri)
.map_err(|source| RejectedUriReferenceError {
reason: format!(
"service URI reference `{}` could not be resolved against \
BMC endpoint `{}`: {source}",
uri, self.base_url
),
})?;
if resolved.origin() != self.base_url.origin() {
return Err(RejectedUriReferenceError {
reason: format!(
"service URI reference `{}` resolved to `{resolved}`, \
which is not same-origin with BMC endpoint `{}`",
uri, self.base_url
),
});
}
Ok(resolved)
}
#[must_use]
pub fn with_path_and_query(&self, path: &str, query: &str) -> Url {
let mut url = self.with_path(path);
url.set_query(Some(query));
url
}
}
#[derive(Clone, Copy)]
pub struct CacheSettings {
capacity: usize,
}
impl Default for CacheSettings {
fn default() -> Self {
Self { capacity: 100 }
}
}
impl CacheSettings {
#[must_use]
pub const fn with_capacity(capacity: usize) -> Self {
Self { capacity }
}
}
impl From<Url> for RedfishEndpoint {
fn from(url: Url) -> Self {
Self::new(url)
}
}
impl From<&RedfishEndpoint> for Url {
fn from(endpoint: &RedfishEndpoint) -> Self {
endpoint.base_url.clone()
}
}
pub trait CacheableError {
fn is_cached(&self) -> bool;
fn cache_miss() -> Self;
fn cache_error(reason: String) -> Self;
}
pub trait RequestError {
fn rejected_uri_reference(error: RejectedUriReferenceError) -> Self;
}
impl<C: HttpClient> HttpBmc<C>
where
C::Error: CacheableError + RequestError + StdError + Send + Sync,
{
#[allow(clippy::panic)] fn read_credentials(&self) -> Arc<BmcCredentials> {
self.credentials
.read()
.map(|credentials| Arc::clone(&credentials))
.expect("lock poisoned")
}
#[allow(clippy::significant_drop_tightening)]
async fn get_with_cache<T: EntityTypeRef + for<'de> Deserialize<'de> + 'static>(
&self,
endpoint_url: Url,
) -> Result<Arc<T>, C::Error> {
let cache_key = endpoint_url.clone();
let etag = if self.cache_enabled {
let etags = self
.etags
.read()
.map_err(|e| C::Error::cache_error(e.to_string()))?;
etags.get(&cache_key).cloned()
} else {
None
};
let credentials = self.read_credentials();
match self
.client
.get::<T>(
endpoint_url,
credentials.as_ref(),
etag,
&self.custom_headers,
)
.await
{
Ok(response) if !self.cache_enabled => {
Ok(Arc::new(response))
}
Ok(response) => {
let entity = Arc::new(response);
if let Some(etag) = entity.etag() {
let mut cache = self
.cache
.write()
.map_err(|e| C::Error::cache_error(e.to_string()))?;
let mut etags = self
.etags
.write()
.map_err(|e| C::Error::cache_error(e.to_string()))?;
if let Some(evicted_url) =
cache.put_typed(cache_key.clone(), Arc::clone(&entity))
{
etags.remove(&evicted_url);
}
etags.insert(cache_key.clone(), etag.clone());
}
Ok(entity)
}
Err(e) => {
if e.is_cached() {
let mut cache = self
.cache
.write()
.map_err(|e| C::Error::cache_error(e.to_string()))?;
cache
.get_typed::<Arc<T>>(&cache_key)
.cloned()
.ok_or_else(C::Error::cache_miss)
} else {
Err(e)
}
}
}
}
}
impl<C: HttpClient> Bmc for HttpBmc<C>
where
C::Error: CacheableError + RequestError + StdError + Send + Sync,
{
type Error = C::Error;
async fn get<T: EntityTypeRef + for<'de> Deserialize<'de> + 'static>(
&self,
id: &ODataId,
) -> Result<Arc<T>, Self::Error> {
let endpoint_url = self.redfish_endpoint.with_odata_id(id);
self.get_with_cache(endpoint_url).await
}
async fn expand<T: Expandable + 'static>(
&self,
id: &ODataId,
query: ExpandQuery,
) -> Result<Arc<T>, Self::Error> {
let endpoint_url = self
.redfish_endpoint
.with_odata_id_and_query(id, &query.to_query_string());
self.get_with_cache(endpoint_url).await
}
async fn create<V: Sync + Send + Serialize, R: Sync + Send + for<'de> Deserialize<'de>>(
&self,
id: &ODataId,
v: &V,
) -> Result<ModificationResponse<R>, Self::Error> {
let endpoint_url = self.redfish_endpoint.with_odata_id(id);
let credentials = self.read_credentials();
self.client
.post(endpoint_url, v, credentials.as_ref(), &self.custom_headers)
.await
}
async fn create_session<
V: Sync + Send + Serialize,
R: Sync + Send + for<'de> Deserialize<'de>,
>(
&self,
id: &ODataId,
v: &V,
) -> Result<SessionCreateResponse<R>, Self::Error> {
let endpoint_url = self.redfish_endpoint.with_odata_id(id);
self.client
.post_session(endpoint_url, v, &self.custom_headers)
.await
}
async fn update<V: Sync + Send + Serialize, R: Sync + Send + for<'de> Deserialize<'de>>(
&self,
id: &ODataId,
etag: Option<&ODataETag>,
v: &V,
) -> Result<ModificationResponse<R>, Self::Error> {
let endpoint_url = self.redfish_endpoint.with_odata_id(id);
let etag = etag
.cloned()
.unwrap_or_else(|| ODataETag::from(String::from("*")));
let credentials = self.read_credentials();
self.client
.patch(
endpoint_url,
etag,
v,
credentials.as_ref(),
&self.custom_headers,
)
.await
}
async fn delete<T: Sync + Send + for<'de> Deserialize<'de>>(
&self,
id: &ODataId,
) -> Result<ModificationResponse<T>, Self::Error> {
let endpoint_url = self.redfish_endpoint.with_odata_id(id);
let credentials = self.read_credentials();
self.client
.delete(endpoint_url, credentials.as_ref(), &self.custom_headers)
.await
}
async fn action<T: Send + Sync + Serialize, R: Send + Sync + for<'de> Deserialize<'de>>(
&self,
action: &Action<T, R>,
params: &T,
) -> Result<ModificationResponse<R>, Self::Error> {
let endpoint_url = self
.redfish_endpoint
.with_same_origin_uri_reference(UriReference(action.target.as_str()))
.map_err(C::Error::rejected_uri_reference)?;
let credentials = self.read_credentials();
self.client
.post(
endpoint_url,
params,
credentials.as_ref(),
&self.custom_headers,
)
.await
}
async fn multipart_update<U, V, R>(
&self,
uri: &str,
request: MultipartUpdateRequest<'_, U, V>,
) -> Result<ModificationResponse<R>, Self::Error>
where
U: UploadReader,
R: Send + Sync + for<'de> Deserialize<'de>,
V: Send + Sync + Serialize,
{
let endpoint_url = self
.redfish_endpoint
.with_same_origin_uri_reference(UriReference(uri))
.map_err(C::Error::rejected_uri_reference)?;
let credentials = self.read_credentials();
self.client
.post_multipart_update(
endpoint_url,
request,
credentials.as_ref(),
&self.custom_headers,
)
.await
}
#[cfg(feature = "update-service-deprecated")]
async fn http_push_uri_update<U, R>(
&self,
uri: &str,
request: HttpPushUriUpdateRequest<U>,
) -> Result<ModificationResponse<R>, Self::Error>
where
U: UploadReader,
R: Send + Sync + for<'de> Deserialize<'de>,
{
let endpoint_url = self
.redfish_endpoint
.with_same_origin_uri_reference(UriReference(uri))
.map_err(C::Error::rejected_uri_reference)?;
let credentials = self.read_credentials();
self.client
.post_http_push_uri_update(
endpoint_url,
request,
credentials.as_ref(),
&self.custom_headers,
)
.await
}
async fn filter<T: EntityTypeRef + for<'de> Deserialize<'de> + 'static>(
&self,
id: &ODataId,
query: FilterQuery,
) -> Result<Arc<T>, Self::Error> {
let endpoint_url = self
.redfish_endpoint
.with_odata_id_and_query(id, &query.to_query_string());
self.get_with_cache(endpoint_url).await
}
async fn stream<T: Send + Sized + for<'de> Deserialize<'de>>(
&self,
uri: &str,
) -> Result<BoxTryStream<T, Self::Error>, Self::Error> {
let endpoint_url = self
.redfish_endpoint
.with_same_origin_uri_reference(UriReference(uri))
.map_err(C::Error::rejected_uri_reference)?;
let credentials = self.read_credentials();
self.client
.sse(endpoint_url, credentials.as_ref(), &self.custom_headers)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error;
#[test]
fn same_origin_uri_reference_matches_documented_examples() -> Result<(), Box<dyn Error>> {
let endpoint = RedfishEndpoint::new(Url::parse("https://bmc.example")?);
let cases = [
(
"https://bmc.example/redfish/v1/Actions/Reset",
"https://bmc.example/redfish/v1/Actions/Reset",
),
(
"//bmc.example/redfish/v1/EventService/SSE",
"https://bmc.example/redfish/v1/EventService/SSE",
),
(
"/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
"https://bmc.example/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
),
(
"redfish/v1/UpdateService/upload",
"https://bmc.example/redfish/v1/UpdateService/upload",
),
];
for (uri, expected) in cases {
assert_eq!(
endpoint
.with_same_origin_uri_reference(UriReference(uri))?
.as_str(),
expected,
"{uri}"
);
}
Ok(())
}
#[test]
fn odata_id_query_is_preserved_as_url_query() -> Result<(), Box<dyn Error>> {
let endpoint = RedfishEndpoint::new(Url::parse("https://bmc.example")?);
let id =
ODataId::from("/redfish/v1/TaskService/Tasks/42?token=a%2Fb&state=ready".to_string());
let resolved = endpoint.with_odata_id(&id);
assert_eq!(resolved.path(), "/redfish/v1/TaskService/Tasks/42");
assert_eq!(resolved.query(), Some("token=a%2Fb&state=ready"));
Ok(())
}
#[test]
fn odata_id_query_is_combined_with_request_query() -> Result<(), Box<dyn Error>> {
let endpoint = RedfishEndpoint::new(Url::parse("https://bmc.example")?);
let id = ODataId::from("/redfish/v1/Systems?$skiptoken=abc".to_string());
let resolved = endpoint.with_odata_id_and_query(&id, "$filter=value%20gt%2010");
assert_eq!(resolved.path(), "/redfish/v1/Systems");
assert_eq!(
resolved.query(),
Some("$skiptoken=abc&$filter=value%20gt%2010")
);
Ok(())
}
#[test]
fn uri_reference_relative_path_follows_base_path() -> Result<(), Box<dyn Error>> {
let endpoint = RedfishEndpoint::new(Url::parse("https://bmc.example/proxy/")?);
let resolved = endpoint
.with_same_origin_uri_reference(UriReference("redfish/v1/UpdateService/upload"))?;
assert_eq!(
resolved.as_str(),
"https://bmc.example/proxy/redfish/v1/UpdateService/upload"
);
Ok(())
}
#[test]
fn rejects_prefix_lookalike_uri_reference() -> Result<(), Box<dyn Error>> {
let endpoint = RedfishEndpoint::new(Url::parse("https://bmc.example")?);
let result = endpoint.with_same_origin_uri_reference(UriReference(
"https://bmc.example.evil/redfish/v1/Actions/Reset",
));
let error = result.expect_err("expected cross-origin URI reference error");
assert!(error.reason.contains("not same-origin"));
Ok(())
}
#[test]
fn rejects_malformed_authority_uri_reference() -> Result<(), Box<dyn Error>> {
let endpoint = RedfishEndpoint::new(Url::parse("https://bmc.example")?);
for uri in ["//[:::1]/path", "//host:99999/path"] {
let result = endpoint.with_same_origin_uri_reference(UriReference(uri));
let error = result.expect_err("expected malformed URI reference error");
assert!(error.reason.contains("could not be resolved"));
}
Ok(())
}
}