product-os-command-control 0.0.29

Product OS : Command and Control provides a set of tools for running command and control across a distributed set of Product OS : Servers.
Documentation
//! Command execution module
//!
//! Provides structures and functions for executing commands and queries
//! on remote Product OS nodes.

use std::prelude::v1::*;

use std::collections::BTreeMap;
use product_os_request::{ProductOSClient, ProductOSResponse};

use crate::registry::Node;


/// Command structure for executing commands on remote nodes.
///
/// This struct holds all the necessary information to execute a command
/// on a remote Product OS node, including authentication and target details.
///
/// # Examples
///
/// ```no_run
/// # use product_os_command_control::commands::Command;
/// # use product_os_request::{ProductOSRequestClient, Uri};
/// # use std::str::FromStr;
/// let command = Command {
///     requester: ProductOSRequestClient::new(),
///     node_url: Uri::from_str("https://localhost:8443").unwrap(),
///     verify_key: vec![1, 2, 3, 4],
///     module: "status".to_string(),
///     instruction: "ping".to_string(),
///     data: None,
/// };
/// ```
pub struct Command {
    /// HTTP client for making requests
    pub requester: product_os_request::ProductOSRequestClient,
    /// Target node URL
    pub node_url: product_os_request::Uri,
    /// Verification key for authentication
    pub verify_key: Vec<u8>,
    /// Module name to execute
    pub module: String,
    /// Instruction/command name
    pub instruction: String,
    /// Optional data payload
    pub data: Option<serde_json::Value>
}

impl Command {
    /// Execute this command on the target node.
    ///
    /// Sends the command to the remote node and returns the response.
    ///
    /// # Errors
    ///
    /// Returns an error if the request fails or the node cannot be reached.
    pub async fn command(&self) -> Result<ProductOSResponse<product_os_request::BodyBytes>, product_os_request::ProductOSRequestError> {
        command(&self.requester, self.node_url.to_owned(), self.verify_key.to_owned(), self.module.as_str(), self.instruction.as_str(), self.data.to_owned()).await
    }
}



/// Ask structure for querying remote nodes.
///
/// Similar to Command but for general HTTP requests to remote nodes.
///
/// # Examples
///
/// ```no_run
/// # use product_os_command_control::commands::Ask;
/// # use product_os_request::{ProductOSRequestClient, Uri, Method};
/// # use std::str::FromStr;
/// # use std::collections::BTreeMap;
/// let ask = Ask {
///     requester: ProductOSRequestClient::new(),
///     node_url: Uri::from_str("https://localhost:8443").unwrap(),
///     verify_key: vec![1, 2, 3, 4],
///     path: "/api/status".to_string(),
///     data: None,
///     headers: BTreeMap::new(),
///     params: BTreeMap::new(),
///     method: Method::GET,
/// };
/// ```
pub struct Ask {
    /// HTTP client for making requests
    pub requester: product_os_request::ProductOSRequestClient,
    /// Target node URL
    pub node_url: product_os_request::Uri,
    /// Verification key for authentication
    pub verify_key: Vec<u8>,
    /// Request path
    pub path: String,
    /// Optional request data
    pub data: Option<serde_json::Value>,
    /// HTTP headers
    pub headers: BTreeMap<String, String>,
    /// Query parameters
    pub params: BTreeMap<String, String>,
    /// HTTP method
    pub method: product_os_request::Method
}

impl Ask {
    /// Execute this query on the target node.
    ///
    /// Sends the HTTP request to the remote node and returns the response.
    ///
    /// # Errors
    ///
    /// Returns an error if the request fails or the node cannot be reached.
    pub async fn ask(&self) -> Result<ProductOSResponse<product_os_request::BodyBytes>, product_os_request::ProductOSRequestError> {
        ask(&self.requester, &self.node_url, self.verify_key.as_slice(), self.path.as_str(), &self.data, &self.headers, &self.params, &self.method).await
    }
}



/// Execute a command on a remote node.
///
/// Sends a command to the specified node URL with authentication.
///
/// # Arguments
///
/// * `requester` - HTTP client for making the request
/// * `uri` - Target node URI
/// * `verify_key` - Authentication key
/// * `module` - Module name
/// * `instruction` - Instruction/command name
/// * `data` - Optional JSON data payload
///
/// # Errors
///
/// Returns an error if the request fails or authentication is rejected.
pub async fn command(requester: &product_os_request::ProductOSRequestClient, uri: product_os_request::Uri, verify_key: Vec<u8>,
                     module: &str, instruction: &str, data: Option<serde_json::Value>) -> Result<ProductOSResponse<product_os_request::BodyBytes>, product_os_request::ProductOSRequestError> {
    let endpoint = String::from(uri.to_string().trim_end_matches("/")) +
        "/command/" + module + "/" + instruction;

    let mut request = requester.new_request(product_os_request::Method::POST, endpoint.as_str());

    match &data {
        None => {}
        Some(data) => {
            requester.set_body_json(&mut request, data.to_owned()).await;
        }
    }

    request.add_header("x-product-os-verify",
                       product_os_security::create_auth_request(None, false, data,
                                                                   None, &[], Some(verify_key.as_slice())).as_str(),
                       true);

    match requester.request(request).await {
        Ok(response) => {
            tracing::trace!("Successfully sent {:?}/{:?} command to server {:?}", module, instruction, uri);
            Ok(response)
        },
        Err(e) => {
            tracing::error!("Error encountered {:?} from {}", e, uri);
            Err(product_os_request::ProductOSRequestError::Error(format!("No matching node found: {:?}", e)))
        }
    }
}

/// Execute a query on a specific node.
///
/// Helper function that wraps `ask` with node-specific details.
///
/// # Arguments
///
/// * `requester` - HTTP client
/// * `node` - Target node
/// * `verify_key` - Authentication key
/// * `path` - Request path
/// * `data` - Optional JSON data
/// * `headers` - HTTP headers
/// * `params` - Query parameters
/// * `method` - HTTP method
///
/// # Errors
///
/// Returns an error if the request fails.
pub async fn ask_node(requester: &product_os_request::ProductOSRequestClient, node: &Node, verify_key: &[u8],
       path: &str, data: &Option<serde_json::Value>, headers: &BTreeMap<String, String>,
       params: &BTreeMap<String, String>, method: &product_os_request::Method) -> Result<ProductOSResponse<product_os_request::BodyBytes>, product_os_request::ProductOSRequestError> {
    #[allow(deprecated)]
    let uri = node.get_address();

    ask(requester, &uri, verify_key, path, data, headers, params, method).await
}

/// Send an HTTP request to a remote node.
///
/// General-purpose function for making authenticated requests to Product OS nodes.
///
/// # Arguments
///
/// * `requester` - HTTP client
/// * `url` - Target URL
/// * `verify_key` - Authentication key
/// * `path` - Request path
/// * `data` - Optional JSON data
/// * `headers` - HTTP headers
/// * `params` - Query parameters
/// * `method` - HTTP method
///
/// # Errors
///
/// Returns an error if the request fails or the path is invalid.
pub async fn ask(requester: &product_os_request::ProductOSRequestClient, url: &product_os_request::Uri, verify_key: &[u8],
                 path: &str, data: &Option<serde_json::Value>, headers: &BTreeMap<String, String>,
                 params: &BTreeMap<String, String>, method: &product_os_request::Method) -> Result<ProductOSResponse<product_os_request::BodyBytes>, product_os_request::ProductOSRequestError> {
    let path_clean = clean_path(path, false);
    let endpoint = String::from(url.to_string().trim_end_matches("/")) +
        path_clean.as_str();

    let mut request = requester.new_request(method.to_owned(), endpoint.as_str());

    request.add_headers(headers.to_owned(), false);
    request.add_params(params.to_owned());

    match &data {
        None => {}
        Some(data) => {
            requester.set_body_json(&mut request, data.to_owned()).await;
        }
    }

    request.add_header("x-product-os-verify",
                       product_os_security::create_auth_request(None, false, data.to_owned(),
                                                                   None, &[], Some(verify_key)).as_str(),
                       true);

    match requester.request(request).await {
        Ok(response) => {
            tracing::trace!("Successfully sent ask {:?} command to server {:?}", path_clean, url);
            Ok(response)
        },
        Err(e) => {
            tracing::error!("Error encountered {:?} from {}", e, url);
            Err(product_os_request::ProductOSRequestError::Error(format!("No matching node found: {:?}", e)))
        }
    }
}

/// Sanitises a URL path by collapsing double slashes, removing path traversal
/// sequences, and stripping trailing slashes.
///
/// Ensures the path starts with `/`. If `add_back_slash` is true, ensures it ends with `/`.
pub(crate) fn clean_path(input: &str, add_back_slash: bool) -> String {
    let mut path = input.to_string();

    if path != "/" {
        while path.contains("//") {
            path = path.replace("//", "/");
        }
        path = path.trim_end_matches('/').to_string();
        if path.is_empty() {
            path = "/".to_string();
        }
    }

    if !path.starts_with('/') {
        path = format!("/{}", path);
    }

    if add_back_slash && !path.ends_with('/') {
        path.push('/');
    }

    path.replace("..", "")
}

#[cfg(test)]
mod tests {
    use super::clean_path;

    #[test]
    fn test_clean_path_normal() {
        assert_eq!(clean_path("/api/test", false), "/api/test");
    }

    #[test]
    fn test_clean_path_root() {
        assert_eq!(clean_path("/", false), "/");
    }

    #[test]
    fn test_clean_path_double_slashes() {
        let result = clean_path("//api//test", false);
        assert!(!result.contains("//"));
    }

    #[test]
    fn test_clean_path_trailing_slash() {
        assert_eq!(clean_path("/api/test/", false), "/api/test");
    }

    #[test]
    fn test_clean_path_no_leading_slash() {
        let result = clean_path("api/test", false);
        assert!(result.starts_with('/'));
    }

    #[test]
    fn test_clean_path_traversal() {
        let result = clean_path("/../../../etc/passwd", false);
        assert!(!result.contains(".."));
    }

    #[test]
    fn test_clean_path_add_back_slash() {
        let result = clean_path("/api/test", true);
        assert!(result.ends_with('/'));
    }

    #[test]
    fn test_clean_path_add_back_slash_already_present() {
        let result = clean_path("/", true);
        assert_eq!(result, "/");
    }

    #[test]
    fn test_clean_path_empty_string() {
        let result = clean_path("", false);
        assert!(result.starts_with('/'));
    }
}