ironshield-types 0.1.27

Standardized Types, Headers, & Methods for IronShield Bindings
Documentation
use serde::{Deserialize, Serialize};
use crate::IronShieldChallenge;

/// IronShield Challenge Response structure
/// 
/// * `solved_challenge`: The complete original IronShieldChallenge that was solved.
/// * `solution`:         The nonce solution found by the proof-of-work algorithm.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IronShieldChallengeResponse {
    pub solved_challenge: IronShieldChallenge,
    pub solution:         i64,
}

impl IronShieldChallengeResponse {
    /// Constructor for creating a new `IronShieldChallengeResponse` instance.
    /// 
    /// # Arguments
    /// * `solved_challenge`: The solved ironshield challenge.
    /// * `solution`:         The random nonce that correlates with `solved_challenge`.
    /// 
    /// # Returns
    /// * `Self`:             A new correlating response with the challenge and its 
    ///                       deciphered nonce. 
    pub fn new(
        solved_challenge: IronShieldChallenge, 
        solution: i64
    ) -> Self {
        Self {
            solved_challenge,
            solution,
        }
    }

    /// Concatenates the response data into a string.
    ///
    /// Concatenates:
    /// - `solved_challenge`: As its concatenated string representation.
    /// - `solution`:         As a string.
    pub fn concat_struct(&self) -> String {
        format!(
            "{}|{}",
            self.solved_challenge.concat_struct(),
            self.solution
        )
    }

    /// Creates an `IronShieldChallengeResponse` from a concatenated string.
    ///
    /// This function reverses the operation of
    /// `IronShieldChallengeResponse::concat_struct`.
    /// Expects a string in the format: "challenge_concat_string|solution".
    ///
    /// # Arguments
    /// * `concat_string`: The concatenated string to parse, typically
    ///                    generated by `concat_struct()`.
    ///
    /// # Returns
    /// * `Result<Self, String>`: A result containing the parsed 
    ///                           `IronShieldChallengeResponse`
    ///                           or an error message if parsing fails.
    pub fn from_concat_struct(concat_string: &str) -> Result<Self, String> {
        // Split on the last '|' to separate challenge from solution
        let last_pipe_pos = concat_string.rfind('|')
            .ok_or("Expected at least one '|' separator")?;
        
        let challenge_part = &concat_string[..last_pipe_pos];
        let solution_part = &concat_string[last_pipe_pos + 1..];
        
        let solved_challenge = IronShieldChallenge::from_concat_struct(challenge_part)?;
        let solution = solution_part.parse::<i64>()
            .map_err(|_| "Failed to parse solution as i64")?;

        Ok(Self {
            solved_challenge,
            solution,
        })
    }

    /// Encodes the response as a base64url string for HTTP header transport.
    /// 
    /// This method concatenates all response fields using the established `|` delimiter
    /// format, and then base64url-encodes the result for safe transport in HTTP headers.
    /// 
    /// # Returns
    /// * `String` - Base64url-encoded string ready for HTTP header use
    /// 
    /// # Example
    /// ```
    /// use ironshield_types::{IronShieldChallengeResponse, IronShieldChallenge, SigningKey};
    /// let dummy_key = SigningKey::from_bytes(&[0u8; 32]);
    /// let challenge = IronShieldChallenge::new("test".to_string(), [0x12; 32], dummy_key, [0x34; 32]);
    /// let response = IronShieldChallengeResponse::new(challenge, 12345);
    /// let header_value = response.to_base64url_header();
    /// // Use header_value in HTTP header: "X-IronShield-Challenge-Response: {header_value}"
    /// ```
    pub fn to_base64url_header(&self) -> String {
        crate::serde_utils::concat_struct_base64url_encode(&self.concat_struct())
    }
    
    /// Decodes a base64url-encoded response from an HTTP header.
    /// 
    /// This method reverses the `to_base64url_header()` operation by first base64url-decoding
    /// the input string and then parsing it using the established `|` delimiter format.
    /// 
    /// # Arguments
    /// * `encoded_header` - The base64url-encoded string from the HTTP header
    /// 
    /// # Returns
    /// * `Result<Self, String>` - Decoded response or detailed error message
    /// 
    /// # Example
    /// ```
    /// use ironshield_types::{IronShieldChallengeResponse, IronShieldChallenge, SigningKey};
    /// let dummy_key = SigningKey::from_bytes(&[0u8; 32]);
    /// let challenge = IronShieldChallenge::new("test".to_string(), [0x12; 32], dummy_key, [0x34; 32]);
    /// // Create a response and encode it
    /// let original = IronShieldChallengeResponse::new(challenge, 12345);
    /// let header_value = original.to_base64url_header();
    /// // Decode it back
    /// let decoded = IronShieldChallengeResponse::from_base64url_header(&header_value).unwrap();
    /// assert_eq!(original.solution, decoded.solution);
    /// ```
    pub fn from_base64url_header(encoded_header: &str) -> Result<Self, String> {
        // Decode using the existing serde_utils function.
        let concat_str: String = crate::serde_utils::concat_struct_base64url_decode(encoded_header.to_string())?;
        
        // Parse using the existing concat_struct format.
        Self::from_concat_struct(&concat_str)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::SigningKey;

    #[test]
    fn test_response_base64url_header_encoding_roundtrip() {
        // Create a test challenge and response.
        let dummy_key = SigningKey::from_bytes(&[0u8; 32]);
        let challenge = IronShieldChallenge::new(
            "test_website".to_string(),
            [0x12; 32],
            dummy_key,
            [0x34; 32],
        );
        let response: IronShieldChallengeResponse = IronShieldChallengeResponse::new(challenge, 12345);

        // Test base64url encoding and decoding.
        let encoded: String = response.to_base64url_header();
        let decoded: IronShieldChallengeResponse = IronShieldChallengeResponse::from_base64url_header(&encoded).unwrap();

        // Verify all fields are preserved through a round-trip.
        assert_eq!(response.solved_challenge.random_nonce, decoded.solved_challenge.random_nonce);
        assert_eq!(response.solved_challenge.website_id, decoded.solved_challenge.website_id);
        assert_eq!(response.solved_challenge.challenge_param, decoded.solved_challenge.challenge_param);
        assert_eq!(response.solved_challenge.public_key, decoded.solved_challenge.public_key);
        assert_eq!(response.solved_challenge.challenge_signature, decoded.solved_challenge.challenge_signature);
        assert_eq!(response.solution, decoded.solution);
    }

    #[test]
    fn test_response_base64url_header_invalid_data() {
        // Test invalid base64url.
        let result: Result<IronShieldChallengeResponse, String> = IronShieldChallengeResponse::from_base64url_header("invalid-base64!");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Base64 decode error"));

        // Test valid base64url but invalid concatenated format.
        use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
        let invalid_format: String = URL_SAFE_NO_PAD.encode(b"only_one_part");
        let result: Result<IronShieldChallengeResponse, String> = IronShieldChallengeResponse::from_base64url_header(&invalid_format);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Expected at least one '|' separator"));
    }

    #[test]
    fn test_concat_struct() {
        let dummy_key = SigningKey::from_bytes(&[0u8; 32]);
        let challenge = IronShieldChallenge::new(
            "test_website".to_string(),
            [0x12; 32],
            dummy_key,
            [0x34; 32],
        );
        let response = IronShieldChallengeResponse::new(challenge.clone(), 42);
        let concat = response.concat_struct();
        let expected = format!("{}|{}", challenge.concat_struct(), 42);
        assert_eq!(concat, expected);
    }

    #[test]
    fn test_from_concat_struct() {
        let dummy_key = SigningKey::from_bytes(&[0u8; 32]);
        let challenge = IronShieldChallenge::new(
            "test_website".to_string(),
            [0x12; 32],
            dummy_key,
            [0x34; 32],
        );
        let concat = format!("{}|{}", challenge.concat_struct(), 42);
        let response = IronShieldChallengeResponse::from_concat_struct(&concat).unwrap();
        assert_eq!(response.solved_challenge.website_id, challenge.website_id);
        assert_eq!(response.solved_challenge.challenge_param, challenge.challenge_param);
        assert_eq!(response.solution, 42);
    }

    #[test]
    fn test_from_concat_struct_edge_cases() {
        let dummy_key = SigningKey::from_bytes(&[0u8; 32]);
        let challenge = IronShieldChallenge::new(
            "test_website".to_string(),
            [0xFF; 32],
            dummy_key,
            [0x00; 32],
        );
        
        // Test with negative solution
        let concat = format!("{}|{}", challenge.concat_struct(), -1);
        let result = IronShieldChallengeResponse::from_concat_struct(&concat);
        assert!(result.is_ok());
        let parsed = result.unwrap();
        assert_eq!(parsed.solution, -1);
        
        // Test with zero solution  
        let concat = format!("{}|{}", challenge.concat_struct(), 0);
        let result = IronShieldChallengeResponse::from_concat_struct(&concat);
        assert!(result.is_ok());
        let parsed = result.unwrap();
        assert_eq!(parsed.solution, 0);
        
        // Test with large solution
        let concat = format!("{}|{}", challenge.concat_struct(), i64::MAX);
        let result = IronShieldChallengeResponse::from_concat_struct(&concat);
        assert!(result.is_ok());
        let parsed = result.unwrap();
        assert_eq!(parsed.solution, i64::MAX);
    }

    #[test]
    fn test_from_concat_struct_error_cases() {
        // Test with no pipe separator
        let result = IronShieldChallengeResponse::from_concat_struct("no_pipe_separator");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Expected at least one '|' separator"));
        
        // Test with invalid solution
        let dummy_key = SigningKey::from_bytes(&[0u8; 32]);
        let challenge = IronShieldChallenge::new(
            "test_website".to_string(),
            [0xFF; 32],
            dummy_key,
            [0x00; 32],
        );
        let concat = format!("{}|{}", challenge.concat_struct(), "not_a_number");
        let result = IronShieldChallengeResponse::from_concat_struct(&concat);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Failed to parse solution as i64"));
    }
}