Files
lanspread/crates/lanspread-peer/src/transfer_status.rs
T
ddidderr 60fd7ba0c2 feat(peer)!: cut over to authenticated catalog sharing
Replace address-only trust and pushed peer state with installation identities,
SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned
protocol-8 pulls. The runtime now owns each network generation and all admitted
work through shutdown.

Add exact bundled content identities, reproducible manifest publishing,
capability-confined downloads, streaming BLAKE3 verification, quarantine and
retry, and crash-recoverable download and install transactions. Ship generated
fixture catalogs and fail closed when production manifests are absent.

The Tauri backend exposes durable sharing policy, redacted identity state, and
attempt-keyed transfer snapshots. Frontend consumption follows in the next
commit. Repository-wide test certificates and protocol-7 paths are removed.

BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts;
protocol-7 frames and shared-certificate identities are no longer accepted.

Test Plan:
- `just test` -- passed on the completed stack (708 workspace tests)
- `just clippy` -- passed on the completed stack
- `just build` -- passed with fixture catalogs on the completed stack
- `just catalog-check-production` -- failed closed because the external
  production manifest corpus is absent
- `git diff --cached --check` -- passed
2026-08-10 13:59:18 +02:00

602 lines
19 KiB
Rust

//! Attempt-keyed download status and cancellation coordination.
use std::{
fmt,
sync::{
Arc,
Mutex,
atomic::{AtomicU64, Ordering},
},
};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use tokio::sync::mpsc::UnboundedSender;
use tokio_util::sync::CancellationToken;
use crate::{DownloadProgress, PeerEvent, events};
static NEXT_DOWNLOAD_ATTEMPT_ID: AtomicU64 = AtomicU64::new(1);
/// Process-local monotonic identity for one download command attempt.
///
/// The integer is deliberately serialized as a decimal string so JavaScript
/// consumers never truncate it through IEEE-754 number conversion.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DownloadAttemptId(u64);
impl DownloadAttemptId {
fn next() -> Self {
let value = NEXT_DOWNLOAD_ATTEMPT_ID
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
current.checked_add(1)
})
.expect("download attempt ID space exhausted");
Self(value)
}
}
impl fmt::Display for DownloadAttemptId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}
impl Serialize for DownloadAttemptId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.collect_str(self)
}
}
struct DownloadAttemptIdVisitor;
impl de::Visitor<'_> for DownloadAttemptIdVisitor {
type Value = DownloadAttemptId;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("an unsigned 64-bit download attempt ID encoded as a decimal string")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
if value.is_empty()
|| !value.bytes().all(|byte| byte.is_ascii_digit())
|| (value.len() > 1 && value.starts_with('0'))
{
return Err(E::invalid_value(de::Unexpected::Str(value), &self));
}
value
.parse::<u64>()
.map(DownloadAttemptId)
.map_err(|_| E::invalid_value(de::Unexpected::Str(value), &self))
}
}
impl<'de> Deserialize<'de> for DownloadAttemptId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(DownloadAttemptIdVisitor)
}
}
/// Stable correlation key carried by every non-diagnostic download event.
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct DownloadAttemptKey {
pub id: String,
pub attempt_id: DownloadAttemptId,
}
impl DownloadAttemptKey {
pub(crate) fn next(id: String) -> Self {
Self {
id,
attempt_id: DownloadAttemptId::next(),
}
}
}
/// Nonterminal verification activity shown for one download attempt.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum DownloadVerificationActivity {
VerifyingDownloadedChunks,
RetryingInvalidSource,
}
/// Stable terminal failure classification for one download attempt.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum DownloadFailureReason {
VerifiedCatalogSourcesExhausted,
OperationFailed,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
enum AttemptLifecycle {
#[default]
Open,
SourceClosed,
UserCancelled,
SourcesExhausted,
OwnerDone,
}
#[derive(Debug, Default)]
struct AttemptRuntimeState {
begin_emitted: bool,
activity: Option<DownloadVerificationActivity>,
retry_activity_emitted: bool,
lifecycle: AttemptLifecycle,
terminal_emitted: bool,
}
struct DownloadAttemptState {
key: DownloadAttemptKey,
cancellation: CancellationToken,
tx_notify_ui: UnboundedSender<PeerEvent>,
runtime: Mutex<AttemptRuntimeState>,
}
/// Owner-side status handle retained for the complete structured operation.
pub(crate) struct DownloadAttemptStatus {
state: Arc<DownloadAttemptState>,
}
impl DownloadAttemptStatus {
pub(crate) fn new(
key: DownloadAttemptKey,
cancellation: CancellationToken,
tx_notify_ui: UnboundedSender<PeerEvent>,
) -> Self {
Self {
state: Arc::new(DownloadAttemptState {
key,
cancellation,
tx_notify_ui,
runtime: Mutex::new(AttemptRuntimeState::default()),
}),
}
}
pub(crate) fn key(&self) -> &DownloadAttemptKey {
&self.state.key
}
pub(crate) fn signal(&self) -> ActiveDownloadSignal {
ActiveDownloadSignal {
state: Arc::clone(&self.state),
}
}
pub(crate) fn reporter(&self) -> DownloadAttemptReporter {
DownloadAttemptReporter {
state: Arc::clone(&self.state),
}
}
pub(crate) fn emit_begin(&self) -> bool {
let mut runtime = self.lock_runtime();
if runtime.begin_emitted || runtime.terminal_emitted {
return false;
}
runtime.begin_emitted = true;
events::send(
&self.state.tx_notify_ui,
PeerEvent::DownloadGameFilesBegin {
attempt: self.state.key.clone(),
},
);
true
}
pub(crate) fn set_activity(&self, activity: DownloadVerificationActivity) -> bool {
set_activity(&self.state, activity)
}
pub(crate) fn clear_activity(&self) -> bool {
let mut runtime = self.lock_runtime();
if runtime.activity.take().is_none() {
return false;
}
events::send(
&self.state.tx_notify_ui,
PeerEvent::DownloadGameFilesActivityChanged {
attempt: self.state.key.clone(),
activity: None,
},
);
true
}
/// Closes liveness source-loss admission after all receive children drain.
/// User cancellation remains accepted until terminal owner settlement.
pub(crate) fn close_source_admission(&self) -> bool {
let mut runtime = self.lock_runtime();
match runtime.lifecycle {
AttemptLifecycle::Open if self.state.cancellation.is_cancelled() => {
runtime.lifecycle = AttemptLifecycle::UserCancelled;
false
}
AttemptLifecycle::Open => {
runtime.lifecycle = AttemptLifecycle::SourceClosed;
true
}
AttemptLifecycle::SourceClosed
| AttemptLifecycle::UserCancelled
| AttemptLifecycle::SourcesExhausted
| AttemptLifecycle::OwnerDone => false,
}
}
pub(crate) fn resolve_failure(
&self,
direct_reason: Option<DownloadFailureReason>,
) -> Option<DownloadFailureReason> {
let runtime = self.lock_runtime();
match (direct_reason, runtime.lifecycle) {
(Some(DownloadFailureReason::OperationFailed), _) => direct_reason,
(_, AttemptLifecycle::UserCancelled | AttemptLifecycle::OwnerDone)
| (None, AttemptLifecycle::Open | AttemptLifecycle::SourceClosed) => None,
(Some(DownloadFailureReason::VerifiedCatalogSourcesExhausted), _)
| (None, AttemptLifecycle::SourcesExhausted) => {
Some(DownloadFailureReason::VerifiedCatalogSourcesExhausted)
}
}
}
pub(crate) fn emit_finished(&self) -> bool {
self.emit_terminal(None)
}
pub(crate) fn emit_failed(&self, reason: DownloadFailureReason) -> bool {
self.emit_terminal(Some(reason))
}
fn emit_terminal(&self, reason: Option<DownloadFailureReason>) -> bool {
let mut runtime = self.lock_runtime();
if runtime.terminal_emitted {
return false;
}
if runtime.activity.is_some() {
log::error!(
"Download attempt {} for {} reached a terminal state before activity was cleared",
self.state.key.attempt_id,
self.state.key.id
);
runtime.activity = None;
events::send(
&self.state.tx_notify_ui,
PeerEvent::DownloadGameFilesActivityChanged {
attempt: self.state.key.clone(),
activity: None,
},
);
}
runtime.terminal_emitted = true;
runtime.lifecycle = AttemptLifecycle::OwnerDone;
let event = match reason {
Some(reason) => PeerEvent::DownloadGameFilesFailed {
attempt: self.state.key.clone(),
reason,
},
None => PeerEvent::DownloadGameFilesFinished {
attempt: self.state.key.clone(),
},
};
events::send(&self.state.tx_notify_ui, event);
true
}
fn lock_runtime(&self) -> std::sync::MutexGuard<'_, AttemptRuntimeState> {
self.state
.runtime
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
impl Drop for DownloadAttemptStatus {
fn drop(&mut self) {
let clear_activity = {
let mut runtime = self.lock_runtime();
let clear_activity = runtime.activity.take().is_some();
runtime.lifecycle = AttemptLifecycle::OwnerDone;
clear_activity
};
if clear_activity {
events::send(
&self.state.tx_notify_ui,
PeerEvent::DownloadGameFilesActivityChanged {
attempt: self.state.key.clone(),
activity: None,
},
);
}
}
}
/// Cloneable, nonterminal-only view used by structured transfer children.
#[derive(Clone)]
pub(crate) struct DownloadAttemptReporter {
state: Arc<DownloadAttemptState>,
}
impl DownloadAttemptReporter {
pub(crate) fn key(&self) -> &DownloadAttemptKey {
&self.state.key
}
pub(crate) fn set_activity(&self, activity: DownloadVerificationActivity) -> bool {
set_activity(&self.state, activity)
}
pub(crate) fn emit_progress(&self, progress: DownloadProgress) -> bool {
emit_progress(&self.state, progress)
}
}
fn set_activity(state: &DownloadAttemptState, activity: DownloadVerificationActivity) -> bool {
let mut runtime = state
.runtime
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if runtime.lifecycle != AttemptLifecycle::Open || runtime.terminal_emitted {
return false;
}
if runtime.retry_activity_emitted {
return false;
}
if activity == DownloadVerificationActivity::RetryingInvalidSource {
runtime.retry_activity_emitted = true;
}
if runtime.activity == Some(activity) {
return false;
}
runtime.activity = Some(activity);
events::send(
&state.tx_notify_ui,
PeerEvent::DownloadGameFilesActivityChanged {
attempt: state.key.clone(),
activity: Some(activity),
},
);
true
}
fn emit_progress(state: &DownloadAttemptState, progress: DownloadProgress) -> bool {
let runtime = state
.runtime
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if runtime.lifecycle != AttemptLifecycle::Open
|| runtime.terminal_emitted
|| progress.attempt != state.key
{
return false;
}
events::send(
&state.tx_notify_ui,
PeerEvent::DownloadGameFilesProgress(progress),
);
true
}
/// Restricted signal retained in `Ctx` for cancellation authorities.
///
/// It deliberately exposes no activity or terminal event methods; the owning
/// operation remains solely responsible for clearing activity and publishing
/// its one terminal outcome after drainage and settlement.
#[derive(Clone)]
pub(crate) struct ActiveDownloadSignal {
state: Arc<DownloadAttemptState>,
}
impl ActiveDownloadSignal {
pub(crate) fn key(&self) -> &DownloadAttemptKey {
&self.state.key
}
#[cfg(test)]
fn cancellation(&self) -> &CancellationToken {
&self.state.cancellation
}
pub(crate) fn cancel_silently(&self) -> bool {
let mut runtime = self.lock_runtime();
match runtime.lifecycle {
AttemptLifecycle::Open | AttemptLifecycle::SourceClosed => {
if self.state.cancellation.is_cancelled() {
runtime.lifecycle = AttemptLifecycle::UserCancelled;
return false;
}
runtime.lifecycle = AttemptLifecycle::UserCancelled;
self.state.cancellation.cancel();
true
}
AttemptLifecycle::UserCancelled
| AttemptLifecycle::SourcesExhausted
| AttemptLifecycle::OwnerDone => false,
}
}
pub(crate) fn cancel_sources_exhausted(&self) -> bool {
let mut runtime = self.lock_runtime();
match runtime.lifecycle {
AttemptLifecycle::Open if self.state.cancellation.is_cancelled() => {
runtime.lifecycle = AttemptLifecycle::UserCancelled;
false
}
AttemptLifecycle::Open => {
runtime.lifecycle = AttemptLifecycle::SourcesExhausted;
self.state.cancellation.cancel();
true
}
AttemptLifecycle::SourceClosed
| AttemptLifecycle::UserCancelled
| AttemptLifecycle::SourcesExhausted
| AttemptLifecycle::OwnerDone => false,
}
}
fn lock_runtime(&self) -> std::sync::MutexGuard<'_, AttemptRuntimeState> {
self.state
.runtime
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn attempt() -> (
DownloadAttemptStatus,
tokio::sync::mpsc::UnboundedReceiver<PeerEvent>,
) {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(
DownloadAttemptStatus::new(
DownloadAttemptKey::next("game".to_owned()),
CancellationToken::new(),
tx,
),
rx,
)
}
#[test]
fn attempt_ids_are_monotonic_and_serialize_only_as_decimal_strings() {
let first = DownloadAttemptKey::next("first".to_owned()).attempt_id;
let second = DownloadAttemptKey::next("second".to_owned()).attempt_id;
assert!(second > first);
let encoded = serde_json::to_string(&first).expect("attempt ID should serialize");
assert_eq!(encoded, format!("\"{first}\""));
assert_eq!(
serde_json::from_str::<DownloadAttemptId>(&encoded)
.expect("decimal string should deserialize"),
first
);
assert!(serde_json::from_str::<DownloadAttemptId>("1").is_err());
assert!(serde_json::from_str::<DownloadAttemptId>("\"01\"").is_err());
}
#[test]
fn activity_is_deduplicated_cleared_and_never_reopened_after_terminal() {
let (attempt, mut rx) = attempt();
assert!(attempt.emit_begin());
assert!(attempt.set_activity(DownloadVerificationActivity::VerifyingDownloadedChunks));
assert!(!attempt.set_activity(DownloadVerificationActivity::VerifyingDownloadedChunks));
assert!(attempt.set_activity(DownloadVerificationActivity::RetryingInvalidSource));
assert!(!attempt.set_activity(DownloadVerificationActivity::RetryingInvalidSource));
assert!(!attempt.set_activity(DownloadVerificationActivity::VerifyingDownloadedChunks));
assert!(attempt.clear_activity());
assert!(!attempt.clear_activity());
assert!(attempt.emit_finished());
assert!(!attempt.emit_failed(DownloadFailureReason::OperationFailed));
assert!(!attempt.set_activity(DownloadVerificationActivity::VerifyingDownloadedChunks));
assert!(matches!(
rx.try_recv(),
Ok(PeerEvent::DownloadGameFilesBegin { .. })
));
assert!(matches!(
rx.try_recv(),
Ok(PeerEvent::DownloadGameFilesActivityChanged {
activity: Some(DownloadVerificationActivity::VerifyingDownloadedChunks),
..
})
));
assert!(matches!(
rx.try_recv(),
Ok(PeerEvent::DownloadGameFilesActivityChanged {
activity: Some(DownloadVerificationActivity::RetryingInvalidSource),
..
})
));
assert!(matches!(
rx.try_recv(),
Ok(PeerEvent::DownloadGameFilesActivityChanged { activity: None, .. })
));
assert!(matches!(
rx.try_recv(),
Ok(PeerEvent::DownloadGameFilesFinished { .. })
));
assert!(rx.try_recv().is_err());
}
#[test]
fn first_cancellation_cause_is_stable_and_operation_failure_dominates() {
let (attempt, _rx) = attempt();
let signal = attempt.signal();
assert!(signal.cancel_sources_exhausted());
assert!(!signal.cancel_silently());
assert_eq!(
attempt.resolve_failure(None),
Some(DownloadFailureReason::VerifiedCatalogSourcesExhausted)
);
assert_eq!(
attempt.resolve_failure(Some(DownloadFailureReason::OperationFailed)),
Some(DownloadFailureReason::OperationFailed)
);
}
#[test]
fn silent_cancellation_cannot_be_reclassified_by_liveness() {
let (attempt, _rx) = attempt();
let signal = attempt.signal();
assert!(signal.cancel_silently());
assert!(!signal.cancel_sources_exhausted());
assert_eq!(attempt.resolve_failure(None), None);
}
#[test]
fn source_close_rejects_liveness_but_still_accepts_user_cancellation() {
let (attempt, _rx) = attempt();
let signal = attempt.signal();
assert!(attempt.close_source_admission());
assert!(!signal.cancel_sources_exhausted());
assert!(signal.cancel_silently());
assert!(signal.cancellation().is_cancelled());
assert_eq!(attempt.resolve_failure(None), None);
}
#[test]
fn owner_drop_clears_activity_and_makes_stale_signals_inert() {
let (attempt, mut rx) = attempt();
let signal = attempt.signal();
let reporter = attempt.reporter();
assert!(attempt.set_activity(DownloadVerificationActivity::VerifyingDownloadedChunks));
drop(attempt);
assert!(!signal.cancel_sources_exhausted());
assert!(!signal.cancel_silently());
assert!(!reporter.set_activity(DownloadVerificationActivity::RetryingInvalidSource));
assert!(matches!(
rx.try_recv(),
Ok(PeerEvent::DownloadGameFilesActivityChanged {
activity: Some(DownloadVerificationActivity::VerifyingDownloadedChunks),
..
})
));
assert!(matches!(
rx.try_recv(),
Ok(PeerEvent::DownloadGameFilesActivityChanged { activity: None, .. })
));
assert!(rx.try_recv().is_err());
}
}