Skip to main content

detcore/preemptions/
reserved_input.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! Provenance for the private input mapping, at the standalone JSON boundary.
10//!
11//! None of these types is a Config, ThreadHistory, binary-event or RPC field.
12//! An imported value is preserved verbatim as JSON data, including `null` and
13//! values from a future producer. Only Chosen execution interprets it.
14
15use detcore_model::pid::GuestIds;
16use serde::Deserialize;
17use serde::Serialize;
18
19use super::PreemptionRecord;
20
21const CONTRACT: &str = "sealed-private-input-v1";
22const BASE: &str = "0x71010000";
23
24/// An optional JSON member, distinguishing absence from a present null.
25///
26/// Construction and editing never confer provenance. Values are imported from
27/// a recording or supplied to the live writer by authenticated backend setup.
28#[derive(Clone, Debug, Default, Eq, PartialEq, Hash)]
29pub struct ReservedInputMetadata(Option<serde_json::Value>);
30
31impl ReservedInputMetadata {
32    pub(super) fn is_missing(&self) -> bool {
33        self.0.is_none()
34    }
35
36    /// The stored JSON value. `None` is an absent member, not a present null.
37    pub fn as_json(&self) -> Option<&serde_json::Value> {
38        self.0.as_ref()
39    }
40
41    /// Require equal recognized provenance when combining Chosen inputs.
42    /// Editors call this before choosing an unchanged endpoint as well.
43    pub fn require_same_for_chosen(&self, other: &Self) -> Result<(), RecordAdmissionRefusal> {
44        let first = self.recognized("first transformation input")?;
45        let second = other.recognized("second transformation input")?;
46        if first != second {
47            return Err(provenance_refusal(
48                "transformation inputs",
49                "mixed reserved-input layouts",
50            ));
51        }
52        Ok(())
53    }
54
55    fn recognized(&self, source: &str) -> Result<ReservedInputLayout, RecordAdmissionRefusal> {
56        let value = self.0.as_ref().ok_or_else(|| {
57            provenance_refusal(
58                source,
59                "missing reserved_input provenance (legacy or unqualified recording)",
60            )
61        })?;
62        let layout: ReservedInputLayout =
63            serde_json::from_value(value.clone()).map_err(|error| {
64                provenance_refusal(
65                    source,
66                    &format!("malformed or unknown reserved_input descriptor: {error}"),
67                )
68            })?;
69        layout.require_supported(source)?;
70        Ok(layout)
71    }
72
73    pub(super) fn require_matching_layout(
74        &self,
75        source: &str,
76        expected: &ReservedInputLayout,
77    ) -> Result<(), RecordAdmissionRefusal> {
78        let actual = self.recognized(source)?;
79        if actual != *expected {
80            return Err(provenance_refusal(
81                source,
82                &format!(
83                    "reserved-input layout differs: recorded ABI {} at {} with {} bytes, runtime ABI {} at {} with {} bytes",
84                    actual.abi,
85                    actual.base,
86                    actual.page_bytes,
87                    expected.abi,
88                    expected.base,
89                    expected.page_bytes,
90                ),
91            ));
92        }
93        Ok(())
94    }
95}
96
97impl Serialize for ReservedInputMetadata {
98    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
99        self.0.serialize(serializer)
100    }
101}
102
103impl<'de> Deserialize<'de> for ReservedInputMetadata {
104    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
105        // Option<Value> would collapse a present null into absence.
106        serde_json::Value::deserialize(deserializer).map(|value| Self(Some(value)))
107    }
108}
109
110/// A recognized layout, supplied to the live writer only at authenticated
111/// target-image setup. The constructor is crate-private so CLI editors cannot
112/// stamp a record from requested configuration or a host page-size probe.
113#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
114#[serde(deny_unknown_fields)]
115pub(crate) struct ReservedInputLayout {
116    contract: String,
117    base: String,
118    page_bytes: u64,
119    abi: String,
120}
121
122impl ReservedInputLayout {
123    /// Convert actual backend setup facts, never a requested layout, to the
124    /// JSON contract. The caller owns the setup receipt and its target-image
125    /// lifetime; this conversion alone is not that authority.
126    pub(crate) fn from_authenticated_setup(
127        abi: &str,
128        base: u64,
129        page_bytes: u64,
130    ) -> Result<Self, RecordAdmissionRefusal> {
131        let layout = Self {
132            contract: CONTRACT.to_owned(),
133            base: format!("0x{base:x}"),
134            page_bytes,
135            abi: abi.to_owned(),
136        };
137        layout.require_supported("authenticated target setup")?;
138        Ok(layout)
139    }
140
141    fn require_supported(&self, source: &str) -> Result<(), RecordAdmissionRefusal> {
142        if self.contract != CONTRACT {
143            return Err(provenance_refusal(
144                source,
145                "unknown reserved-input contract",
146            ));
147        }
148        if self.base != BASE
149            || !matches!(self.abi.as_str(), "x86_64" | "aarch64")
150            || !matches!(self.page_bytes, 4096 | 16384 | 65536)
151        {
152            return Err(provenance_refusal(
153                source,
154                "unsupported reserved-input ABI, address or page extent",
155            ));
156        }
157        Ok(())
158    }
159
160    fn native() -> Result<Self, RecordAdmissionRefusal> {
161        let abi = std::env::consts::ARCH;
162        // Admission observes the native ABI granule; it neither chooses a turn
163        // nor changes guest virtual time. Setup later authenticates the VMA.
164        let page_bytes = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
165        let page_bytes = u64::try_from(page_bytes).map_err(|_| {
166            provenance_refusal("runtime", "cannot establish the native page extent")
167        })?;
168        Self::from_authenticated_setup(abi, 0x7101_0000, page_bytes)
169    }
170
171    fn metadata(&self) -> ReservedInputMetadata {
172        ReservedInputMetadata(Some(serde_json::to_value(self).expect("layout serializes")))
173    }
174}
175
176/// A schedule admission policy failure, not malformed JSON or a replay desync.
177#[derive(Debug, Clone, Eq, PartialEq)]
178pub struct RecordAdmissionRefusal(pub String);
179
180impl std::fmt::Display for RecordAdmissionRefusal {
181    fn fmt(&self, output: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        output.write_str(&self.0)
183    }
184}
185
186impl std::error::Error for RecordAdmissionRefusal {}
187
188fn provenance_refusal(source: &str, reason: &str) -> RecordAdmissionRefusal {
189    RecordAdmissionRefusal(format!(
190        "HERMIT_CHOSEN_JSON_INPUT_PROVENANCE: {source}: {reason}; Chosen replay requires a recording produced with this reserved-input layout. The guest has not started. Record the run again with this Hermit, or replay the original with its producing Hermit; editing or overriding provenance cannot qualify an old recording."
191    ))
192}
193
194impl PreemptionRecord {
195    /// Preserve the source's optional metadata on an edit. This is not a live
196    /// producer certificate; unmarked/unknown input remains unmarked/unknown.
197    pub fn with_reserved_input(mut self, metadata: ReservedInputMetadata) -> Self {
198        self.reserved_input = metadata;
199        self
200    }
201
202    /// The exact optional standalone JSON metadata, without interpreting it.
203    pub fn reserved_input(&self) -> &ReservedInputMetadata {
204        &self.reserved_input
205    }
206
207    /// Reconcile numbering and private-input provenance on the complete
208    /// decoded record that an authoritative replay loader will install.
209    pub fn validate_replay_inputs(
210        &self,
211        source: &str,
212        guest_ids: GuestIds,
213    ) -> Result<(), RecordAdmissionRefusal> {
214        if let Some(reason) =
215            super::guest_ids_mismatch(source, self.guest_ids.unwrap_or_default(), guest_ids)
216        {
217            return Err(RecordAdmissionRefusal(reason));
218        }
219        if matches!(guest_ids, GuestIds::Chosen { .. }) {
220            self.reserved_input
221                .require_matching_layout(source, &ReservedInputLayout::native()?)?;
222        }
223        Ok(())
224    }
225
226    /// Validate a combination before any endpoint or identity shortcut. A
227    /// Chosen input requires the same recognized descriptor on every source.
228    /// Non-Chosen edits retain their former admission and opaque metadata.
229    pub fn validate_combined_reserved_inputs(
230        &self,
231        other: &Self,
232    ) -> Result<(), RecordAdmissionRefusal> {
233        if !matches!(self.guest_ids, Some(GuestIds::Chosen { .. }))
234            && !matches!(other.guest_ids, Some(GuestIds::Chosen { .. }))
235        {
236            return Ok(());
237        }
238        self.reserved_input
239            .require_same_for_chosen(&other.reserved_input)
240    }
241}
242
243/// Preserve missing/unknown metadata when inspecting either JSON envelope.
244pub fn read_recorded_reserved_input(
245    path: &std::path::Path,
246) -> Result<ReservedInputMetadata, String> {
247    #[derive(Deserialize)]
248    struct MetadataOnly {
249        #[serde(default)]
250        reserved_input: ReservedInputMetadata,
251    }
252    Ok(super::boundary_recording::read_settings::<MetadataOnly>(path)?.reserved_input)
253}
254
255/// Early launch preflight. Each authoritative loader repeats the check on
256/// the same immutable decoded bytes it installs; this pathname check is not
257/// that final authority. Both Config inputs are checked independently.
258pub fn validate_config_replay_inputs(config: &crate::Config) -> anyhow::Result<()> {
259    for (option, path) in [
260        ("--replay-schedule-from", &config.replay_schedule_from),
261        ("--replay-preemptions-from", &config.replay_preemptions_from),
262    ] {
263        if let Some(path) = path {
264            let recording = super::boundary_recording::Recording::read(path)?;
265            recording.validate_replay_inputs(
266                &format!("{option} {}", path.display()),
267                config.guest_ids,
268            )?;
269        }
270    }
271    Ok(())
272}
273
274/// Live-writer state. Failure is sticky: a later exec cannot retroactively
275/// qualify an earlier executed target image. Launcher bootstrap never calls
276/// this observer, and Default/Drop do not mint a producer declaration.
277#[derive(Debug, Default)]
278pub(super) enum ProducerQualification {
279    #[default]
280    Unobserved,
281    Qualified(ReservedInputLayout),
282    Disqualified,
283}
284
285impl ProducerQualification {
286    pub(super) fn observe(&mut self, layout: Option<ReservedInputLayout>) -> ReservedInputMetadata {
287        *self = match (&*self, layout) {
288            (Self::Unobserved, Some(layout)) => Self::Qualified(layout),
289            (Self::Qualified(previous), Some(layout)) if *previous == layout => {
290                Self::Qualified(layout)
291            }
292            _ => Self::Disqualified,
293        };
294        match self {
295            Self::Qualified(layout) => layout.metadata(),
296            _ => ReservedInputMetadata::default(),
297        }
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::preemptions::PreemptionReader;
305    use crate::preemptions::PreemptionWriter;
306    use crate::preemptions::TimeSkip;
307    use crate::preemptions::TimeSkipReason;
308    use crate::preemptions::boundary_recording::BoundaryRecording;
309    use crate::preemptions::boundary_recording::HistoryReader;
310    use crate::preemptions::boundary_recording::Recording;
311    use crate::types::DetTid;
312    use crate::types::LogicalTime;
313    use crate::types::SchedEvent;
314
315    fn chosen() -> GuestIds {
316        GuestIds::Chosen {
317            base: 5000,
318            refuse_host_ids: true,
319        }
320    }
321
322    fn marked() -> PreemptionRecord {
323        let layout = ReservedInputLayout::native().unwrap();
324        PreemptionRecord::from_sched_events(vec![SchedEvent::branches(DetTid::from_raw(5000), 2)])
325            .with_guest_ids(chosen())
326            .with_current_format()
327            .with_time_skips(vec![TimeSkip {
328                event: 0,
329                target: LogicalTime::from_nanos(1000),
330                reason: TimeSkipReason::IdleGuestPollers,
331            }])
332            .with_reserved_input(layout.metadata())
333    }
334
335    #[test]
336    fn absence_null_and_unknown_provenance_remain_distinct_in_both_envelopes() {
337        for value in [
338            None,
339            Some(serde_json::Value::Null),
340            Some(serde_json::json!({
341                "future_contract": [1, null, "opaque"], "nested": {"extra": false}
342            })),
343        ] {
344            let original = PreemptionRecord::default()
345                .with_reserved_input(ReservedInputMetadata(value.clone()));
346            for recording in [
347                Recording::Legacy(original.clone()),
348                Recording::Boundaries(
349                    BoundaryRecording::new(
350                        original.clone(),
351                        detcore_model::schedule::boundary::Schedule::new(vec![]).unwrap(),
352                    )
353                    .unwrap(),
354                ),
355            ] {
356                let decoded = Recording::decode(&recording.encode().unwrap()).unwrap();
357                assert_eq!(decoded.reserved_input().as_json(), value.as_ref());
358                decoded
359                    .validate_replay_inputs("non-Chosen", GuestIds::Namespace)
360                    .unwrap();
361                assert_eq!(decoded.encode().unwrap(), recording.encode().unwrap());
362                let directory = tempfile::tempdir().unwrap();
363                let path = directory.path().join("record.json");
364                recording.write(&path).unwrap();
365                assert_eq!(
366                    read_recorded_reserved_input(&path).unwrap().as_json(),
367                    value.as_ref()
368                );
369                assert_eq!(
370                    HistoryReader::read(&path).unwrap().to_string().as_bytes(),
371                    recording.encode().unwrap()
372                );
373            }
374        }
375    }
376
377    #[test]
378    fn chosen_requires_recognized_matching_provenance_without_overrides() {
379        let record = marked();
380        record
381            .validate_replay_inputs("qualified", chosen())
382            .unwrap();
383        for value in [
384            None,
385            Some(serde_json::Value::Null),
386            Some(serde_json::json!(42)),
387            Some(serde_json::json!({"contract": "future"})),
388        ] {
389            let legacy = record
390                .clone()
391                .with_reserved_input(ReservedInputMetadata(value));
392            let refusal = legacy
393                .validate_replay_inputs("old fixture", chosen())
394                .unwrap_err();
395            assert!(
396                refusal
397                    .0
398                    .starts_with("HERMIT_CHOSEN_JSON_INPUT_PROVENANCE:")
399            );
400            assert!(refusal.0.contains("guest has not started"));
401            assert!(refusal.0.contains("Record the run again"));
402            // Changing strict/tolerant policy is not a provenance override.
403            assert!(
404                legacy
405                    .validate_replay_inputs(
406                        "old fixture",
407                        GuestIds::Chosen {
408                            base: 5000,
409                            refuse_host_ids: false,
410                        }
411                    )
412                    .is_err()
413            );
414        }
415        for (field, value) in [
416            ("contract", serde_json::json!("sealed-private-input-v2")),
417            ("base", serde_json::json!("0x71020000")),
418            ("page_bytes", serde_json::json!(8192)),
419            ("abi", serde_json::json!("unsupported")),
420            ("extra", serde_json::json!(true)),
421        ] {
422            let mut raw = serde_json::to_value(&record).unwrap();
423            raw["reserved_input"][field] = value;
424            let changed: PreemptionRecord = serde_json::from_value(raw).unwrap();
425            let refusal = changed
426                .validate_replay_inputs("changed", chosen())
427                .unwrap_err();
428            assert!(
429                refusal
430                    .0
431                    .starts_with("HERMIT_CHOSEN_JSON_INPUT_PROVENANCE:")
432            );
433        }
434        let mut other = ReservedInputLayout::native().unwrap();
435        other.page_bytes = if other.page_bytes == 4096 {
436            16384
437        } else {
438            4096
439        };
440        let changed = record.with_reserved_input(other.metadata());
441        assert!(
442            changed
443                .validate_replay_inputs("different native page", chosen())
444                .unwrap_err()
445                .0
446                .contains("layout differs")
447        );
448    }
449
450    #[test]
451    fn numbering_gate_cannot_be_bypassed_by_an_unlabelled_or_relabelled_record() {
452        let record = marked();
453        assert!(
454            record
455                .validate_replay_inputs("wrong runtime", GuestIds::Namespace)
456                .is_err()
457        );
458        assert!(
459            record
460                .validate_replay_inputs(
461                    "wrong base",
462                    GuestIds::Chosen {
463                        base: 6000,
464                        refuse_host_ids: true,
465                    }
466                )
467                .is_err()
468        );
469        let unlabelled = record.clone().with_guest_ids(GuestIds::Namespace);
470        assert!(
471            unlabelled
472                .validate_replay_inputs("unlabelled", chosen())
473                .is_err()
474        );
475        assert!(
476            record
477                .with_guest_ids(GuestIds::Host)
478                .validate_replay_inputs("host IDs", chosen())
479                .is_err()
480        );
481    }
482
483    #[test]
484    fn config_admission_checks_both_actual_inputs_and_preserves_error_classes() {
485        let directory = tempfile::tempdir().unwrap();
486        let good = directory.path().join("qualified.json");
487        let bad = directory.path().join("legacy.json");
488        marked().write_to_disk(&good).unwrap();
489        marked()
490            .with_reserved_input(ReservedInputMetadata::default())
491            .write_to_disk(&bad)
492            .unwrap();
493        for (schedule, priorities) in [
494            (Some(bad.clone()), None),
495            (None, Some(bad.clone())),
496            (Some(good.clone()), Some(bad.clone())),
497            (Some(bad.clone()), Some(good.clone())),
498        ] {
499            let config = crate::Config {
500                guest_ids: chosen(),
501                replay_schedule_from: schedule,
502                replay_preemptions_from: priorities,
503                ..crate::Config::default()
504            };
505            let error = validate_config_replay_inputs(&config).unwrap_err();
506            assert!(
507                error
508                    .chain()
509                    .any(|cause| cause.is::<RecordAdmissionRefusal>())
510            );
511        }
512        let config = crate::Config {
513            guest_ids: chosen(),
514            replay_schedule_from: Some(good.clone()),
515            replay_preemptions_from: Some(good.clone()),
516            ..crate::Config::default()
517        };
518        validate_config_replay_inputs(&config).unwrap();
519        std::fs::write(&good, b"{broken JSON").unwrap();
520        let error = validate_config_replay_inputs(&config).unwrap_err();
521        assert!(
522            !error
523                .chain()
524                .any(|cause| cause.is::<RecordAdmissionRefusal>())
525        );
526    }
527
528    #[test]
529    fn authoritative_history_admits_decoded_bytes_not_a_preflight_path() {
530        let directory = tempfile::tempdir().unwrap();
531        let path = directory.path().join("history.json");
532        marked().write_to_disk(&path).unwrap();
533        let config = crate::Config {
534            guest_ids: chosen(),
535            replay_preemptions_from: Some(path.clone()),
536            ..crate::Config::default()
537        };
538        validate_config_replay_inputs(&config).unwrap();
539        marked()
540            .with_reserved_input(ReservedInputMetadata::default())
541            .write_to_disk(&path)
542            .unwrap();
543        let error = HistoryReader::read_for_replay(&path, chosen()).unwrap_err();
544        assert!(
545            error
546                .chain()
547                .any(|cause| cause.is::<RecordAdmissionRefusal>())
548        );
549    }
550
551    #[test]
552    fn identity_interpolation_validates_all_sources_before_either_endpoint() {
553        let original = marked();
554        let legacy = original
555            .clone()
556            .with_reserved_input(ReservedInputMetadata::default());
557        for (first, second) in [(&original, &legacy), (&legacy, &original)] {
558            let error = first
559                .interpolate_events(second, original.global.clone())
560                .unwrap_err();
561            assert!(error.starts_with("HERMIT_CHOSEN_JSON_INPUT_PROVENANCE:"));
562        }
563        let mut mixed = original.clone();
564        let mut descriptor = ReservedInputLayout::native().unwrap();
565        descriptor.abi = if descriptor.abi == "x86_64" {
566            "aarch64"
567        } else {
568            "x86_64"
569        }
570        .to_owned();
571        mixed.reserved_input = descriptor.metadata();
572        assert!(
573            original
574                .interpolate_events(&mixed, original.global.clone())
575                .is_err()
576        );
577        let identity = original
578            .interpolate_events(&original, original.global.clone())
579            .unwrap();
580        assert_eq!(identity.to_string(), original.to_string());
581        assert_eq!(identity.format_version(), Some(3));
582        assert_eq!(identity.time_skips(), original.time_skips());
583    }
584
585    #[test]
586    fn non_chosen_interpolation_preserves_opaque_metadata_without_new_admission() {
587        let first = PreemptionRecord::from_sched_events(vec![
588            SchedEvent::branches(DetTid::from_raw(3), 2),
589            SchedEvent::branches(DetTid::from_raw(5), 3),
590        ])
591        .with_current_format()
592        .with_time_skips(vec![])
593        .with_reserved_input(ReservedInputMetadata(Some(
594            serde_json::json!({"future": [null, 17]}),
595        )));
596        let second = PreemptionRecord::from_sched_events(vec![
597            SchedEvent::branches(DetTid::from_raw(7), 2),
598            SchedEvent::branches(DetTid::from_raw(9), 3),
599        ])
600        .with_current_format()
601        .with_time_skips(vec![])
602        .with_reserved_input(ReservedInputMetadata(Some(serde_json::Value::Null)));
603        // The inherited format stores no jumps as None, even in format 3.
604        // Check the inputs so this test cannot mistake normalization for a
605        // metadata loss introduced by interpolation.
606        for source in [&first, &second] {
607            assert_eq!(source.time_skips(), None);
608            assert_eq!(source.format_version(), Some(3));
609            source.validate().unwrap();
610            assert!(
611                serde_json::to_value(source)
612                    .unwrap()
613                    .get("time_skips")
614                    .is_none()
615            );
616        }
617        let mixed = first
618            .interpolate_events(
619                &second,
620                vec![first.global[0].clone(), second.global[1].clone()],
621            )
622            .unwrap();
623        assert_eq!(mixed.reserved_input(), first.reserved_input());
624        assert_eq!(mixed.format_version(), Some(3));
625        assert_eq!(mixed.time_skips(), None);
626        assert!(
627            serde_json::to_value(&mixed)
628                .unwrap()
629                .get("time_skips")
630                .is_none()
631        );
632        assert_eq!(
633            first
634                .interpolate_events(&second, second.global.clone())
635                .unwrap(),
636            second
637        );
638        // An explicitly stored empty list is invalid under the inherited
639        // format contract, including when the requested events are an endpoint.
640        let mut invalid = first.clone();
641        invalid.time_skips = Some(vec![]);
642        for (left, right) in [(&invalid, &second), (&second, &invalid)] {
643            for events in [left.global.clone(), right.global.clone()] {
644                assert_eq!(
645                    left.interpolate_events(right, events).unwrap_err(),
646                    "an empty list of time skips is written as no list"
647                );
648            }
649        }
650    }
651
652    #[test]
653    fn direct_readers_prefixes_and_priority_edits_preserve_provenance() {
654        let original = marked();
655        let directory = tempfile::tempdir().unwrap();
656        let path = directory.path().join("record.json");
657        original.write_to_disk(&path).unwrap();
658        for record in [
659            PreemptionReader::new(&path).load_all(),
660            PreemptionReader::try_new(&path).unwrap().into_inner(),
661            original.schedule_prefix(original.global.len()),
662            original.clone_preemptions_only(),
663        ] {
664            assert_eq!(record.reserved_input(), original.reserved_input());
665        }
666        let prefix = original.schedule_prefix(original.global.len());
667        assert_eq!(prefix.format_version(), Some(3));
668        assert_eq!(prefix.time_skips(), original.time_skips());
669        assert_eq!(original.clone_preemptions_only().format_version(), None);
670    }
671
672    #[test]
673    fn writer_never_qualifies_from_configuration_default_or_drop() {
674        let directory = tempfile::tempdir().unwrap();
675        let path = directory.path().join("partial.json");
676        {
677            let _writer = PreemptionWriter::new(Some(path.clone())).with_guest_ids(chosen());
678        }
679        let dropped = PreemptionReader::new(&path).load_all();
680        assert!(dropped.reserved_input().is_missing());
681        for record in [
682            PreemptionRecord::default().with_guest_ids(chosen()),
683            PreemptionRecord::from_sched_events(vec![]).with_guest_ids(chosen()),
684            PreemptionRecord::from_vecs(&Default::default()).with_guest_ids(chosen()),
685        ] {
686            assert!(record.reserved_input().is_missing());
687        }
688    }
689
690    /// Serializer coverage only: this imported declaration is not evidence
691    /// that a backend established a target image. The physical setup observer
692    /// has separate execution controls.
693    #[test]
694    fn writer_string_flush_and_drop_preserve_the_complete_imported_metadata() {
695        let directory = tempfile::tempdir().unwrap();
696        for boundaries in [false, true] {
697            for metadata in [
698                ReservedInputMetadata::default(),
699                ReservedInputMetadata(Some(serde_json::Value::Null)),
700                ReservedInputMetadata(Some(serde_json::json!({"future": [17, null]}))),
701                ReservedInputLayout::native().unwrap().metadata(),
702            ] {
703                let mut record = marked().with_reserved_input(metadata);
704                if boundaries {
705                    record.global.clear();
706                }
707                let make = |path| {
708                    let mut writer = PreemptionWriter::new(path);
709                    writer.inner = record.clone();
710                    if boundaries {
711                        writer = writer.with_slice_boundaries().unwrap();
712                    }
713                    writer
714                };
715                let string = make(None).into_string();
716                let expected = Recording::decode(string.as_bytes()).unwrap();
717                assert_eq!(expected.reserved_input(), record.reserved_input());
718                assert_eq!(expected.format_version(), Some(3));
719                assert_eq!(expected.time_skips(), record.time_skips());
720                for explicit_flush in [false, true] {
721                    let path = directory.path().join("writer.json");
722                    let writer = make(Some(path.clone()));
723                    if explicit_flush {
724                        writer.flush().unwrap();
725                    } else {
726                        drop(writer);
727                    }
728                    let written = Recording::read(&path).unwrap();
729                    assert_eq!(written.encode().unwrap(), expected.encode().unwrap());
730                }
731            }
732        }
733    }
734
735    #[test]
736    fn an_unqualified_target_cannot_be_retroactively_qualified_by_exec_or_flush() {
737        let layout = ReservedInputLayout::native().unwrap();
738        for first in [Some(layout.clone()), None] {
739            let mut qualifier = ProducerQualification::default();
740            qualifier.observe(first);
741            assert!(qualifier.observe(None).is_missing());
742            assert!(qualifier.observe(Some(layout.clone())).is_missing());
743        }
744        let mut qualifier = ProducerQualification::default();
745        assert_eq!(qualifier.observe(Some(layout.clone())), layout.metadata());
746        assert_eq!(qualifier.observe(Some(layout.clone())), layout.metadata());
747        let mut mismatch = layout;
748        mismatch.page_bytes *= 2;
749        assert!(qualifier.observe(Some(mismatch)).is_missing());
750    }
751
752    /// Exercise the actual writer observer and serialization using synthetic
753    /// setup facts. This does not establish that a backend mapped or sealed a
754    /// real target; the CLI producer/replay control covers that separate seam.
755    #[test]
756    fn writer_setup_observer_carries_verified_layout_and_keeps_failure_sticky() {
757        use reverie::reserved_input::InputAbi;
758        use reverie::reserved_input::InputSetupOutcome;
759        use reverie::reserved_input::InputSetupReceipt;
760        use reverie::reserved_input::InputUnavailable;
761        use reverie::reserved_input::ReservedInputControl;
762
763        let native = ReservedInputLayout::native().unwrap();
764        let layout = reverie::reserved_input::ReservedInputLayout {
765            base: 0x7101_0000,
766            page_bytes: native.page_bytes as usize,
767            abi: if native.abi == "x86_64" {
768                InputAbi::X86_64
769            } else {
770                InputAbi::Aarch64
771            },
772        };
773        let control = ReservedInputControl::new_backend().unwrap();
774        let task = control.backend_register_root(17, 17).unwrap().task;
775        let good = InputSetupReceipt {
776            task,
777            outcome: InputSetupOutcome::Qualified(layout),
778        };
779        let skip = TimeSkip {
780            event: 0,
781            target: LogicalTime::from_nanos(1000),
782            reason: TimeSkipReason::IdleGuestPollers,
783        };
784        for failure in [
785            InputSetupOutcome::Unqualified(InputUnavailable::new("test-unqualified-image")),
786            InputSetupOutcome::Qualified(reverie::reserved_input::ReservedInputLayout {
787                base: layout.base + 4096,
788                ..layout
789            }),
790        ] {
791            let mut writer = PreemptionWriter::new(None)
792                .with_guest_ids(chosen())
793                .with_format(Some(3));
794            writer.insert_schedevent(SchedEvent::branches(DetTid::from_raw(5000), 2));
795            writer.insert_time_skip(skip);
796            assert!(writer.inner.reserved_input().is_missing());
797            writer.observe_reserved_input_setup(&good).unwrap();
798            let recorded =
799                Recording::decode(writer.serialized_string().unwrap().as_bytes()).unwrap();
800            assert_eq!(recorded.reserved_input(), &native.metadata());
801            assert_eq!(recorded.format_version(), Some(3));
802            assert_eq!(recorded.time_skips(), Some([skip].as_slice()));
803            recorded
804                .validate_replay_inputs("writer observer", chosen())
805                .unwrap();
806
807            let unsupported_layout = matches!(failure, InputSetupOutcome::Qualified(_));
808            let outcome = writer.observe_reserved_input_setup(&InputSetupReceipt {
809                task,
810                outcome: failure,
811            });
812            assert_eq!(outcome.is_err(), unsupported_layout);
813            assert!(writer.inner.reserved_input().is_missing());
814            writer.observe_reserved_input_setup(&good).unwrap();
815            let partial = Recording::decode(writer.into_string().as_bytes()).unwrap();
816            assert!(partial.reserved_input().is_missing());
817            assert_eq!(partial.format_version(), Some(3));
818            assert_eq!(partial.time_skips(), Some([skip].as_slice()));
819            assert!(
820                partial
821                    .validate_replay_inputs("unqualified earlier image", chosen())
822                    .unwrap_err()
823                    .to_string()
824                    .contains("missing reserved_input provenance")
825            );
826        }
827    }
828}