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