Skip to content

Commit 1f80796

Browse files
committed
Add cpu vendor to snapshot and reject cross-vendor snapshots when loading
Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com>
1 parent b36f336 commit 1f80796

6 files changed

Lines changed: 139 additions & 27 deletions

File tree

‎Justfile‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,8 @@ test-isolated target=default-target features="" :
243243
{{ cargo-cmd }} test {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} -p hyperlight-host --lib -- sandbox::uninitialized::tests::test_log_trace --exact --ignored
244244
{{ cargo-cmd }} test {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} -p hyperlight-host --lib -- sandbox::outb::tests::test_log_outb_log --exact --ignored
245245
{{ cargo-cmd }} test {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} -p hyperlight-host --test integration_test -- log_message --exact --ignored
246+
@# CPU vendor check, gated to known CI runner hardware
247+
{{ cargo-cmd }} test {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} -p hyperlight-host --lib -- sandbox::snapshot::file::config::tests::cpu_vendor_current_is_recognized --exact --ignored
246248
@# metrics tests
247249
{{ cargo-cmd }} test {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F function_call_metrics," + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} -p hyperlight-host --lib -- metrics::tests::test_metrics_are_emitted --exact
248250

‎docs/snapshot-oci-format.md‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,10 @@ Three blob kinds per tag:
3232
pointer record selected via `index.json`. References one config and
3333
one layer by digest.
3434
* **config** (`application/vnd.hyperlight.snapshot.config.v1+json`). The
35-
snapshot descriptor: arch, hypervisor, ABI version, resume address
36-
and captured registers, memory layout, registered host functions,
37-
snapshot generation counter. Loaded eagerly and fully parsed.
35+
snapshot descriptor: arch, hypervisor, CPU vendor, ABI version,
36+
resume address and captured registers, memory layout, registered
37+
host functions, snapshot generation counter. Loaded eagerly and
38+
fully parsed.
3839
* **layer / memory** (`application/vnd.hyperlight.snapshot.memory.v1`).
3940
The raw guest memory image, exactly `memory_size` bytes. mmap'd on
4041
restore.
@@ -48,8 +49,9 @@ A single saved `Snapshot` consists of exactly:
4849

4950
* one entry in `index.json`, carrying the `tag` as
5051
`org.opencontainers.image.ref.name`, plus advisory
51-
`dev.hyperlight.snapshot.arch` and
52-
`dev.hyperlight.snapshot.hypervisor` annotations that mirror the
52+
`dev.hyperlight.snapshot.arch`,
53+
`dev.hyperlight.snapshot.hypervisor`, and
54+
`dev.hyperlight.snapshot.cpu.vendor` annotations that mirror the
5355
config blob for tooling visibility,
5456
* one **manifest** blob (referenced by that index entry),
5557
* one **config** blob (referenced by the manifest's `config` field),
@@ -103,8 +105,8 @@ the manifest, config, or snapshot blobs against their sha256 digests.
103105
digest returned by `save`. `Snapshot::checked_load` adds the digest
104106
check on those three blobs, catching accidental corruption on disk.
105107
Both run every other check (OCI structure, descriptor sizes, schema
106-
versions, arch / hypervisor / ABI tags, layout bounds, entrypoint
107-
bounds). The caller is responsible for trusting the source.
108+
versions, arch / hypervisor / CPU vendor / ABI tags, layout bounds,
109+
entrypoint bounds). The caller is responsible for trusting the source.
108110

109111
A reference that matches no manifest, or a tag that matches more than
110112
one manifest in `index.json`, is rejected.
@@ -113,7 +115,12 @@ one manifest in `index.json`, is rejected.
113115

114116
## Portability
115117

116-
Snapshot images are bound to a specific CPU architecture and
117-
hypervisor. Both are recorded in the config blob and checked at load
118-
time, with mismatches rejected with a clear error. The hypervisor
119-
tag (`kvm`, `mshv`, `whp`) constrains the host OS.
118+
Snapshot images are bound to a specific CPU architecture, hypervisor,
119+
and CPU vendor. All three are recorded in the config blob and checked
120+
at load time, with mismatches rejected with a clear error. The
121+
hypervisor tag (`kvm`, `mshv`, `whp`) constrains the host OS. The CPU
122+
vendor is the x86_64 CPUID leaf-0 vendor string (e.g. `GenuineIntel`)
123+
or the aarch64 `MIDR_EL1` implementer byte. A load on a different
124+
vendor is rejected because the resumed CPU state can be incompatible.
125+
A future version may relax this binding once a wider compatibility set
126+
is proven safe.

‎src/hyperlight_host/src/sandbox/snapshot/file/config.rs‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,42 @@ impl Hypervisor {
103103
}
104104
}
105105

106+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
107+
pub(super) struct CpuVendor(String);
108+
109+
impl CpuVendor {
110+
/// The vendor identifier of the running host.
111+
pub(super) fn current() -> Self {
112+
#[cfg(target_arch = "x86_64")]
113+
{
114+
// SAFETY: CPUID leaf 0 is always available on x86_64.
115+
// TODO: Remove the `unsafe`/allow when MSRV is raised above
116+
// 1.89. On Rust 1.89 `__cpuid` requires `unsafe`; on newer
117+
// compilers it is safe and clippy flags it as unnecessary.
118+
#[allow(unused_unsafe)]
119+
let r = unsafe { core::arch::x86_64::__cpuid(0) };
120+
let mut bytes = [0u8; 12];
121+
bytes[0..4].copy_from_slice(&r.ebx.to_le_bytes());
122+
bytes[4..8].copy_from_slice(&r.edx.to_le_bytes());
123+
bytes[8..12].copy_from_slice(&r.ecx.to_le_bytes());
124+
Self(String::from_utf8_lossy(&bytes).into_owned())
125+
}
126+
#[cfg(all(target_arch = "aarch64", target_os = "linux"))]
127+
{
128+
let midr: u64;
129+
// SAFETY: Linux emulates MIDR_EL1 reads from EL0.
130+
unsafe { core::arch::asm!("mrs {}, MIDR_EL1", out(reg) midr) };
131+
let implementer = (midr >> 24) & 0xff;
132+
// `0x` prefix padded to width 4, e.g. Apple `0x61`, Arm `0x41`.
133+
Self(format!("{implementer:#04x}"))
134+
}
135+
}
136+
137+
pub(super) fn as_str(&self) -> &str {
138+
&self.0
139+
}
140+
}
141+
106142
// --- Config JSON shape ----------------------------------------------
107143

108144
/// Top-level Hyperlight snapshot config JSON. Lives at
@@ -123,6 +159,8 @@ pub(super) struct OciSnapshotConfig {
123159
/// Memory blob ABI version. See `SNAPSHOT_ABI_VERSION`.
124160
pub(super) abi_version: u32,
125161
pub(super) hypervisor: Hypervisor,
162+
/// CPU vendor captured at snapshot time. Checked on load.
163+
pub(super) cpu_vendor: CpuVendor,
126164
/// Top of the guest stack, in guest virtual address space.
127165
pub(super) stack_top_gva: u64,
128166
/// Guest virtual address the loader resumes the paused call at.
@@ -331,6 +369,16 @@ impl OciSnapshotConfig {
331369
self.hyperlight_version
332370
));
333371
}
372+
let current_vendor = CpuVendor::current();
373+
if self.cpu_vendor != current_vendor {
374+
return Err(crate::new_error!(
375+
"snapshot CPU vendor mismatch: file was created on {} but the current CPU is {} \
376+
(snapshot produced by hyperlight {})",
377+
self.cpu_vendor.as_str(),
378+
current_vendor.as_str(),
379+
self.hyperlight_version
380+
));
381+
}
334382
// Bound memory size early so the subsequent file-size check
335383
// does not have to deal with absurd values.
336384
if self.memory_size == 0 || self.memory_size > SandboxMemoryLayout::MAX_MEMORY_SIZE as u64 {
@@ -574,4 +622,23 @@ mod tests {
574622
assert_eq!(back, r, "return type {:?} did not round-trip", r);
575623
}
576624
}
625+
626+
/// `CpuVendor::current` returns the expected host vendor. Ignored
627+
/// by default and run explicitly in CI, where the runner hardware
628+
/// is known. Extend the allowlist when new runner hardware is
629+
/// added.
630+
#[test]
631+
#[ignore = "hardware-specific; run explicitly in CI"]
632+
fn cpu_vendor_current_is_recognized() {
633+
let vendor = CpuVendor::current();
634+
let v = vendor.as_str();
635+
#[cfg(target_arch = "x86_64")]
636+
assert!(
637+
matches!(v, "GenuineIntel" | "AuthenticAMD"),
638+
"unrecognized x86_64 CPU vendor: {v:?}"
639+
);
640+
#[cfg(all(target_arch = "aarch64", target_os = "linux"))]
641+
// MIDR_EL1 implementer byte for Apple silicon.
642+
assert_eq!(v, "0x61", "unexpected aarch64 CPU implementer");
643+
}
577644
}

‎src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,12 @@ pub(super) const SNAPSHOT_ABI_VERSION: u32 = 1;
3939
/// the Image Layout spec.
4040
pub(super) const ANNOTATION_REF_NAME: &str = "org.opencontainers.image.ref.name";
4141

42-
/// Advisory annotation keys recording the guest arch and hypervisor
43-
/// backend on the manifest descriptor in `index.json`. These mirror
44-
/// the authoritative `arch` and `hypervisor` fields in the config
45-
/// blob so registry UIs and tools like `oras` and `skopeo` can show
46-
/// them. The loader validates against the config blob.
42+
/// Advisory annotation keys recording the guest arch, hypervisor
43+
/// backend, and CPU vendor on the manifest descriptor in
44+
/// `index.json`. These mirror the authoritative `arch`, `hypervisor`,
45+
/// and `cpu_vendor` fields in the config blob so registry UIs and
46+
/// tools like `oras` and `skopeo` can show them. The loader validates
47+
/// against the config blob.
4748
pub(super) const ANNOTATION_ARCH: &str = "dev.hyperlight.snapshot.arch";
4849
pub(super) const ANNOTATION_HYPERVISOR: &str = "dev.hyperlight.snapshot.hypervisor";
50+
pub(super) const ANNOTATION_CPU: &str = "dev.hyperlight.snapshot.cpu.vendor";

‎src/hyperlight_host/src/sandbox/snapshot/file/mod.rs‎

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ use oci_spec::image::{
3232
ImageManifestBuilder, MediaType, SCHEMA_VERSION,
3333
};
3434

35-
use self::config::{Arch, HostFunction, Hypervisor, MemoryLayout, OciSnapshotConfig};
35+
use self::config::{Arch, CpuVendor, HostFunction, Hypervisor, MemoryLayout, OciSnapshotConfig};
3636
use self::digest::{Digest256, oci_digest, parse_oci_digest, verify_blob_bytes, verify_blob_file};
3737
use self::fsutil::{put_blob, put_blob_if_absent, read_bounded, replace_file_atomic};
3838
use self::media_types::{
39-
ANNOTATION_ARCH, ANNOTATION_HYPERVISOR, ANNOTATION_REF_NAME, MT_CONFIG_CURRENT, MT_CONFIG_V1,
40-
MT_SNAPSHOT_CURRENT, MT_SNAPSHOT_V1, SNAPSHOT_ABI_VERSION,
39+
ANNOTATION_ARCH, ANNOTATION_CPU, ANNOTATION_HYPERVISOR, ANNOTATION_REF_NAME, MT_CONFIG_CURRENT,
40+
MT_CONFIG_V1, MT_SNAPSHOT_CURRENT, MT_SNAPSHOT_V1, SNAPSHOT_ABI_VERSION,
4141
};
4242
use self::reference::{OciDigest, OciReference, OciTag};
4343
use super::{NextAction, Snapshot};
@@ -282,10 +282,12 @@ impl Snapshot {
282282
///
283283
/// # Portability
284284
///
285-
/// Snapshot images are bound to the specific CPU architecture and
286-
/// hypervisor that the snapshot was created on. For example, a
287-
/// snapshot taken on x86_64 with KVM can only be loaded on an
288-
/// x86_64 host running KVM. Loading on any other host is rejected.
285+
/// Snapshot images are bound to the specific CPU architecture,
286+
/// hypervisor, and CPU vendor that the snapshot was created on.
287+
/// For example, a snapshot taken on an Intel x86_64 host with KVM
288+
/// can only be loaded on an Intel x86_64 host running KVM. Loading
289+
/// on any other host is rejected. A future version may relax this
290+
/// binding once a wider compatibility set is proven safe.
289291
///
290292
/// # Compatibility
291293
///
@@ -523,6 +525,10 @@ impl Snapshot {
523525
ANNOTATION_HYPERVISOR.to_string(),
524526
cfg.hypervisor.as_str().to_string(),
525527
);
528+
anns.insert(
529+
ANNOTATION_CPU.to_string(),
530+
cfg.cpu_vendor.as_str().to_string(),
531+
);
526532
DescriptorBuilder::default()
527533
.media_type(MediaType::ImageManifest)
528534
.digest(oci_digest(&manifest_digest)?)
@@ -565,6 +571,7 @@ impl Snapshot {
565571
abi_version: SNAPSHOT_ABI_VERSION,
566572
hypervisor: Hypervisor::current()
567573
.ok_or_else(|| crate::new_error!("no hypervisor available to tag snapshot"))?,
574+
cpu_vendor: CpuVendor::current(),
568575
stack_top_gva: self.stack_top_gva,
569576
entrypoint_addr,
570577
sregs: *sregs,
@@ -602,10 +609,12 @@ impl Snapshot {
602609
///
603610
/// # Portability
604611
///
605-
/// Snapshot images are bound to the specific CPU architecture and
606-
/// hypervisor that the snapshot was created on. For example, a
607-
/// snapshot taken on x86_64 with KVM can only be loaded on an
608-
/// x86_64 host running KVM. Loading on any other host is rejected.
612+
/// Snapshot images are bound to the specific CPU architecture,
613+
/// hypervisor, and CPU vendor that the snapshot was created on.
614+
/// For example, a snapshot taken on an Intel x86_64 host with KVM
615+
/// can only be loaded on an Intel x86_64 host running KVM. Loading
616+
/// on any other host is rejected. A future version may relax this
617+
/// binding once a wider compatibility set is proven safe.
609618
///
610619
/// # Compatibility
611620
///

‎src/hyperlight_host/src/sandbox/snapshot/file_tests.rs‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,31 @@ fn cfg_current_hypervisor() -> &'static str {
502502
}
503503
}
504504

505+
#[test]
506+
fn cpu_vendor_mismatch_rejected() {
507+
let snapshot = create_snapshot();
508+
let dir = tempfile::tempdir().unwrap();
509+
let path = dir.path().join("snap");
510+
snapshot
511+
.save(&path, &OciTag::new("latest").unwrap())
512+
.unwrap();
513+
514+
rewrite_config(&path, |cfg| {
515+
cfg["cpu_vendor"] = Value::from("not-this-cpu-vendor");
516+
});
517+
518+
let err = unwrap_err_snapshot(Snapshot::checked_load(
519+
&path,
520+
OciTag::new("latest").unwrap(),
521+
));
522+
let msg = format!("{}", err);
523+
assert!(
524+
msg.contains("vendor"),
525+
"expected CPU vendor mismatch, got: {}",
526+
msg
527+
);
528+
}
529+
505530
// A call snapshot must carry sregs. serde rejects a config that
506531
// omits the field.
507532

0 commit comments

Comments
 (0)