alpine-protocol-sdk 0.2.4

High-level SDK on top of the ALPINE protocol layer.
Documentation
use std::net::SocketAddr;

use alpine::crypto::identity::NodeCredentials;
use alpine::messages::{CapabilitySet, DeviceIdentity};

use crate::discovery::{DiscoveryAttempt, DiscoveryOutcome, DiscoveryRunReport};
use crate::error::AlpineSdkError;
use crate::session::{AlpineClient, ProbeOptions, ProbeState};
use std::time::Instant;
use tracing::warn;

#[derive(Debug, Clone)]
pub struct ConnectPolicy {
    pub require_trust: bool,
    pub require_probe: bool,
    pub allow_degraded: bool,
    pub probe_options: ProbeOptions,
}

impl Default for ConnectPolicy {
    fn default() -> Self {
        Self {
            require_trust: true,
            require_probe: true,
            allow_degraded: false,
            probe_options: ProbeOptions::default(),
        }
    }
}

#[derive(Debug)]
pub enum HandshakeReportResult {
    Connected(AlpineClient),
    Failed(AlpineSdkError),
}

#[derive(Debug, Clone)]
pub struct TraceStep {
    pub label: String,
    pub elapsed_ms: u128,
}

#[derive(Debug, Clone)]
pub struct TraceTimeline {
    pub steps: Vec<TraceStep>,
}

impl TraceTimeline {
    pub fn new() -> Self {
        Self { steps: Vec::new() }
    }

    pub fn push(&mut self, label: impl Into<String>, started: Instant) {
        self.steps.push(TraceStep {
            label: label.into(),
            elapsed_ms: started.elapsed().as_millis(),
        });
    }
}

#[must_use]
#[derive(Debug)]
pub struct HandshakeReport {
    pub run_id: Option<String>,
    pub remote_addr: Option<SocketAddr>,
    pub device_id: Option<String>,
    pub policy: ConnectPolicy,
    pub probe: Option<crate::session::ProbeResult>,
    pub discovery_attempts: Vec<DiscoveryAttempt>,
    pub result: HandshakeReportResult,
    pub timeline: TraceTimeline,
}

impl HandshakeReport {
    pub fn into_result(self) -> Result<AlpineClient, AlpineSdkError> {
        match self.result {
            HandshakeReportResult::Connected(client) => Ok(client),
            HandshakeReportResult::Failed(err) => Err(err),
        }
    }
}

/// Initiates a handshake and returns a fully established session client.
pub async fn connect(
    local_addr: SocketAddr,
    remote_addr: SocketAddr,
    identity: DeviceIdentity,
    capabilities: CapabilitySet,
    credentials: NodeCredentials,
) -> Result<AlpineClient, AlpineSdkError> {
    AlpineClient::connect(local_addr, remote_addr, identity, capabilities, credentials).await
}

/// Connects using a discovery outcome and enforces the provided policy.
pub async fn connect_with_policy(
    local_addr: SocketAddr,
    outcome: DiscoveryOutcome,
    credentials: NodeCredentials,
    policy: ConnectPolicy,
) -> Result<AlpineClient, AlpineSdkError> {
    let run_id = outcome.run_id.clone();
    let outcome = if policy.require_trust {
        outcome.require_trusted()?.outcome
    } else {
        outcome
    };
    let protocol_report = outcome.protocol_report();
    if !protocol_report.compatible {
        return Err(AlpineSdkError::IncompatibleProtocol(protocol_report.note));
    }
    let identity = DeviceIdentity {
        device_id: outcome.reply.device_id.clone(),
        manufacturer_id: outcome.reply.manufacturer_id.clone(),
        model_id: outcome.reply.model_id.clone(),
        hardware_rev: outcome.reply.hardware_rev.clone(),
        firmware_rev: outcome.reply.firmware_rev.clone(),
    };
    let capabilities = outcome.reply.capabilities.clone();
    let client = AlpineClient::connect(
        local_addr,
        outcome.peer,
        identity,
        capabilities,
        credentials,
    )
    .await
    .map_err(|err| {
        warn_clock_skew_if_needed(&err);
        err
    })?;
    let mut client = client;
    client.set_run_id(run_id);

    if policy.require_probe {
        let probe = client
            .probe_status_with_options(policy.probe_options.clone())
            .await;
        let acceptable = match probe.state {
            ProbeState::Online => true,
            ProbeState::Degraded => policy.allow_degraded,
            ProbeState::Offline => false,
        };
        if !acceptable {
            let detail = probe
                .detail
                .or_else(|| probe.errors.first().map(|err| err.message.clone()))
                .unwrap_or_else(|| format!("probe state {:?}", probe.state));
            return Err(AlpineSdkError::ProbeFailed(detail));
        }
    }

    Ok(client)
}

/// Connects using a discovery outcome and returns a detailed report.
pub async fn connect_with_policy_report(
    local_addr: SocketAddr,
    outcome: DiscoveryOutcome,
    credentials: NodeCredentials,
    policy: ConnectPolicy,
) -> HandshakeReport {
    let started = Instant::now();
    let mut timeline = TraceTimeline::new();
    let device_id = Some(outcome.reply.device_id.clone());
    let remote_addr = Some(outcome.peer);
    let run_id = Some(outcome.run_id.clone());
    let mut probe = None;
    let outcome = if policy.require_trust {
        match outcome.require_trusted() {
            Ok(trusted) => trusted.outcome,
            Err(err) => {
                timeline.push("trust_failed", started);
                return HandshakeReport {
                    run_id,
                    remote_addr,
                    device_id,
                    policy,
                    probe: None,
                    discovery_attempts: Vec::new(),
                    result: HandshakeReportResult::Failed(err),
                    timeline,
                };
            }
        }
    } else {
        timeline.push("trust_skipped", started);
        outcome
    };
    let protocol_report = outcome.protocol_report();
    if !protocol_report.compatible {
        timeline.push("protocol_mismatch", started);
        return HandshakeReport {
            run_id,
            remote_addr,
            device_id,
            policy,
            probe: None,
            discovery_attempts: Vec::new(),
            result: HandshakeReportResult::Failed(AlpineSdkError::IncompatibleProtocol(
                protocol_report.note,
            )),
            timeline,
        };
    }
    let identity = DeviceIdentity {
        device_id: outcome.reply.device_id.clone(),
        manufacturer_id: outcome.reply.manufacturer_id.clone(),
        model_id: outcome.reply.model_id.clone(),
        hardware_rev: outcome.reply.hardware_rev.clone(),
        firmware_rev: outcome.reply.firmware_rev.clone(),
    };
    let capabilities = outcome.reply.capabilities.clone();
    let client = match AlpineClient::connect(
        local_addr,
        outcome.peer,
        identity,
        capabilities,
        credentials,
    )
    .await
    {
        Ok(client) => client,
        Err(err) => {
            warn_clock_skew_if_needed(&err);
            timeline.push("handshake_failed", started);
            return HandshakeReport {
                run_id,
                remote_addr,
                device_id,
                policy,
                probe: None,
                discovery_attempts: Vec::new(),
                result: HandshakeReportResult::Failed(err),
                timeline,
            };
        }
    };

    let mut client = client;
    if let Some(run_id) = run_id.clone() {
        client.set_run_id(run_id);
    }
    timeline.push("handshake_connected", started);
    let result = if policy.require_probe {
        let probe_result = client
            .probe_status_with_options(policy.probe_options.clone())
            .await;
        let acceptable = match probe_result.state {
            ProbeState::Online => true,
            ProbeState::Degraded => policy.allow_degraded,
            ProbeState::Offline => false,
        };
        probe = Some(probe_result.clone());
        if !acceptable {
            let detail = probe_result
                .detail
                .clone()
                .or_else(|| probe_result.errors.first().map(|err| err.message.clone()))
                .unwrap_or_else(|| format!("probe state {:?}", probe_result.state));
            client.close().await;
            timeline.push("probe_failed", started);
            HandshakeReportResult::Failed(AlpineSdkError::ProbeFailed(detail))
        } else {
            timeline.push("probe_ok", started);
            HandshakeReportResult::Connected(client)
        }
    } else {
        timeline.push("probe_skipped", started);
        HandshakeReportResult::Connected(client)
    };

    HandshakeReport {
        run_id,
        remote_addr,
        device_id,
        policy,
        probe,
        discovery_attempts: Vec::new(),
        result,
        timeline,
    }
}

/// Connects using a discovery report and includes diagnostics in failures.
pub async fn connect_with_policy_from_report(
    local_addr: SocketAddr,
    report: DiscoveryRunReport,
    credentials: NodeCredentials,
    policy: ConnectPolicy,
) -> Result<AlpineClient, AlpineSdkError> {
    let outcome = match report.result {
        Ok(outcome) => outcome,
        Err(err) => {
            let attempts = format_attempts(&report.attempts);
            return Err(AlpineSdkError::DiscoveryFailed(format!(
                "{}; attempts: {}",
                err, attempts
            )));
        }
    };

    match connect_with_policy(local_addr, outcome, credentials, policy).await {
        Ok(client) => Ok(client),
        Err(AlpineSdkError::ProbeFailed(detail)) => {
            let attempts = format_attempts(&report.attempts);
            Err(AlpineSdkError::ProbeFailed(format!(
                "{}; attempts: {}",
                detail, attempts
            )))
        }
        Err(err) => Err(err),
    }
}

/// Connects using a discovery report and returns a detailed handshake report.
pub async fn connect_with_policy_from_report_report(
    local_addr: SocketAddr,
    report: DiscoveryRunReport,
    credentials: NodeCredentials,
    policy: ConnectPolicy,
) -> HandshakeReport {
    let attempts = report.attempts.clone();
    match report.result {
        Ok(outcome) => {
            let mut handshake =
                connect_with_policy_report(local_addr, outcome, credentials, policy).await;
            handshake.discovery_attempts = attempts;
            handshake
        }
        Err(err) => {
            let detail = format_attempts(&attempts);
            HandshakeReport {
                run_id: None,
                remote_addr: None,
                device_id: None,
                policy,
                probe: None,
                discovery_attempts: attempts,
                result: HandshakeReportResult::Failed(AlpineSdkError::DiscoveryFailed(format!(
                    "{}; attempts: {}",
                    err, detail
                ))),
                timeline: TraceTimeline::new(),
            }
        }
    }
}

fn format_attempts(attempts: &[DiscoveryAttempt]) -> String {
    if attempts.is_empty() {
        return "none".into();
    }
    let mut rendered = Vec::new();
    for attempt in attempts {
        let error = attempt.error.as_ref().map(|err| err.label).unwrap_or("ok");
        rendered.push(format!(
            "{} {} {}",
            attempt.mode.as_str(),
            attempt.target,
            error
        ));
    }
    rendered.join(", ")
}

fn warn_clock_skew_if_needed(err: &AlpineSdkError) {
    if let AlpineSdkError::HandshakeFailed(detail) = err {
        let lowered = detail.to_ascii_lowercase();
        if lowered.contains("signature")
            || lowered.contains("expired")
            || lowered.contains("timestamp")
        {
            warn!(
                "[ALPINE][HANDSHAKE][WARN] signature validation failed; clock skew may be causing auth errors"
            );
        }
    }
}