Skip to main content

gatehouse_runtime_protocol/
store.rs

1//! Unix cooperative durable state, not an executor. One private directory per
2//! intent, provisioned by the trusted operator. Never deletes a completed record.
3use crate::intent::{Intent, Phase};
4use crate::{Invalid, Label, Operation, Request, Response, MAX_MESSAGE_BYTES};
5use std::fs::{self, File, OpenOptions};
6use std::io::{Read, Write};
7use std::os::fd::AsRawFd;
8use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
9use std::path::{Path, PathBuf};
10use std::time::{Duration, Instant};
11
12const RECORD: &str = "intent.json";
13
14#[cfg(test)]
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16enum CommitStage {
17    FileSync,
18    Publish,
19    DirectorySync,
20}
21type Result<T> = std::result::Result<T, Invalid>;
22
23fn io<T>(value: std::io::Result<T>) -> Result<T> {
24    value.map_err(|_| Invalid("intent store I/O failure; retain state"))
25}
26
27/// Keeps the directory lock until drop. Do not release this guard between the
28/// durable Unknown transition and a future dispatch/result commit. A failed
29/// commit poisons this guard; reopen and reconcile rather than retry mutation.
30pub struct Store {
31    root: PathBuf,
32    directory: File,
33    expected: Request,
34    bytes: Option<Vec<u8>>,
35    intent: Option<Intent>,
36    poisoned: bool,
37    prepared_verified: bool,
38}
39
40/// A single, store-bound authorization created only after Unknown is synced.
41/// Not Clone: a mutation host must consume this value, never accept a separate
42/// request. The mutable borrow keeps the locked store alive until consumption.
43/// This is sequencing authority only, not adapter authentication.
44///
45/// ```compile_fail
46/// // Fields are private and there is no public constructor.
47/// let _forged = gatehouse_runtime_protocol::store::DispatchAuthorization {
48///     store: unimplemented!(),
49/// };
50/// ```
51///
52/// ```compile_fail
53/// fn duplicate(token: gatehouse_runtime_protocol::store::DispatchAuthorization<'_>) {
54///     let _ = token.clone();
55/// }
56/// ```
57#[must_use]
58pub struct DispatchAuthorization<'a> {
59    store: &'a mut Store,
60}
61
62impl DispatchAuthorization<'_> {
63    pub fn request(&self) -> &Request {
64        &self.store.expected
65    }
66
67    /// All mutation outcomes remain Unknown. Dropping without observation also
68    /// leaves the persisted Unknown intact; it never authorizes another attempt.
69    pub fn observe(self, response: Option<&Response>) -> Result<()> {
70        self.store.observe_dispatch(response)
71    }
72}
73
74fn private(metadata: &fs::Metadata) -> Result<()> {
75    // SAFETY: geteuid has no preconditions or borrowed pointers.
76    if metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o077 != 0 {
77        return Err(Invalid("intent store must be private to the current user"));
78    }
79    Ok(())
80}
81
82impl Store {
83    pub fn open(
84        root: &Path,
85        expected: &Request,
86        timeout: Duration,
87        cancelled: impl Fn() -> bool,
88    ) -> Result<Self> {
89        expected.validate()?;
90        if !matches!(expected.operation, Operation::Remove { .. }) {
91            return Err(Invalid("intent store requires a removal request"));
92        }
93        if timeout.is_zero() || timeout > Duration::from_secs(30) {
94            return Err(Invalid("invalid intent lock timeout"));
95        }
96        if !root.is_absolute() || io(fs::canonicalize(root))? != root {
97            return Err(Invalid("intent directory must be canonical and absolute"));
98        }
99        let directory = io(OpenOptions::new()
100            .read(true)
101            .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
102            .open(root))?;
103        private(&io(directory.metadata())?)?;
104        lock(&directory, timeout, &cancelled)?;
105        let mut store = Self {
106            root: root.into(),
107            directory,
108            expected: expected.clone(),
109            bytes: None,
110            intent: None,
111            poisoned: false,
112            prepared_verified: false,
113        };
114        store.check_directory()?;
115        let bytes = store.read()?;
116        store.intent = bytes
117            .as_deref()
118            .map(|bytes| Intent::restore(bytes, expected))
119            .transpose()?;
120        store.bytes = bytes;
121        if cancelled() {
122            return Err(Invalid("intent open cancelled"));
123        }
124        Ok(store)
125    }
126
127    /// Informational only, possibly stale after a failed commit; never authority.
128    pub fn phase(&self) -> Option<Phase> {
129        self.intent.as_ref().map(Intent::phase)
130    }
131
132    pub fn prepare(&mut self, inspection: &Response, labels: &[Label]) -> Result<()> {
133        if self.intent.is_some() {
134            return Err(Invalid("intent already exists"));
135        }
136        let next = Intent::prepare(&self.expected, inspection, labels)?;
137        self.commit(next)?;
138        self.prepared_verified = true;
139        Ok(())
140    }
141
142    /// Reopened Prepared records cannot authorize dispatch until a fresh,
143    /// authenticated exact-ID inspection passes independently trusted labels.
144    /// Caller authentication is required; this method validates data bindings.
145    pub fn resume_prepared(&mut self, inspection: &Response, labels: &[Label]) -> Result<()> {
146        self.prepared_verified = false;
147        if self.current()?.phase() != Phase::Prepared {
148            return Err(Invalid("intent is not prepared for ownership revalidation"));
149        }
150        Intent::prepare(&self.expected, inspection, labels)?;
151        if self.read()? != self.bytes {
152            self.poisoned = true;
153            return Err(Invalid("intent changed outside its held lock"));
154        }
155        self.prepared_verified = true;
156        Ok(())
157    }
158
159    /// Success means Unknown was synced. This method never starts a process.
160    pub fn begin_dispatch(&mut self) -> Result<DispatchAuthorization<'_>> {
161        if !self.prepared_verified {
162            return Err(Invalid("fresh prepared ownership verification required"));
163        }
164        let next = self.current()?.begin_dispatch(&self.expected)?;
165        self.prepared_verified = false;
166        self.commit(next)?;
167        Ok(DispatchAuthorization { store: self })
168    }
169
170    /// Unknown recovery can only inspect the bound exact ID. No mutation token
171    /// is issued and no transition back to Prepared exists.
172    pub fn recovery_request(&self) -> Result<Request> {
173        if self.current()?.phase() != Phase::Unknown {
174            return Err(Invalid("intent is not awaiting reconciliation"));
175        }
176        let Operation::Remove { object_id } = &self.expected.operation else {
177            return Err(Invalid("intent requires removal"));
178        };
179        Ok(Request {
180            binding: self.expected.binding.clone(),
181            operation: Operation::Inspect {
182                object_id: object_id.clone(),
183            },
184        })
185    }
186
187    pub fn observe_dispatch(&mut self, response: Option<&Response>) -> Result<()> {
188        let next = self.current()?.observe_dispatch(&self.expected, response)?;
189        self.commit(next)
190    }
191
192    pub fn reconcile(&mut self, inspection: &Response) -> Result<()> {
193        let next = self.current()?.reconcile(&self.expected, inspection)?;
194        self.commit(next)
195    }
196
197    fn current(&self) -> Result<&Intent> {
198        if self.poisoned {
199            return Err(Invalid("intent store requires reopen after failed commit"));
200        }
201        self.intent.as_ref().ok_or(Invalid("intent not prepared"))
202    }
203
204    fn check_directory(&self) -> Result<()> {
205        let held = io(self.directory.metadata())?;
206        let named = io(fs::symlink_metadata(&self.root))?;
207        private(&held)?;
208        if !named.is_dir() || held.dev() != named.dev() || held.ino() != named.ino() {
209            return Err(Invalid("intent directory identity changed"));
210        }
211        Ok(())
212    }
213
214    fn read(&self) -> Result<Option<Vec<u8>>> {
215        self.check_directory()?;
216        let file = match OpenOptions::new()
217            .read(true)
218            .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC)
219            .open(self.root.join(RECORD))
220        {
221            Ok(file) => file,
222            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
223            Err(_) => return Err(Invalid("cannot open intent record")),
224        };
225        let metadata = io(file.metadata())?;
226        private(&metadata)?;
227        if !metadata.is_file() || metadata.nlink() != 1 {
228            return Err(Invalid(
229                "intent record must be a private regular single-link file",
230            ));
231        }
232        let mut bytes = Vec::new();
233        io(file
234            .take(MAX_MESSAGE_BYTES as u64 + 1)
235            .read_to_end(&mut bytes))?;
236        if bytes.len() > MAX_MESSAGE_BYTES {
237            return Err(Invalid("intent record exceeds size limit"));
238        }
239        Ok(Some(bytes))
240    }
241
242    #[cfg(test)]
243    fn fail_at(&self, stage: CommitStage) -> Result<()> {
244        if commit_failpoint::take(self, stage) {
245            return Err(Invalid("injected intent commit failure"));
246        }
247        Ok(())
248    }
249
250    fn commit(&mut self, next: Intent) -> Result<()> {
251        if self.poisoned {
252            return Err(Invalid("intent store requires reopen after failed commit"));
253        }
254        // Any ambiguous write/sync failure prohibits another action on this
255        // handle, even if the rename actually succeeded.
256        self.poisoned = true;
257        if self.read()? != self.bytes {
258            return Err(Invalid("intent changed outside its held lock"));
259        }
260        let bytes = serde_json::to_vec(&next).map_err(|_| Invalid("cannot serialize intent"))?;
261        if bytes.len() > MAX_MESSAGE_BYTES {
262            return Err(Invalid("intent record exceeds size limit"));
263        }
264        let mut temporary = io(tempfile::NamedTempFile::new_in(&self.root))?;
265        io(temporary.write_all(&bytes))?;
266        #[cfg(test)]
267        self.fail_at(CommitStage::FileSync)?;
268        io(temporary.as_file().sync_all())?;
269        self.check_directory()?;
270        #[cfg(test)]
271        self.fail_at(CommitStage::Publish)?;
272        if self.bytes.is_none() {
273            temporary
274                .persist_noclobber(self.root.join(RECORD))
275                .map_err(|_| Invalid("cannot create intent record; reopen required"))?;
276        } else {
277            temporary
278                .persist(self.root.join(RECORD))
279                .map_err(|_| Invalid("cannot commit intent record; reopen required"))?;
280        }
281        #[cfg(test)]
282        self.fail_at(CommitStage::DirectorySync)?;
283        io(self.directory.sync_all())?;
284        self.bytes = Some(bytes);
285        self.intent = Some(next);
286        self.poisoned = false;
287        Ok(())
288    }
289}
290
291fn lock(directory: &File, timeout: Duration, cancelled: &impl Fn() -> bool) -> Result<()> {
292    let started = Instant::now();
293    loop {
294        if cancelled() {
295            return Err(Invalid("intent lock cancelled"));
296        }
297        if started.elapsed() >= timeout {
298            return Err(Invalid("intent lock deadline exceeded"));
299        }
300        // SAFETY: directory owns this live descriptor until the Store is dropped.
301        if unsafe { libc::flock(directory.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
302            return Ok(());
303        }
304        let error = std::io::Error::last_os_error();
305        if error.kind() != std::io::ErrorKind::WouldBlock
306            && error.kind() != std::io::ErrorKind::Interrupted
307        {
308            return Err(Invalid("cannot lock intent directory"));
309        }
310        std::thread::sleep(
311            Duration::from_millis(10).min(timeout.saturating_sub(started.elapsed())),
312        );
313    }
314}
315
316// Keep test state out of production structs/initializers so coverage can classify
317// executable production ranges without mixing cfg(test) expressions into them.
318#[cfg(test)]
319mod commit_failpoint {
320    use super::*;
321    use std::sync::Mutex;
322
323    #[derive(PartialEq, Eq)]
324    struct Key {
325        root: PathBuf,
326        device: u64,
327        inode: u64,
328    }
329
330    // Test-only one-shot slot. A Mutex (not thread_local!) keeps standard
331    // library internals out of instrumented coverage paths.
332    static ARMED: Mutex<Option<(Key, CommitStage)>> = Mutex::new(None);
333
334    fn key(store: &Store) -> Key {
335        let metadata = store.directory.metadata().expect("test directory metadata");
336        Key {
337            root: store.root.clone(),
338            device: metadata.dev(),
339            inode: metadata.ino(),
340        }
341    }
342
343    pub(super) fn arm(store: &Store, stage: CommitStage) {
344        *ARMED.lock().expect("test failpoint lock") = Some((key(store), stage));
345    }
346
347    pub(super) fn take(store: &Store, stage: CommitStage) -> bool {
348        let mut armed = ARMED.lock().expect("test failpoint lock");
349        if armed.as_ref() == Some(&(key(store), stage)) {
350            *armed = None;
351            true
352        } else {
353            false
354        }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use crate::intent::Intent;
362    use crate::{Binding, Engine, Namespace, Object, Outcome, Protocol, Response};
363    use serde_json::json;
364    use std::io::{BufRead, BufReader};
365    use std::os::unix::fs::PermissionsExt;
366    use std::process::{Child, Command, Stdio};
367    use tempfile::TempDir;
368
369    // These are real temporary-filesystem tests. I/O failures are injected with
370    // permissions; no power-loss or hostile same-user claim is made.
371
372    fn id() -> String {
373        "a".repeat(64)
374    }
375
376    fn binding() -> Binding {
377        Binding {
378            protocol: Protocol::V1,
379            adapter_sha256: "b".repeat(64),
380            adapter_version: "1.0.0".into(),
381            invocation: "inv-1".into(),
382            operation_key: "op-1".into(),
383            generation: "gen-1".into(),
384            namespace: Namespace {
385                engine: Engine::Docker,
386                endpoint: "unix:///run/docker.sock".into(),
387                context: "ctx-1".into(),
388                principal: "uid:1000".into(),
389                rootless: true,
390                scope: "scope-1".into(),
391            },
392        }
393    }
394
395    fn removal() -> Request {
396        Request {
397            binding: binding(),
398            operation: Operation::Remove { object_id: id() },
399        }
400    }
401
402    fn labels() -> Vec<Label> {
403        vec![Label {
404            key: "harness-gate.owner".into(),
405            value: "harness-gate".into(),
406        }]
407    }
408
409    fn present() -> Response {
410        Response {
411            binding: binding(),
412            operation: Operation::Inspect { object_id: id() },
413            outcome: Outcome::Present {
414                object: Object {
415                    object_id: id(),
416                    labels: labels(),
417                },
418            },
419        }
420    }
421
422    fn absent() -> Response {
423        Response {
424            binding: binding(),
425            operation: Operation::Inspect { object_id: id() },
426            outcome: Outcome::Absent,
427        }
428    }
429
430    fn private_root() -> (TempDir, PathBuf) {
431        let temporary = tempfile::tempdir().unwrap();
432        let directory = temporary.path().join("intent");
433        fs::create_dir(&directory).unwrap();
434        fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).unwrap();
435        (temporary, fs::canonicalize(directory).unwrap())
436    }
437
438    fn open(root: &Path) -> Store {
439        Store::open(root, &removal(), Duration::from_secs(1), || false).unwrap()
440    }
441
442    fn prepared_record() -> Vec<u8> {
443        serde_json::to_vec(&Intent::prepare(&removal(), &present(), &labels()).unwrap()).unwrap()
444    }
445
446    fn write_record(root: &Path, bytes: &[u8]) {
447        fs::write(root.join(RECORD), bytes).unwrap();
448        fs::set_permissions(root.join(RECORD), fs::Permissions::from_mode(0o600)).unwrap();
449    }
450
451    fn prepare(store: &mut Store) {
452        store.prepare(&present(), &labels()).unwrap();
453    }
454
455    fn open_prepared(root: &Path) {
456        let mut store = open(root);
457        prepare(&mut store);
458    }
459
460    fn binding_mutations() -> Vec<Binding> {
461        let mut values = Vec::new();
462        let mutations: [fn(&mut Binding); 11] = [
463            |b| b.adapter_sha256 = "c".repeat(64),
464            |b| b.adapter_version = "2".into(),
465            |b| b.invocation = "inv-2".into(),
466            |b| b.operation_key = "op-2".into(),
467            |b| b.generation = "gen-2".into(),
468            |b| b.namespace.engine = Engine::Podman,
469            |b| b.namespace.endpoint = "unix:///other".into(),
470            |b| b.namespace.context = "ctx-2".into(),
471            |b| b.namespace.principal = "uid:1001".into(),
472            |b| b.namespace.rootless = false,
473            |b| b.namespace.scope = "scope-2".into(),
474        ];
475        for mutate in mutations {
476            let mut value = binding();
477            mutate(&mut value);
478            values.push(value);
479        }
480        values
481    }
482
483    struct LockChild(Child);
484
485    impl Drop for LockChild {
486        fn drop(&mut self) {
487            let _ = self.0.kill();
488            let _ = self.0.wait();
489        }
490    }
491
492    fn hold_lock(root: &Path) -> LockChild {
493        let mut child = Command::new("python3")
494            .args([
495                "-c",
496                "import fcntl,os,sys,time\nfd=os.open(sys.argv[1],os.O_RDONLY|os.O_DIRECTORY)\nfcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\nprint('ready',flush=True)\ntime.sleep(30)",
497            ])
498            .arg(root)
499            .stdout(Stdio::piped())
500            .spawn()
501            .unwrap();
502        let stdout = child.stdout.take().unwrap();
503        let mut line = String::new();
504        BufReader::new(stdout).read_line(&mut line).unwrap();
505        assert_eq!(line.trim(), "ready");
506        LockChild(child)
507    }
508
509    #[test]
510    fn prepare_unknown_reopen_reconcile_completed_cycle() {
511        let (_temporary, root) = private_root();
512        {
513            let mut store = open(&root);
514            assert_eq!(store.phase(), None);
515            assert!(store.begin_dispatch().is_err());
516            prepare(&mut store);
517            assert_eq!(store.phase(), Some(Phase::Prepared));
518            assert!(store.prepare(&present(), &labels()).is_err());
519        }
520        {
521            let mut store = open(&root);
522            assert_eq!(store.phase(), Some(Phase::Prepared));
523            store.resume_prepared(&present(), &labels()).unwrap();
524            let authorization = store.begin_dispatch().unwrap();
525            drop(authorization);
526            assert_eq!(store.phase(), Some(Phase::Unknown));
527        }
528        let mut store = open(&root);
529        assert_eq!(store.phase(), Some(Phase::Unknown));
530        store.observe_dispatch(None).unwrap();
531        assert_eq!(store.phase(), Some(Phase::Unknown));
532        store.reconcile(&absent()).unwrap();
533        assert_eq!(store.phase(), Some(Phase::Completed));
534        assert!(store.begin_dispatch().is_err());
535        assert!(store.observe_dispatch(None).is_err());
536        assert!(store.reconcile(&absent()).is_err());
537        drop(store);
538        let store = open(&root);
539        assert_eq!(store.phase(), Some(Phase::Completed));
540    }
541
542    #[test]
543    fn conflicting_expected_is_rejected() {
544        let (_temporary, root) = private_root();
545        open_prepared(&root);
546        for changed in binding_mutations() {
547            let expected = Request {
548                binding: changed,
549                operation: removal().operation,
550            };
551            assert!(Store::open(&root, &expected, Duration::from_secs(1), || false).is_err());
552        }
553        let expected = Request {
554            binding: binding(),
555            operation: Operation::Remove {
556                object_id: "b".repeat(64),
557            },
558        };
559        assert!(Store::open(&root, &expected, Duration::from_secs(1), || false).is_err());
560    }
561
562    #[test]
563    fn lock_timeout_and_cancel_are_bounded_in_a_subprocess() {
564        let (_temporary, root) = private_root();
565        let child = hold_lock(&root);
566        assert!(Store::open(&root, &removal(), Duration::from_millis(100), || false).is_err());
567        drop(child);
568        let store = open(&root);
569        drop(store);
570
571        let child = hold_lock(&root);
572        let calls = std::cell::Cell::new(0_u32);
573        let result = Store::open(&root, &removal(), Duration::from_secs(5), || {
574            let current = calls.get();
575            calls.set(current + 1);
576            current > 0
577        });
578        assert!(result.is_err());
579        drop(child);
580    }
581
582    #[test]
583    fn record_inputs_reject_links_fifo_modes_size_and_legacy() {
584        type Case = (&'static str, fn(&Path));
585        let cases: [Case; 6] = [
586            ("symlink", |root| {
587                let target = root.join("record-target");
588                write_record(root, &prepared_record());
589                fs::rename(root.join(RECORD), &target).unwrap();
590                std::os::unix::fs::symlink(&target, root.join(RECORD)).unwrap();
591            }),
592            ("hardlink", |root| {
593                let target = root.join("record-target");
594                write_record(root, &prepared_record());
595                fs::hard_link(root.join(RECORD), &target).unwrap();
596            }),
597            ("fifo", |root| {
598                let status = Command::new("mkfifo")
599                    .arg(root.join(RECORD))
600                    .status()
601                    .unwrap();
602                assert!(status.success());
603            }),
604            ("mode", |root| {
605                write_record(root, &prepared_record());
606                fs::set_permissions(root.join(RECORD), fs::Permissions::from_mode(0o644)).unwrap();
607            }),
608            ("oversize", |root| {
609                let mut bytes = prepared_record();
610                bytes.extend(std::iter::repeat_n(b' ', MAX_MESSAGE_BYTES));
611                write_record(root, &bytes);
612            }),
613            ("legacy-v2", |root| {
614                write_record(
615                    root,
616                    &serde_json::to_vec(&json!({
617                        "owner_marker": "harness-gate",
618                        "schema_version": 2,
619                        "runtime": "docker",
620                        "runtime_object_id": id()
621                    }))
622                    .unwrap(),
623                );
624            }),
625        ];
626        for (name, setup) in cases {
627            let (_temporary, root) = private_root();
628            setup(&root);
629            assert!(
630                Store::open(&root, &removal(), Duration::from_secs(1), || false).is_err(),
631                "{name}"
632            );
633        }
634    }
635
636    #[test]
637    fn record_change_and_directory_rebind_are_rejected() {
638        let (_temporary, root) = private_root();
639        open_prepared(&root);
640        let mut store = open(&root);
641        store.resume_prepared(&present(), &labels()).unwrap();
642        fs::write(root.join(RECORD), b"{}").unwrap();
643        assert!(store.begin_dispatch().is_err());
644        assert!(store.reconcile(&absent()).is_err());
645        drop(store);
646
647        let (_temporary, root) = private_root();
648        let moved = root.parent().unwrap().join("moved");
649        let mut store = open(&root);
650        fs::rename(&root, &moved).unwrap();
651        fs::create_dir(&root).unwrap();
652        fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap();
653        assert!(store.prepare(&present(), &labels()).is_err());
654    }
655
656    #[test]
657    fn reopened_prepared_requires_resume_and_failed_resume_clears_gate() {
658        let (_temporary, root) = private_root();
659        open_prepared(&root);
660        let mut store = open(&root);
661        assert_eq!(store.phase(), Some(Phase::Prepared));
662        assert!(store.begin_dispatch().is_err());
663        let mut wrong_id = present();
664        if let Outcome::Present { object } = &mut wrong_id.outcome {
665            object.object_id = "b".repeat(64);
666        }
667        assert!(store.resume_prepared(&wrong_id, &labels()).is_err());
668        assert!(store.begin_dispatch().is_err());
669        let wrong_labels = [Label {
670            key: "harness-gate.owner".into(),
671            value: "other".into(),
672        }];
673        assert!(store.resume_prepared(&present(), &wrong_labels).is_err());
674        assert!(store.begin_dispatch().is_err());
675        assert!(store.resume_prepared(&present(), &labels()).is_ok());
676        let authorization = store.begin_dispatch().unwrap();
677        drop(authorization);
678        assert_eq!(store.phase(), Some(Phase::Unknown));
679    }
680
681    #[test]
682    fn dispatch_authorization_binds_request_and_drop_leaves_unknown() {
683        let (_temporary, root) = private_root();
684        let mut store = open(&root);
685        prepare(&mut store);
686        let authorization = store.begin_dispatch().unwrap();
687        assert_eq!(authorization.request(), &removal());
688        drop(authorization);
689        assert_eq!(store.phase(), Some(Phase::Unknown));
690        assert_eq!(store.phase(), Some(Phase::Unknown));
691        assert!(store.begin_dispatch().is_err());
692        let recovery = store.recovery_request().unwrap();
693        assert_eq!(recovery.binding, binding());
694        assert_eq!(recovery.operation, Operation::Inspect { object_id: id() });
695        store.reconcile(&absent()).unwrap();
696        assert_eq!(store.phase(), Some(Phase::Completed));
697        assert!(store.recovery_request().is_err());
698    }
699
700    #[test]
701    fn recovery_request_is_unknown_only_and_exact() {
702        let (_temporary, root) = private_root();
703        let mut store = open(&root);
704        prepare(&mut store);
705        assert!(store.recovery_request().is_err());
706        let authorization = store.begin_dispatch().unwrap();
707        drop(authorization);
708        let recovery = store.recovery_request().unwrap();
709        assert_eq!(recovery.binding, binding());
710        assert_eq!(recovery.operation, Operation::Inspect { object_id: id() });
711        assert!(store.reconcile(&present()).is_err());
712        assert_eq!(store.phase(), Some(Phase::Unknown));
713        store.reconcile(&absent()).unwrap();
714        assert_eq!(store.phase(), Some(Phase::Completed));
715        assert!(store.recovery_request().is_err());
716    }
717
718    #[test]
719    fn commit_failure_stages_issue_no_token_and_poison() {
720        for (stage, expected_phase) in [
721            (CommitStage::FileSync, Phase::Prepared),
722            (CommitStage::Publish, Phase::Prepared),
723            (CommitStage::DirectorySync, Phase::Unknown),
724        ] {
725            let (_temporary, root) = private_root();
726            open_prepared(&root);
727            let mut store = open(&root);
728            store.resume_prepared(&present(), &labels()).unwrap();
729            commit_failpoint::arm(&store, stage);
730            assert!(store.begin_dispatch().is_err());
731            assert!(store.resume_prepared(&present(), &labels()).is_err());
732            assert!(store.observe_dispatch(None).is_err());
733            drop(store);
734            let store = open(&root);
735            assert_eq!(store.phase(), Some(expected_phase), "{stage:?}");
736        }
737    }
738
739    #[test]
740    fn initial_noclobber_and_commit_failure_poison_until_reopen() {
741        let (_temporary, root) = private_root();
742        let mut store = open(&root);
743        write_record(&root, &prepared_record());
744        assert!(store.prepare(&present(), &labels()).is_err());
745
746        let (_temporary, root) = private_root();
747        let mut store = open(&root);
748        fs::set_permissions(&root, fs::Permissions::from_mode(0o500)).unwrap();
749        assert!(store.prepare(&present(), &labels()).is_err());
750        fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap();
751        assert!(store.begin_dispatch().is_err());
752        assert!(store.prepare(&present(), &labels()).is_err());
753        drop(store);
754        let mut store = open(&root);
755        assert_eq!(store.phase(), None);
756        prepare(&mut store);
757        assert_eq!(store.phase(), Some(Phase::Prepared));
758    }
759}