oxide-batch-repository 0.5.0

Internal OxideBatch implementation crate; use oxide-batch instead
Documentation
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
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! The bounded operator request envelope shared by every audited action.
//!
//! The envelope validates bounded closed-charset references, classifies the
//! authorization a deployment must grant, and computes the canonical request
//! digest that makes one operation identifier replayable. It never
//! authenticates a caller, never accepts a credential, and never treats the
//! supplied actor reference as proof of authorization.

use std::error::Error;
use std::fmt;

use sha2::{Digest, Sha256};

use oxide_batch_core::{DefinitionIdentity, ExecutionVersion};

use crate::RecoveryDirective;

/// Maximum accepted UTF-8 bytes of an opaque actor reference.
pub const MAX_ACTOR_REF_BYTES: usize = 128;
/// Maximum accepted UTF-8 bytes of a closed-set reason code.
pub const MAX_REASON_CODE_BYTES: usize = 64;
/// Maximum accepted UTF-8 bytes of a caller-supplied idempotency key.
pub const MAX_OPERATION_ID_BYTES: usize = 64;

/// A mutating action a deployment authorizes and the core guards.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[non_exhaustive]
pub enum OperatorAction {
    /// Create the instance when required and one `STARTING` execution.
    Launch,
    /// Create another execution attempt from the committed checkpoint.
    Restart,
    /// Durably record a cooperative stop request.
    Stop,
    /// Make a stopped, failed, or recovered execution permanently terminal.
    Abandon,
    /// Append one evidence-bound recovery decision and apply its result.
    Recover,
}

impl OperatorAction {
    /// Returns the stable durable code for this action.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Launch => "LAUNCH",
            Self::Restart => "RESTART",
            Self::Stop => "STOP",
            Self::Abandon => "ABANDON",
            Self::Recover => "RECOVER",
        }
    }

    /// Returns the class a deployment authorizes separately.
    #[must_use]
    pub const fn authorization_class(self) -> AuthorizationClass {
        match self {
            Self::Launch | Self::Restart | Self::Stop => AuthorizationClass::Lifecycle,
            Self::Abandon | Self::Recover => AuthorizationClass::Destructive,
        }
    }
}

impl fmt::Display for OperatorAction {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

/// The separately authorizable class of a service call.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[non_exhaustive]
pub enum AuthorizationClass {
    /// Every explorer query and every retention plan.
    Read,
    /// Launch, restart, and stop.
    Lifecycle,
    /// Abandon, recover, hold, hold release, and purge application.
    Destructive,
}

impl AuthorizationClass {
    /// Returns the stable durable code for this class.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Read => "READ",
            Self::Lifecycle => "LIFECYCLE",
            Self::Destructive => "DESTRUCTIVE",
        }
    }
}

impl fmt::Display for AuthorizationClass {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

macro_rules! bounded_reference {
    (
        $(#[$meta:meta])*
        $name:ident, $field:expr, $max:expr, $allowed:expr
    ) => {
        $(#[$meta])*
        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
        pub struct $name(String);

        impl $name {
            /// Validates a bounded closed-charset reference.
            ///
            /// # Errors
            ///
            /// Returns [`RequestFieldError`] when the value is empty, exceeds
            /// its byte bound, or contains a character outside the closed set.
            pub fn new(value: impl Into<String>) -> Result<Self, RequestFieldError> {
                let value = value.into();
                validate_reference(&value, $field, $max, $allowed)?;
                Ok(Self(value))
            }

            /// Borrows the validated reference.
            #[must_use]
            pub fn as_str(&self) -> &str {
                &self.0
            }
        }

        impl fmt::Display for $name {
            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
                formatter.write_str(&self.0)
            }
        }
    };
}

bounded_reference!(
    /// Deployment-supplied opaque reference to the authorized caller.
    ///
    /// The core never authenticates this value and never treats it as proof of
    /// authorization. It is an audit correlation, never a credential.
    ActorRef,
    RequestField::ActorRef,
    MAX_ACTOR_REF_BYTES,
    is_actor_character
);

bounded_reference!(
    /// Bounded closed-set machine reason code.
    ///
    /// Reason codes are uppercase machine vocabulary rather than operator
    /// prose, so audit records contain no free text.
    ReasonCode,
    RequestField::ReasonCode,
    MAX_REASON_CODE_BYTES,
    is_reason_character
);

bounded_reference!(
    /// Caller-supplied idempotency key for one mutating action.
    OperationId,
    RequestField::OperationId,
    MAX_OPERATION_ID_BYTES,
    is_operation_character
);

const fn is_actor_character(value: char) -> bool {
    value.is_ascii_alphanumeric() || matches!(value, '.' | '_' | ':' | '@' | '-')
}

const fn is_reason_character(value: char) -> bool {
    value.is_ascii_uppercase() || value.is_ascii_digit() || value == '_'
}

const fn is_operation_character(value: char) -> bool {
    value.is_ascii_alphanumeric() || matches!(value, '.' | '_' | ':' | '-')
}

fn validate_reference(
    value: &str,
    field: RequestField,
    max_bytes: usize,
    allowed: fn(char) -> bool,
) -> Result<(), RequestFieldError> {
    if value.is_empty() {
        return Err(RequestFieldError::Empty { field });
    }
    if value.len() > max_bytes {
        return Err(RequestFieldError::TooLong { field, max_bytes });
    }
    if !value.chars().all(allowed) {
        return Err(RequestFieldError::InvalidCharacter { field });
    }
    Ok(())
}

/// A bounded request-envelope field category.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum RequestField {
    /// Opaque authorized-caller reference.
    ActorRef,
    /// Closed-set machine reason code.
    ReasonCode,
    /// Caller-supplied idempotency key.
    OperationId,
}

impl RequestField {
    const fn as_str(self) -> &'static str {
        match self {
            Self::ActorRef => "actor reference",
            Self::ReasonCode => "reason code",
            Self::OperationId => "operation identifier",
        }
    }
}

/// An invalid bounded request-envelope field.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum RequestFieldError {
    /// The field was empty.
    Empty {
        /// Rejected field.
        field: RequestField,
    },
    /// The field exceeded its UTF-8 byte bound.
    TooLong {
        /// Rejected field.
        field: RequestField,
        /// Maximum accepted UTF-8 bytes.
        max_bytes: usize,
    },
    /// The field contained a character outside its closed set.
    InvalidCharacter {
        /// Rejected field.
        field: RequestField,
    },
}

impl fmt::Display for RequestFieldError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty { field } => write!(formatter, "{} must not be empty", field.as_str()),
            Self::TooLong { field, max_bytes } => {
                write!(formatter, "{} exceeds {max_bytes} bytes", field.as_str())
            }
            Self::InvalidCharacter { field } => write!(
                formatter,
                "{} contains an unaccepted character",
                field.as_str()
            ),
        }
    }
}

impl Error for RequestFieldError {}

/// A framework-computed SHA-256 digest of one canonical request.
///
/// The digest covers the action, target identity, expected version, and
/// bounded arguments. It never covers the actor reference, so replaying an
/// operation identifier from a different authorized caller is still a replay
/// of the same request rather than a conflict.
#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RequestDigest([u8; 32]);

impl RequestDigest {
    /// Reconstructs a digest recorded by a repository.
    #[must_use]
    pub const fn from_bytes(value: [u8; 32]) -> Self {
        Self(value)
    }

    /// Returns the raw digest bytes.
    #[must_use]
    pub const fn as_bytes(&self) -> &[u8; 32] {
        &self.0
    }

    /// Returns the lowercase hexadecimal encoding of the digest.
    #[must_use]
    pub fn to_hex(&self) -> String {
        hex_digest(&self.0)
    }
}

impl fmt::Debug for RequestDigest {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_tuple("RequestDigest")
            .field(&self.to_hex())
            .finish()
    }
}

impl fmt::Display for RequestDigest {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.to_hex())
    }
}

/// Renders bytes as their lowercase hexadecimal encoding.
#[doc(hidden)]
#[must_use]
pub fn hex_digest(value: &[u8]) -> String {
    let mut encoded = String::with_capacity(value.len() * 2);
    for byte in value {
        // Hexadecimal formatting of a byte cannot fail on a `String`.
        let _ = fmt::Write::write_fmt(&mut encoded, format_args!("{byte:02x}"));
    }
    encoded
}

/// Deterministic canonical encoder for digest inputs.
#[derive(Default)]
pub(crate) struct CanonicalWriter {
    bytes: Vec<u8>,
}

impl CanonicalWriter {
    pub(crate) fn new(tag: &str) -> Self {
        let mut writer = Self::default();
        writer.push_bytes(tag.as_bytes());
        writer
    }

    pub(crate) fn push_bytes(&mut self, value: &[u8]) {
        let length = u64::try_from(value.len()).unwrap_or(u64::MAX);
        self.bytes.extend_from_slice(&length.to_be_bytes());
        self.bytes.extend_from_slice(value);
    }

    pub(crate) fn push_str(&mut self, value: &str) {
        self.push_bytes(value.as_bytes());
    }

    pub(crate) fn push_u64(&mut self, value: u64) {
        self.bytes.extend_from_slice(&value.to_be_bytes());
    }

    pub(crate) fn push_optional_u64(&mut self, value: Option<u64>) {
        match value {
            Some(value) => {
                self.bytes.push(1);
                self.push_u64(value);
            }
            None => self.bytes.push(0),
        }
    }

    pub(crate) fn digest(&self) -> [u8; 32] {
        let mut hasher = Sha256::new();
        hasher.update(&self.bytes);
        hasher.finalize().into()
    }
}

/// Bounded arguments of one operator action that participate in its digest.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum RequestArguments {
    Definition(Box<DefinitionIdentity>),
    None,
    Recovery {
        directive: RecoveryDirective,
        evidence_digest: [u8; 32],
        unknown_commit: bool,
    },
}

pub(crate) fn request_digest(
    action: OperatorAction,
    target: &str,
    expected_version: Option<ExecutionVersion>,
    reason: Option<&ReasonCode>,
    arguments: &RequestArguments,
) -> RequestDigest {
    let mut writer = CanonicalWriter::new("oxide-batch.operator-request.v1");
    writer.push_str(action.as_str());
    writer.push_str(target);
    writer.push_optional_u64(expected_version.map(ExecutionVersion::get));
    writer.push_str(reason.map_or("", ReasonCode::as_str));
    match arguments {
        RequestArguments::None => writer.push_str("NONE"),
        RequestArguments::Definition(definition) => {
            writer.push_str("DEFINITION");
            writer.push_str(definition.revision().as_str());
            writer.push_bytes(definition.manifest_digest());
        }
        RequestArguments::Recovery {
            directive,
            evidence_digest,
            unknown_commit,
        } => {
            writer.push_str("RECOVERY");
            writer.push_str(directive.disposition().resulting_status().as_str());
            writer.push_bytes(evidence_digest);
            writer.push_u64(u64::from(*unknown_commit));
            match directive.failure() {
                Some(failure) => {
                    writer.push_str(failure.category().as_str());
                    writer.push_u64(failure.failure_id().get());
                }
                None => writer.push_str(""),
            }
        }
    }
    RequestDigest::from_bytes(writer.digest())
}