lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

status.rs (11892B)


      1 //! Signer progress and status models.
      2 
      3 use core::fmt;
      4 
      5 #[cfg(not(feature = "std"))]
      6 use alloc::{string::String, vec::Vec};
      7 #[cfg(feature = "std")]
      8 use std::{string::String, vec::Vec};
      9 
     10 use crate::{Error, capability::SignerCapability, error::Kind};
     11 
     12 const MAX_AUTH_URI_BYTES: usize = 2_048;
     13 
     14 /// A remote authentication interaction required to continue signing.
     15 #[non_exhaustive]
     16 #[cfg_attr(feature = "serde", derive(serde::Serialize))]
     17 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
     18 #[derive(Clone, PartialEq, Eq)]
     19 pub struct AuthChallenge {
     20     uri: String,
     21     required_at_unix: u64,
     22     expires_at_unix: Option<u64>,
     23 }
     24 
     25 impl AuthChallenge {
     26     /// Creates a bounded HTTPS authentication challenge.
     27     pub fn new(
     28         uri: impl Into<String>,
     29         required_at_unix: u64,
     30         expires_at_unix: Option<u64>,
     31     ) -> Result<Self, Error> {
     32         let uri = uri.into();
     33         if uri.len() > MAX_AUTH_URI_BYTES
     34             || uri.trim() != uri
     35             || !uri.starts_with("https://")
     36             || uri.chars().any(char::is_control)
     37         {
     38             return Err(Error::new(Kind::InvalidArgument));
     39         }
     40         if let Some(expires_at_unix) = expires_at_unix
     41             && expires_at_unix < required_at_unix
     42         {
     43             return Err(Error::new(Kind::InvalidArgument));
     44         }
     45         Ok(Self {
     46             uri,
     47             required_at_unix,
     48             expires_at_unix,
     49         })
     50     }
     51 
     52     /// Borrows the host-displayable authentication URI.
     53     #[must_use]
     54     pub fn uri(&self) -> &str {
     55         self.uri.as_str()
     56     }
     57 
     58     /// Returns when the challenge became required.
     59     #[must_use]
     60     pub const fn required_at_unix(&self) -> u64 {
     61         self.required_at_unix
     62     }
     63 
     64     /// Returns the optional absolute challenge expiry.
     65     #[must_use]
     66     pub const fn expires_at_unix(&self) -> Option<u64> {
     67         self.expires_at_unix
     68     }
     69 }
     70 
     71 impl fmt::Debug for AuthChallenge {
     72     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     73         formatter
     74             .debug_struct("AuthChallenge")
     75             .field("uri", &"[redacted]")
     76             .field("required_at_unix", &self.required_at_unix)
     77             .field("expires_at_unix", &self.expires_at_unix)
     78             .finish()
     79     }
     80 }
     81 
     82 #[cfg(feature = "serde")]
     83 impl<'de> serde::Deserialize<'de> for AuthChallenge {
     84     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
     85     where
     86         D: serde::Deserializer<'de>,
     87     {
     88         #[derive(serde::Deserialize)]
     89         #[serde(deny_unknown_fields)]
     90         struct Repr {
     91             uri: String,
     92             required_at_unix: u64,
     93             expires_at_unix: Option<u64>,
     94         }
     95 
     96         let value = Repr::deserialize(deserializer)?;
     97         Self::new(value.uri, value.required_at_unix, value.expires_at_unix)
     98             .map_err(serde::de::Error::custom)
     99     }
    100 }
    101 
    102 /// Stable signing progress stages.
    103 #[non_exhaustive]
    104 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    105 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    106 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    107 pub enum SignProgressStage {
    108     Queued,
    109     Validating,
    110     AwaitingAuthentication,
    111     RequestPublished,
    112     AwaitingSignature,
    113     VerifyingOutput,
    114     Complete,
    115 }
    116 
    117 /// One immutable signer progress update.
    118 #[non_exhaustive]
    119 #[cfg_attr(feature = "serde", derive(serde::Serialize))]
    120 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
    121 #[derive(Clone, Debug, PartialEq, Eq)]
    122 pub struct SignProgress {
    123     stage: SignProgressStage,
    124     challenge: Option<AuthChallenge>,
    125 }
    126 
    127 impl SignProgress {
    128     /// Creates a progress update without an authentication challenge.
    129     pub const fn stage(stage: SignProgressStage) -> Result<Self, Error> {
    130         if matches!(stage, SignProgressStage::AwaitingAuthentication) {
    131             return Err(Error::new(Kind::InvalidArgument));
    132         }
    133         Ok(Self {
    134             stage,
    135             challenge: None,
    136         })
    137     }
    138 
    139     /// Creates an explicit authentication-challenge update.
    140     #[must_use]
    141     pub const fn authentication(challenge: AuthChallenge) -> Self {
    142         Self {
    143             stage: SignProgressStage::AwaitingAuthentication,
    144             challenge: Some(challenge),
    145         }
    146     }
    147 
    148     /// Returns the stable progress stage.
    149     #[must_use]
    150     pub const fn stage_value(&self) -> SignProgressStage {
    151         self.stage
    152     }
    153 
    154     /// Borrows the authentication challenge, when present.
    155     #[must_use]
    156     pub const fn challenge(&self) -> Option<&AuthChallenge> {
    157         self.challenge.as_ref()
    158     }
    159 }
    160 
    161 #[cfg(feature = "serde")]
    162 impl<'de> serde::Deserialize<'de> for SignProgress {
    163     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    164     where
    165         D: serde::Deserializer<'de>,
    166     {
    167         #[derive(serde::Deserialize)]
    168         #[serde(deny_unknown_fields)]
    169         struct Repr {
    170             stage: SignProgressStage,
    171             challenge: Option<AuthChallenge>,
    172         }
    173 
    174         let value = Repr::deserialize(deserializer)?;
    175         match (value.stage, value.challenge) {
    176             (SignProgressStage::AwaitingAuthentication, Some(challenge)) => {
    177                 Ok(Self::authentication(challenge))
    178             }
    179             (SignProgressStage::AwaitingAuthentication, None) => {
    180                 Err(serde::de::Error::custom(Error::new(Kind::InvalidArgument)))
    181             }
    182             (_, Some(_)) => Err(serde::de::Error::custom(Error::new(Kind::InvalidArgument))),
    183             (stage, None) => Self::stage(stage).map_err(serde::de::Error::custom),
    184         }
    185     }
    186 }
    187 
    188 /// Current signer availability.
    189 #[non_exhaustive]
    190 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    191 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    192 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    193 pub enum SignerAvailability {
    194     Ready,
    195     Busy,
    196     AwaitingAuthentication,
    197     Unavailable,
    198 }
    199 
    200 /// Current signer availability, capabilities, and optional progress.
    201 #[non_exhaustive]
    202 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    203 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
    204 #[derive(Clone, Debug, PartialEq, Eq)]
    205 pub struct SignerStatus {
    206     availability: SignerAvailability,
    207     capabilities: Vec<SignerCapability>,
    208     progress: Option<SignProgress>,
    209 }
    210 
    211 impl SignerStatus {
    212     /// Creates an explicit status snapshot.
    213     #[must_use]
    214     pub fn new(
    215         availability: SignerAvailability,
    216         capabilities: Vec<SignerCapability>,
    217         progress: Option<SignProgress>,
    218     ) -> Self {
    219         Self {
    220             availability,
    221             capabilities,
    222             progress,
    223         }
    224     }
    225 
    226     /// Creates an unavailable status without claiming capabilities.
    227     #[must_use]
    228     pub const fn unavailable() -> Self {
    229         Self {
    230             availability: SignerAvailability::Unavailable,
    231             capabilities: Vec::new(),
    232             progress: None,
    233         }
    234     }
    235 
    236     #[must_use]
    237     pub const fn availability(&self) -> SignerAvailability {
    238         self.availability
    239     }
    240 
    241     #[must_use]
    242     pub fn capabilities(&self) -> &[SignerCapability] {
    243         &self.capabilities
    244     }
    245 
    246     #[must_use]
    247     pub const fn progress(&self) -> Option<&SignProgress> {
    248         self.progress.as_ref()
    249     }
    250 }
    251 
    252 #[cfg(test)]
    253 mod tests {
    254     use super::*;
    255     #[cfg(feature = "serde")]
    256     use crate::capability::{CancellationSupport, SignerKind};
    257     #[cfg(feature = "serde")]
    258     use crate::recovery::ReplayCapability;
    259 
    260     #[cfg(not(feature = "std"))]
    261     use alloc::format;
    262     #[cfg(all(not(feature = "std"), feature = "serde"))]
    263     use alloc::vec;
    264 
    265     #[test]
    266     fn challenge_validation_and_debug_redaction_are_explicit() {
    267         let challenge =
    268             AuthChallenge::new("https://auth.example/approve?token=sensitive", 10, Some(20))
    269                 .expect("challenge");
    270 
    271         assert_eq!(challenge.required_at_unix(), 10);
    272         assert_eq!(
    273             challenge.uri(),
    274             "https://auth.example/approve?token=sensitive"
    275         );
    276         assert_eq!(challenge.expires_at_unix(), Some(20));
    277         assert!(!format!("{challenge:?}").contains("sensitive"));
    278         assert_eq!(
    279             AuthChallenge::new("http://auth.example", 10, None)
    280                 .expect_err("HTTP challenge must fail")
    281                 .kind(),
    282             Kind::InvalidArgument
    283         );
    284         assert_eq!(
    285             AuthChallenge::new("https://auth.example", 20, Some(10))
    286                 .expect_err("invalid expiry must fail")
    287                 .kind(),
    288             Kind::InvalidArgument
    289         );
    290         for invalid in [
    291             " https://auth.example",
    292             "https://auth.example ",
    293             "https://auth.example/line\nbreak",
    294         ] {
    295             assert_eq!(
    296                 AuthChallenge::new(invalid, 10, None).unwrap_err().kind(),
    297                 Kind::InvalidArgument
    298             );
    299         }
    300         assert_eq!(
    301             AuthChallenge::new(
    302                 format!("https://auth.example/{}", "x".repeat(MAX_AUTH_URI_BYTES)),
    303                 10,
    304                 None
    305             )
    306             .unwrap_err()
    307             .kind(),
    308             Kind::InvalidArgument
    309         );
    310     }
    311 
    312     #[test]
    313     fn progress_requires_challenges_only_at_the_authentication_stage() {
    314         assert_eq!(
    315             SignProgress::stage(SignProgressStage::AwaitingAuthentication)
    316                 .expect_err("missing challenge must fail")
    317                 .kind(),
    318             Kind::InvalidArgument
    319         );
    320         let challenge =
    321             AuthChallenge::new("https://auth.example/approve", 10, None).expect("challenge");
    322         let progress = SignProgress::authentication(challenge);
    323         assert_eq!(
    324             progress.stage_value(),
    325             SignProgressStage::AwaitingAuthentication
    326         );
    327         assert!(progress.challenge().is_some());
    328         let queued = SignProgress::stage(SignProgressStage::Queued).unwrap();
    329         assert_eq!(queued.stage_value(), SignProgressStage::Queued);
    330         assert_eq!(queued.challenge(), None);
    331         let unavailable = SignerStatus::unavailable();
    332         assert_eq!(unavailable.availability(), SignerAvailability::Unavailable);
    333         assert!(unavailable.capabilities().is_empty());
    334         assert_eq!(unavailable.progress(), None);
    335     }
    336 
    337     #[cfg(feature = "serde")]
    338     #[test]
    339     fn status_round_trips_and_invalid_progress_fails_closed() {
    340         let capability = SignerCapability::new(
    341             SignerKind::Remote,
    342             ReplayCapability::ExactReplayByRequestId,
    343             CancellationSupport::BeforePublication,
    344             true,
    345             true,
    346         );
    347         let challenge =
    348             AuthChallenge::new("https://auth.example/approve", 10, Some(20)).expect("challenge");
    349         let status = SignerStatus::new(
    350             SignerAvailability::AwaitingAuthentication,
    351             vec![capability],
    352             Some(SignProgress::authentication(challenge)),
    353         );
    354         let encoded = serde_json::to_string(&status).expect("serialize status");
    355         let decoded: SignerStatus = serde_json::from_str(&encoded).expect("deserialize status");
    356 
    357         assert_eq!(decoded, status);
    358         assert!(
    359             serde_json::from_str::<SignProgress>(
    360                 r#"{"stage":"awaiting_authentication","challenge":null}"#
    361             )
    362             .is_err()
    363         );
    364         assert!(serde_json::from_str::<SignProgress>(
    365             r#"{"stage":"queued","challenge":{"uri":"https://auth.example","required_at_unix":1,"expires_at_unix":null}}"#
    366         )
    367         .is_err());
    368         assert!(
    369             serde_json::from_str::<AuthChallenge>(
    370                 r#"{"uri":"http://auth.example","required_at_unix":1,"expires_at_unix":null}"#
    371             )
    372             .is_err()
    373         );
    374         assert!(
    375             serde_json::from_str::<SignProgress>(r#"{"stage":"queued","challenge":null}"#).is_ok()
    376         );
    377     }
    378 }