Skip to main content

lodestone_client/
workflow_journal.rs

1//! Crash-safe local recovery journal for bounded operator workflows.
2//!
3//! Product state remains authoritative in Core. This journal fixes local
4//! effect identities and records observations so a caller can resolve or
5//! resume after response loss without guessing.
6
7use 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/// Closed local workflows admitted to the shared journal implementation.
25#[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
36/// A client-owned, closed, secret-free workflow intent.
37///
38/// The sealed trait keeps arbitrary JSON values out of the journal. New
39/// workflows define their typed intent and transition in this crate.
40pub 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
48/// One closed, secret-free transition for a client-owned operator workflow.
49pub trait WorkflowJournalTransition: sealed::Sealed + DeserializeOwned + Serialize + Send + Sync + 'static {
50    fn validate(&self) -> Result<(), WorkflowJournalError>;
51}
52
53/// Random identity of one exact local workflow transaction.
54#[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/// Durable identity returned after one transition is fully published.
69#[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
87/// Open journal plus its fully verified transition history.
88pub 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
120/// Exclusive writer for one exact local operator workflow.
121pub 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    /// Creates or exactly resumes and locks one workflow journal.
131    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    /// Opens an existing journal without supplying or creating its intent.
153    ///
154    /// This is crate-private because only a closed workflow owner may turn a
155    /// persisted intent into caller-visible recovery state.
156    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    /// Appends and synchronizes one transition before returning its receipt.
186    ///
187    /// Any publication error poisons this handle because the create-new link
188    /// may have committed. Drop and reopen the journal to resolve it by replay.
189    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                // INVARIANT: release the worker's journal reference before the
203                // join handle becomes ready so drop-and-reopen cannot race the
204                // blocking scheduler's task-allocation cleanup.
205                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            // INVARIANT: a caller that drops and immediately resumes a local
355            // workflow must not depend on deferred descriptor cleanup to
356            // release the exclusive writer fence.
357            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        // Repeat after the lock because the prior holder may have completed a
390        // pending create-new publication between the first listing and lock.
391        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(&current, 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        // NONBLOCK prevents a corrupt reserved name backed by a FIFO from
830        // hanging the bounded worker before the file-type check can reject it.
831        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}