Skip to main content

oxide_batch_repository/
request.rs

1//! The bounded operator request envelope shared by every audited action.
2//!
3//! The envelope validates bounded closed-charset references, classifies the
4//! authorization a deployment must grant, and computes the canonical request
5//! digest that makes one operation identifier replayable. It never
6//! authenticates a caller, never accepts a credential, and never treats the
7//! supplied actor reference as proof of authorization.
8
9use std::error::Error;
10use std::fmt;
11
12use sha2::{Digest, Sha256};
13
14use oxide_batch_core::{DefinitionIdentity, ExecutionVersion};
15
16use crate::RecoveryDirective;
17
18/// Maximum accepted UTF-8 bytes of an opaque actor reference.
19pub const MAX_ACTOR_REF_BYTES: usize = 128;
20/// Maximum accepted UTF-8 bytes of a closed-set reason code.
21pub const MAX_REASON_CODE_BYTES: usize = 64;
22/// Maximum accepted UTF-8 bytes of a caller-supplied idempotency key.
23pub const MAX_OPERATION_ID_BYTES: usize = 64;
24
25/// A mutating action a deployment authorizes and the core guards.
26#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
27#[non_exhaustive]
28pub enum OperatorAction {
29    /// Create the instance when required and one `STARTING` execution.
30    Launch,
31    /// Create another execution attempt from the committed checkpoint.
32    Restart,
33    /// Durably record a cooperative stop request.
34    Stop,
35    /// Make a stopped, failed, or recovered execution permanently terminal.
36    Abandon,
37    /// Append one evidence-bound recovery decision and apply its result.
38    Recover,
39}
40
41impl OperatorAction {
42    /// Returns the stable durable code for this action.
43    #[must_use]
44    pub const fn as_str(self) -> &'static str {
45        match self {
46            Self::Launch => "LAUNCH",
47            Self::Restart => "RESTART",
48            Self::Stop => "STOP",
49            Self::Abandon => "ABANDON",
50            Self::Recover => "RECOVER",
51        }
52    }
53
54    /// Returns the class a deployment authorizes separately.
55    #[must_use]
56    pub const fn authorization_class(self) -> AuthorizationClass {
57        match self {
58            Self::Launch | Self::Restart | Self::Stop => AuthorizationClass::Lifecycle,
59            Self::Abandon | Self::Recover => AuthorizationClass::Destructive,
60        }
61    }
62}
63
64impl fmt::Display for OperatorAction {
65    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
66        formatter.write_str(self.as_str())
67    }
68}
69
70/// The separately authorizable class of a service call.
71#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
72#[non_exhaustive]
73pub enum AuthorizationClass {
74    /// Every explorer query and every retention plan.
75    Read,
76    /// Launch, restart, and stop.
77    Lifecycle,
78    /// Abandon, recover, hold, hold release, and purge application.
79    Destructive,
80}
81
82impl AuthorizationClass {
83    /// Returns the stable durable code for this class.
84    #[must_use]
85    pub const fn as_str(self) -> &'static str {
86        match self {
87            Self::Read => "READ",
88            Self::Lifecycle => "LIFECYCLE",
89            Self::Destructive => "DESTRUCTIVE",
90        }
91    }
92}
93
94impl fmt::Display for AuthorizationClass {
95    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96        formatter.write_str(self.as_str())
97    }
98}
99
100macro_rules! bounded_reference {
101    (
102        $(#[$meta:meta])*
103        $name:ident, $field:expr, $max:expr, $allowed:expr
104    ) => {
105        $(#[$meta])*
106        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
107        pub struct $name(String);
108
109        impl $name {
110            /// Validates a bounded closed-charset reference.
111            ///
112            /// # Errors
113            ///
114            /// Returns [`RequestFieldError`] when the value is empty, exceeds
115            /// its byte bound, or contains a character outside the closed set.
116            pub fn new(value: impl Into<String>) -> Result<Self, RequestFieldError> {
117                let value = value.into();
118                validate_reference(&value, $field, $max, $allowed)?;
119                Ok(Self(value))
120            }
121
122            /// Borrows the validated reference.
123            #[must_use]
124            pub fn as_str(&self) -> &str {
125                &self.0
126            }
127        }
128
129        impl fmt::Display for $name {
130            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
131                formatter.write_str(&self.0)
132            }
133        }
134    };
135}
136
137bounded_reference!(
138    /// Deployment-supplied opaque reference to the authorized caller.
139    ///
140    /// The core never authenticates this value and never treats it as proof of
141    /// authorization. It is an audit correlation, never a credential.
142    ActorRef,
143    RequestField::ActorRef,
144    MAX_ACTOR_REF_BYTES,
145    is_actor_character
146);
147
148bounded_reference!(
149    /// Bounded closed-set machine reason code.
150    ///
151    /// Reason codes are uppercase machine vocabulary rather than operator
152    /// prose, so audit records contain no free text.
153    ReasonCode,
154    RequestField::ReasonCode,
155    MAX_REASON_CODE_BYTES,
156    is_reason_character
157);
158
159bounded_reference!(
160    /// Caller-supplied idempotency key for one mutating action.
161    OperationId,
162    RequestField::OperationId,
163    MAX_OPERATION_ID_BYTES,
164    is_operation_character
165);
166
167const fn is_actor_character(value: char) -> bool {
168    value.is_ascii_alphanumeric() || matches!(value, '.' | '_' | ':' | '@' | '-')
169}
170
171const fn is_reason_character(value: char) -> bool {
172    value.is_ascii_uppercase() || value.is_ascii_digit() || value == '_'
173}
174
175const fn is_operation_character(value: char) -> bool {
176    value.is_ascii_alphanumeric() || matches!(value, '.' | '_' | ':' | '-')
177}
178
179fn validate_reference(
180    value: &str,
181    field: RequestField,
182    max_bytes: usize,
183    allowed: fn(char) -> bool,
184) -> Result<(), RequestFieldError> {
185    if value.is_empty() {
186        return Err(RequestFieldError::Empty { field });
187    }
188    if value.len() > max_bytes {
189        return Err(RequestFieldError::TooLong { field, max_bytes });
190    }
191    if !value.chars().all(allowed) {
192        return Err(RequestFieldError::InvalidCharacter { field });
193    }
194    Ok(())
195}
196
197/// A bounded request-envelope field category.
198#[derive(Clone, Copy, Debug, Eq, PartialEq)]
199#[non_exhaustive]
200pub enum RequestField {
201    /// Opaque authorized-caller reference.
202    ActorRef,
203    /// Closed-set machine reason code.
204    ReasonCode,
205    /// Caller-supplied idempotency key.
206    OperationId,
207}
208
209impl RequestField {
210    const fn as_str(self) -> &'static str {
211        match self {
212            Self::ActorRef => "actor reference",
213            Self::ReasonCode => "reason code",
214            Self::OperationId => "operation identifier",
215        }
216    }
217}
218
219/// An invalid bounded request-envelope field.
220#[derive(Clone, Copy, Debug, Eq, PartialEq)]
221#[non_exhaustive]
222pub enum RequestFieldError {
223    /// The field was empty.
224    Empty {
225        /// Rejected field.
226        field: RequestField,
227    },
228    /// The field exceeded its UTF-8 byte bound.
229    TooLong {
230        /// Rejected field.
231        field: RequestField,
232        /// Maximum accepted UTF-8 bytes.
233        max_bytes: usize,
234    },
235    /// The field contained a character outside its closed set.
236    InvalidCharacter {
237        /// Rejected field.
238        field: RequestField,
239    },
240}
241
242impl fmt::Display for RequestFieldError {
243    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
244        match self {
245            Self::Empty { field } => write!(formatter, "{} must not be empty", field.as_str()),
246            Self::TooLong { field, max_bytes } => {
247                write!(formatter, "{} exceeds {max_bytes} bytes", field.as_str())
248            }
249            Self::InvalidCharacter { field } => write!(
250                formatter,
251                "{} contains an unaccepted character",
252                field.as_str()
253            ),
254        }
255    }
256}
257
258impl Error for RequestFieldError {}
259
260/// A framework-computed SHA-256 digest of one canonical request.
261///
262/// The digest covers the action, target identity, expected version, and
263/// bounded arguments. It never covers the actor reference, so replaying an
264/// operation identifier from a different authorized caller is still a replay
265/// of the same request rather than a conflict.
266#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
267pub struct RequestDigest([u8; 32]);
268
269impl RequestDigest {
270    /// Reconstructs a digest recorded by a repository.
271    #[must_use]
272    pub const fn from_bytes(value: [u8; 32]) -> Self {
273        Self(value)
274    }
275
276    /// Returns the raw digest bytes.
277    #[must_use]
278    pub const fn as_bytes(&self) -> &[u8; 32] {
279        &self.0
280    }
281
282    /// Returns the lowercase hexadecimal encoding of the digest.
283    #[must_use]
284    pub fn to_hex(&self) -> String {
285        hex_digest(&self.0)
286    }
287}
288
289impl fmt::Debug for RequestDigest {
290    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
291        formatter
292            .debug_tuple("RequestDigest")
293            .field(&self.to_hex())
294            .finish()
295    }
296}
297
298impl fmt::Display for RequestDigest {
299    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
300        formatter.write_str(&self.to_hex())
301    }
302}
303
304/// Renders bytes as their lowercase hexadecimal encoding.
305#[doc(hidden)]
306#[must_use]
307pub fn hex_digest(value: &[u8]) -> String {
308    let mut encoded = String::with_capacity(value.len() * 2);
309    for byte in value {
310        // Hexadecimal formatting of a byte cannot fail on a `String`.
311        let _ = fmt::Write::write_fmt(&mut encoded, format_args!("{byte:02x}"));
312    }
313    encoded
314}
315
316/// Deterministic canonical encoder for digest inputs.
317#[derive(Default)]
318pub(crate) struct CanonicalWriter {
319    bytes: Vec<u8>,
320}
321
322impl CanonicalWriter {
323    pub(crate) fn new(tag: &str) -> Self {
324        let mut writer = Self::default();
325        writer.push_bytes(tag.as_bytes());
326        writer
327    }
328
329    pub(crate) fn push_bytes(&mut self, value: &[u8]) {
330        let length = u64::try_from(value.len()).unwrap_or(u64::MAX);
331        self.bytes.extend_from_slice(&length.to_be_bytes());
332        self.bytes.extend_from_slice(value);
333    }
334
335    pub(crate) fn push_str(&mut self, value: &str) {
336        self.push_bytes(value.as_bytes());
337    }
338
339    pub(crate) fn push_u64(&mut self, value: u64) {
340        self.bytes.extend_from_slice(&value.to_be_bytes());
341    }
342
343    pub(crate) fn push_optional_u64(&mut self, value: Option<u64>) {
344        match value {
345            Some(value) => {
346                self.bytes.push(1);
347                self.push_u64(value);
348            }
349            None => self.bytes.push(0),
350        }
351    }
352
353    pub(crate) fn digest(&self) -> [u8; 32] {
354        let mut hasher = Sha256::new();
355        hasher.update(&self.bytes);
356        hasher.finalize().into()
357    }
358}
359
360/// Bounded arguments of one operator action that participate in its digest.
361#[derive(Clone, Debug, Eq, PartialEq)]
362pub(crate) enum RequestArguments {
363    Definition(Box<DefinitionIdentity>),
364    None,
365    Recovery {
366        directive: RecoveryDirective,
367        evidence_digest: [u8; 32],
368        unknown_commit: bool,
369    },
370}
371
372pub(crate) fn request_digest(
373    action: OperatorAction,
374    target: &str,
375    expected_version: Option<ExecutionVersion>,
376    reason: Option<&ReasonCode>,
377    arguments: &RequestArguments,
378) -> RequestDigest {
379    let mut writer = CanonicalWriter::new("oxide-batch.operator-request.v1");
380    writer.push_str(action.as_str());
381    writer.push_str(target);
382    writer.push_optional_u64(expected_version.map(ExecutionVersion::get));
383    writer.push_str(reason.map_or("", ReasonCode::as_str));
384    match arguments {
385        RequestArguments::None => writer.push_str("NONE"),
386        RequestArguments::Definition(definition) => {
387            writer.push_str("DEFINITION");
388            writer.push_str(definition.revision().as_str());
389            writer.push_bytes(definition.manifest_digest());
390        }
391        RequestArguments::Recovery {
392            directive,
393            evidence_digest,
394            unknown_commit,
395        } => {
396            writer.push_str("RECOVERY");
397            writer.push_str(directive.disposition().resulting_status().as_str());
398            writer.push_bytes(evidence_digest);
399            writer.push_u64(u64::from(*unknown_commit));
400            match directive.failure() {
401                Some(failure) => {
402                    writer.push_str(failure.category().as_str());
403                    writer.push_u64(failure.failure_id().get());
404                }
405                None => writer.push_str(""),
406            }
407        }
408    }
409    RequestDigest::from_bytes(writer.digest())
410}