1use std::error::Error;
8use std::fmt::{self, Display, Formatter};
9use std::marker::PhantomData;
10use std::path::Path;
11
12use serde::de::DeserializeOwned;
13use serde::{Deserialize, Serialize};
14
15pub const WORKFLOW_JOURNAL_FORMAT_VERSION: u16 = 1;
16pub const MAX_WORKFLOW_JOURNAL_INTENT_BYTES: usize = 64 * 1024;
17pub const MAX_WORKFLOW_JOURNAL_RECORD_BYTES: usize = 64 * 1024;
18pub const MAX_WORKFLOW_JOURNAL_RECORDS: u64 = 256;
19
20pub(crate) mod sealed {
21 pub trait Sealed {}
22}
23
24#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
26#[serde(rename_all = "snake_case")]
27pub enum LocalOperatorWorkflowKind {
28 DevelopmentMaterialize,
29 DevelopmentPkiSuccessor,
30 DevelopmentReconcile,
31 ExactMutation,
32 ProvisionRetainedAppend,
33 RotateCredential,
34}
35
36pub trait WorkflowJournalIntent: sealed::Sealed + DeserializeOwned + Serialize + Send + Sync + 'static {
41 type Transition: WorkflowJournalTransition;
42
43 const KIND: LocalOperatorWorkflowKind;
44
45 fn validate(&self) -> Result<(), WorkflowJournalError>;
46}
47
48pub trait WorkflowJournalTransition: sealed::Sealed + DeserializeOwned + Serialize + Send + Sync + 'static {
50 fn validate(&self) -> Result<(), WorkflowJournalError>;
51}
52
53#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
55pub struct WorkflowTransactionId([u8; 32]);
56
57impl WorkflowTransactionId {
58 #[must_use]
59 pub const fn as_bytes(self) -> [u8; 32] {
60 self.0
61 }
62
63 fn is_zero(self) -> bool {
64 self.0.iter().all(|byte| *byte == 0)
65 }
66}
67
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70pub struct WorkflowJournalRecordReceipt {
71 sequence: u64,
72 digest: [u8; 32],
73}
74
75impl WorkflowJournalRecordReceipt {
76 #[must_use]
77 pub const fn sequence(self) -> u64 {
78 self.sequence
79 }
80
81 #[must_use]
82 pub const fn digest(self) -> [u8; 32] {
83 self.digest
84 }
85}
86
87pub struct WorkflowJournalReplay<I: WorkflowJournalIntent> {
89 journal: WorkflowJournal<I>,
90 transitions: Vec<I::Transition>,
91}
92
93impl<I: WorkflowJournalIntent> WorkflowJournalReplay<I> {
94 #[must_use]
95 pub const fn journal(&self) -> &WorkflowJournal<I> {
96 &self.journal
97 }
98
99 #[must_use]
100 pub fn transitions(&self) -> &[I::Transition] {
101 &self.transitions
102 }
103
104 #[must_use]
105 pub fn into_parts(self) -> (WorkflowJournal<I>, Vec<I::Transition>) {
106 (self.journal, self.transitions)
107 }
108}
109
110pub struct WorkflowJournal<I: WorkflowJournalIntent> {
112 transaction_id: WorkflowTransactionId,
113 intent_digest: [u8; 32],
114 #[cfg(any(target_os = "linux", target_os = "macos"))]
115 inner: std::sync::Arc<std::sync::Mutex<unix::JournalState>>,
116 marker: PhantomData<I>,
117}
118
119impl<I: WorkflowJournalIntent> WorkflowJournal<I> {
120 pub async fn open_or_create(
122 path: impl AsRef<Path>,
123 intent: I,
124 ) -> Result<WorkflowJournalReplay<I>, WorkflowJournalError> {
125 intent.validate()?;
126 let path = path.as_ref().to_path_buf();
127
128 #[cfg(any(target_os = "linux", target_os = "macos"))]
129 {
130 tokio::task::spawn_blocking(move || unix::open_or_create(path, intent))
131 .await
132 .map_err(|_| WorkflowJournalError::WorkerUnavailable)?
133 }
134
135 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
136 {
137 let _ = (path, intent);
138 Err(WorkflowJournalError::UnsupportedPlatform)
139 }
140 }
141
142 #[must_use]
143 pub const fn transaction_id(&self) -> WorkflowTransactionId {
144 self.transaction_id
145 }
146
147 #[must_use]
148 pub const fn intent_digest(&self) -> [u8; 32] {
149 self.intent_digest
150 }
151
152 pub async fn append(
157 &self,
158 transition: I::Transition,
159 ) -> Result<WorkflowJournalRecordReceipt, WorkflowJournalError> {
160 transition.validate()?;
161
162 #[cfg(any(target_os = "linux", target_os = "macos"))]
163 {
164 let inner = std::sync::Arc::clone(&self.inner);
165 let transaction_id = self.transaction_id;
166 let intent_digest = self.intent_digest;
167 tokio::task::spawn_blocking(move || {
168 let result = unix::append_transition(&inner, transaction_id, intent_digest, transition);
169 drop(inner);
173 result
174 })
175 .await
176 .map_err(|_| WorkflowJournalError::WorkerUnavailable)?
177 }
178
179 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
180 {
181 let _ = transition;
182 Err(WorkflowJournalError::UnsupportedPlatform)
183 }
184 }
185}
186
187#[derive(Clone, Copy, Debug, Eq, PartialEq)]
188pub enum WorkflowJournalError {
189 UnsupportedPlatform,
190 InvalidPath,
191 InsecurePath,
192 Busy,
193 IntentMismatch,
194 InvalidIntent,
195 InvalidTransition,
196 LimitExceeded,
197 Corrupt,
198 Io,
199 Codec,
200 RandomUnavailable,
201 ReopenRequired,
202 WorkerUnavailable,
203}
204
205impl WorkflowJournalError {
206 #[must_use]
207 pub const fn code(self) -> &'static str {
208 match self {
209 Self::UnsupportedPlatform => "unsupported_platform",
210 Self::InvalidPath => "invalid_path",
211 Self::InsecurePath => "insecure_path",
212 Self::Busy => "busy",
213 Self::IntentMismatch => "intent_mismatch",
214 Self::InvalidIntent => "invalid_intent",
215 Self::InvalidTransition => "invalid_transition",
216 Self::LimitExceeded => "limit_exceeded",
217 Self::Corrupt => "corrupt",
218 Self::Io => "io",
219 Self::Codec => "codec",
220 Self::RandomUnavailable => "random_unavailable",
221 Self::ReopenRequired => "reopen_required",
222 Self::WorkerUnavailable => "worker_unavailable",
223 }
224 }
225}
226
227impl Display for WorkflowJournalError {
228 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
229 formatter.write_str(match self {
230 Self::UnsupportedPlatform => "the platform cannot provide the required workflow journal guarantees",
231 Self::InvalidPath => "the workflow journal path is not a canonical absolute directory path",
232 Self::InsecurePath => "the workflow journal path or metadata is not private and no-follow safe",
233 Self::Busy => "the workflow journal is locked by another writer",
234 Self::IntentMismatch => "the workflow journal belongs to different invocation inputs",
235 Self::InvalidIntent => "the workflow intent violates its closed contract",
236 Self::InvalidTransition => "the workflow transition violates its closed contract",
237 Self::LimitExceeded => "the workflow journal exceeds a fixed bound",
238 Self::Corrupt => "the workflow journal is incomplete, forked, or corrupt",
239 Self::Io => "the workflow journal filesystem operation failed",
240 Self::Codec => "the workflow journal value is not canonical current-format JSON",
241 Self::RandomUnavailable => "a workflow transaction identity could not be generated",
242 Self::ReopenRequired => "the workflow journal must be reopened to resolve local publication",
243 Self::WorkerUnavailable => "the bounded filesystem worker did not complete",
244 })
245 }
246}
247
248impl Error for WorkflowJournalError {}
249
250#[derive(Deserialize, Serialize)]
251#[serde(deny_unknown_fields)]
252struct IntentEnvelope<I> {
253 format_version: u16,
254 workflow_kind: LocalOperatorWorkflowKind,
255 transaction_id: WorkflowTransactionId,
256 intent: I,
257}
258
259#[derive(Deserialize, Serialize)]
260#[serde(deny_unknown_fields)]
261struct TransitionEnvelope<T> {
262 format_version: u16,
263 transaction_id: WorkflowTransactionId,
264 intent_digest: [u8; 32],
265 sequence: u64,
266 previous_record_digest: [u8; 32],
267 transition: T,
268}
269
270#[cfg(any(target_os = "linux", target_os = "macos"))]
271mod unix {
272 use std::collections::BTreeSet;
273 use std::ffi::OsStr;
274 use std::fs::File;
275 use std::io::{Read as _, Write as _};
276 use std::marker::PhantomData;
277 use std::os::unix::ffi::OsStrExt as _;
278 use std::path::{Component, Path, PathBuf};
279 use std::sync::{Arc, Mutex};
280
281 use aws_lc_rs::digest;
282 use rustix::fs::{
283 AtFlags, Dir, FileType, FlockOperation, Mode, OFlags, flock, fstat, fsync, linkat, mkdirat, open, openat,
284 unlinkat,
285 };
286 use serde::Serialize;
287 use serde::de::DeserializeOwned;
288
289 use super::{
290 IntentEnvelope, MAX_WORKFLOW_JOURNAL_INTENT_BYTES, MAX_WORKFLOW_JOURNAL_RECORD_BYTES,
291 MAX_WORKFLOW_JOURNAL_RECORDS, TransitionEnvelope, WORKFLOW_JOURNAL_FORMAT_VERSION, WorkflowJournal,
292 WorkflowJournalError, WorkflowJournalIntent, WorkflowJournalRecordReceipt, WorkflowJournalReplay,
293 WorkflowJournalTransition, WorkflowTransactionId,
294 };
295
296 const DIRECTORY_MODE: rustix::fs::RawMode = 0o700;
297 const FILE_MODE: rustix::fs::RawMode = 0o600;
298 const INTENT_NAME: &str = "intent.json";
299 const INTENT_PENDING_NAME: &str = "intent.pending";
300 const LOCK_NAME: &str = "journal.lock";
301 const RECORDS_NAME: &str = "records";
302
303 fn stat_value_u64<T: TryInto<u64>>(value: T) -> Option<u64> {
304 value.try_into().ok()
305 }
306
307 pub(super) struct JournalState {
308 _root: File,
309 records: File,
310 lock: File,
311 last_sequence: u64,
312 last_record_digest: [u8; 32],
313 usable: bool,
314 }
315
316 impl Drop for JournalState {
317 fn drop(&mut self) {
318 let _ = flock(&self.lock, FlockOperation::Unlock);
322 }
323 }
324
325 pub(super) fn open_or_create<I: WorkflowJournalIntent>(
326 path: PathBuf,
327 intent: I,
328 ) -> Result<WorkflowJournalReplay<I>, WorkflowJournalError> {
329 let (parent_path, name) = split_absolute_target(&path)?;
330 let parent = open_directory_chain(parent_path)?;
331 validate_parent_directory(&parent)?;
332
333 let root_created = match mkdirat(&parent, name, Mode::from_raw_mode(DIRECTORY_MODE)) {
334 Ok(()) => true,
335 Err(error) if error == rustix::io::Errno::EXIST => false,
336 Err(_) => return Err(WorkflowJournalError::Io),
337 };
338 if root_created {
339 fsync(&parent).map_err(|_| WorkflowJournalError::Io)?;
340 }
341
342 let root = File::from(open_directory_at(&parent, name)?);
343 validate_private_directory(&root)?;
344 validate_root_names(&root)?;
345
346 let lock = open_lock_file(&root)?;
347 match flock(&lock, FlockOperation::NonBlockingLockExclusive) {
348 Ok(()) => {}
349 Err(error) if error == rustix::io::Errno::AGAIN => return Err(WorkflowJournalError::Busy),
350 Err(_) => return Err(WorkflowJournalError::Io),
351 }
352
353 validate_root_names(&root)?;
356 recover_pending(&root, INTENT_PENDING_NAME, INTENT_NAME)?;
357 let records = open_or_create_records_directory(&root)?;
358 recover_record_pendings(&records)?;
359
360 let current_intent_bytes = encode_bounded(&intent, MAX_WORKFLOW_JOURNAL_INTENT_BYTES)?;
361 let (transaction_id, intent_digest) =
362 match read_optional_file(&root, INTENT_NAME, MAX_WORKFLOW_JOURNAL_INTENT_BYTES)? {
363 Some(bytes) => {
364 let envelope: IntentEnvelope<I> = decode_canonical(&bytes)?;
365 if envelope.format_version != WORKFLOW_JOURNAL_FORMAT_VERSION
366 || envelope.workflow_kind != I::KIND
367 || envelope.transaction_id.is_zero()
368 {
369 return Err(WorkflowJournalError::Corrupt);
370 }
371 envelope.intent.validate()?;
372 if encode_bounded(&envelope.intent, MAX_WORKFLOW_JOURNAL_INTENT_BYTES)? != current_intent_bytes {
373 return Err(WorkflowJournalError::IntentMismatch);
374 }
375 (envelope.transaction_id, sha256(&bytes)?)
376 }
377 None => {
378 let transaction_id = generate_transaction_id()?;
379 let envelope = IntentEnvelope {
380 format_version: WORKFLOW_JOURNAL_FORMAT_VERSION,
381 workflow_kind: I::KIND,
382 transaction_id,
383 intent,
384 };
385 let bytes = encode_bounded(&envelope, MAX_WORKFLOW_JOURNAL_INTENT_BYTES)?;
386 atomic_publish(&root, INTENT_PENDING_NAME, INTENT_NAME, &bytes)?;
387 (transaction_id, sha256(&bytes)?)
388 }
389 };
390
391 let (transitions, last_sequence, last_record_digest) =
392 replay_records::<I::Transition>(&records, transaction_id, intent_digest)?;
393 let journal = WorkflowJournal {
394 transaction_id,
395 intent_digest,
396 inner: Arc::new(Mutex::new(JournalState {
397 _root: root,
398 records,
399 lock,
400 last_sequence,
401 last_record_digest,
402 usable: true,
403 })),
404 marker: PhantomData,
405 };
406 Ok(WorkflowJournalReplay { journal, transitions })
407 }
408
409 pub(super) fn append_transition<T: WorkflowJournalTransition>(
410 inner: &Arc<Mutex<JournalState>>,
411 transaction_id: WorkflowTransactionId,
412 intent_digest: [u8; 32],
413 transition: T,
414 ) -> Result<WorkflowJournalRecordReceipt, WorkflowJournalError> {
415 let mut state = inner.lock().map_err(|_| WorkflowJournalError::ReopenRequired)?;
416 if !state.usable {
417 return Err(WorkflowJournalError::ReopenRequired);
418 }
419 let sequence = state
420 .last_sequence
421 .checked_add(1)
422 .ok_or(WorkflowJournalError::LimitExceeded)?;
423 if sequence > MAX_WORKFLOW_JOURNAL_RECORDS {
424 return Err(WorkflowJournalError::LimitExceeded);
425 }
426 let envelope = TransitionEnvelope {
427 format_version: WORKFLOW_JOURNAL_FORMAT_VERSION,
428 transaction_id,
429 intent_digest,
430 sequence,
431 previous_record_digest: state.last_record_digest,
432 transition,
433 };
434 let bytes = encode_bounded(&envelope, MAX_WORKFLOW_JOURNAL_RECORD_BYTES)?;
435 let digest = sha256(&bytes)?;
436 let final_name = record_name(sequence);
437 let pending_name = pending_record_name(sequence);
438 if let Err(error) = atomic_publish(&state.records, &pending_name, &final_name, &bytes) {
439 state.usable = false;
440 return Err(error);
441 }
442 state.last_sequence = sequence;
443 state.last_record_digest = digest;
444 Ok(WorkflowJournalRecordReceipt { sequence, digest })
445 }
446
447 fn split_absolute_target(path: &Path) -> Result<(&Path, &OsStr), WorkflowJournalError> {
448 if !path.is_absolute()
449 || path.components().any(|component| {
450 matches!(
451 component,
452 Component::CurDir | Component::ParentDir | Component::Prefix(_)
453 )
454 })
455 {
456 return Err(WorkflowJournalError::InvalidPath);
457 }
458 let parent = path.parent().ok_or(WorkflowJournalError::InvalidPath)?;
459 let name = path.file_name().ok_or(WorkflowJournalError::InvalidPath)?;
460 if name.as_bytes().is_empty() || name.as_bytes().contains(&0) {
461 return Err(WorkflowJournalError::InvalidPath);
462 }
463 Ok((parent, name))
464 }
465
466 fn open_directory_chain(path: &Path) -> Result<File, WorkflowJournalError> {
467 let mut components = path.components();
468 if !matches!(components.next(), Some(Component::RootDir)) {
469 return Err(WorkflowJournalError::InvalidPath);
470 }
471 let flags = directory_open_flags();
472 let mut current = File::from(open("/", flags, Mode::empty()).map_err(map_open_error)?);
473 for component in components {
474 let Component::Normal(name) = component else {
475 return Err(WorkflowJournalError::InvalidPath);
476 };
477 current = File::from(openat(¤t, name, flags, Mode::empty()).map_err(map_open_error)?);
478 }
479 Ok(current)
480 }
481
482 fn open_directory_at(parent: &File, name: &OsStr) -> Result<std::os::fd::OwnedFd, WorkflowJournalError> {
483 openat(parent, name, directory_open_flags(), Mode::empty()).map_err(map_open_error)
484 }
485
486 const fn directory_open_flags() -> OFlags {
487 OFlags::RDONLY
488 .union(OFlags::DIRECTORY)
489 .union(OFlags::CLOEXEC)
490 .union(OFlags::NOFOLLOW)
491 }
492
493 fn validate_parent_directory(directory: &File) -> Result<(), WorkflowJournalError> {
494 let stat = fstat(directory).map_err(|_| WorkflowJournalError::Io)?;
495 if FileType::from_raw_mode(stat.st_mode) != FileType::Directory
496 || stat.st_uid != rustix::process::geteuid().as_raw()
497 || stat.st_mode & 0o022 != 0
498 {
499 return Err(WorkflowJournalError::InsecurePath);
500 }
501 Ok(())
502 }
503
504 fn validate_private_directory(directory: &File) -> Result<(), WorkflowJournalError> {
505 let stat = fstat(directory).map_err(|_| WorkflowJournalError::Io)?;
506 if FileType::from_raw_mode(stat.st_mode) != FileType::Directory
507 || stat.st_uid != rustix::process::geteuid().as_raw()
508 || stat.st_mode & 0o777 != DIRECTORY_MODE
509 {
510 return Err(WorkflowJournalError::InsecurePath);
511 }
512 Ok(())
513 }
514
515 fn validate_root_names(root: &File) -> Result<(), WorkflowJournalError> {
516 let names = list_names(root)?;
517 if names
518 .iter()
519 .any(|name| name != LOCK_NAME && name != INTENT_NAME && name != INTENT_PENDING_NAME && name != RECORDS_NAME)
520 {
521 return Err(WorkflowJournalError::Corrupt);
522 }
523 Ok(())
524 }
525
526 fn open_lock_file(root: &File) -> Result<File, WorkflowJournalError> {
527 let (lock, created) = open_or_create_private_file(root, LOCK_NAME, true)?;
528 let stat = validate_private_file(&lock, Some(0), 1)?;
529 if stat.st_size != 0 {
530 return Err(WorkflowJournalError::Corrupt);
531 }
532 if created {
533 lock.sync_all().map_err(|_| WorkflowJournalError::Io)?;
534 fsync(root).map_err(|_| WorkflowJournalError::Io)?;
535 }
536 Ok(lock)
537 }
538
539 fn open_or_create_records_directory(root: &File) -> Result<File, WorkflowJournalError> {
540 let created = match mkdirat(root, RECORDS_NAME, Mode::from_raw_mode(DIRECTORY_MODE)) {
541 Ok(()) => true,
542 Err(error) if error == rustix::io::Errno::EXIST => false,
543 Err(_) => return Err(WorkflowJournalError::Io),
544 };
545 if created {
546 fsync(root).map_err(|_| WorkflowJournalError::Io)?;
547 }
548 let records =
549 File::from(openat(root, RECORDS_NAME, directory_open_flags(), Mode::empty()).map_err(map_open_error)?);
550 validate_private_directory(&records)?;
551 Ok(records)
552 }
553
554 fn open_or_create_private_file(
555 directory: &File,
556 name: &str,
557 read_write: bool,
558 ) -> Result<(File, bool), WorkflowJournalError> {
559 let access = if read_write { OFlags::RDWR } else { OFlags::WRONLY };
560 let create_flags = access | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW;
561 match openat(directory, name, create_flags, Mode::from_raw_mode(FILE_MODE)) {
562 Ok(fd) => Ok((File::from(fd), true)),
563 Err(error) if error == rustix::io::Errno::EXIST => {
564 let flags = access | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK;
565 let file = File::from(openat(directory, name, flags, Mode::empty()).map_err(map_open_error)?);
566 Ok((file, false))
567 }
568 Err(error) => Err(map_open_error(error)),
569 }
570 }
571
572 fn recover_record_pendings(records: &File) -> Result<(), WorkflowJournalError> {
573 let names = list_names(records)?;
574 for name in &names {
575 if let Some(sequence) = parse_pending_record_name(name)? {
576 if sequence == 0 || sequence > MAX_WORKFLOW_JOURNAL_RECORDS {
577 return Err(WorkflowJournalError::Corrupt);
578 }
579 recover_pending(records, name, &record_name(sequence))?;
580 }
581 }
582 let names = list_names(records)?;
583 if names.iter().any(|name| parse_record_name(name).is_err()) {
584 return Err(WorkflowJournalError::Corrupt);
585 }
586 Ok(())
587 }
588
589 fn recover_pending(directory: &File, pending_name: &str, final_name: &str) -> Result<(), WorkflowJournalError> {
590 let pending = open_optional_file_handle(directory, pending_name, OFlags::RDONLY)?;
591 let final_file = open_optional_file_handle(directory, final_name, OFlags::RDONLY)?;
592 match (pending, final_file) {
593 (None, _) => Ok(()),
594 (Some(pending), None) => {
595 validate_private_file(&pending, None, 1)?;
596 unlinkat(directory, pending_name, AtFlags::empty()).map_err(|_| WorkflowJournalError::Io)?;
597 fsync(directory).map_err(|_| WorkflowJournalError::Io)
598 }
599 (Some(pending), Some(final_file)) => {
600 let pending_stat = validate_private_file(&pending, None, 2)?;
601 let final_stat = validate_private_file(&final_file, None, 2)?;
602 if pending_stat.st_dev != final_stat.st_dev
603 || pending_stat.st_ino != final_stat.st_ino
604 || pending_stat.st_size != final_stat.st_size
605 {
606 return Err(WorkflowJournalError::Corrupt);
607 }
608 unlinkat(directory, pending_name, AtFlags::empty()).map_err(|_| WorkflowJournalError::Io)?;
609 fsync(directory).map_err(|_| WorkflowJournalError::Io)?;
610 validate_private_file(&final_file, None, 1)?;
611 Ok(())
612 }
613 }
614 }
615
616 fn replay_records<T: WorkflowJournalTransition>(
617 records: &File,
618 transaction_id: WorkflowTransactionId,
619 intent_digest: [u8; 32],
620 ) -> Result<(Vec<T>, u64, [u8; 32]), WorkflowJournalError> {
621 let names = list_names(records)?;
622 if names.len()
623 > usize::try_from(MAX_WORKFLOW_JOURNAL_RECORDS).map_err(|_| WorkflowJournalError::LimitExceeded)?
624 {
625 return Err(WorkflowJournalError::LimitExceeded);
626 }
627 let mut expected_sequence = 1_u64;
628 let mut previous_digest = [0_u8; 32];
629 let mut transitions = Vec::with_capacity(names.len());
630 for name in names {
631 let sequence = parse_record_name(&name)?;
632 if sequence != expected_sequence {
633 return Err(WorkflowJournalError::Corrupt);
634 }
635 let bytes = read_file(records, &name, MAX_WORKFLOW_JOURNAL_RECORD_BYTES)?;
636 let envelope: TransitionEnvelope<T> = decode_canonical(&bytes)?;
637 if envelope.format_version != WORKFLOW_JOURNAL_FORMAT_VERSION
638 || envelope.transaction_id != transaction_id
639 || envelope.intent_digest != intent_digest
640 || envelope.sequence != sequence
641 || envelope.previous_record_digest != previous_digest
642 {
643 return Err(WorkflowJournalError::Corrupt);
644 }
645 envelope.transition.validate()?;
646 previous_digest = sha256(&bytes)?;
647 transitions.push(envelope.transition);
648 expected_sequence = expected_sequence
649 .checked_add(1)
650 .ok_or(WorkflowJournalError::LimitExceeded)?;
651 }
652 Ok((transitions, expected_sequence.saturating_sub(1), previous_digest))
653 }
654
655 fn atomic_publish(
656 directory: &File,
657 pending_name: &str,
658 final_name: &str,
659 bytes: &[u8],
660 ) -> Result<(), WorkflowJournalError> {
661 if open_optional_file_handle(directory, pending_name, OFlags::RDONLY)?.is_some()
662 || open_optional_file_handle(directory, final_name, OFlags::RDONLY)?.is_some()
663 {
664 return Err(WorkflowJournalError::Corrupt);
665 }
666 let (mut pending, created) = open_or_create_private_file(directory, pending_name, false)?;
667 if !created {
668 return Err(WorkflowJournalError::Corrupt);
669 }
670 validate_private_file(&pending, Some(0), 1)?;
671 pending.write_all(bytes).map_err(|_| WorkflowJournalError::Io)?;
672 pending.sync_all().map_err(|_| WorkflowJournalError::Io)?;
673 linkat(directory, pending_name, directory, final_name, AtFlags::empty())
674 .map_err(|_| WorkflowJournalError::Io)?;
675 fsync(directory).map_err(|_| WorkflowJournalError::Io)?;
676 unlinkat(directory, pending_name, AtFlags::empty()).map_err(|_| WorkflowJournalError::Io)?;
677 fsync(directory).map_err(|_| WorkflowJournalError::Io)?;
678 let final_bytes = read_file(directory, final_name, bytes.len())?;
679 if final_bytes != bytes {
680 return Err(WorkflowJournalError::Corrupt);
681 }
682 Ok(())
683 }
684
685 fn read_optional_file(
686 directory: &File,
687 name: &str,
688 maximum_bytes: usize,
689 ) -> Result<Option<Vec<u8>>, WorkflowJournalError> {
690 let Some(mut file) = open_optional_file_handle(directory, name, OFlags::RDONLY)? else {
691 return Ok(None);
692 };
693 validate_private_file(&file, None, 1)?;
694 read_bounded(&mut file, maximum_bytes).map(Some)
695 }
696
697 fn read_file(directory: &File, name: &str, maximum_bytes: usize) -> Result<Vec<u8>, WorkflowJournalError> {
698 read_optional_file(directory, name, maximum_bytes)?.ok_or(WorkflowJournalError::Corrupt)
699 }
700
701 fn read_bounded(file: &mut File, maximum_bytes: usize) -> Result<Vec<u8>, WorkflowJournalError> {
702 let capacity = maximum_bytes
703 .checked_add(1)
704 .ok_or(WorkflowJournalError::LimitExceeded)?;
705 let limit = u64::try_from(capacity).map_err(|_| WorkflowJournalError::LimitExceeded)?;
706 let mut bytes = Vec::new();
707 file.take(limit)
708 .read_to_end(&mut bytes)
709 .map_err(|_| WorkflowJournalError::Io)?;
710 if bytes.is_empty() || bytes.len() > maximum_bytes {
711 return Err(WorkflowJournalError::LimitExceeded);
712 }
713 Ok(bytes)
714 }
715
716 fn open_optional_file_handle(
717 directory: &File,
718 name: &str,
719 access: OFlags,
720 ) -> Result<Option<File>, WorkflowJournalError> {
721 let flags = access | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK;
724 match openat(directory, name, flags, Mode::empty()) {
725 Ok(fd) => Ok(Some(File::from(fd))),
726 Err(error) if error == rustix::io::Errno::NOENT => Ok(None),
727 Err(error) => Err(map_open_error(error)),
728 }
729 }
730
731 fn validate_private_file(
732 file: &File,
733 expected_size: Option<u64>,
734 expected_links: u64,
735 ) -> Result<rustix::fs::Stat, WorkflowJournalError> {
736 let stat = fstat(file).map_err(|_| WorkflowJournalError::Io)?;
737 let size = u64::try_from(stat.st_size).map_err(|_| WorkflowJournalError::Corrupt)?;
738 let links = stat_value_u64(stat.st_nlink).ok_or(WorkflowJournalError::Corrupt)?;
739 if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile
740 || stat.st_uid != rustix::process::geteuid().as_raw()
741 || stat.st_mode & 0o777 != FILE_MODE
742 || links != expected_links
743 || expected_size.is_some_and(|expected| expected != size)
744 {
745 return Err(WorkflowJournalError::InsecurePath);
746 }
747 Ok(stat)
748 }
749
750 fn list_names(directory: &File) -> Result<BTreeSet<String>, WorkflowJournalError> {
751 let mut names = BTreeSet::new();
752 let entries = Dir::read_from(directory).map_err(|_| WorkflowJournalError::Io)?;
753 for entry in entries {
754 let entry = entry.map_err(|_| WorkflowJournalError::Io)?;
755 let bytes = entry.file_name().to_bytes();
756 if bytes == b"." || bytes == b".." {
757 continue;
758 }
759 let name = std::str::from_utf8(bytes).map_err(|_| WorkflowJournalError::Corrupt)?;
760 if !names.insert(name.to_owned()) {
761 return Err(WorkflowJournalError::Corrupt);
762 }
763 }
764 Ok(names)
765 }
766
767 fn parse_record_name(name: &str) -> Result<u64, WorkflowJournalError> {
768 let Some(digits) = name.strip_suffix(".json") else {
769 return Err(WorkflowJournalError::Corrupt);
770 };
771 parse_sequence_digits(digits)
772 }
773
774 fn parse_pending_record_name(name: &str) -> Result<Option<u64>, WorkflowJournalError> {
775 let Some(rest) = name.strip_prefix('.') else {
776 return Ok(None);
777 };
778 let Some(digits) = rest.strip_suffix(".pending") else {
779 return Err(WorkflowJournalError::Corrupt);
780 };
781 parse_sequence_digits(digits).map(Some)
782 }
783
784 fn parse_sequence_digits(digits: &str) -> Result<u64, WorkflowJournalError> {
785 if digits.len() != 20 || digits.bytes().any(|byte| !byte.is_ascii_digit()) {
786 return Err(WorkflowJournalError::Corrupt);
787 }
788 let sequence = digits.parse::<u64>().map_err(|_| WorkflowJournalError::Corrupt)?;
789 if record_name(sequence) != format!("{digits}.json") {
790 return Err(WorkflowJournalError::Corrupt);
791 }
792 Ok(sequence)
793 }
794
795 fn record_name(sequence: u64) -> String {
796 format!("{sequence:020}.json")
797 }
798
799 fn pending_record_name(sequence: u64) -> String {
800 format!(".{sequence:020}.pending")
801 }
802
803 fn encode_bounded<T: Serialize>(value: &T, maximum_bytes: usize) -> Result<Vec<u8>, WorkflowJournalError> {
804 let bytes = serde_json::to_vec(value).map_err(|_| WorkflowJournalError::Codec)?;
805 if bytes.is_empty() || bytes.len() > maximum_bytes {
806 return Err(WorkflowJournalError::LimitExceeded);
807 }
808 Ok(bytes)
809 }
810
811 fn decode_canonical<T: DeserializeOwned + Serialize>(bytes: &[u8]) -> Result<T, WorkflowJournalError> {
812 let value = serde_json::from_slice(bytes).map_err(|_| WorkflowJournalError::Codec)?;
813 if serde_json::to_vec(&value).map_err(|_| WorkflowJournalError::Codec)? != bytes {
814 return Err(WorkflowJournalError::Codec);
815 }
816 Ok(value)
817 }
818
819 fn sha256(bytes: &[u8]) -> Result<[u8; 32], WorkflowJournalError> {
820 let value = digest::digest(&digest::SHA256, bytes);
821 let source = value.as_ref();
822 if source.len() != 32 {
823 return Err(WorkflowJournalError::Codec);
824 }
825 let mut output = [0_u8; 32];
826 output.copy_from_slice(source);
827 Ok(output)
828 }
829
830 fn generate_transaction_id() -> Result<WorkflowTransactionId, WorkflowJournalError> {
831 let mut bytes = [0_u8; 32];
832 getrandom::fill(&mut bytes).map_err(|_| WorkflowJournalError::RandomUnavailable)?;
833 let identity = WorkflowTransactionId(bytes);
834 if identity.is_zero() {
835 return Err(WorkflowJournalError::RandomUnavailable);
836 }
837 Ok(identity)
838 }
839
840 fn map_open_error(error: rustix::io::Errno) -> WorkflowJournalError {
841 if error == rustix::io::Errno::LOOP || error == rustix::io::Errno::NOTDIR {
842 WorkflowJournalError::InsecurePath
843 } else {
844 WorkflowJournalError::Io
845 }
846 }
847}
848
849#[cfg(test)]
850mod tests {
851 use std::path::PathBuf;
852
853 use super::*;
854
855 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
856 #[serde(deny_unknown_fields)]
857 struct TestIntent {
858 value: String,
859 }
860
861 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
862 #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
863 enum TestTransition {
864 EffectPrepared { action: u64 },
865 EffectObserved { action: u64 },
866 }
867
868 impl sealed::Sealed for TestIntent {}
869 impl WorkflowJournalIntent for TestIntent {
870 type Transition = TestTransition;
871
872 const KIND: LocalOperatorWorkflowKind = LocalOperatorWorkflowKind::RotateCredential;
873
874 fn validate(&self) -> Result<(), WorkflowJournalError> {
875 if self.value.is_empty() || self.value.len() > 128 {
876 return Err(WorkflowJournalError::InvalidIntent);
877 }
878 Ok(())
879 }
880 }
881
882 impl sealed::Sealed for TestTransition {}
883 impl WorkflowJournalTransition for TestTransition {
884 fn validate(&self) -> Result<(), WorkflowJournalError> {
885 let action = match self {
886 Self::EffectPrepared { action } | Self::EffectObserved { action } => *action,
887 };
888 if action == 0 {
889 return Err(WorkflowJournalError::InvalidTransition);
890 }
891 Ok(())
892 }
893 }
894
895 #[cfg(any(target_os = "linux", target_os = "macos"))]
896 struct TestDirectory(PathBuf);
897
898 #[cfg(any(target_os = "linux", target_os = "macos"))]
899 impl TestDirectory {
900 fn create() -> Result<Self, Box<dyn Error>> {
901 use std::os::unix::fs::PermissionsExt as _;
902
903 let mut nonce = [0_u8; 16];
904 getrandom::fill(&mut nonce)?;
905 let suffix: String = nonce.iter().map(|byte| format!("{byte:02x}")).collect();
906 let path = std::env::temp_dir()
907 .canonicalize()?
908 .join(format!("lodestone-workflow-journal-{suffix}"));
909 std::fs::create_dir(&path)?;
910 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))?;
911 Ok(Self(path))
912 }
913
914 fn journal(&self) -> PathBuf {
915 self.0.join("transaction")
916 }
917 }
918
919 #[cfg(any(target_os = "linux", target_os = "macos"))]
920 impl Drop for TestDirectory {
921 fn drop(&mut self) {
922 let _ = std::fs::remove_dir_all(&self.0);
923 }
924 }
925
926 fn intent(value: &str) -> TestIntent {
927 TestIntent {
928 value: value.to_owned(),
929 }
930 }
931
932 #[cfg(any(target_os = "linux", target_os = "macos"))]
933 #[tokio::test]
934 async fn create_append_and_exact_replay_are_canonical_and_private() -> Result<(), Box<dyn Error>> {
935 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
936
937 let directory = TestDirectory::create()?;
938 let path = directory.journal();
939 let replay = WorkflowJournal::<TestIntent>::open_or_create(&path, intent("same")).await?;
940 assert!(replay.transitions().is_empty());
941 let transaction_id = replay.journal().transaction_id();
942 let first = replay
943 .journal()
944 .append(TestTransition::EffectPrepared { action: 7 })
945 .await?;
946 assert_eq!(first.sequence(), 1);
947 drop(replay);
948
949 let resumed = WorkflowJournal::<TestIntent>::open_or_create(&path, intent("same")).await?;
950 assert_eq!(resumed.journal().transaction_id(), transaction_id);
951 assert_eq!(resumed.transitions(), &[TestTransition::EffectPrepared { action: 7 }]);
952 let second = resumed
953 .journal()
954 .append(TestTransition::EffectObserved { action: 7 })
955 .await?;
956 assert_eq!(second.sequence(), 2);
957
958 let root = std::fs::metadata(&path)?;
959 let intent_metadata = std::fs::metadata(path.join("intent.json"))?;
960 let record_metadata = std::fs::metadata(path.join("records/00000000000000000001.json"))?;
961 assert_eq!(root.permissions().mode() & 0o777, 0o700);
962 assert_eq!(intent_metadata.permissions().mode() & 0o777, 0o600);
963 assert_eq!(intent_metadata.nlink(), 1);
964 assert_eq!(record_metadata.permissions().mode() & 0o777, 0o600);
965 assert_eq!(record_metadata.nlink(), 1);
966 Ok(())
967 }
968
969 #[cfg(any(target_os = "linux", target_os = "macos"))]
970 #[tokio::test]
971 async fn concurrent_writer_and_different_intent_fail_closed() -> Result<(), Box<dyn Error>> {
972 let directory = TestDirectory::create()?;
973 let path = directory.journal();
974 let first = WorkflowJournal::<TestIntent>::open_or_create(&path, intent("same")).await?;
975 let busy = WorkflowJournal::<TestIntent>::open_or_create(&path, intent("same")).await;
976 assert!(matches!(busy, Err(WorkflowJournalError::Busy)));
977 drop(first);
978 let mismatch = WorkflowJournal::<TestIntent>::open_or_create(&path, intent("different")).await;
979 match mismatch {
980 Err(WorkflowJournalError::IntentMismatch) => {}
981 Err(error) => return Err(format!("unexpected mismatch result: {error:?}").into()),
982 Ok(_) => return Err("different intent unexpectedly reopened the journal".into()),
983 }
984 Ok(())
985 }
986
987 #[cfg(any(target_os = "linux", target_os = "macos"))]
988 #[tokio::test]
989 async fn noncanonical_record_gap_and_unknown_file_are_refused() -> Result<(), Box<dyn Error>> {
990 use std::io::Write as _;
991
992 let directory = TestDirectory::create()?;
993 let path = directory.journal();
994 let replay = WorkflowJournal::<TestIntent>::open_or_create(&path, intent("same")).await?;
995 replay
996 .journal()
997 .append(TestTransition::EffectPrepared { action: 7 })
998 .await?;
999 drop(replay);
1000
1001 let record = path.join("records/00000000000000000001.json");
1002 std::fs::OpenOptions::new()
1003 .append(true)
1004 .open(&record)?
1005 .write_all(b" ")?;
1006 let noncanonical = WorkflowJournal::<TestIntent>::open_or_create(&path, intent("same")).await;
1007 assert!(matches!(noncanonical, Err(WorkflowJournalError::Codec)));
1008
1009 std::fs::remove_dir_all(&path)?;
1010 let replay = WorkflowJournal::<TestIntent>::open_or_create(&path, intent("same")).await?;
1011 replay
1012 .journal()
1013 .append(TestTransition::EffectPrepared { action: 7 })
1014 .await?;
1015 drop(replay);
1016 std::fs::rename(
1017 path.join("records/00000000000000000001.json"),
1018 path.join("records/00000000000000000002.json"),
1019 )?;
1020 let gap = WorkflowJournal::<TestIntent>::open_or_create(&path, intent("same")).await;
1021 assert!(matches!(gap, Err(WorkflowJournalError::Corrupt)));
1022
1023 std::fs::remove_dir_all(&path)?;
1024 let replay = WorkflowJournal::<TestIntent>::open_or_create(&path, intent("same")).await?;
1025 drop(replay);
1026 std::fs::write(path.join("unexpected"), b"unexpected")?;
1027 let unknown = WorkflowJournal::<TestIntent>::open_or_create(&path, intent("same")).await;
1028 assert!(matches!(unknown, Err(WorkflowJournalError::Corrupt)));
1029 Ok(())
1030 }
1031
1032 #[cfg(any(target_os = "linux", target_os = "macos"))]
1033 #[tokio::test]
1034 async fn known_pending_links_are_recovered_but_symlinks_are_refused() -> Result<(), Box<dyn Error>> {
1035 use std::os::unix::fs::{PermissionsExt as _, symlink};
1036
1037 let directory = TestDirectory::create()?;
1038 let path = directory.journal();
1039 let replay = WorkflowJournal::<TestIntent>::open_or_create(&path, intent("same")).await?;
1040 replay
1041 .journal()
1042 .append(TestTransition::EffectPrepared { action: 7 })
1043 .await?;
1044 drop(replay);
1045
1046 let final_record = path.join("records/00000000000000000001.json");
1047 let pending_record = path.join("records/.00000000000000000001.pending");
1048 std::fs::hard_link(&final_record, &pending_record)?;
1049 let resumed = WorkflowJournal::<TestIntent>::open_or_create(&path, intent("same")).await?;
1050 assert!(!pending_record.exists());
1051 assert_eq!(resumed.transitions().len(), 1);
1052 drop(resumed);
1053
1054 std::fs::remove_dir_all(&path)?;
1055 let target = directory.0.join("target");
1056 std::fs::create_dir(&target)?;
1057 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o700))?;
1058 symlink(&target, &path)?;
1059 let symlinked = WorkflowJournal::<TestIntent>::open_or_create(&path, intent("same")).await;
1060 assert!(matches!(symlinked, Err(WorkflowJournalError::InsecurePath)));
1061 Ok(())
1062 }
1063
1064 #[cfg(any(target_os = "linux", target_os = "macos"))]
1065 #[tokio::test]
1066 async fn reserved_fifo_is_refused_without_blocking_the_worker() -> Result<(), Box<dyn Error>> {
1067 let directory = TestDirectory::create()?;
1068 let path = directory.journal();
1069 let replay = WorkflowJournal::<TestIntent>::open_or_create(&path, intent("same")).await?;
1070 drop(replay);
1071
1072 let intent_path = path.join("intent.json");
1073 std::fs::remove_file(&intent_path)?;
1074 let status = std::process::Command::new("mkfifo").arg(&intent_path).status()?;
1075 assert!(status.success());
1076
1077 let corrupt = WorkflowJournal::<TestIntent>::open_or_create(&path, intent("same")).await;
1078 assert!(matches!(corrupt, Err(WorkflowJournalError::InsecurePath)));
1079 Ok(())
1080 }
1081
1082 #[cfg(any(target_os = "linux", target_os = "macos"))]
1083 #[tokio::test]
1084 async fn uncommitted_pending_inode_is_removed_without_becoming_history() -> Result<(), Box<dyn Error>> {
1085 use std::os::unix::fs::PermissionsExt as _;
1086
1087 let directory = TestDirectory::create()?;
1088 let path = directory.journal();
1089 let replay = WorkflowJournal::<TestIntent>::open_or_create(&path, intent("same")).await?;
1090 drop(replay);
1091
1092 let pending = path.join("records/.00000000000000000001.pending");
1093 std::fs::write(&pending, b"partial")?;
1094 std::fs::set_permissions(&pending, std::fs::Permissions::from_mode(0o600))?;
1095 let resumed = WorkflowJournal::<TestIntent>::open_or_create(&path, intent("same")).await?;
1096 assert!(!pending.exists());
1097 assert!(resumed.transitions().is_empty());
1098 Ok(())
1099 }
1100
1101 #[cfg(any(target_os = "linux", target_os = "macos"))]
1102 #[tokio::test]
1103 async fn unknown_intent_format_is_refused_without_compatibility_decode() -> Result<(), Box<dyn Error>> {
1104 let directory = TestDirectory::create()?;
1105 let path = directory.journal();
1106 let replay = WorkflowJournal::<TestIntent>::open_or_create(&path, intent("same")).await?;
1107 drop(replay);
1108
1109 let intent_path = path.join("intent.json");
1110 let bytes = std::fs::read(&intent_path)?;
1111 let mut envelope: IntentEnvelope<TestIntent> = serde_json::from_slice(&bytes)?;
1112 envelope.format_version = WORKFLOW_JOURNAL_FORMAT_VERSION.saturating_add(1);
1113 std::fs::write(&intent_path, serde_json::to_vec(&envelope)?)?;
1114 let result = WorkflowJournal::<TestIntent>::open_or_create(&path, intent("same")).await;
1115 assert!(matches!(result, Err(WorkflowJournalError::Corrupt)));
1116 Ok(())
1117 }
1118}