Skip to main content

canokey_protocol/
operation.rs

1//! Owned operations and bounded logical-command conversations.
2//!
3//! Applications normally construct operations through applet factories. Use
4//! [`conversation`](crate::operation::conversation) only when deliberately working at the raw command/status layer.
5use crate::{
6    ApduEncoding, ApduHeader, CommandApdu, Error, ErrorKind, ExpectedLength, Phase, ResponseApdu,
7    SecretBytes, StatusWord,
8};
9use std::collections::VecDeque;
10
11/// Caller-reported limits for a raw transport exchange.
12/// Defaults to short APDUs: 261 command bytes and 258 response bytes.
13#[derive(Clone, Copy, Debug)]
14pub struct ExchangeOptions {
15    /// Maximum complete command size, including header and Lc/Le.
16    pub max_command_bytes: usize,
17    /// Maximum complete response size, including SW1/SW2.
18    pub max_response_bytes: usize,
19    /// Permit extended encoding when the logical command also permits it.
20    /// This does not discover or guarantee card support.
21    pub allow_extended: bool,
22}
23impl Default for ExchangeOptions {
24    fn default() -> Self {
25        Self {
26            max_command_bytes: 261,
27            max_response_bytes: 258,
28            allow_extended: false,
29        }
30    }
31}
32/// Budgets applied by the operation; not claims about card storage capacity.
33#[derive(Clone, Copy, Debug)]
34pub struct OperationLimits {
35    /// Sum of response-data bytes across all exchanges, excluding SW (default 1 MiB).
36    /// Applet parsers may reuse this as their decoded-output limit.
37    pub max_total_response_bytes: usize,
38    /// Maximum number of physical commands exposed, including retries/continuations
39    /// (default 4096). Reading the same command again does not consume a count.
40    pub max_exchanges: usize,
41    /// Maximum data length of each logical command before segmentation
42    /// (default 1 MiB); not an aggregate across a multi-command operation.
43    pub max_input_bytes: usize,
44}
45impl Default for OperationLimits {
46    fn default() -> Self {
47        Self {
48            max_total_response_bytes: 1024 * 1024,
49            max_exchanges: 4096,
50            max_input_bytes: 1024 * 1024,
51        }
52    }
53}
54/// Owned channel constraints and resource budgets; default values are bounded.
55#[derive(Clone, Copy, Debug, Default)]
56pub struct OperationOptions {
57    /// Limits for complete physical APDUs.
58    pub exchange: ExchangeOptions,
59    /// Logical-command input, cumulative response, and exchange budgets.
60    pub limits: OperationLimits,
61}
62impl OperationOptions {
63    /// Validate budgets and return the unchanged options.
64    ///
65    /// # Errors
66    /// Returns [`ErrorKind::InvalidArgument`] for command limits below five bytes,
67    /// response limits below three bytes, or any zero operation budget.
68    pub fn validate(self) -> Result<Self, Error> {
69        if self.exchange.max_command_bytes < 5
70            || self.exchange.max_response_bytes < 3
71            || self.limits.max_exchanges == 0
72            || self.limits.max_total_response_bytes == 0
73            || self.limits.max_input_bytes == 0
74        {
75            return Err(Error::new(ErrorKind::InvalidArgument));
76        }
77        Ok(self)
78    }
79}
80/// The caller's next action after a successful start or advance.
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82pub enum Step {
83    /// Read `command()`, exchange it once, then call `advance()` with data plus SW.
84    Exchange,
85    /// Read or take the completed typed result; no command remains pending.
86    Done,
87}
88/// Local lifecycle state, independent of card login or connection state.
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub enum OperationState {
91    /// Constructed but not started.
92    Created,
93    /// One physical command is available and awaits a complete response.
94    AwaitingResponse,
95    /// A typed result is available; working protocol state has been released.
96    Completed,
97    /// Protocol execution failed; the stored error is available.
98    Failed,
99    /// Active working state was discarded locally without performing I/O.
100    Cancelled,
101    /// The result has been moved out and cannot be read or taken again.
102    ResultTaken,
103}
104
105/// Applet-specific conversation policy; OATH uses its own continuation command.
106#[derive(Clone, Copy, Debug)]
107pub enum Continuation {
108    /// Follow OATH 61xx and optionally nonempty 9000 with SEND REMAINING.
109    /// Only an empty 6985 after a speculative nonempty-9000 poll means completion;
110    /// the same status after 61xx is a failure. Continuations never correct Le.
111    Oath {
112        /// 0x06 for legacy or 0xa5 for modern OATH; other values are invalid.
113        instruction: u8,
114        /// Poll after nonempty success for firmware without a final-page marker.
115        probe_after_success: bool,
116    },
117    /// Follow 61xx with GET RESPONSE using the specified class byte.
118    Iso7816 {
119        /// Class byte for generated GET RESPONSE commands.
120        cla: u8,
121    },
122    /// Return the final raw status without performing continuation.
123    None,
124}
125/// Owned semantic command before physical encoding, splitting, or continuation.
126/// Use applet builders when available so authentication and policy remain correct.
127#[derive(Clone, Debug)]
128pub struct LogicalCommand {
129    /// Header used for the initial command and its chained fragments.
130    pub header: ApduHeader,
131    /// Owned command data, wiped when no longer needed.
132    pub data: SecretBytes,
133    /// Requested final response-data length.
134    pub le: ExpectedLength,
135    /// Permit short command chaining when one physical command is insufficient.
136    pub allow_chaining: bool,
137    /// Permit extended encoding when the exchange options also permit it.
138    /// This does not discover or guarantee card support.
139    pub allow_extended: bool,
140    /// Allow one 6Cxx Le correction per physical command. Enable only when safe.
141    pub correct_le: bool,
142    /// Continuation policy; ISO GET RESPONSE with CLA 0 by default.
143    pub continuation: Continuation,
144}
145impl LogicalCommand {
146    /// Take ownership of command data with conservative encoding/retry defaults.
147    /// Chaining, extended encoding and Le correction start disabled; ISO continuation
148    /// uses CLA 0. Validation occurs when constructing a conversation.
149    pub fn new(header: ApduHeader, data: Vec<u8>, le: ExpectedLength) -> Self {
150        Self {
151            header,
152            data: SecretBytes::new(data),
153            le,
154            allow_chaining: false,
155            allow_extended: false,
156            correct_le: false,
157            continuation: Continuation::Iso7816 { cla: 0 },
158        }
159    }
160}
161/// Owned reassembled response with its final raw status.
162/// A successful low-level conversation can still contain a non-9000 status.
163#[derive(Debug)]
164pub struct ResponseData {
165    /// Reassembled response data without status words, wiped on drop.
166    pub data: SecretBytes,
167    /// Final status after any permitted continuation.
168    pub status: StatusWord,
169}
170impl ResponseData {
171    /// Require 9000, mapping any other status with the supplied phase and no
172    /// secret reference. Authentication callers should use credential-aware mapping.
173    ///
174    /// # Errors
175    /// Returns a status-derived [`Error`] retaining the raw status.
176    pub fn ensure_success(&self, phase: Phase) -> Result<(), Error> {
177        if self.status.is_success() {
178            Ok(())
179        } else {
180            Err(Error::status(self.status, phase, None))
181        }
182    }
183}
184
185/// Implementation interface for the workspace applet crates, not an I/O hook.
186/// Applications normally use applet factory functions returning `Operation<T>`.
187#[doc(hidden)]
188pub mod engine {
189    use super::*;
190    pub enum Action<T> {
191        Command(LogicalCommand),
192        Done(T),
193    }
194    pub trait Machine<T>: Send {
195        fn next(&mut self, response: Option<ResponseData>) -> Result<Action<T>, Error>;
196        fn progress(&self) -> Option<&T> {
197            None
198        }
199        fn take_progress(&mut self) -> Option<T> {
200            None
201        }
202    }
203}
204use engine::{Action, Machine};
205
206struct ConversationState {
207    pending: VecDeque<CommandApdu>,
208    current: CommandApdu,
209    corrected: bool,
210    correct_le: bool,
211    continuation: Continuation,
212    continuing: bool,
213    oath_poll: bool,
214    data: SecretBytes,
215}
216impl ConversationState {
217    fn new(mut logical: LogicalCommand, options: OperationOptions) -> Result<Self, Error> {
218        if matches!(logical.continuation, Continuation::Oath { instruction, .. } if ![0x06, 0xa5].contains(&instruction))
219        {
220            return Err(Error::new(ErrorKind::InvalidArgument));
221        }
222        if logical.data.len() > options.limits.max_input_bytes {
223            return Err(Error::new(ErrorKind::LimitExceeded));
224        }
225        if matches!(logical.le, ExpectedLength::Exact(n) if n == 0 || n > 65536) {
226            return Err(Error::new(ErrorKind::InvalidArgument));
227        }
228        if let ExpectedLength::Exact(n) = logical.le {
229            if n as usize > options.exchange.max_response_bytes - 2 {
230                if matches!(logical.continuation, Continuation::Iso7816 { .. })
231                    && logical.correct_le
232                {
233                    logical.le =
234                        ExpectedLength::Exact((options.exchange.max_response_bytes - 2) as u32);
235                } else {
236                    return Err(Error::new(ErrorKind::LimitExceeded));
237                }
238            }
239        }
240        // Reserve space for Le, so any fragment fits even on smaller channels.
241        let max_short = options
242            .exchange
243            .max_command_bytes
244            .saturating_sub(6)
245            .min(255);
246        let mut frames = VecDeque::new();
247        let extended = logical.allow_extended && options.exchange.allow_extended;
248        if extended && logical.data.len() <= 65535 {
249            let cmd = CommandApdu::encode(
250                logical.header,
251                logical.data.as_bytes(),
252                logical.le,
253                ApduEncoding::Extended,
254            )?;
255            if cmd.as_bytes().len() <= options.exchange.max_command_bytes {
256                frames.push_back(cmd);
257            }
258        }
259        if frames.is_empty() {
260            if matches!(logical.le, ExpectedLength::Exact(n) if n > 256) {
261                return Err(Error::new(ErrorKind::LimitExceeded));
262            }
263            let data = logical.data.as_bytes();
264            if data.is_empty() || data.len() <= max_short {
265                frames.push_back(CommandApdu::encode(
266                    logical.header,
267                    data,
268                    logical.le,
269                    ApduEncoding::Short,
270                )?);
271            } else {
272                if !logical.allow_chaining || max_short == 0 || logical.header.cla & 0x10 != 0 {
273                    return Err(Error::new(ErrorKind::LimitExceeded));
274                }
275                let count = data.len().div_ceil(max_short);
276                if count > options.limits.max_exchanges {
277                    return Err(Error::new(ErrorKind::LimitExceeded));
278                }
279                for (i, chunk) in data.chunks(max_short).enumerate() {
280                    let last = i + 1 == count;
281                    let mut header = logical.header;
282                    if !last {
283                        header.cla |= 0x10;
284                    }
285                    frames.push_back(CommandApdu::encode(
286                        header,
287                        chunk,
288                        if last {
289                            logical.le
290                        } else {
291                            ExpectedLength::Absent
292                        },
293                        ApduEncoding::Short,
294                    )?);
295                }
296            }
297        }
298        let current = frames
299            .pop_front()
300            .ok_or_else(|| Error::new(ErrorKind::ProtocolViolation))?;
301        if current.as_bytes().len() > options.exchange.max_command_bytes {
302            return Err(Error::new(ErrorKind::LimitExceeded));
303        }
304        Ok(Self {
305            current,
306            pending: frames,
307            corrected: false,
308            correct_le: logical.correct_le,
309            continuation: logical.continuation,
310            continuing: false,
311            oath_poll: false,
312            data: SecretBytes::default(),
313        })
314    }
315    fn advance(
316        &mut self,
317        response: ResponseApdu<'_>,
318        options: OperationOptions,
319    ) -> Result<Option<ResponseData>, Error> {
320        let sw = response.status().raw();
321        if sw >> 8 == 0x6c && self.correct_le && self.pending.is_empty() && !self.corrected {
322            let le = if sw & 255 == 0 { 256 } else { sw & 255 } as u32;
323            if le as usize > options.exchange.max_response_bytes - 2 {
324                return Err(Error::new(ErrorKind::LimitExceeded).at(Phase::Conversation));
325            }
326            self.current = self.current.corrected(le)?;
327            if self.current.as_bytes().len() > options.exchange.max_command_bytes {
328                return Err(Error::new(ErrorKind::LimitExceeded).at(Phase::Conversation));
329            }
330            self.corrected = true;
331            return Ok(None);
332        }
333        if !self.pending.is_empty() {
334            if !response.status().is_success() {
335                return Err(Error::status(response.status(), Phase::Conversation, None));
336            }
337            if !response.data().is_empty() {
338                return Err(Error::new(ErrorKind::ProtocolViolation).at(Phase::Conversation));
339            }
340            self.current = self
341                .pending
342                .pop_front()
343                .ok_or_else(|| Error::new(ErrorKind::ProtocolViolation))?;
344            self.corrected = false;
345            return Ok(None);
346        }
347        if let Continuation::Oath {
348            instruction,
349            probe_after_success,
350        } = self.continuation
351        {
352            if self.oath_poll && sw == 0x6985 && response.data().is_empty() {
353                return Ok(Some(ResponseData {
354                    data: std::mem::take(&mut self.data),
355                    status: StatusWord::new(0x9000),
356                }));
357            }
358            if sw >> 8 == 0x61
359                || (probe_after_success && sw == 0x9000 && !response.data().is_empty())
360            {
361                if response.data().is_empty() {
362                    return Err(Error::new(ErrorKind::ProtocolViolation).at(Phase::Conversation));
363                }
364                self.data.extend(response.data());
365                self.oath_poll = sw == 0x9000;
366                self.current = CommandApdu::encode(
367                    ApduHeader::new(0, instruction, 0, 0),
368                    &[],
369                    ExpectedLength::Exact(255.min(options.exchange.max_response_bytes - 2) as u32),
370                    ApduEncoding::Short,
371                )?;
372                self.corrected = false;
373                self.correct_le = false;
374                return Ok(None);
375            }
376        }
377        self.data.extend(response.data());
378        if let Continuation::Iso7816 { cla } = self.continuation {
379            if sw >> 8 == 0x61 {
380                if self.continuing && response.data().is_empty() {
381                    return Err(Error::new(ErrorKind::ProtocolViolation).at(Phase::Conversation));
382                }
383                self.continuing = true;
384                let le = if sw & 255 == 0 {
385                    256
386                } else {
387                    (sw & 255) as usize
388                };
389                let le = le.min(options.exchange.max_response_bytes - 2) as u32;
390                self.current = CommandApdu::encode(
391                    ApduHeader::new(cla, 0xc0, 0, 0),
392                    &[],
393                    ExpectedLength::Exact(le),
394                    ApduEncoding::Short,
395                )?;
396                self.corrected = false;
397                self.correct_le = true;
398                return Ok(None);
399            }
400        }
401        Ok(Some(ResponseData {
402            data: std::mem::take(&mut self.data),
403            status: response.status(),
404        }))
405    }
406}
407
408/// An owned protocol operation. All getters are local and never send commands.
409///
410/// Applet factories copy required profile configuration and own their inputs;
411/// there is no caller lifetime, connection handle, or mutable global registry.
412/// Hold one exclusive application connection lease across start and every advance.
413/// Drop releases memory only, including on application transport failure.
414///
415/// # Examples
416/// ```
417/// use canokey_protocol::{ApduHeader, ExpectedLength, OperationState, Step};
418/// use canokey_protocol::operation::{conversation, LogicalCommand};
419/// let command = LogicalCommand::new(ApduHeader::new(0, 0xca, 0, 0),
420///     vec![], ExpectedLength::Exact(256));
421/// let mut op = conversation(command, Default::default())?;
422/// assert_eq!(op.start()?, Step::Exchange);
423/// assert_eq!(op.command()?.as_bytes(), &[0, 0xca, 0, 0, 0]);
424/// // Offline response fixture; real applications supply their raw I/O result.
425/// assert_eq!(op.advance(&[0x42, 0x90, 0])?, Step::Done);
426/// let response = op.take_result()?;
427/// assert_eq!(op.state(), OperationState::ResultTaken);
428/// drop(op);
429/// assert_eq!(response.data.as_bytes(), &[0x42]);
430/// # Ok::<(), canokey_protocol::Error>(())
431/// ```
432pub struct Operation<T> {
433    machine: Option<Box<dyn Machine<T>>>,
434    conversation: Option<ConversationState>,
435    output: Option<T>,
436    progress: Option<T>,
437    error: Option<Error>,
438    state: OperationState,
439    options: OperationOptions,
440    exchanges: usize,
441    response_bytes: usize,
442}
443impl<T> std::fmt::Debug for Operation<T> {
444    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
445        f.debug_struct("Operation")
446            .field("state", &self.state)
447            .finish_non_exhaustive()
448    }
449}
450impl<T> Operation<T> {
451    #[doc(hidden)]
452    pub fn from_machine(
453        machine: impl Machine<T> + 'static,
454        options: OperationOptions,
455    ) -> Result<Self, Error> {
456        Ok(Self {
457            machine: Some(Box::new(machine)),
458            conversation: None,
459            output: None,
460            progress: None,
461            error: None,
462            state: OperationState::Created,
463            options: options.validate()?,
464            exchanges: 0,
465            response_bytes: 0,
466        })
467    }
468    /// Inspect the local lifecycle without advancing execution.
469    pub fn state(&self) -> OperationState {
470        self.state
471    }
472    /// Borrow the stored protocol failure, or `None` before any failure.
473    /// Invalid-state getter/drive calls do not overwrite this error.
474    pub fn error(&self) -> Option<&Error> {
475        self.error.as_ref()
476    }
477    /// Borrow partial results only for an operation explicitly designed to expose
478    /// progress (PIV Batch and Admin). Other operations return None. Failures
479    /// retain completed items/write counts without retaining execution secrets.
480    /// Returns None after cancellation/result transfer and on completion, when
481    /// the ordinary result getter applies. Repeated calls never advance execution.
482    pub fn progress(&self) -> Option<&T> {
483        self.progress
484            .as_ref()
485            .or_else(|| self.machine.as_ref().and_then(|m| m.progress()))
486    }
487    /// Borrow the pending complete physical APDU. Repeated reads never resend it.
488    ///
489    /// # Errors
490    /// Returns [`ErrorKind::OperationStateError`] outside AwaitingResponse.
491    /// The borrow cannot outlive the next mutable call to this operation.
492    pub fn command(&self) -> Result<&CommandApdu, Error> {
493        if self.state != OperationState::AwaitingResponse {
494            return Err(state_error());
495        }
496        self.conversation
497            .as_ref()
498            .map(|c| &c.current)
499            .ok_or_else(state_error)
500    }
501    /// Borrow the completed typed result without card access.
502    ///
503    /// # Errors
504    /// Returns [`ErrorKind::OperationStateError`] unless the state is Completed.
505    pub fn result(&self) -> Result<&T, Error> {
506        if self.state != OperationState::Completed {
507            return Err(state_error());
508        }
509        self.output.as_ref().ok_or_else(state_error)
510    }
511    /// Move the result into caller ownership and enter ResultTaken.
512    /// The result can outlive this operation.
513    ///
514    /// # Errors
515    /// Returns [`ErrorKind::OperationStateError`] unless the state is Completed,
516    /// including on any second attempt.
517    pub fn take_result(&mut self) -> Result<T, Error> {
518        if self.state != OperationState::Completed {
519            return Err(state_error());
520        }
521        let value = self.output.take().ok_or_else(state_error)?;
522        self.state = OperationState::ResultTaken;
523        Ok(value)
524    }
525    /// Discard working data from Created/AwaitingResponse and enter Cancelled.
526    /// Other states are unchanged. Does not cancel transport I/O, send logout, or
527    /// roll back card effects; drain or isolate pending I/O before connection reuse.
528    pub fn cancel(&mut self) {
529        if matches!(
530            self.state,
531            OperationState::Created | OperationState::AwaitingResponse
532        ) {
533            self.machine = None;
534            self.conversation = None;
535            self.state = OperationState::Cancelled;
536        }
537    }
538    /// Start a Created operation, exposing its first command or completing locally.
539    ///
540    /// # Errors
541    /// Returns [`ErrorKind::OperationStateError`] in any other state, without changing
542    /// it. Construction/encoding/machine errors enter Failed and are retained.
543    pub fn start(&mut self) -> Result<Step, Error> {
544        if self.state != OperationState::Created {
545            return Err(state_error());
546        }
547        let result = self.drive(None);
548        self.record(result)
549    }
550    /// Consume one complete response (data followed by SW1/SW2) to the pending command.
551    /// Input is borrowed only during this call. Do not supply transport errors or
552    /// responses already processed by another continuation loop.
553    ///
554    /// # Errors
555    /// Outside AwaitingResponse, returns [`ErrorKind::OperationStateError`] unchanged.
556    /// Malformed responses, exhausted limits, conversation violations and applet
557    /// failures enter Failed, retain the error and release working state.
558    pub fn advance(&mut self, bytes: &[u8]) -> Result<Step, Error> {
559        if self.state != OperationState::AwaitingResponse {
560            return Err(state_error());
561        }
562        let result = self.advance_inner(bytes);
563        self.record(result)
564    }
565    fn advance_inner(&mut self, bytes: &[u8]) -> Result<Step, Error> {
566        if bytes.len() > self.options.exchange.max_response_bytes {
567            return Err(Error::new(ErrorKind::LimitExceeded).at(Phase::Conversation));
568        }
569        let response = ResponseApdu::parse(bytes)?;
570        self.response_bytes = self
571            .response_bytes
572            .checked_add(response.data().len())
573            .ok_or_else(|| Error::new(ErrorKind::LimitExceeded).at(Phase::Conversation))?;
574        if self.response_bytes > self.options.limits.max_total_response_bytes {
575            return Err(Error::new(ErrorKind::LimitExceeded).at(Phase::Conversation));
576        }
577        let result = self
578            .conversation
579            .as_mut()
580            .ok_or_else(state_error)?
581            .advance(response, self.options)?;
582        match result {
583            None => self.exchange(),
584            Some(response) => {
585                self.conversation = None;
586                self.drive(Some(response))
587            }
588        }
589    }
590    fn drive(&mut self, response: Option<ResponseData>) -> Result<Step, Error> {
591        match self
592            .machine
593            .as_mut()
594            .ok_or_else(state_error)?
595            .next(response)?
596        {
597            Action::Done(value) => {
598                self.output = Some(value);
599                self.machine = None;
600                self.conversation = None;
601                self.state = OperationState::Completed;
602                Ok(Step::Done)
603            }
604            Action::Command(command) => {
605                self.conversation = Some(ConversationState::new(command, self.options)?);
606                self.exchange()
607            }
608        }
609    }
610    fn exchange(&mut self) -> Result<Step, Error> {
611        if self.exchanges >= self.options.limits.max_exchanges {
612            return Err(Error::new(ErrorKind::LimitExceeded).at(Phase::Conversation));
613        }
614        self.exchanges += 1;
615        self.state = OperationState::AwaitingResponse;
616        Ok(Step::Exchange)
617    }
618    fn record(&mut self, result: Result<Step, Error>) -> Result<Step, Error> {
619        if let Err(error) = &result {
620            self.progress = self.machine.as_mut().and_then(|m| m.take_progress());
621            self.machine = None;
622            self.conversation = None;
623            self.error = Some(error.clone());
624            self.state = OperationState::Failed;
625        }
626        result
627    }
628}
629fn state_error() -> Error {
630    Error::new(ErrorKind::OperationStateError)
631}
632struct SingleCommand {
633    command: Option<LogicalCommand>,
634}
635impl Machine<ResponseData> for SingleCommand {
636    fn next(&mut self, response: Option<ResponseData>) -> Result<Action<ResponseData>, Error> {
637        match response {
638            Some(response) => Ok(Action::Done(response)),
639            None => Ok(Action::Command(
640                self.command.take().ok_or_else(state_error)?,
641            )),
642        }
643    }
644}
645/// Construct a low-level operation that returns the final raw status.
646///
647/// Unlike applet factories, this does not SELECT, authenticate, or turn a final
648/// non-9000 status into an applet error. It handles only the command's declared
649/// chaining, continuation and Le-correction policy.
650///
651/// # Errors
652/// Returns an error before execution for invalid options, input/encoding limits,
653/// or a command that cannot fit the channel under its permitted encoding policy.
654pub fn conversation(
655    command: LogicalCommand,
656    options: OperationOptions,
657) -> Result<Operation<ResponseData>, Error> {
658    validate_command(&command, options)?;
659    Operation::from_machine(
660        SingleCommand {
661            command: Some(command),
662        },
663        options,
664    )
665}
666
667/// Preflight a fully known logical command without sending or retaining it.
668///
669/// # Errors
670/// Returns invalid-option, input-limit, or physical-encoding errors that would
671/// otherwise occur when constructing the command's conversation. This validates
672/// host encoding constraints, not card support or authentication.
673pub fn validate_command(command: &LogicalCommand, options: OperationOptions) -> Result<(), Error> {
674    ConversationState::new(command.clone(), options.validate()?).map(|_| ())
675}