1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
// SPDX-FileCopyrightText: Copyright (C) 2025 David Stainton
// SPDX-License-Identifier: AGPL-3.0-only
use std::error::Error;
use std::fmt;
#[derive(Debug)]
pub enum ThinClientError {
/// An underlying `std::io::Error` was encountered when reading from or
/// writing to the local daemon socket.
IoError(std::io::Error),
/// CBOR (de)serialisation failed. The thin client ↔ daemon protocol is
/// CBOR-framed; this variant indicates a malformed or unexpected payload.
CborError(serde_cbor::Error),
/// The thin client failed to establish a connection to the local daemon
/// socket during `ThinClient::new`. The daemon may not be running, or the
/// socket path in the config may be wrong.
ConnectError,
/// No PKI document is currently available. The daemon forwards the latest
/// consensus on connect; receiving this error generally means the daemon
/// has not yet received its first PKI document from the mixnet.
MissingPkiDocument,
/// `get_service` was called with a service name that no mix node in the
/// current PKI document advertises.
ServiceNotFound,
/// The requested operation cannot be performed because the daemon is not
/// currently connected to the mixnet. Raised by send/receive methods;
/// callers can poll `is_connected()` to decide when to retry.
OfflineMode(String),
// Pigeonhole replica error codes (from pigeonhole/errors.go)
/// Box ID not found on the replica (error code 1)
BoxNotFound,
/// Invalid box ID format (error code 2)
InvalidBoxId,
/// Invalid or missing signature (error code 3)
InvalidSignature,
/// Database operation failed (error code 4)
DatabaseFailure,
/// Invalid payload data (error code 5)
InvalidPayload,
/// Storage capacity exceeded (error code 6)
StorageFull,
/// Internal replica error (error code 7)
ReplicaInternalError,
/// Invalid epoch (error code 8)
InvalidEpoch,
/// Replication to other replicas failed (error code 9)
ReplicationFailed,
/// Box already exists / already written (error code 10)
BoxAlreadyExists,
/// Box contains a tombstone - intentional deletion (error code 11)
Tombstone,
/// MKEM decryption failed (error code 22)
MkemDecryptionFailed,
/// BACAP decryption failed (error code 23)
BacapDecryptionFailed,
/// Operation was cancelled (error code 24)
StartResendingCancelled,
/// Tombstone signature verification failed (error code 25)
InvalidTombstoneSignature,
/// Copy command reported CopyStatusFailed by the courier (error code 26).
///
/// `replica_error_code` is the pigeonhole replica `ErrorCode` that
/// triggered the abort (e.g. `10 = BoxAlreadyExists`); 0 if not reported.
/// `failed_envelope_index` is the 1-based position in the copy stream of
/// the envelope whose write triggered the abort; 0 if not applicable.
/// This is NOT a BACAP message index.
CopyCommandFailed {
replica_error_code: u8,
failed_envelope_index: u64,
},
/// A WriteStream plaintext or a ReadStream result exceeded the daemon's
/// configured maximum stream payload size (error code 27).
PayloadTooLarge,
/// `blocking_send_message` did not receive a reply within the caller's
/// supplied timeout. The message may still have been sent and may still
/// elicit a reply that is later dropped.
Timeout(String),
/// A miscellaneous error carrying a free-form description. Used when no
/// more specific variant applies, including unknown daemon error codes.
Other(String),
}
/// Maps daemon error codes to ThinClientError variants.
/// This matches the Go `errorCodeToSentinel` function.
pub fn error_code_to_error(error_code: u8) -> ThinClientError {
match error_code {
0 => ThinClientError::Other("unexpected success code in error path".to_string()),
1 => ThinClientError::BoxNotFound,
2 => ThinClientError::InvalidBoxId,
3 => ThinClientError::InvalidSignature,
4 => ThinClientError::DatabaseFailure,
5 => ThinClientError::InvalidPayload,
6 => ThinClientError::StorageFull,
7 => ThinClientError::ReplicaInternalError,
8 => ThinClientError::InvalidEpoch,
9 => ThinClientError::ReplicationFailed,
10 => ThinClientError::BoxAlreadyExists,
11 => ThinClientError::Tombstone,
22 => ThinClientError::MkemDecryptionFailed,
23 => ThinClientError::BacapDecryptionFailed,
24 => ThinClientError::StartResendingCancelled,
25 => ThinClientError::InvalidTombstoneSignature,
27 => ThinClientError::PayloadTooLarge,
code => ThinClientError::Other(format!("unknown error code: {}", code)),
}
}
impl fmt::Display for ThinClientError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ThinClientError::IoError(err) => write!(f, "IO Error: {}", err),
ThinClientError::CborError(err) => write!(f, "CBOR Error: {}", err),
ThinClientError::ConnectError => write!(f, "Connection error."),
ThinClientError::MissingPkiDocument => write!(f, "Missing PKI document."),
ThinClientError::ServiceNotFound => write!(f, "Service not found."),
ThinClientError::OfflineMode(msg) => write!(f, "Offline mode error: {}", msg),
ThinClientError::BoxNotFound => write!(f, "Box ID not found"),
ThinClientError::InvalidBoxId => write!(f, "Invalid box ID"),
ThinClientError::InvalidSignature => write!(f, "Invalid signature"),
ThinClientError::DatabaseFailure => write!(f, "Database failure"),
ThinClientError::InvalidPayload => write!(f, "Invalid payload"),
ThinClientError::StorageFull => write!(f, "Storage full"),
ThinClientError::ReplicaInternalError => write!(f, "Replica internal error"),
ThinClientError::InvalidEpoch => write!(f, "Invalid epoch"),
ThinClientError::ReplicationFailed => write!(f, "Replication failed"),
ThinClientError::BoxAlreadyExists => write!(f, "Box already exists"),
ThinClientError::Tombstone => write!(f, "Tombstone"),
ThinClientError::MkemDecryptionFailed => write!(f, "MKEM decryption failed"),
ThinClientError::BacapDecryptionFailed => write!(f, "BACAP decryption failed"),
ThinClientError::StartResendingCancelled => write!(f, "Start resending cancelled"),
ThinClientError::InvalidTombstoneSignature => write!(f, "Invalid tombstone signature"),
ThinClientError::CopyCommandFailed {
replica_error_code,
failed_envelope_index,
} => write!(
f,
"Copy command failed: replica_error_code={}, failed_envelope_index={}",
replica_error_code, failed_envelope_index
),
ThinClientError::PayloadTooLarge => write!(f, "Payload too large"),
ThinClientError::Timeout(msg) => write!(f, "Timeout: {}", msg),
ThinClientError::Other(msg) => write!(f, "Error: {}", msg),
}
}
}
impl ThinClientError {
/// Returns true for error codes that represent completed operations
/// rather than failures. These errors should not trigger retries.
pub fn is_expected_outcome(&self) -> bool {
matches!(self, ThinClientError::Tombstone | ThinClientError::BoxNotFound | ThinClientError::BoxAlreadyExists)
}
}
impl Error for ThinClientError {}
impl From<std::io::Error> for ThinClientError {
fn from(err: std::io::Error) -> Self {
ThinClientError::IoError(err)
}
}
impl From<serde_cbor::Error> for ThinClientError {
fn from(err: serde_cbor::Error) -> Self {
ThinClientError::CborError(err)
}
}