1use std::error::Error;
10use std::fmt;
11
12use sha2::{Digest, Sha256};
13
14use oxide_batch_core::{DefinitionIdentity, ExecutionVersion};
15
16use crate::RecoveryDirective;
17
18pub const MAX_ACTOR_REF_BYTES: usize = 128;
20pub const MAX_REASON_CODE_BYTES: usize = 64;
22pub const MAX_OPERATION_ID_BYTES: usize = 64;
24
25#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
27#[non_exhaustive]
28pub enum OperatorAction {
29 Launch,
31 Restart,
33 Stop,
35 Abandon,
37 Recover,
39}
40
41impl OperatorAction {
42 #[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 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
72#[non_exhaustive]
73pub enum AuthorizationClass {
74 Read,
76 Lifecycle,
78 Destructive,
80}
81
82impl AuthorizationClass {
83 #[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 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 #[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 ActorRef,
143 RequestField::ActorRef,
144 MAX_ACTOR_REF_BYTES,
145 is_actor_character
146);
147
148bounded_reference!(
149 ReasonCode,
154 RequestField::ReasonCode,
155 MAX_REASON_CODE_BYTES,
156 is_reason_character
157);
158
159bounded_reference!(
160 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
199#[non_exhaustive]
200pub enum RequestField {
201 ActorRef,
203 ReasonCode,
205 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
221#[non_exhaustive]
222pub enum RequestFieldError {
223 Empty {
225 field: RequestField,
227 },
228 TooLong {
230 field: RequestField,
232 max_bytes: usize,
234 },
235 InvalidCharacter {
237 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#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
267pub struct RequestDigest([u8; 32]);
268
269impl RequestDigest {
270 #[must_use]
272 pub const fn from_bytes(value: [u8; 32]) -> Self {
273 Self(value)
274 }
275
276 #[must_use]
278 pub const fn as_bytes(&self) -> &[u8; 32] {
279 &self.0
280 }
281
282 #[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#[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 let _ = fmt::Write::write_fmt(&mut encoded, format_args!("{byte:02x}"));
312 }
313 encoded
314}
315
316#[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#[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}