boundgraph_core/isolation.rs
1//! Which isolation tier a tool runs in, what it enforced, and the record of
2//! what actually confined a step.
3//!
4//! Named here, in the crate with no execution in it, because four layers need
5//! the same vocabulary and none of them should have to depend on the others to
6//! get it: a tool manifest *declares* a tier, the compiler *checks* it against
7//! the profile a flow claims, the sandbox *is* one and reports what it
8//! [`Confinement`] amounts to on this host, and the ledger records the
9//! [`Isolation`] a step actually ran under (US-0508).
10//!
11//! The last of those is what turns "this ran confined" from a claim into a
12//! fact: `bg plan` says which tier a tool *needs* and a tier says what it
13//! *can* do here, and neither says what happened.
14//!
15//! `ARCHITECTURE.md` D1 schedules all four — in-process now, subprocess as the
16//! Layer 3 target through M2 to M6, Wasmtime at M7 for Profile C, Firecracker
17//! later — so the enum is complete from the start even though two of them are
18//! not built yet. A tier arriving later should be a new implementation, not a
19//! new word.
20
21use std::fmt;
22
23use serde::{Deserialize, Serialize};
24
25use crate::grant::{GrantAtom, GrantSet};
26
27/// How strongly a tool is isolated from the machine it runs on.
28///
29/// Ordered by strength, weakest first, and that ordering is load-bearing:
30/// [`Tier::weakest`] is how a flow's overall isolation is summarised, and
31/// summarising it any other way would let one unconfined tool hide behind
32/// several confined ones.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35#[non_exhaustive]
36pub enum Tier {
37 /// In this process, sharing its address space. Trusted built-in tools only.
38 InProcess,
39 /// A jailed subprocess. The safe default for anything not written here.
40 Subprocess,
41 /// Wasmtime with WASI preopens. The Profile C tier.
42 Wasm,
43 /// A microVM. Scheduled after Wasm; named so the vocabulary is complete.
44 MicroVm,
45}
46
47impl Tier {
48 /// Every tier, weakest first.
49 pub const ALL: [Self; 4] = [Self::InProcess, Self::Subprocess, Self::Wasm, Self::MicroVm];
50
51 /// The name used in a manifest, a plan, and a ledger entry.
52 pub const fn name(self) -> &'static str {
53 match self {
54 Self::InProcess => "inproc",
55 Self::Subprocess => "process",
56 Self::Wasm => "wasm",
57 Self::MicroVm => "microvm",
58 }
59 }
60
61 /// Whether this tier virtualises the tool's view of the machine.
62 ///
63 /// What Profile C asks for. In-process and subprocess do not: the first
64 /// shares the address space, and the second shares the kernel's namespaces
65 /// unless something outside the tier has separated them.
66 pub const fn is_virtualising(self) -> bool {
67 matches!(self, Self::Wasm | Self::MicroVm)
68 }
69
70 /// Whether a tool has to be trusted for this tier to be safe.
71 ///
72 /// True only in-process, and it is the whole reason that tier refuses
73 /// anything not built in: a tool sharing this address space is not confined
74 /// by anything at all, so the only control left is who wrote it.
75 pub const fn requires_trust(self) -> bool {
76 matches!(self, Self::InProcess)
77 }
78
79 /// The weaker of two tiers.
80 pub const fn weaker(self, other: Self) -> Self {
81 if (self as u8) <= (other as u8) {
82 self
83 } else {
84 other
85 }
86 }
87
88 /// The weakest tier in a collection, if there is one.
89 ///
90 /// How a flow's isolation is reported. A flow is only as isolated as its
91 /// least isolated tool, and saying anything else would let one in-process
92 /// tool hide behind a dozen sandboxed ones.
93 pub fn weakest(tiers: impl IntoIterator<Item = Self>) -> Option<Self> {
94 tiers.into_iter().reduce(Self::weaker)
95 }
96}
97
98/// The safe default: anything that did not say gets a subprocess.
99///
100/// D1 calls process isolation "the safe default for untrusted code", and a
101/// manifest that forgot to declare is exactly the case that needs one. Defaulting
102/// to in-process would mean a forgotten field silently bought full trust.
103impl Default for Tier {
104 fn default() -> Self {
105 Self::Subprocess
106 }
107}
108
109impl fmt::Display for Tier {
110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111 f.write_str(self.name())
112 }
113}
114
115/// How strongly a tier confines one resource domain.
116///
117/// Named here for the same reason [`Tier`] is: a tier *reports* one, the
118/// runtime *carries* it, and a ledger entry *records* it, so the vocabulary
119/// belongs to the crate none of the three can avoid depending on.
120///
121/// # Why the honest answer has three values
122///
123/// [`Enforcement::Kernel`] and [`Enforcement::None`] are the easy cases. The
124/// interesting one is [`Enforcement::Interposed`]: the host stands between the
125/// tool and the resource, which is real enforcement for everything that goes
126/// through the handle it was given, and no enforcement at all against a tool
127/// that makes its own syscall. An in-process tool is in exactly that position.
128///
129/// Collapsing that into "enforced" would be a lie and collapsing it into "not
130/// enforced" would make the in-process tier unusable, so it is its own value
131/// and the caller decides what it is worth. Profile C decides it is worth
132/// nothing.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
134#[serde(rename_all = "snake_case")]
135pub enum Enforcement {
136 /// The kernel refuses. A tool cannot exceed it whatever it does.
137 Kernel,
138 /// The host stands in the way.
139 ///
140 /// Real for everything reached through the handle the tool was given, and
141 /// worth nothing against a tool that makes its own syscall. See the type
142 /// documentation on why this is its own value rather than rounded to one of
143 /// the other two.
144 Interposed,
145 /// Not enforced. A tier reporting this for a domain must refuse any
146 /// capability set that touches it.
147 None,
148}
149
150impl Enforcement {
151 /// Every grade, strongest first.
152 pub const ALL: [Self; 3] = [Self::Kernel, Self::Interposed, Self::None];
153
154 /// Whether a grant in this domain can be handed out at all.
155 pub const fn permits_grants(self) -> bool {
156 !matches!(self, Self::None)
157 }
158
159 /// The name used in a report and in a ledger entry.
160 pub const fn name(self) -> &'static str {
161 match self {
162 Self::Kernel => "kernel",
163 Self::Interposed => "interposed",
164 Self::None => "none",
165 }
166 }
167
168 /// How it reads in a diagnostic.
169 pub const fn describe(self) -> &'static str {
170 match self {
171 Self::Kernel => "enforced by the kernel",
172 Self::Interposed => "enforced by the host, which a tool making its own syscalls evades",
173 Self::None => "not enforced",
174 }
175 }
176}
177
178impl fmt::Display for Enforcement {
179 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180 f.write_str(self.name())
181 }
182}
183
184/// What a tier actually enforces, per domain, on this host.
185///
186/// Per host, not per tier in the abstract: the subprocess tier confines a
187/// filesystem grant with a preopened directory everywhere, and confines a
188/// network grant only where the operating system will give it a network
189/// namespace. Reporting the abstract answer would be reporting the answer for
190/// somebody else's machine.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
192pub struct Confinement {
193 /// `fs:*` — reading, listing, writing files.
194 pub filesystem: Enforcement,
195 /// `net:*` — reaching anything off this machine.
196 pub network: Enforcement,
197 /// `proc:*` — starting other programs.
198 pub process: Enforcement,
199 /// `env:*` — reading the environment.
200 pub environment: Enforcement,
201 /// `secret:*` — using a credential.
202 pub secret: Enforcement,
203}
204
205impl Confinement {
206 /// A tier that enforces nothing. The floor to build from, so a tier claims
207 /// enforcement only by saying so.
208 pub const NONE: Self = Self {
209 filesystem: Enforcement::None,
210 network: Enforcement::None,
211 process: Enforcement::None,
212 environment: Enforcement::None,
213 secret: Enforcement::None,
214 };
215
216 /// How this tier confines the given grant domain.
217 ///
218 /// An unknown domain is [`Enforcement::None`]. The grant vocabulary is
219 /// meant to grow ([`KNOWN_DOMAINS`](crate::grant::KNOWN_DOMAINS) says so),
220 /// and a tier that treated a domain it had never heard of as confined would
221 /// be guessing in the one direction that is unsafe.
222 pub const fn of(&self, domain: &str) -> Enforcement {
223 // `match` on a `&str` in a const fn needs bytes.
224 match domain.as_bytes() {
225 b"fs" => self.filesystem,
226 b"net" => self.network,
227 b"proc" => self.process,
228 b"env" => self.environment,
229 b"secret" => self.secret,
230 _ => Enforcement::None,
231 }
232 }
233
234 /// Each domain and how it is confined, weakest-enforced domains included.
235 pub const fn domains(&self) -> [(&'static str, Enforcement); 5] {
236 [
237 ("fs", self.filesystem),
238 ("net", self.network),
239 ("proc", self.process),
240 ("env", self.environment),
241 ("secret", self.secret),
242 ]
243 }
244
245 /// Whether nothing at all is enforced.
246 ///
247 /// Not the same as saying nothing: a tier that reports this has been asked
248 /// and has answered "none", which is exactly the fact a reviewer of a
249 /// Profile C run came looking for.
250 pub fn enforces_nothing(&self) -> bool {
251 *self == Self::NONE
252 }
253
254 /// The first atom of `grants` this tier cannot enforce, if there is one.
255 ///
256 /// What a tier refuses a capability set on: a grant nothing enforces is an
257 /// advisory permission, and handing one over would make the compiler's
258 /// proof false at run time.
259 pub fn unenforceable<'g>(&self, grants: &'g GrantSet) -> Option<&'g GrantAtom> {
260 grants
261 .iter()
262 .find(|atom| !self.of(&atom.domain).permits_grants())
263 }
264}
265
266impl fmt::Display for Confinement {
267 /// `fs=kernel net=none proc=interposed env=kernel secret=interposed`.
268 ///
269 /// Every domain, every time, including the ones enforcing nothing. A
270 /// rendering that omitted them would make "not confined" and "not
271 /// mentioned" look the same, which is the distinction this type exists to
272 /// keep.
273 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274 for (index, (domain, enforcement)) in self.domains().into_iter().enumerate() {
275 if index > 0 {
276 f.write_str(" ")?;
277 }
278 write!(f, "{domain}={enforcement}")?;
279 }
280 Ok(())
281 }
282}
283
284/// What actually confined one step (US-0508).
285///
286/// # Why this is recorded rather than inferred
287///
288/// A plan says which tier a tool *needs*, and a tier says what it *can*
289/// enforce on this host. Neither says what happened. A host that could not
290/// deliver what the plan assumed is exactly the case worth catching — a
291/// Profile C flow that silently ran in-process is a flow whose reproducibility
292/// claim is false — so the tier that ran the step writes down what it was and
293/// what it enforced, and the ledger keeps that beside the grant delta.
294///
295/// # Absent is not the same as none
296///
297/// The ledger holds an `Option<Isolation>`, and the two cases are different
298/// facts. `None` means the runner did not say: nothing is claimed, and a
299/// reviewer knows to go and ask. `Some` carrying [`Confinement::NONE`] means
300/// the step ran with nothing enforced, which is a claim, and one somebody may
301/// have to act on. Rounding either into the other would lose the difference
302/// between an unaudited step and an unconfined one.
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
304pub struct Isolation {
305 /// The tier that actually ran the step.
306 pub tier: Tier,
307 /// What that tier actually enforced, per domain, on the host it ran on.
308 pub confinement: Confinement,
309 /// The reused worker that served it, where one did (US-0909).
310 ///
311 /// `None` is a child of its own: one process, one call, gone afterwards.
312 /// `Some` means a warm worker served it, and names which — because "the
313 /// tool ran in a subprocess" stops being the whole answer once a
314 /// subprocess serves more than one step. Two steps confined identically
315 /// and served by the *same* worker are a different fact from two steps
316 /// that each had a process to themselves, and only one of them can be
317 /// investigated by asking what else that worker did.
318 ///
319 /// Skipped when absent, so a cold child's entry encodes exactly as it did
320 /// before warm workers existed.
321 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub worker: Option<Worker>,
323}
324
325/// Which reused worker served a step, and how much it had already done
326/// (US-0909).
327#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
328pub struct Worker {
329 /// Which worker, counting from one over the pool's whole life.
330 ///
331 /// A generation rather than an index: a worker that dies is replaced, and
332 /// the replacement occupying the same slot is a *different* process. Reusing
333 /// the number would make two workers' histories look like one's.
334 pub generation: u64,
335 /// How many calls it had already served when this one arrived.
336 ///
337 /// Zero for the first. What turns "worker 3 served this" into "worker 3 had
338 /// already served nine other steps when it served this", which is the
339 /// question an auditor looking at a warm pool actually has.
340 pub served: u32,
341}
342
343impl Worker {
344 /// The `served`-th call on worker `generation`.
345 pub const fn new(generation: u64, served: u32) -> Self {
346 Self { generation, served }
347 }
348
349 /// Whether this was the worker's first call, so nothing preceded it there.
350 pub const fn is_first(&self) -> bool {
351 self.served == 0
352 }
353}
354
355impl fmt::Display for Worker {
356 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
357 write!(
358 f,
359 "worker {} after {} call(s)",
360 self.generation, self.served
361 )
362 }
363}
364
365impl Isolation {
366 /// A step that ran in `tier`, which enforced `confinement`.
367 ///
368 /// In a child of its own: [`served_by`](Self::served_by) is what says a
369 /// reused worker ran it instead.
370 pub const fn new(tier: Tier, confinement: Confinement) -> Self {
371 Self {
372 tier,
373 confinement,
374 worker: None,
375 }
376 }
377
378 /// Record that a reused worker served this step (US-0909).
379 #[must_use]
380 pub const fn served_by(mut self, worker: Worker) -> Self {
381 self.worker = Some(worker);
382 self
383 }
384
385 /// Whether a reused worker served it, rather than a child of its own.
386 pub const fn was_warm(&self) -> bool {
387 self.worker.is_some()
388 }
389
390 /// A step that ran in `tier` with nothing enforced at all.
391 ///
392 /// The record a trusted in-process tool leaves when nothing stands between
393 /// it and the machine. Written down rather than omitted: see the type
394 /// documentation.
395 pub const fn unconfined(tier: Tier) -> Self {
396 Self::new(tier, Confinement::NONE)
397 }
398
399 /// Whether the tier that ran it virtualises the machine — what Profile C
400 /// asks for.
401 pub const fn is_virtualising(&self) -> bool {
402 self.tier.is_virtualising()
403 }
404
405 /// Whether nothing at all was enforced.
406 pub fn enforces_nothing(&self) -> bool {
407 self.confinement.enforces_nothing()
408 }
409
410 /// Whether every domain was refused by the kernel rather than by the host.
411 ///
412 /// The question a reviewer asking "was this confined, or merely asked
413 /// nicely?" is actually asking.
414 pub fn is_kernel_enforced(&self) -> bool {
415 self.confinement
416 .domains()
417 .into_iter()
418 .all(|(_, enforcement)| enforcement == Enforcement::Kernel)
419 }
420}
421
422impl fmt::Display for Isolation {
423 /// `process (fs=kernel net=none proc=interposed env=kernel secret=interposed)`,
424 /// and where a warm worker served it, which one.
425 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
426 write!(f, "{} ({})", self.tier, self.confinement)?;
427 match self.worker {
428 Some(worker) => write!(f, ", {worker}"),
429 None => Ok(()),
430 }
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn every_tier_has_a_stable_name() {
440 for (tier, name) in [
441 (Tier::InProcess, "inproc"),
442 (Tier::Subprocess, "process"),
443 (Tier::Wasm, "wasm"),
444 (Tier::MicroVm, "microvm"),
445 ] {
446 assert_eq!(tier.name(), name);
447 assert_eq!(tier.to_string(), name);
448 }
449 }
450
451 /// Profile C asks for a virtualising sandbox, and only two tiers are one.
452 #[test]
453 fn only_the_virtualising_tiers_say_they_are() {
454 assert!(!Tier::InProcess.is_virtualising());
455 assert!(!Tier::Subprocess.is_virtualising());
456 assert!(Tier::Wasm.is_virtualising());
457 assert!(Tier::MicroVm.is_virtualising());
458 }
459
460 /// The ordering is strength, and the rest of the system relies on it.
461 #[test]
462 fn tiers_are_ordered_weakest_first() {
463 assert!(Tier::InProcess < Tier::Subprocess);
464 assert!(Tier::Subprocess < Tier::Wasm);
465 assert!(Tier::Wasm < Tier::MicroVm);
466
467 let mut shuffled = [Tier::Wasm, Tier::InProcess, Tier::MicroVm, Tier::Subprocess];
468 shuffled.sort();
469 assert_eq!(shuffled, Tier::ALL);
470 }
471
472 /// A flow is only as isolated as its least isolated tool.
473 #[test]
474 fn the_weakest_tier_is_the_one_reported() {
475 assert_eq!(
476 Tier::weakest([Tier::Wasm, Tier::InProcess, Tier::Subprocess]),
477 Some(Tier::InProcess)
478 );
479 assert_eq!(Tier::weakest([Tier::Wasm, Tier::MicroVm]), Some(Tier::Wasm));
480 assert_eq!(Tier::weakest([]), None);
481 }
482
483 /// A forgotten field must not buy full trust.
484 #[test]
485 fn the_default_is_the_safe_one_rather_than_the_convenient_one() {
486 assert_eq!(Tier::default(), Tier::Subprocess);
487 assert!(!Tier::default().requires_trust());
488 }
489
490 #[test]
491 fn only_the_in_process_tier_depends_on_trusting_the_tool() {
492 assert!(Tier::InProcess.requires_trust());
493 for tier in [Tier::Subprocess, Tier::Wasm, Tier::MicroVm] {
494 assert!(!tier.requires_trust(), "{tier}");
495 }
496 }
497
498 #[test]
499 fn a_tier_round_trips_through_its_serialised_name() {
500 for tier in Tier::ALL {
501 let json = serde_json::to_string(&tier).unwrap();
502 assert_eq!(serde_json::from_str::<Tier>(&json).unwrap(), tier);
503 }
504 assert_eq!(
505 serde_json::to_string(&Tier::InProcess).unwrap(),
506 "\"in_process\""
507 );
508 }
509
510 // ── what a tier enforced (US-0508) ──────────────────────────────────────
511
512 fn kernel_everywhere() -> Confinement {
513 Confinement {
514 filesystem: Enforcement::Kernel,
515 network: Enforcement::Kernel,
516 process: Enforcement::Kernel,
517 environment: Enforcement::Kernel,
518 secret: Enforcement::Kernel,
519 }
520 }
521
522 /// The floor claims nothing, which is what makes claiming something mean
523 /// something.
524 #[test]
525 fn the_confinement_floor_enforces_nothing_in_every_domain() {
526 assert!(Confinement::NONE.enforces_nothing());
527 for (domain, enforcement) in Confinement::NONE.domains() {
528 assert_eq!(enforcement, Enforcement::None, "{domain}");
529 assert!(!Confinement::NONE.of(domain).permits_grants());
530 }
531 }
532
533 /// A domain nobody has heard of yet is not confined, and the answer errs in
534 /// the safe direction rather than the convenient one.
535 #[test]
536 fn an_unknown_domain_is_never_reported_as_confined() {
537 assert_eq!(kernel_everywhere().of("quantum"), Enforcement::None);
538 assert!(!kernel_everywhere().of("quantum").permits_grants());
539 }
540
541 /// The distinction the whole grade exists for: interposed is enforcement,
542 /// and it is not the kernel's.
543 #[test]
544 fn interposed_permits_grants_and_is_still_not_the_kernel() {
545 assert!(Enforcement::Interposed.permits_grants());
546 assert!(Enforcement::Kernel.permits_grants());
547 assert!(!Enforcement::None.permits_grants());
548 assert_ne!(Enforcement::Interposed, Enforcement::Kernel);
549 }
550
551 /// A step that ran with nothing enforced says so, and that is different
552 /// from a runner that did not say.
553 #[test]
554 fn running_unconfined_is_a_claim_rather_than_a_silence() {
555 let ran_unconfined = Isolation::unconfined(Tier::InProcess);
556 let recorded: Option<Isolation> = Some(ran_unconfined);
557 let unrecorded: Option<Isolation> = None;
558
559 assert_ne!(recorded, unrecorded);
560 assert!(ran_unconfined.enforces_nothing());
561 assert!(!ran_unconfined.is_kernel_enforced());
562 }
563
564 #[test]
565 fn only_a_virtualising_tier_satisfies_profile_c_after_the_fact() {
566 assert!(!Isolation::unconfined(Tier::InProcess).is_virtualising());
567 assert!(!Isolation::new(Tier::Subprocess, kernel_everywhere()).is_virtualising());
568 assert!(Isolation::new(Tier::Wasm, kernel_everywhere()).is_virtualising());
569 }
570
571 /// Kernel enforcement is claimed for the whole step or not at all: one
572 /// interposed domain is enough to make the claim false.
573 #[test]
574 fn one_interposed_domain_defeats_a_kernel_claim() {
575 let mut confinement = kernel_everywhere();
576 assert!(Isolation::new(Tier::Subprocess, confinement).is_kernel_enforced());
577
578 confinement.secret = Enforcement::Interposed;
579 assert!(!Isolation::new(Tier::Subprocess, confinement).is_kernel_enforced());
580 }
581
582 /// The rendering names every domain, so "not confined" never reads as "not
583 /// mentioned".
584 #[test]
585 fn the_rendering_names_every_domain_including_the_unenforced_ones() {
586 let rendered = Isolation::unconfined(Tier::InProcess).to_string();
587 assert_eq!(
588 rendered,
589 "inproc (fs=none net=none proc=none env=none secret=none)"
590 );
591 for domain in ["fs", "net", "proc", "env", "secret"] {
592 assert!(
593 rendered.contains(domain),
594 "{domain} missing from {rendered}"
595 );
596 }
597 }
598
599 // ── which worker served it (US-0909) ────────────────────────────────────
600
601 /// A cold child and a warm worker are different facts about a step, and the
602 /// record keeps them apart.
603 #[test]
604 fn a_reused_worker_is_named_and_a_child_of_its_own_is_not() {
605 let cold = Isolation::new(Tier::Subprocess, kernel_everywhere());
606 let warm = cold.served_by(Worker::new(3, 9));
607
608 assert!(!cold.was_warm());
609 assert!(warm.was_warm());
610 assert_ne!(cold, warm, "same confinement, different history");
611
612 let worker = warm.worker.expect("a warm step names its worker");
613 assert_eq!(worker.generation, 3);
614 assert_eq!(worker.served, 9);
615 assert!(!worker.is_first());
616 assert!(Worker::new(1, 0).is_first());
617 }
618
619 /// A dead worker's replacement is a different process, so it gets a
620 /// different number rather than inheriting one.
621 #[test]
622 fn a_generation_is_not_a_slot() {
623 assert_ne!(Worker::new(1, 0), Worker::new(2, 0));
624 assert!(Worker::new(1, 5) < Worker::new(2, 0), "ordered by age");
625 }
626
627 #[test]
628 fn the_rendering_names_the_worker_only_when_there_was_one() {
629 let cold = Isolation::new(Tier::Subprocess, Confinement::NONE);
630 assert!(!cold.to_string().contains("worker"));
631 assert!(
632 cold.served_by(Worker::new(2, 4))
633 .to_string()
634 .ends_with("worker 2 after 4 call(s)")
635 );
636 }
637
638 #[test]
639 fn an_isolation_record_round_trips_through_json() {
640 for isolation in [
641 Isolation::unconfined(Tier::InProcess),
642 Isolation::new(Tier::Subprocess, kernel_everywhere()),
643 Isolation::new(Tier::Subprocess, kernel_everywhere()).served_by(Worker::new(7, 2)),
644 Isolation::new(Tier::Wasm, Confinement::NONE),
645 ] {
646 let json = serde_json::to_string(&isolation).unwrap();
647 assert_eq!(serde_json::from_str::<Isolation>(&json).unwrap(), isolation);
648 }
649 }
650}