From 78e65a89418bf9fbf732562630d9347b5906dbff Mon Sep 17 00:00:00 2001 From: Adrien Langou Date: Thu, 8 Oct 2026 18:15:29 +0200 Subject: [PATCH] fix(vm): bind cached registry images to verified manifest digests Signed-off-by: Adrien Langou --- crates/openshell-driver-vm/README.md | 7 + crates/openshell-driver-vm/src/driver.rs | 121 +++-- .../src/registry_cache_tests.rs | 480 ++++++++++++++++++ docs/how-it-works/sandboxes/runtimes.mdx | 2 + 4 files changed, 575 insertions(+), 35 deletions(-) create mode 100644 crates/openshell-driver-vm/src/registry_cache_tests.rs diff --git a/crates/openshell-driver-vm/README.md b/crates/openshell-driver-vm/README.md index 334d968dee..4d6454e810 100644 --- a/crates/openshell-driver-vm/README.md +++ b/crates/openshell-driver-vm/README.md @@ -208,6 +208,13 @@ prepared inside the bootstrap VM. The driver checks that a prepared disk contains the unpacked rootfs before caching it; on failure it caches nothing and reports the image-prep console tail. Set `OPENSHELL_VM_IMAGE_PULL_CONCURRENCY` to tune registry layer download parallelism (default `4`, maximum `16`). +Registry pulls in both caches use the resolved digest as an immutable reference. +The driver verifies the downloaded manifest against that digest before downloading +layers. For multi-platform images, it verifies the index and then the selected +platform manifest. Digest-pinned requests retain the caller's digest. +Cache keys hash source identities to keep cache and staging filenames bounded. +Bootstrap cache layout v6 and prepared-image layout v4 rebuild older entries on +first use; older cache files remain on disk but are not reused for registry images. Both caches are scoped by source image identity and OpenShell version, so an OpenShell upgrade builds a fresh guest rootfs instead of reusing one with an old embedded supervisor. Host-side bootstrap layer assembly preserves relative diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index ab1aca902b..965c934ae8 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -6,6 +6,10 @@ #[path = "preparation.rs"] mod preparation; +#[cfg(test)] +#[path = "registry_cache_tests.rs"] +mod registry_cache_tests; + use crate::gpu::{GpuInventory, allocate_vsock_cid}; use crate::isolation::VmBoundarySpec; @@ -200,8 +204,9 @@ const GUEST_IMAGE_CONFIG_DIR: &str = "openshell-image"; const GUEST_IMAGE_OCI_LAYOUT_DIR: &str = "oci"; const GUEST_IMAGE_OCI_REF: &str = "openshell"; const IMAGE_EXPORT_ROOTFS_ARCHIVE: &str = "source-rootfs.tar"; -const BOOTSTRAP_IMAGE_CACHE_LAYOUT_VERSION: &str = "sandbox-bootstrap-rootfs-ext4-v5"; -const PREPARED_IMAGE_CACHE_LAYOUT_VERSION: &str = "sandbox-prepared-rootfs-ext4-umoci-v3"; +// Older registry cache entries were not bound to the downloaded manifest. +const BOOTSTRAP_IMAGE_CACHE_LAYOUT_VERSION: &str = "sandbox-bootstrap-rootfs-ext4-v6"; +const PREPARED_IMAGE_CACHE_LAYOUT_VERSION: &str = "sandbox-prepared-rootfs-ext4-umoci-v4"; const IMAGE_IDENTITY_FILE: &str = "image-identity"; const IMAGE_REFERENCE_FILE: &str = "image-reference"; const IMAGE_PREP_INIT_MODE: &str = "image-prep"; @@ -3199,10 +3204,27 @@ impl VmDriver { return span_status.finish(result); } + span_status.finish( + self.ensure_cached_registry_rootfs_image( + sandbox_id, + image_ref, + ®istry_client(), + ®istry_auth(image_ref)?, + ) + .await, + ) + } + + async fn ensure_cached_registry_rootfs_image( + &self, + sandbox_id: &str, + image_ref: &str, + client: &OciClient, + auth: &RegistryAuth, + ) -> Result { + let span_status = openshell_otel::ErrorStatusGuard::current(); info!(image_ref = %image_ref, "vm driver: ensuring cached root disk image (registry)"); let reference = parse_registry_reference(image_ref)?; - let client = registry_client(); - let auth = registry_auth(image_ref)?; info!(image_ref = %image_ref, "vm driver: authenticating with registry"); self.publish_vm_progress( sandbox_id, @@ -3214,7 +3236,7 @@ impl VmDriver { ]), ); retry_registry_request("authenticate with registry", || { - client.auth(&reference, &auth, RegistryOperation::Pull) + client.auth(&reference, auth, RegistryOperation::Pull) }) .await .map_err(|err| { @@ -3232,15 +3254,13 @@ impl VmDriver { ("image_source".to_string(), "registry".to_string()), ]), ); - let source_image_identity = retry_registry_request("fetch manifest digest", || { - client.fetch_manifest_digest(&reference, &auth) - }) - .await - .map_err(|err| { - Status::failed_precondition(format!( - "failed to resolve vm sandbox image '{image_ref}': {err}" - )) - })?; + let (reference, source_image_identity) = pin_registry_reference(client, &reference, auth) + .await + .map_err(|err| { + Status::failed_precondition(format!( + "failed to resolve vm sandbox image '{image_ref}': {err}" + )) + })?; info!( image_ref = %image_ref, image_identity = %source_image_identity, @@ -3335,9 +3355,9 @@ impl VmDriver { self.build_cached_registry_image_rootfs_image( sandbox_id, - &client, + client, &reference, - &auth, + auth, image_ref, &image_identity, ) @@ -3522,8 +3542,14 @@ impl VmDriver { .await; } - self.ensure_prepared_registry_image_disk(sandbox_id, image_ref, bootstrap_root_disk) - .await + self.ensure_prepared_registry_image_disk( + sandbox_id, + image_ref, + bootstrap_root_disk, + ®istry_client(), + ®istry_auth(image_ref)?, + ) + .await } async fn ensure_prepared_local_image_disk( @@ -3746,10 +3772,10 @@ impl VmDriver { sandbox_id: &str, image_ref: &str, bootstrap_root_disk: &Path, + client: &OciClient, + auth: &RegistryAuth, ) -> Result { let reference = parse_registry_reference(image_ref)?; - let client = registry_client(); - let auth = registry_auth(image_ref)?; self.publish_vm_progress( sandbox_id, @@ -3761,7 +3787,7 @@ impl VmDriver { ]), ); retry_registry_request("authenticate with registry", || { - client.auth(&reference, &auth, RegistryOperation::Pull) + client.auth(&reference, auth, RegistryOperation::Pull) }) .await .map_err(|err| { @@ -3779,15 +3805,13 @@ impl VmDriver { ("image_source".to_string(), "registry".to_string()), ]), ); - let source_image_identity = retry_registry_request("fetch manifest digest", || { - client.fetch_manifest_digest(&reference, &auth) - }) - .await - .map_err(|err| { - Status::failed_precondition(format!( - "failed to resolve vm sandbox image '{image_ref}': {err}" - )) - })?; + let (reference, source_image_identity) = pin_registry_reference(client, &reference, auth) + .await + .map_err(|err| { + Status::failed_precondition(format!( + "failed to resolve vm sandbox image '{image_ref}': {err}" + )) + })?; let cache_identity = prepared_image_cache_identity(&source_image_identity, &self.config); let image_path = image_cache_rootfs_image(&self.config.state_dir, &cache_identity); @@ -3814,7 +3838,7 @@ impl VmDriver { let layout_dir = staging_dir.join(GUEST_IMAGE_OCI_LAYOUT_DIR); let (manifest, _) = retry_registry_request("pull image manifest", || { - client.pull_image_manifest(&reference, &auth) + client.pull_image_manifest(&reference, auth) }) .await .map_err(|err| { @@ -3827,7 +3851,7 @@ impl VmDriver { .map_err(|err| Status::internal(format!("create guest OCI layout failed: {err}")))?; download_registry_descriptor_blob_file( - &client, + client, &reference, image_ref, &layout_dir, @@ -5202,6 +5226,28 @@ fn registry_client() -> OciClient { }) } +async fn pin_registry_reference( + client: &OciClient, + reference: &Reference, + auth: &RegistryAuth, +) -> Result<(Reference, String), OciDistributionError> { + // Preserve caller pins, including their digest algorithm. A HEAD response + // is only a resolution hint for tags, never proof of the image's contents. + let digest = match reference.digest() { + Some(digest) => digest.to_string(), + None => { + retry_registry_request("fetch manifest digest", || { + client.fetch_manifest_digest(reference, auth) + }) + .await? + } + }; + // oci-client verifies GET bytes against this pin. For an index it verifies + // the index first, then the selected platform manifest against its entry. + // All retries must use this same reference before publishing to the cache. + Ok((reference.clone_with_digest(digest.clone()), digest)) +} + async fn retry_registry_request( operation: &str, request: F, @@ -6465,6 +6511,9 @@ fn write_oci_layout_for_manifest( } fn bootstrap_image_cache_identity(image_identity: &str) -> String { + // Bound new cache/staging filenames even for long source digests such as + // SHA-512. Persisted identities still use the unchanged path lookup. + let image_identity = compute_bytes_sha256_hex(image_identity.as_bytes()); format!( "{BOOTSTRAP_IMAGE_CACHE_LAYOUT_VERSION}:openshell-{}:guest-{}:{image_identity}", openshell_core::VERSION, @@ -6480,6 +6529,7 @@ fn configured_sandbox_identity(config: &VmDriverConfig) -> Option<(u32, u32)> { } fn prepared_image_cache_identity(image_identity: &str, config: &VmDriverConfig) -> String { + let image_identity = compute_bytes_sha256_hex(image_identity.as_bytes()); let identity = configured_sandbox_identity(config).map_or_else( || "image-account".to_string(), |(uid, gid)| format!("configured-{uid}-{gid}"), @@ -11493,11 +11543,12 @@ mod tests { #[test] fn prepared_image_cache_identity_includes_rootfs_layout_and_openshell_version() { let image = "sha256:local-image"; + let image_hash = compute_bytes_sha256_hex(image.as_bytes()); let image_account = prepared_image_cache_identity(image, &VmDriverConfig::default()); assert_eq!( image_account, format!( - "sandbox-prepared-rootfs-ext4-umoci-v3:openshell-{}:image-account:{image}", + "sandbox-prepared-rootfs-ext4-umoci-v4:openshell-{}:image-account:{image_hash}", openshell_core::VERSION ) ); @@ -11539,10 +11590,10 @@ mod tests { fn bootstrap_image_cache_identity_includes_rootfs_layout_version_and_guest_runtime() { let identity = bootstrap_image_cache_identity("sha256:bootstrap-image"); assert!(identity.starts_with(&format!( - "sandbox-bootstrap-rootfs-ext4-v5:openshell-{}:guest-", + "sandbox-bootstrap-rootfs-ext4-v6:openshell-{}:guest-", openshell_core::VERSION ))); - assert!(identity.ends_with(":sha256:bootstrap-image")); + assert!(identity.ends_with(&compute_bytes_sha256_hex(b"sha256:bootstrap-image"))); assert!(identity.contains(&sandbox_guest_runtime_identity())); } diff --git a/crates/openshell-driver-vm/src/registry_cache_tests.rs b/crates/openshell-driver-vm/src/registry_cache_tests.rs new file mode 100644 index 0000000000..21c843e319 --- /dev/null +++ b/crates/openshell-driver-vm/src/registry_cache_tests.rs @@ -0,0 +1,480 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use oci_client::client::ClientProtocol; +use oci_client::manifest::OCI_IMAGE_INDEX_MEDIA_TYPE; +use tokio::io::AsyncReadExt as _; + +/// A loopback registry with independent HEAD identity and GET contents. Blob +/// requests deliberately fail, so a rejected manifest needs no VM or formatter. +struct Registry { + address: String, + requests: Arc>>, + server: JoinHandle<()>, +} + +impl Registry { + async fn start( + head_digest: String, + manifests: HashMap>, + get_digest: Option, + mut retry_once: bool, + ) -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let requests = Arc::new(std::sync::Mutex::new(Vec::new())); + let recorded = requests.clone(); + let server = tokio::spawn(async move { + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + openshell_core::net::set_tcp_nodelay_best_effort(&stream); + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + request.push(stream.read_u8().await.unwrap()); + assert!(request.len() < 16 * 1024); + } + let request = String::from_utf8(request).unwrap(); + let mut parts = request.split_whitespace(); + let method = parts.next().unwrap(); + let path = parts.next().unwrap(); + recorded.lock().unwrap().push(format!("{method} {path}")); + let manifest = path.strip_prefix("/v2/test/image/manifests/"); + let mut digest = None; + let (status, body) = if path == "/v2/" { + ("200 OK", b"{}".as_slice()) + } else if manifest.is_some() && method == "HEAD" { + digest = Some(head_digest.clone()); + ("200 OK", b"".as_slice()) + } else if let Some(body) = manifest.and_then(|key| manifests.get(key)) { + if retry_once { + retry_once = false; + ("503 Service Unavailable", b"retry".as_slice()) + } else { + digest = Some(get_digest.clone().unwrap_or_else(|| sha256(body))); + ("200 OK", body.as_slice()) + } + } else { + ("404 Not Found", b"fixture has no blob".as_slice()) + }; + let digest_header = digest.map_or_else(String::new, |value| { + format!("Docker-Content-Digest: {value}\r\n") + }); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\n{digest_header}Content-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + stream.write_all(body).await.unwrap(); + stream.shutdown().await.unwrap(); + } + }); + Self { + address, + requests, + server, + } + } + + fn reference(&self, digest: Option<&str>) -> String { + let suffix = digest.map_or_else(|| ":latest".to_string(), |value| format!("@{value}")); + format!("{}/test/image{suffix}", self.address) + } + + fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } +} + +impl Drop for Registry { + fn drop(&mut self) { + self.server.abort(); + } +} + +fn client() -> OciClient { + OciClient::new(ClientConfig { + protocol: ClientProtocol::Http, + platform_resolver: Some(Box::new(linux_platform_resolver)), + no_proxy: Some("*".to_string()), + read_timeout: Some(Duration::from_secs(5)), + connect_timeout: Some(Duration::from_secs(5)), + ..Default::default() + }) +} + +fn sha256(body: &[u8]) -> String { + format!("sha256:{:x}", Sha256::digest(body)) +} + +fn manifest(label: &str) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 2, + "mediaType": OCI_IMAGE_MEDIA_TYPE, + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": sha256(label.as_bytes()), + "size": label.len(), + }, + "layers": [{ + "mediaType": "application/vnd.oci.image.layer.v1.tar", + "digest": sha256(label.as_bytes()), + "size": label.len(), + }], + })) + .unwrap() +} + +fn index(body: &[u8]) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 2, + "mediaType": OCI_IMAGE_INDEX_MEDIA_TYPE, + "manifests": [{ + "mediaType": OCI_IMAGE_MEDIA_TYPE, + "digest": sha256(body), + "size": body.len(), + "platform": { "os": "linux", "architecture": linux_oci_arch() }, + }], + })) + .unwrap() +} + +fn driver(root: &Path) -> VmDriver { + let socket_root_fd = rustix::fs::open( + root, + rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::DIRECTORY, + rustix::fs::Mode::empty(), + ) + .unwrap(); + VmDriver { + config: VmDriverConfig { + state_dir: root.to_path_buf(), + ..Default::default() + }, + socket_root: root.to_path_buf(), + socket_root_fd: Arc::new(socket_root_fd), + launcher_bin: root.join("unused-vm-launcher"), + registry: Arc::new(Mutex::new(HashMap::new())), + image_cache_lock: Arc::new(Mutex::new(())), + preparation_root: None, + events: broadcast::channel(WATCH_BUFFER).0, + gpu_inventory: None, + lifecycle_extensions: Arc::new(LifecycleExtensionRegistry::new()), + } +} + +async fn ensure_image( + driver: &VmDriver, + image_ref: &str, + client: &OciClient, + bootstrap: bool, +) -> Result { + if bootstrap { + driver + .ensure_cached_registry_rootfs_image( + "test", + image_ref, + client, + &RegistryAuth::Anonymous, + ) + .await + } else { + driver + .ensure_prepared_registry_image_disk( + "test", + image_ref, + Path::new("unused"), + client, + &RegistryAuth::Anonymous, + ) + .await + .map(|prepared| prepared.image_identity) + } +} + +async fn rejects_unbound_manifest(bootstrap: bool) { + // The GET body and its header agree with each other, but not with HEAD. + // Before the fix this passed manifest verification and requested blobs. + let trusted = manifest("trusted"); + let replacement = manifest("replacement"); + let trusted_digest = sha256(&trusted); + let registry = Registry::start( + trusted_digest.clone(), + HashMap::from([ + ("latest".to_string(), replacement.clone()), + (trusted_digest.clone(), replacement), + ]), + None, + false, + ) + .await; + let root = tempfile::tempdir().unwrap(); + let driver = driver(root.path()); + let identity = if bootstrap { + bootstrap_image_cache_identity(&trusted_digest) + } else { + prepared_image_cache_identity(&trusted_digest, &driver.config) + }; + // An upgrade must not silently reuse an entry populated by older code. + let legacy_identity = if bootstrap { + format!( + "sandbox-bootstrap-rootfs-ext4-v5:openshell-{}:guest-{}:{trusted_digest}", + openshell_core::VERSION, + sandbox_guest_runtime_identity(), + ) + } else { + format!( + "sandbox-prepared-rootfs-ext4-umoci-v3:openshell-{}:image-account:{trusted_digest}", + openshell_core::VERSION, + ) + }; + assert_ne!(legacy_identity, identity); + let legacy_path = image_cache_rootfs_image(root.path(), &legacy_identity); + fs::create_dir_all(legacy_path.parent().unwrap()).unwrap(); + fs::write(&legacy_path, b"legacy unverified disk").unwrap(); + + let client = client(); + let error = ensure_image(&driver, ®istry.reference(None), &client, bootstrap) + .await + .unwrap_err(); + assert!(error.message().contains("Invalid digest"), "{error}"); + assert!(error.message().contains(&trusted_digest), "{error}"); + assert!( + registry + .requests() + .iter() + .all(|request| !request.contains("/blobs/")) + ); + assert!(!image_cache_rootfs_image(root.path(), &identity).exists()); + + // A subsequent consumer pins the trusted image. It must miss the cache, + // fetch that manifest, and fail rather than receive the replacement disk. + let error = ensure_image( + &driver, + ®istry.reference(Some(&trusted_digest)), + &client, + bootstrap, + ) + .await + .unwrap_err(); + assert!(error.message().contains("Invalid digest"), "{error}"); + assert!(!image_cache_rootfs_image(root.path(), &identity).exists()); + assert_eq!(fs::read(legacy_path).unwrap(), b"legacy unverified disk"); +} + +#[tokio::test] +async fn prepared_registry_cache_rejects_manifest_identity_mismatch() { + rejects_unbound_manifest(false).await; +} + +#[tokio::test] +async fn bootstrap_registry_cache_rejects_manifest_identity_mismatch() { + rejects_unbound_manifest(true).await; +} + +async fn accepts_sha512_pin(bootstrap: bool) { + let trusted = manifest("trusted"); + for is_index in [false, true] { + let top = if is_index { + index(&trusted) + } else { + trusted.clone() + }; + let digest = format!("sha512:{:x}", sha2::Sha512::digest(&top)); + // Canonical SHA-256 response headers are valid for a SHA-512 request. + // The complete pin must still be checked, including for an index. + let registry = Registry::start( + sha256(&top), + HashMap::from([(digest.clone(), top), (sha256(&trusted), trusted.clone())]), + None, + false, + ) + .await; + let root = tempfile::tempdir().unwrap(); + let driver = driver(root.path()); + let error = ensure_image( + &driver, + ®istry.reference(Some(&digest)), + &client(), + bootstrap, + ) + .await + .unwrap_err(); + // Reaching the deliberate blob 404 proves real cache staging and + // manifest verification succeeded without requiring a VM or formatter. + let expected = if bootstrap { + "failed to download layer" + } else { + "failed to download config" + }; + assert!(error.message().contains(expected), "{error}"); + assert!(error.message().contains("404 Not Found"), "{error}"); + let requests = registry.requests(); + assert!(requests.contains(&format!("GET /v2/test/image/manifests/{digest}"))); + assert!(!requests.iter().any(|request| request.starts_with("HEAD "))); + if is_index { + assert!(requests.contains(&format!( + "GET /v2/test/image/manifests/{}", + sha256(&trusted) + ))); + } + assert!(requests.contains(&format!("GET /v2/test/image/blobs/{}", sha256(b"trusted")))); + } +} + +#[tokio::test] +async fn bootstrap_registry_cache_accepts_sha512_pins_and_canonical_sha256_headers() { + accepts_sha512_pin(true).await; +} + +#[tokio::test] +async fn prepared_registry_cache_accepts_sha512_pins_and_canonical_sha256_headers() { + accepts_sha512_pin(false).await; +} + +#[tokio::test] +async fn registry_pulls_pin_single_and_index_manifests_across_tag_changes_and_retries() { + let trusted = manifest("trusted"); + let replacement = manifest("replacement"); + for is_index in [false, true] { + for caller_pinned in [false, true] { + let top = if is_index { + index(&trusted) + } else { + trusted.clone() + }; + let top_digest = sha256(&top); + let registry = Registry::start( + top_digest.clone(), + HashMap::from([ + ("latest".to_string(), replacement.clone()), + (top_digest.clone(), top), + (sha256(&trusted), trusted.clone()), + ]), + None, + true, + ) + .await; + let client = client(); + let reference = parse_registry_reference( + ®istry.reference(caller_pinned.then_some(top_digest.as_str())), + ) + .unwrap(); + let (reference, identity) = + pin_registry_reference(&client, &reference, &RegistryAuth::Anonymous) + .await + .unwrap(); + let (pulled, platform_digest) = + retry_registry_request_with_delay("test manifest", Duration::ZERO, || { + client.pull_image_manifest(&reference, &RegistryAuth::Anonymous) + }) + .await + .unwrap(); + assert_eq!(identity, top_digest); + assert_eq!(platform_digest, sha256(&trusted)); + assert_eq!(pulled.config.digest, sha256(b"trusted")); + let requests = registry.requests(); + assert!( + !requests + .iter() + .any(|request| request == "GET /v2/test/image/manifests/latest") + ); + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with("HEAD ")) + .count(), + usize::from(!caller_pinned) + ); + assert_eq!( + requests + .iter() + .filter( + |request| *request == &format!("GET /v2/test/image/manifests/{top_digest}") + ) + .count(), + 2 + ); + } + } +} + +#[tokio::test] +async fn registry_pulls_reject_changed_index_children_and_forged_get_headers() { + let trusted = manifest("trusted"); + let replacement = manifest("replacement"); + for is_index in [false, true] { + let top = if is_index { + index(&trusted) + } else { + trusted.clone() + }; + let digest = sha256(&top); + let registry = Registry::start( + digest.clone(), + HashMap::from([ + (digest.clone(), top), + (sha256(&trusted), replacement.clone()), + ]), + // Single manifest: even a forged GET header must not make the + // replacement valid. Index: the child's own valid header differs. + (!is_index).then(|| digest.clone()), + false, + ) + .await; + let client = client(); + let reference = parse_registry_reference(®istry.reference(Some(&digest))).unwrap(); + let (reference, _) = pin_registry_reference(&client, &reference, &RegistryAuth::Anonymous) + .await + .unwrap(); + let error = client + .pull_image_manifest(&reference, &RegistryAuth::Anonymous) + .await + .unwrap_err(); + assert!(error.to_string().contains("Invalid digest"), "{error}"); + } +} + +#[tokio::test] +async fn registry_cache_preserves_caller_digest_when_head_uses_another_algorithm() { + let trusted = manifest("trusted"); + let digest = sha256(&trusted); + let registry = Registry::start( + format!("sha512:{:x}", sha2::Sha512::digest(b"replacement")), + HashMap::new(), + None, + false, + ) + .await; + let root = tempfile::tempdir().unwrap(); + let driver = driver(root.path()); + let client = client(); + for bootstrap in [false, true] { + let identity = if bootstrap { + bootstrap_image_cache_identity(&digest) + } else { + prepared_image_cache_identity(&digest, &driver.config) + }; + let path = image_cache_rootfs_image(root.path(), &identity); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, b"verified cached disk").unwrap(); + assert_eq!( + ensure_image( + &driver, + ®istry.reference(Some(&digest)), + &client, + bootstrap + ) + .await + .unwrap(), + identity, + ); + assert_eq!(fs::read(path).unwrap(), b"verified cached disk"); + } + assert!( + registry + .requests() + .iter() + .all(|request| request == "GET /v2/") + ); +} diff --git a/docs/how-it-works/sandboxes/runtimes.mdx b/docs/how-it-works/sandboxes/runtimes.mdx index 382e800e27..31c23d3101 100644 --- a/docs/how-it-works/sandboxes/runtimes.mdx +++ b/docs/how-it-works/sandboxes/runtimes.mdx @@ -219,6 +219,8 @@ Common options in `[openshell.drivers.vm]` are `default_image`, `bootstrap_image The driver looks up sandbox images in local Docker or Podman before pulling from a registry. On Linux with Podman, start `podman.socket` so the driver can find local images. +Registry downloads must match the resolved image digest, including the selected platform for multi-platform images. A mismatch fails image preparation. After upgrading, the driver rebuilds older bootstrap and prepared-image cache entries on first use, so the first sandbox start can take longer. + VM sandboxes have no network interface. All traffic flows through the OpenShell supervisor on the host. For networks that require a corporate proxy, the VM driver accepts the same `https_proxy` and `proxy_*` options as Podman. To reach a proxy on the gateway host, use `http://host.openshell.internal:`. If the gateway is briefly unavailable while a VM starts, its supervisor retries the session connection. The sandbox stays in provisioning until the gateway accepts the session.