Skip to main content

runner_manager_domain/
workspace.rs

1// owner: a1-workspace-domain
2
3//! Where a runner attempt's files live, and whether they survive it.
4//!
5//! `02-target-architecture.md` separates three path concepts that used to share
6//! one directory: application paths (config, SQLite, logs, package cache),
7//! the **host runner root** where disposable attempts are created, and a
8//! **repository persistent root** holding stable slots. This module owns the two
9//! facts the domain records about the second and third:
10//!
11//! * [`WorkspacePolicy`] — a repository's *configuration*: disposable (the
12//!   default) or persistent under a configured root.
13//! * [`AttemptWorkspace`] — one attempt's *allocation*: disposable, or the
14//!   persistent slot it leased. Journalled before any external effect and
15//!   immutable afterwards, because it is what tells recovery which cleanup
16//!   algorithm is legal (`04-security-recovery.md`, "Safe path handling").
17//!
18//! Three rules are enforced here rather than downstream, and each one is a
19//! decision from the owner ledger:
20//!
21//! 1. **Persistence is repository-scoped only (D7).** An organization-scoped JIT
22//!    runner can accept work from more than one repository and nothing reveals
23//!    which one before launch, so a retained `_work` would cross a repository
24//!    boundary. [`WorkspacePolicy::persistent`] takes the target's scope and
25//!    refuses `Organization`; [`WorkspacePolicy::from_persisted`] applies the
26//!    same rule on load, so a hand-edited row cannot install what the constructor
27//!    refuses.
28//! 2. **A persistent attempt has a positive slot and an ephemeral one has
29//!    none.** The pair is a single fact, so it is one enum with no illegal
30//!    combination rather than two columns a caller must keep consistent.
31//! 3. **Ephemeral is the default everywhere.** D3: disposable mode remains the
32//!    default, and every constructor in this crate keeps producing it.
33//!
34//! This is *not* [`crate::model::CachePolicy`]. That answers "keep the verified
35//! runner package between attempts?"; this answers "keep the job workspace
36//! between attempts?". They have different cleanup paths and different security
37//! consequences (`04-security-recovery.md`, "Revised trust boundary"), and
38//! collapsing them would make the second answerable by accident.
39
40use std::fmt;
41use std::num::NonZeroU16;
42use std::str::FromStr;
43
44use serde::{Deserialize, Serialize};
45
46use crate::model::{TargetScope, ValidationError};
47use crate::path::{LocalAbsolutePath, LocalPathError};
48
49// ---------------------------------------------------------------------------
50// Errors
51// ---------------------------------------------------------------------------
52
53/// Why a workspace configuration or allocation cannot exist.
54///
55/// Every variant is a load-time refusal, because `04-security-recovery.md`
56/// requires "load-time shape validation and immutable attempt workspace kind;
57/// unknown values fail closed". The shape refusals below are already raised by
58/// [`WorkspacePolicy::from_persisted`] and [`AttemptWorkspace::from_persisted`];
59/// [`Self::InvalidPath`] is the `#[from]` conversion for the same load path and
60/// starts being raised when the stored root column is parsed into a
61/// [`LocalAbsolutePath`], which is `a2`'s store work rather than this crate's.
62#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
63pub enum WorkspaceError {
64    #[error(transparent)]
65    InvalidPath(#[from] LocalPathError),
66
67    #[error(
68        "a persistent workspace is repository-scoped only; an organization runner \
69         can accept jobs from more than one repository, so a retained workspace \
70         would cross a repository boundary (D7)"
71    )]
72    PersistentRequiresRepositoryScope,
73
74    #[error("a persistent workspace policy requires a configured root path")]
75    PersistentWithoutRoot,
76
77    #[error(
78        "an ephemeral workspace policy has no root path; attempts are placed \
79         under the host runner root ({root})"
80    )]
81    EphemeralWithRoot { root: String },
82
83    #[error("a persistent attempt requires the slot it leased")]
84    PersistentWithoutSlot,
85
86    #[error("an ephemeral attempt holds no slot, but slot {slot} was stored")]
87    EphemeralWithSlot { slot: u16 },
88
89    #[error("a persistent slot number must be positive; slots are named s1, s2, and so on")]
90    SlotNotPositive,
91}
92
93// ---------------------------------------------------------------------------
94// WorkspaceKind
95// ---------------------------------------------------------------------------
96
97/// The discriminant shared by [`WorkspacePolicy`] and [`AttemptWorkspace`].
98///
99/// It exists so `a2` can store one text column and `d1`/`e1` can render one word
100/// without matching on a payload they do not need. The payload — a root path, a
101/// slot number — belongs to the enum that owns it, and reconstructing either
102/// from this kind plus its raw column goes through `from_persisted`, which is
103/// where the illegal combinations are refused.
104#[derive(
105    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
106)]
107#[serde(rename_all = "snake_case")]
108pub enum WorkspaceKind {
109    /// D3: the default. The attempt directory is unique and removed on cleanup.
110    #[default]
111    Ephemeral,
112    /// D4: opt-in, repository-scoped, retaining `_work` in a stable slot.
113    Persistent,
114}
115
116impl WorkspaceKind {
117    #[must_use]
118    pub const fn is_persistent(self) -> bool {
119        matches!(self, WorkspaceKind::Persistent)
120    }
121}
122
123impl fmt::Display for WorkspaceKind {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        f.write_str(match self {
126            WorkspaceKind::Ephemeral => "ephemeral",
127            WorkspaceKind::Persistent => "persistent",
128        })
129    }
130}
131
132impl FromStr for WorkspaceKind {
133    type Err = ValidationError;
134
135    fn from_str(s: &str) -> Result<Self, Self::Err> {
136        match s.trim().to_ascii_lowercase().as_str() {
137            "ephemeral" => Ok(WorkspaceKind::Ephemeral),
138            "persistent" => Ok(WorkspaceKind::Persistent),
139            other => Err(ValidationError::Unrecognised {
140                what: "a workspace mode",
141                got: other.to_string(),
142            }),
143        }
144    }
145}
146
147// ---------------------------------------------------------------------------
148// WorkspacePolicy
149// ---------------------------------------------------------------------------
150
151/// A repository's configured workspace behaviour.
152///
153/// `Persistent` carries its root rather than pointing at one, so "persistent
154/// without a path" and "ephemeral with a stale path" are both unrepresentable
155/// rather than merely rejected — the rule [`crate::model`] states for capacity,
156/// applied here.
157#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
158#[serde(tag = "mode", rename_all = "snake_case")]
159pub enum WorkspacePolicy {
160    /// D3: attempts are created under the effective host runner root and the
161    /// whole attempt directory is removed on cleanup.
162    #[default]
163    Ephemeral,
164    /// D4/D5: attempts lease a stable `sN` slot under `root` and only `_work`
165    /// survives cleanup.
166    Persistent { root: LocalAbsolutePath },
167}
168
169impl WorkspacePolicy {
170    /// Configure persistence for a target of `scope`.
171    ///
172    /// The scope is an argument rather than something the caller checks first,
173    /// so D7 is stated at the call site rather than remembered. It is *not* the
174    /// only gate: `Persistent { root }` is a public variant and so is
175    /// constructible directly, which is why
176    /// [`ScalePolicy::set_workspace_policy`](crate::policy::ScalePolicy::set_workspace_policy)
177    /// and [`Self::from_persisted`] re-run [`Self::permitted_for`] on the value
178    /// they are handed rather than trusting that it came through here.
179    ///
180    /// # Errors
181    /// [`WorkspaceError::PersistentRequiresRepositoryScope`] for an
182    /// organization target.
183    pub fn persistent(root: LocalAbsolutePath, scope: TargetScope) -> Result<Self, WorkspaceError> {
184        let policy = WorkspacePolicy::Persistent { root };
185        policy.permitted_for(scope)?;
186        Ok(policy)
187    }
188
189    /// D7: whether a target of `scope` may hold this policy.
190    ///
191    /// The one place the rule and its message live, so the constructor, the
192    /// loader and `repo set-workspace` cannot drift apart. `scope` is matched
193    /// exhaustively on purpose: a future third scope has to make this decision
194    /// rather than inherit "not a repository, therefore refused".
195    ///
196    /// # Errors
197    /// [`WorkspaceError::PersistentRequiresRepositoryScope`] for a persistent
198    /// policy on an organization target.
199    pub(crate) const fn permitted_for(&self, scope: TargetScope) -> Result<(), WorkspaceError> {
200        match scope {
201            TargetScope::Repository => Ok(()),
202            TargetScope::Organization if self.is_persistent() => {
203                Err(WorkspaceError::PersistentRequiresRepositoryScope)
204            }
205            TargetScope::Organization => Ok(()),
206        }
207    }
208
209    /// Rebuild a stored workspace policy from its two columns.
210    ///
211    /// # Errors
212    /// [`WorkspaceError::PersistentWithoutRoot`],
213    /// [`WorkspaceError::EphemeralWithRoot`], or
214    /// [`WorkspaceError::PersistentRequiresRepositoryScope`] — the three shapes
215    /// a hand-edited row can claim and this crate cannot have written.
216    pub fn from_persisted(
217        kind: WorkspaceKind,
218        root: Option<LocalAbsolutePath>,
219        scope: TargetScope,
220    ) -> Result<Self, WorkspaceError> {
221        match (kind, root) {
222            (WorkspaceKind::Ephemeral, None) => Ok(WorkspacePolicy::Ephemeral),
223            (WorkspaceKind::Ephemeral, Some(root)) => {
224                Err(WorkspaceError::EphemeralWithRoot { root: root.into() })
225            }
226            (WorkspaceKind::Persistent, None) => Err(WorkspaceError::PersistentWithoutRoot),
227            (WorkspaceKind::Persistent, Some(root)) => Self::persistent(root, scope),
228        }
229    }
230
231    #[must_use]
232    pub const fn kind(&self) -> WorkspaceKind {
233        match self {
234            WorkspacePolicy::Ephemeral => WorkspaceKind::Ephemeral,
235            WorkspacePolicy::Persistent { .. } => WorkspaceKind::Persistent,
236        }
237    }
238
239    /// The configured root, for `a2` to store and `d1`/`e1` to display.
240    #[must_use]
241    pub const fn root(&self) -> Option<&LocalAbsolutePath> {
242        match self {
243            WorkspacePolicy::Ephemeral => None,
244            WorkspacePolicy::Persistent { root } => Some(root),
245        }
246    }
247
248    #[must_use]
249    pub const fn is_persistent(&self) -> bool {
250        self.kind().is_persistent()
251    }
252
253    /// Whether the job workspace survives an attempt under this policy.
254    ///
255    /// The counterpart of [`crate::model::CachePolicy::retains_job_workspace`],
256    /// which is `false` by construction. That constant is still correct: it
257    /// answers whether the *runner package cache policy* retains a workspace,
258    /// and it never will. This is the deliberate new decision D4 introduced, and
259    /// it is spelled on a different type for exactly that reason.
260    #[must_use]
261    pub const fn retains_job_workspace(&self) -> bool {
262        self.is_persistent()
263    }
264}
265
266impl fmt::Display for WorkspacePolicy {
267    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
268        match self {
269            WorkspacePolicy::Ephemeral => f.write_str("ephemeral"),
270            WorkspacePolicy::Persistent { root } => write!(f, "persistent ({root})"),
271        }
272    }
273}
274
275// ---------------------------------------------------------------------------
276// AttemptWorkspace
277// ---------------------------------------------------------------------------
278
279/// The immutable allocation fact journalled with one runner attempt.
280///
281/// `02-target-architecture.md`: "`runtime_path` remains the exact path used by
282/// the attempt. The workspace kind and slot number tell recovery which cleanup
283/// algorithm is legal. Neither may change after allocation." There is therefore
284/// no mutator here and none on `RunnerAttempt` — the value is set by the
285/// allocating constructor and read thereafter.
286///
287/// A persistent variant is also a **durable slot lease**: every attempt whose
288/// state is not `cleaned`, including a terminal one whose cleanup failed, holds
289/// its slot. That is why the slot lives on the attempt rather than in a slot
290/// table — the journal is already the authority on which leases exist.
291#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
292#[serde(tag = "mode", rename_all = "snake_case")]
293pub enum AttemptWorkspace {
294    /// A unique directory under the effective host runner root, removed whole.
295    Ephemeral,
296    /// The `sN` slot leased from the policy's persistent root.
297    PersistentSlot { slot: NonZeroU16 },
298}
299
300impl AttemptWorkspace {
301    /// Lease slot `slot`.
302    #[must_use]
303    pub const fn persistent_slot(slot: NonZeroU16) -> Self {
304        AttemptWorkspace::PersistentSlot { slot }
305    }
306
307    /// Rebuild a journalled allocation from its two columns.
308    ///
309    /// The raw slot is a `u16` rather than a [`NonZeroU16`] precisely so that
310    /// `0` — a value SQLite will happily hold and this crate will never write —
311    /// is a refusal here rather than a panic or a silent `s0`.
312    ///
313    /// # Errors
314    /// [`WorkspaceError::PersistentWithoutSlot`],
315    /// [`WorkspaceError::EphemeralWithSlot`], or
316    /// [`WorkspaceError::SlotNotPositive`].
317    pub fn from_persisted(kind: WorkspaceKind, slot: Option<u16>) -> Result<Self, WorkspaceError> {
318        match (kind, slot) {
319            (WorkspaceKind::Ephemeral, None) => Ok(AttemptWorkspace::Ephemeral),
320            (WorkspaceKind::Ephemeral, Some(slot)) => {
321                Err(WorkspaceError::EphemeralWithSlot { slot })
322            }
323            (WorkspaceKind::Persistent, None) => Err(WorkspaceError::PersistentWithoutSlot),
324            (WorkspaceKind::Persistent, Some(slot)) => NonZeroU16::new(slot)
325                .map(Self::persistent_slot)
326                .ok_or(WorkspaceError::SlotNotPositive),
327        }
328    }
329
330    #[must_use]
331    pub const fn kind(&self) -> WorkspaceKind {
332        match self {
333            AttemptWorkspace::Ephemeral => WorkspaceKind::Ephemeral,
334            AttemptWorkspace::PersistentSlot { .. } => WorkspaceKind::Persistent,
335        }
336    }
337
338    /// The leased slot, for `a2`'s uncleaned-lease index and `c2`'s allocator.
339    #[must_use]
340    pub const fn slot(&self) -> Option<NonZeroU16> {
341        match self {
342            AttemptWorkspace::Ephemeral => None,
343            AttemptWorkspace::PersistentSlot { slot } => Some(*slot),
344        }
345    }
346
347    /// The stored slot column: `None` for an ephemeral attempt.
348    #[must_use]
349    pub const fn slot_number(&self) -> Option<u16> {
350        match self {
351            AttemptWorkspace::Ephemeral => None,
352            AttemptWorkspace::PersistentSlot { slot } => Some(slot.get()),
353        }
354    }
355
356    #[must_use]
357    pub const fn is_persistent(&self) -> bool {
358        self.kind().is_persistent()
359    }
360
361    /// The slot's directory name under the persistent root.
362    ///
363    /// `02-target-architecture.md`: "Names are `s1`, `s2`, and so on to minimize
364    /// path length." It is derived here, once, so no caller builds the string a
365    /// second way — the whole change exists because a path grew too long.
366    #[must_use]
367    pub fn slot_directory_name(&self) -> Option<String> {
368        self.slot().map(|slot| format!("s{slot}"))
369    }
370}
371
372impl fmt::Display for AttemptWorkspace {
373    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
374        match self {
375            AttemptWorkspace::Ephemeral => f.write_str("ephemeral"),
376            AttemptWorkspace::PersistentSlot { slot } => write!(f, "persistent slot s{slot}"),
377        }
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    use crate::path::{LocalAbsolutePath, PathPlatform};
386
387    fn root() -> LocalAbsolutePath {
388        LocalAbsolutePath::parse_for("/srv/rman/acme", PathPlatform::Unix).expect("valid root")
389    }
390
391    fn nz(value: u16) -> NonZeroU16 {
392        NonZeroU16::new(value).expect("a positive slot")
393    }
394
395    // -- defaults -----------------------------------------------------------
396
397    #[test]
398    fn the_default_workspace_policy_is_ephemeral() {
399        // D3: disposable mode remains the default.
400        assert_eq!(WorkspacePolicy::default(), WorkspacePolicy::Ephemeral);
401        assert_eq!(WorkspaceKind::default(), WorkspaceKind::Ephemeral);
402        assert!(!WorkspacePolicy::default().is_persistent());
403        assert!(!WorkspacePolicy::default().retains_job_workspace());
404        assert_eq!(WorkspacePolicy::default().root(), None);
405    }
406
407    #[test]
408    fn a_persistent_policy_retains_the_job_workspace() {
409        let policy = WorkspacePolicy::persistent(root(), TargetScope::Repository)
410            .expect("a repository may be persistent");
411        assert!(policy.is_persistent());
412        assert!(policy.retains_job_workspace());
413        assert_eq!(policy.kind(), WorkspaceKind::Persistent);
414        assert_eq!(policy.root(), Some(&root()));
415    }
416
417    // -- D7 -----------------------------------------------------------------
418
419    #[test]
420    fn an_organization_policy_cannot_be_constructed_as_persistent() {
421        assert_eq!(
422            WorkspacePolicy::persistent(root(), TargetScope::Organization),
423            Err(WorkspaceError::PersistentRequiresRepositoryScope)
424        );
425    }
426
427    #[test]
428    fn an_organization_policy_cannot_be_restored_as_persistent() {
429        assert_eq!(
430            WorkspacePolicy::from_persisted(
431                WorkspaceKind::Persistent,
432                Some(root()),
433                TargetScope::Organization,
434            ),
435            Err(WorkspaceError::PersistentRequiresRepositoryScope)
436        );
437        // The ephemeral row an organization is allowed to hold still loads.
438        assert_eq!(
439            WorkspacePolicy::from_persisted(
440                WorkspaceKind::Ephemeral,
441                None,
442                TargetScope::Organization,
443            ),
444            Ok(WorkspacePolicy::Ephemeral)
445        );
446    }
447
448    // -- stored shape -------------------------------------------------------
449
450    #[test]
451    fn a_workspace_policy_round_trips_through_its_columns() {
452        for scope in [TargetScope::Repository, TargetScope::Organization] {
453            let policy = WorkspacePolicy::Ephemeral;
454            assert_eq!(
455                WorkspacePolicy::from_persisted(policy.kind(), policy.root().cloned(), scope),
456                Ok(policy)
457            );
458        }
459
460        let policy = WorkspacePolicy::persistent(root(), TargetScope::Repository)
461            .expect("a repository may be persistent");
462        assert_eq!(
463            WorkspacePolicy::from_persisted(
464                policy.kind(),
465                policy.root().cloned(),
466                TargetScope::Repository,
467            ),
468            Ok(policy)
469        );
470    }
471
472    #[test]
473    fn mismatched_workspace_policy_columns_fail_closed() {
474        assert_eq!(
475            WorkspacePolicy::from_persisted(
476                WorkspaceKind::Persistent,
477                None,
478                TargetScope::Repository
479            ),
480            Err(WorkspaceError::PersistentWithoutRoot)
481        );
482        assert_eq!(
483            WorkspacePolicy::from_persisted(
484                WorkspaceKind::Ephemeral,
485                Some(root()),
486                TargetScope::Repository,
487            ),
488            Err(WorkspaceError::EphemeralWithRoot {
489                root: "/srv/rman/acme".to_string()
490            })
491        );
492    }
493
494    // -- attempt allocation -------------------------------------------------
495
496    #[test]
497    fn an_ephemeral_attempt_holds_no_slot() {
498        let workspace = AttemptWorkspace::Ephemeral;
499        assert_eq!(workspace.kind(), WorkspaceKind::Ephemeral);
500        assert_eq!(workspace.slot(), None);
501        assert_eq!(workspace.slot_number(), None);
502        assert_eq!(workspace.slot_directory_name(), None);
503        assert!(!workspace.is_persistent());
504    }
505
506    #[test]
507    fn a_persistent_attempt_names_its_slot_directory() {
508        let workspace = AttemptWorkspace::persistent_slot(nz(1));
509        assert_eq!(workspace.kind(), WorkspaceKind::Persistent);
510        assert_eq!(workspace.slot(), Some(nz(1)));
511        assert_eq!(workspace.slot_number(), Some(1));
512        assert_eq!(workspace.slot_directory_name().as_deref(), Some("s1"));
513        assert!(workspace.is_persistent());
514        assert_eq!(
515            AttemptWorkspace::persistent_slot(nz(12))
516                .slot_directory_name()
517                .as_deref(),
518            Some("s12")
519        );
520    }
521
522    #[test]
523    fn the_slot_directory_name_is_a_single_path_component() {
524        // `c2` derives `<root>/sN`; deriving it through `join_child` is what
525        // makes containment a property of construction rather than a check.
526        let workspace = AttemptWorkspace::persistent_slot(nz(3));
527        let name = workspace
528            .slot_directory_name()
529            .expect("a persistent attempt names a slot");
530        assert_eq!(
531            root().join_child(name).expect("a valid child").as_str(),
532            "/srv/rman/acme/s3"
533        );
534    }
535
536    #[test]
537    fn an_attempt_workspace_round_trips_through_its_columns() {
538        for workspace in [
539            AttemptWorkspace::Ephemeral,
540            AttemptWorkspace::persistent_slot(nz(1)),
541            AttemptWorkspace::persistent_slot(nz(u16::MAX)),
542        ] {
543            assert_eq!(
544                AttemptWorkspace::from_persisted(workspace.kind(), workspace.slot_number()),
545                Ok(workspace)
546            );
547        }
548    }
549
550    #[test]
551    fn mismatched_attempt_workspace_columns_fail_closed() {
552        assert_eq!(
553            AttemptWorkspace::from_persisted(WorkspaceKind::Persistent, None),
554            Err(WorkspaceError::PersistentWithoutSlot)
555        );
556        assert_eq!(
557            AttemptWorkspace::from_persisted(WorkspaceKind::Ephemeral, Some(1)),
558            Err(WorkspaceError::EphemeralWithSlot { slot: 1 })
559        );
560        assert_eq!(
561            AttemptWorkspace::from_persisted(WorkspaceKind::Ephemeral, Some(0)),
562            Err(WorkspaceError::EphemeralWithSlot { slot: 0 })
563        );
564        assert_eq!(
565            AttemptWorkspace::from_persisted(WorkspaceKind::Persistent, Some(0)),
566            Err(WorkspaceError::SlotNotPositive)
567        );
568    }
569
570    // -- representation -----------------------------------------------------
571
572    #[test]
573    fn workspace_kind_tokens_are_stable() {
574        for (kind, token) in [
575            (WorkspaceKind::Ephemeral, "ephemeral"),
576            (WorkspaceKind::Persistent, "persistent"),
577        ] {
578            assert_eq!(kind.to_string(), token);
579            assert_eq!(token.parse::<WorkspaceKind>().expect("a known token"), kind);
580            assert_eq!(
581                serde_json::to_value(kind).expect("serialisable"),
582                serde_json::Value::String(token.to_string())
583            );
584        }
585        assert!("durable".parse::<WorkspaceKind>().is_err());
586        assert!(serde_json::from_str::<WorkspaceKind>("\"durable\"").is_err());
587    }
588
589    #[test]
590    fn workspace_values_serialise_without_credentials() {
591        // The whole point of the new state: it is placement, not authentication.
592        // Nothing here may ever carry a token or a JIT configuration, and the
593        // rendered forms are what `d1`, `e1` and status JSON print.
594        let policy = WorkspacePolicy::persistent(root(), TargetScope::Repository)
595            .expect("a repository may be persistent");
596        let attempt = AttemptWorkspace::persistent_slot(nz(2));
597
598        let rendered = format!(
599            "{policy}|{attempt}|{:?}|{:?}|{}|{}",
600            policy,
601            attempt,
602            serde_json::to_string(&policy).expect("serialisable"),
603            serde_json::to_string(&attempt).expect("serialisable"),
604        );
605        for needle in ["token", "secret", "jit", "password", "ghs_", "ghp_"] {
606            assert!(
607                !rendered.to_ascii_lowercase().contains(needle),
608                "workspace rendering leaked {needle:?}: {rendered}"
609            );
610        }
611        assert!(rendered.contains("/srv/rman/acme"));
612        assert!(rendered.contains("s2"));
613    }
614
615    #[test]
616    fn workspace_values_round_trip_through_serde() {
617        let attempt = AttemptWorkspace::persistent_slot(nz(7));
618        let encoded = serde_json::to_string(&attempt).expect("serialisable");
619        assert_eq!(
620            serde_json::from_str::<AttemptWorkspace>(&encoded).expect("deserialisable"),
621            attempt
622        );
623        assert_eq!(
624            serde_json::from_str::<AttemptWorkspace>("{\"mode\":\"ephemeral\"}")
625                .expect("deserialisable"),
626            AttemptWorkspace::Ephemeral
627        );
628        // A slot of zero is not representable, so the journal cannot carry one
629        // even through serde.
630        assert!(
631            serde_json::from_str::<AttemptWorkspace>("{\"mode\":\"persistent_slot\",\"slot\":0}")
632                .is_err()
633        );
634    }
635}