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