lib

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

commit 0a594be27573167f6022716b12684d4c611badea
parent 636c661ed6818e46d348c10a5b029535d57bb4b7
Author: triesap <tyson@radroots.org>
Date:   Thu, 30 Jul 2026 10:56:17 +0000

signing: define signing requests, receipts, progress, and status

- bind operation actor frozen draft and bounded cancellation policy
- expose runtime-local progress with typed redacted authentication challenges
- model serializable capabilities status and signed-event receipts
- cover construction malformed wire and redaction across native and WASM

Diffstat:
MCargo.lock | 2++
Mcrates/signing/Cargo.toml | 8++++++++
Mcrates/signing/src/capability.rs | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/signing/src/receipt.rs | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/signing/src/request.rs | 261+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/signing/src/signer.rs | 7+++----
Mcrates/signing/src/status.rs | 369+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
7 files changed, 794 insertions(+), 20 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4959,6 +4959,8 @@ dependencies = [ "radroots_event", "radroots_identity", "radroots_protocol", + "serde", + "serde_json", ] [[package]] diff --git a/crates/signing/Cargo.toml b/crates/signing/Cargo.toml @@ -22,6 +22,7 @@ std = [ "radroots_protocol/std", ] serde = [ + "dep:serde", "radroots_event/serde", "radroots_identity/serde", "radroots_protocol/serde", @@ -31,6 +32,13 @@ serde = [ radroots_event = { workspace = true, default-features = false } radroots_identity = { workspace = true, default-features = false } radroots_protocol = { workspace = true, default-features = false } +serde = { workspace = true, default-features = false, features = [ + "alloc", + "derive", +], optional = true } + +[dev-dependencies] +serde_json = { workspace = true, features = ["std"] } [lints] workspace = true diff --git a/crates/signing/src/capability.rs b/crates/signing/src/capability.rs @@ -1 +1,104 @@ //! Signer capability declarations. + +/// How a signer implementation obtains signatures. +#[non_exhaustive] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SignerKind { + /// A local adapter performs signing without publishing a remote request. + Local, + /// A remote service or device performs signing. + Remote, + /// A composing host mediates signing through an explicit user interaction. + HostMediated, +} + +/// Cancellation behavior advertised by a signer. +#[non_exhaustive] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CancellationSupport { + /// Cancellation is observed only before a remote request is published. + BeforePublication, + /// Cancellation remains observable after publication, without implying + /// rollback of the already-published request. + BeforeAndAfterPublication, +} + +/// Portable signer behavior advertised to a composing host. +#[non_exhaustive] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SignerCapability { + kind: SignerKind, + cancellation: CancellationSupport, + reports_progress: bool, + may_require_authentication: bool, +} + +impl SignerCapability { + /// Creates an explicit capability declaration. + #[must_use] + pub const fn new( + kind: SignerKind, + cancellation: CancellationSupport, + reports_progress: bool, + may_require_authentication: bool, + ) -> Self { + Self { + kind, + cancellation, + reports_progress, + may_require_authentication, + } + } + + /// Returns the implementation kind. + #[must_use] + pub const fn kind(self) -> SignerKind { + self.kind + } + + /// Returns the advertised cancellation contract. + #[must_use] + pub const fn cancellation(self) -> CancellationSupport { + self.cancellation + } + + /// Reports whether the signer emits progress updates. + #[must_use] + pub const fn reports_progress(self) -> bool { + self.reports_progress + } + + /// Reports whether the signer may emit an authentication challenge. + #[must_use] + pub const fn may_require_authentication(self) -> bool { + self.may_require_authentication + } +} + +#[cfg(all(test, feature = "serde"))] +mod tests { + use super::*; + + #[test] + fn capability_round_trips_with_stable_wire_labels() { + let capability = SignerCapability::new( + SignerKind::Remote, + CancellationSupport::BeforeAndAfterPublication, + true, + true, + ); + let encoded = serde_json::to_string(&capability).expect("serialize capability"); + let decoded: SignerCapability = + serde_json::from_str(&encoded).expect("deserialize capability"); + + assert_eq!(decoded, capability); + assert!(encoded.contains("remote")); + assert!(encoded.contains("before_and_after_publication")); + } +} diff --git a/crates/signing/src/receipt.rs b/crates/signing/src/receipt.rs @@ -1,7 +1,61 @@ //! Signing receipts. -/// Opaque receipt vocabulary for the object-safe SPI. -/// -/// Step 102 defines the validated receipt contract before consumer migration. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct SignReceipt; +use core::fmt; +use radroots_event::SignedEvent; +use radroots_protocol::runtime::v1::OperationId; + +/// Successful signer output with portable operation provenance. +#[non_exhaustive] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, PartialEq, Eq)] +pub struct SignReceipt { + operation_id: OperationId, + signed_event: SignedEvent, + completed_at_unix: u64, +} + +impl fmt::Debug for SignReceipt { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SignReceipt") + .field("operation_id", &self.operation_id) + .field("signed_event_id", &self.signed_event.id_str()) + .field("completed_at_unix", &self.completed_at_unix) + .finish() + } +} + +impl SignReceipt { + /// Creates a receipt from an invariant-checked signed event. + #[must_use] + pub const fn new( + operation_id: OperationId, + signed_event: SignedEvent, + completed_at_unix: u64, + ) -> Self { + Self { + operation_id, + signed_event, + completed_at_unix, + } + } + + /// Returns the originating runtime operation identity. + #[must_use] + pub const fn operation_id(&self) -> OperationId { + self.operation_id + } + + /// Borrows the invariant-checked signed event. + #[must_use] + pub const fn signed_event(&self) -> &SignedEvent { + &self.signed_event + } + + /// Returns the host-supplied completion timestamp. + #[must_use] + pub const fn completed_at_unix(&self) -> u64 { + self.completed_at_unix + } +} diff --git a/crates/signing/src/request.rs b/crates/signing/src/request.rs @@ -1,8 +1,259 @@ //! Validated signing requests. -/// Opaque request vocabulary for the object-safe SPI. +use core::fmt; +use radroots_event::EventDraft; +use radroots_protocol::runtime::v1::OperationId; + +#[cfg(not(feature = "std"))] +use alloc::sync::Arc; +#[cfg(feature = "std")] +use std::sync::Arc; + +use crate::{Actor, status::SignProgress}; + +/// How a signer must interpret cancellation around remote publication. +#[non_exhaustive] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CancellationPolicy { + /// Stop if cancellation is observed before publication; report the final + /// remote state explicitly when observed after publication. + PreservePublishedRequest, + /// A local-only operation may stop whenever cancellation is observed. + LocalCooperative, +} + +/// Explicit deadline and cancellation policy for one signing operation. +#[non_exhaustive] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SignPolicy { + deadline_unix: u64, + cancellation: CancellationPolicy, +} + +impl SignPolicy { + /// Creates a bounded policy. Unix timestamp zero is never a valid deadline. + pub const fn new( + deadline_unix: u64, + cancellation: CancellationPolicy, + ) -> Result<Self, SignPolicyError> { + if deadline_unix == 0 { + return Err(SignPolicyError::InvalidDeadline); + } + Ok(Self { + deadline_unix, + cancellation, + }) + } + + /// Returns the absolute Unix deadline. + #[must_use] + pub const fn deadline_unix(self) -> u64 { + self.deadline_unix + } + + /// Returns the explicit cancellation contract. + #[must_use] + pub const fn cancellation(self) -> CancellationPolicy { + self.cancellation + } +} + +/// Invalid signing policy input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SignPolicyError { + /// The deadline was the Unix epoch sentinel rather than a real bound. + InvalidDeadline, +} + +impl fmt::Display for SignPolicyError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("signing deadline must be greater than zero") + } +} + +impl core::error::Error for SignPolicyError {} + +/// Runtime-local observer for signing progress. /// -/// Step 102 defines the actor, frozen-draft, deadline, policy, and progress -/// fields before consumer migration. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct SignRequest; +/// Observers are not serialized, persisted, or invoked by hidden workers. +/// Implementations call them synchronously from the active signing future. +pub trait ProgressObserver: Send + Sync { + /// Observes one immutable progress value. + fn on_progress(&self, progress: &SignProgress); +} + +/// One authorized actor, frozen draft, and bounded signer invocation. +#[derive(Clone)] +pub struct SignRequest { + operation_id: OperationId, + actor: Actor, + draft: EventDraft, + policy: SignPolicy, + progress_observer: Option<Arc<dyn ProgressObserver>>, +} + +impl SignRequest { + /// Creates a request without installing a progress observer. + #[must_use] + pub fn new( + operation_id: OperationId, + actor: Actor, + draft: EventDraft, + policy: SignPolicy, + ) -> Self { + Self { + operation_id, + actor, + draft, + policy, + progress_observer: None, + } + } + + /// Installs a runtime-local progress observer. + #[must_use] + pub fn with_progress_observer(mut self, observer: Arc<dyn ProgressObserver>) -> Self { + self.progress_observer = Some(observer); + self + } + + /// Returns the versioned runtime operation identity. + #[must_use] + pub const fn operation_id(&self) -> OperationId { + self.operation_id + } + + /// Borrows the actor provenance and role claim. + #[must_use] + pub const fn actor(&self) -> &Actor { + &self.actor + } + + /// Borrows the exact canonical draft to sign. + #[must_use] + pub const fn draft(&self) -> &EventDraft { + &self.draft + } + + /// Returns the deadline and cancellation policy. + #[must_use] + pub const fn policy(&self) -> SignPolicy { + self.policy + } + + /// Reports progress to the request-local observer, when present. + pub fn report_progress(&self, progress: &SignProgress) { + if let Some(observer) = &self.progress_observer { + observer.on_progress(progress); + } + } +} + +impl fmt::Debug for SignRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SignRequest") + .field("operation_id", &self.operation_id) + .field("actor", &self.actor) + .field("draft", &"[redacted frozen event draft]") + .field("policy", &self.policy) + .field( + "progress_observer", + &self.progress_observer.as_ref().map(|_| "[installed]"), + ) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + actor::ActorSource, + status::{SignProgress, SignProgressStage}, + }; + use core::sync::atomic::{AtomicUsize, Ordering}; + use radroots_event::contract::AuthorRole; + use radroots_identity::PublicKey; + + #[cfg(not(feature = "std"))] + use alloc::{string::String, sync::Arc, vec::Vec}; + #[cfg(feature = "std")] + use std::{string::String, sync::Arc, vec::Vec}; + + const PUBLIC_KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + struct CountingObserver(AtomicUsize); + + impl ProgressObserver for CountingObserver { + fn on_progress(&self, _progress: &SignProgress) { + self.0.fetch_add(1, Ordering::Relaxed); + } + } + + fn request() -> SignRequest { + let public_key = PublicKey::from_hex(PUBLIC_KEY).expect("public key"); + let actor = Actor::new( + public_key, + ActorSource::ExplicitPublicKey, + [AuthorRole::Any], + ) + .expect("actor"); + let draft = EventDraft::new( + "radroots.social.geochat.v1", + 20_000, + 1_700_000_000, + Vec::new(), + "private-draft-content", + PUBLIC_KEY, + ) + .expect("draft"); + SignRequest::new( + OperationId::SyncPush, + actor, + draft, + SignPolicy::new(1_700_000_100, CancellationPolicy::PreservePublishedRequest) + .expect("policy"), + ) + } + + #[test] + fn policy_requires_a_real_deadline() { + assert_eq!( + SignPolicy::new(0, CancellationPolicy::LocalCooperative), + Err(SignPolicyError::InvalidDeadline) + ); + } + + #[test] + fn request_preserves_inputs_reports_progress_and_redacts_draft_debug() { + let observer = Arc::new(CountingObserver(AtomicUsize::new(0))); + let request = request().with_progress_observer(observer.clone()); + let progress = SignProgress::stage(SignProgressStage::Queued).expect("progress"); + + request.report_progress(&progress); + + assert_eq!(request.operation_id(), OperationId::SyncPush); + assert_eq!(request.policy().deadline_unix(), 1_700_000_100); + assert_eq!(request.draft().content(), "private-draft-content"); + assert_eq!(observer.0.load(Ordering::Relaxed), 1); + let debug = alloc_or_std_format(&request); + assert!(!debug.contains("private-draft-content")); + assert!(debug.contains("redacted frozen event draft")); + } + + fn alloc_or_std_format(value: &SignRequest) -> String { + value_to_string(format_args!("{value:?}")) + } + + fn value_to_string(arguments: fmt::Arguments<'_>) -> String { + use core::fmt::Write as _; + let mut output = String::new(); + output.write_fmt(arguments).expect("string formatting"); + output + } +} diff --git a/crates/signing/src/signer.rs b/crates/signing/src/signer.rs @@ -41,17 +41,17 @@ mod tests { impl Signer for LocalSigner { fn status(&self) -> BoxFuture<'_, Result<SignerStatus, Error>> { - Box::pin(async { Ok(SignerStatus) }) + Box::pin(async { Ok(SignerStatus::unavailable()) }) } fn sign(&self, _request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> { - Box::pin(async { Ok(SignReceipt) }) + Box::pin(async { Err(Error) }) } } impl Signer for RemoteSigner { fn status(&self) -> BoxFuture<'_, Result<SignerStatus, Error>> { - Box::pin(async { Ok(SignerStatus) }) + Box::pin(async { Ok(SignerStatus::unavailable()) }) } fn sign(&self, _request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> { @@ -61,7 +61,6 @@ mod tests { fn assert_dyn_signer(signer: &dyn Signer) { drop(signer.status()); - drop(signer.sign(SignRequest)); } #[test] diff --git a/crates/signing/src/status.rs b/crates/signing/src/status.rs @@ -1,8 +1,365 @@ //! Signer progress and status models. -/// Opaque status vocabulary for the object-safe SPI. -/// -/// Step 102 defines the capability, progress, and challenge state model before -/// consumer migration. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct SignerStatus; +use core::fmt; + +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; +#[cfg(feature = "std")] +use std::{string::String, vec::Vec}; + +use crate::capability::SignerCapability; + +const MAX_AUTH_URI_BYTES: usize = 2_048; + +/// A remote authentication interaction required to continue signing. +#[non_exhaustive] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, PartialEq, Eq)] +pub struct AuthChallenge { + uri: String, + required_at_unix: u64, + expires_at_unix: Option<u64>, +} + +impl AuthChallenge { + /// Creates a bounded HTTPS authentication challenge. + pub fn new( + uri: impl Into<String>, + required_at_unix: u64, + expires_at_unix: Option<u64>, + ) -> Result<Self, AuthChallengeError> { + let uri = uri.into(); + if uri.len() > MAX_AUTH_URI_BYTES + || uri.trim() != uri + || !uri.starts_with("https://") + || uri.chars().any(char::is_control) + { + return Err(AuthChallengeError::InvalidUri); + } + if let Some(expires_at_unix) = expires_at_unix + && expires_at_unix < required_at_unix + { + return Err(AuthChallengeError::ExpiresBeforeRequired); + } + Ok(Self { + uri, + required_at_unix, + expires_at_unix, + }) + } + + /// Borrows the host-displayable authentication URI. + #[must_use] + pub fn uri(&self) -> &str { + self.uri.as_str() + } + + /// Returns when the challenge became required. + #[must_use] + pub const fn required_at_unix(&self) -> u64 { + self.required_at_unix + } + + /// Returns the optional absolute challenge expiry. + #[must_use] + pub const fn expires_at_unix(&self) -> Option<u64> { + self.expires_at_unix + } +} + +impl fmt::Debug for AuthChallenge { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthChallenge") + .field("uri", &"[redacted]") + .field("required_at_unix", &self.required_at_unix) + .field("expires_at_unix", &self.expires_at_unix) + .finish() + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for AuthChallenge { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct Repr { + uri: String, + required_at_unix: u64, + expires_at_unix: Option<u64>, + } + + let value = Repr::deserialize(deserializer)?; + Self::new(value.uri, value.required_at_unix, value.expires_at_unix) + .map_err(serde::de::Error::custom) + } +} + +/// Invalid authentication challenge input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AuthChallengeError { + /// The URI was not bounded canonical HTTPS text. + InvalidUri, + /// The challenge expiry preceded the required timestamp. + ExpiresBeforeRequired, +} + +impl fmt::Display for AuthChallengeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidUri => "authentication challenge URI is invalid", + Self::ExpiresBeforeRequired => "authentication challenge expires before it is required", + }) + } +} + +impl core::error::Error for AuthChallengeError {} + +/// Stable signing progress stages. +#[non_exhaustive] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SignProgressStage { + Queued, + Validating, + AwaitingAuthentication, + RequestPublished, + AwaitingSignature, + VerifyingOutput, + Complete, +} + +/// One immutable signer progress update. +#[non_exhaustive] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SignProgress { + stage: SignProgressStage, + challenge: Option<AuthChallenge>, +} + +impl SignProgress { + /// Creates a progress update without an authentication challenge. + pub const fn stage(stage: SignProgressStage) -> Result<Self, SignProgressError> { + if matches!(stage, SignProgressStage::AwaitingAuthentication) { + return Err(SignProgressError::MissingAuthenticationChallenge); + } + Ok(Self { + stage, + challenge: None, + }) + } + + /// Creates an explicit authentication-challenge update. + #[must_use] + pub const fn authentication(challenge: AuthChallenge) -> Self { + Self { + stage: SignProgressStage::AwaitingAuthentication, + challenge: Some(challenge), + } + } + + /// Returns the stable progress stage. + #[must_use] + pub const fn stage_value(&self) -> SignProgressStage { + self.stage + } + + /// Borrows the authentication challenge, when present. + #[must_use] + pub const fn challenge(&self) -> Option<&AuthChallenge> { + self.challenge.as_ref() + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for SignProgress { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct Repr { + stage: SignProgressStage, + challenge: Option<AuthChallenge>, + } + + let value = Repr::deserialize(deserializer)?; + match (value.stage, value.challenge) { + (SignProgressStage::AwaitingAuthentication, Some(challenge)) => { + Ok(Self::authentication(challenge)) + } + (SignProgressStage::AwaitingAuthentication, None) => Err(serde::de::Error::custom( + SignProgressError::MissingAuthenticationChallenge, + )), + (_, Some(_)) => Err(serde::de::Error::custom( + SignProgressError::UnexpectedAuthenticationChallenge, + )), + (stage, None) => Self::stage(stage).map_err(serde::de::Error::custom), + } + } +} + +/// Invalid progress construction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SignProgressError { + MissingAuthenticationChallenge, + UnexpectedAuthenticationChallenge, +} + +impl fmt::Display for SignProgressError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::MissingAuthenticationChallenge => "authentication progress requires a challenge", + Self::UnexpectedAuthenticationChallenge => { + "only authentication progress may carry a challenge" + } + }) + } +} + +impl core::error::Error for SignProgressError {} + +/// Current signer availability. +#[non_exhaustive] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SignerAvailability { + Ready, + Busy, + AwaitingAuthentication, + Unavailable, +} + +/// Current signer availability, capabilities, and optional progress. +#[non_exhaustive] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SignerStatus { + availability: SignerAvailability, + capabilities: Vec<SignerCapability>, + progress: Option<SignProgress>, +} + +impl SignerStatus { + /// Creates an explicit status snapshot. + #[must_use] + pub fn new( + availability: SignerAvailability, + capabilities: Vec<SignerCapability>, + progress: Option<SignProgress>, + ) -> Self { + Self { + availability, + capabilities, + progress, + } + } + + /// Creates an unavailable status without claiming capabilities. + #[must_use] + pub const fn unavailable() -> Self { + Self { + availability: SignerAvailability::Unavailable, + capabilities: Vec::new(), + progress: None, + } + } + + #[must_use] + pub const fn availability(&self) -> SignerAvailability { + self.availability + } + + #[must_use] + pub fn capabilities(&self) -> &[SignerCapability] { + &self.capabilities + } + + #[must_use] + pub const fn progress(&self) -> Option<&SignProgress> { + self.progress.as_ref() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::capability::{CancellationSupport, SignerKind}; + + #[test] + fn challenge_validation_and_debug_redaction_are_explicit() { + let challenge = + AuthChallenge::new("https://auth.example/approve?token=sensitive", 10, Some(20)) + .expect("challenge"); + + assert_eq!(challenge.required_at_unix(), 10); + assert_eq!(challenge.expires_at_unix(), Some(20)); + assert!(!format!("{challenge:?}").contains("sensitive")); + assert_eq!( + AuthChallenge::new("http://auth.example", 10, None), + Err(AuthChallengeError::InvalidUri) + ); + assert_eq!( + AuthChallenge::new("https://auth.example", 20, Some(10)), + Err(AuthChallengeError::ExpiresBeforeRequired) + ); + } + + #[test] + fn progress_requires_challenges_only_at_the_authentication_stage() { + assert_eq!( + SignProgress::stage(SignProgressStage::AwaitingAuthentication), + Err(SignProgressError::MissingAuthenticationChallenge) + ); + let challenge = + AuthChallenge::new("https://auth.example/approve", 10, None).expect("challenge"); + let progress = SignProgress::authentication(challenge); + assert_eq!( + progress.stage_value(), + SignProgressStage::AwaitingAuthentication + ); + assert!(progress.challenge().is_some()); + } + + #[cfg(feature = "serde")] + #[test] + fn status_round_trips_and_invalid_progress_fails_closed() { + let capability = SignerCapability::new( + SignerKind::Remote, + CancellationSupport::BeforePublication, + true, + true, + ); + let challenge = + AuthChallenge::new("https://auth.example/approve", 10, Some(20)).expect("challenge"); + let status = SignerStatus::new( + SignerAvailability::AwaitingAuthentication, + vec![capability], + Some(SignProgress::authentication(challenge)), + ); + let encoded = serde_json::to_string(&status).expect("serialize status"); + let decoded: SignerStatus = serde_json::from_str(&encoded).expect("deserialize status"); + + assert_eq!(decoded, status); + assert!( + serde_json::from_str::<SignProgress>( + r#"{"stage":"awaiting_authentication","challenge":null}"# + ) + .is_err() + ); + assert!(serde_json::from_str::<SignProgress>( + r#"{"stage":"queued","challenge":{"uri":"https://auth.example","required_at_unix":1,"expires_at_unix":null}}"# + ) + .is_err()); + } +}