Skip to content

Commit c1b0df3

Browse files
committed
fix(vm): derive rootfs tar cache identity from archive contents
The prepared-disk cache key combined the archive's full path with an mtime truncated to seconds, then mapped punctuation to `-`. Distinct paths such as `/tmp/a/b.tar` and `/tmp/a-b.tar` collapsed onto the same key and reused each other's disk, a rewrite within the same second kept stale contents, and a long path could exceed filesystem component limits. Identity is now a SHA-256 of the archive contents. This is also what makes the cache work at all now that the gateway allocates a fresh staging directory per request: a path-derived key would miss on every create. The archive is hashed, the cache checked, and only on a miss copied — so a hit skips writing a multi-gigabyte file. The copy is hashed as it is written and rejected if the digest differs from the first pass, which closes the window where the source changes during staging rather than approximating it with a re-stat. Refs #2175 Signed-off-by: Philippe Martin <phmartin@redhat.com>
1 parent e772066 commit c1b0df3

1 file changed

Lines changed: 326 additions & 18 deletions

File tree

crates/openshell-driver-vm/src/driver.rs

Lines changed: 326 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ use prost::Message;
5959
use sha2::{Digest, Sha256};
6060
use std::collections::{HashMap, HashSet};
6161
use std::fs;
62-
use std::io::Read;
62+
use std::io::{Read, Write};
6363
use std::net::{IpAddr, Ipv4Addr};
6464
#[cfg(unix)]
6565
use std::os::unix::fs::PermissionsExt;
@@ -2881,20 +2881,30 @@ impl VmDriver {
28812881
}
28822882
};
28832883

2884-
let metadata = tokio::fs::metadata(tar_path).await.map_err(|err| {
2885-
Status::failed_precondition(format!(
2886-
"rootfs tar not accessible at {}: {err}",
2887-
tar_path.display()
2888-
))
2889-
})?;
2890-
let mtime = metadata
2891-
.modified()
2892-
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
2893-
.duration_since(std::time::SystemTime::UNIX_EPOCH)
2894-
.unwrap_or_default()
2895-
.as_secs();
2896-
let tar_identity = format!("rootfs-tar:{}:{mtime}", tar_path.display());
2897-
let cache_identity = prepared_image_cache_identity(&tar_identity);
2884+
// Identity comes from the archive contents. See `rootfs_tar_cache_identity`.
2885+
let hash_source = tar_path.to_path_buf();
2886+
let source_digest = match tokio::task::spawn_blocking(move || {
2887+
compute_file_sha256_hex(&hash_source)
2888+
})
2889+
.await
2890+
{
2891+
Ok(Ok(digest)) => digest,
2892+
Ok(Err(err)) => {
2893+
cleanup_request_staging().await;
2894+
return Err(Status::failed_precondition(format!(
2895+
"rootfs tar not readable at {}: {err}",
2896+
tar_path.display()
2897+
)));
2898+
}
2899+
Err(err) => {
2900+
cleanup_request_staging().await;
2901+
return Err(Status::internal(format!(
2902+
"failed to hash rootfs tar at {}: {err}",
2903+
tar_path.display()
2904+
)));
2905+
}
2906+
};
2907+
let cache_identity = rootfs_tar_cache_identity(&source_digest);
28982908
let image_path = image_cache_rootfs_image(&self.config.state_dir, &cache_identity);
28992909
let tar_display = tar_path.display().to_string();
29002910

@@ -2942,11 +2952,37 @@ impl VmDriver {
29422952
("image_identity".to_string(), cache_identity.clone()),
29432953
]),
29442954
);
2945-
if let Err(err) = tokio::fs::copy(tar_path, &rootfs_archive).await {
2955+
let copy_src = tar_path.to_path_buf();
2956+
let copy_dst = rootfs_archive.clone();
2957+
let copied_digest =
2958+
match tokio::task::spawn_blocking(move || copy_file_sha256_hex(&copy_src, &copy_dst))
2959+
.await
2960+
{
2961+
Ok(Ok(digest)) => digest,
2962+
Ok(Err(err)) => {
2963+
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
2964+
cleanup_request_staging().await;
2965+
return Err(Status::internal(format!(
2966+
"failed to copy rootfs tar to staging: {err}"
2967+
)));
2968+
}
2969+
Err(err) => {
2970+
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
2971+
cleanup_request_staging().await;
2972+
return Err(Status::internal(format!(
2973+
"failed to copy rootfs tar to staging: {err}"
2974+
)));
2975+
}
2976+
};
2977+
2978+
// The archive changed between the hash pass and the copy: the prepared
2979+
// disk we are about to build would not match the identity it is cached
2980+
// under. Reject rather than poison the cache.
2981+
if copied_digest != source_digest {
29462982
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
29472983
cleanup_request_staging().await;
2948-
return Err(Status::internal(format!(
2949-
"failed to copy rootfs tar to staging: {err}"
2984+
return Err(Status::aborted(format!(
2985+
"rootfs tar {tar_display} changed while it was being staged; retry the request"
29502986
)));
29512987
}
29522988
cleanup_request_staging().await;
@@ -4628,6 +4664,46 @@ fn compute_bytes_sha256_hex(bytes: &[u8]) -> String {
46284664
format!("{:x}", hasher.finalize())
46294665
}
46304666

4667+
/// Copy `src` to `dst` and return the SHA-256 of the bytes actually written.
4668+
///
4669+
/// Hashing the copy rather than re-reading the source is what lets the caller
4670+
/// detect an archive that changed underneath it during staging: the digest
4671+
/// describes exactly the bytes that landed in the image cache.
4672+
fn copy_file_sha256_hex(src: &Path, dst: &Path) -> Result<String, String> {
4673+
let mut reader = fs::File::open(src).map_err(|err| format!("open {}: {err}", src.display()))?;
4674+
let mut writer =
4675+
fs::File::create(dst).map_err(|err| format!("create {}: {err}", dst.display()))?;
4676+
let mut hasher = Sha256::new();
4677+
let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice();
4678+
loop {
4679+
let read = reader
4680+
.read(&mut buffer)
4681+
.map_err(|err| format!("read {}: {err}", src.display()))?;
4682+
if read == 0 {
4683+
break;
4684+
}
4685+
hasher.update(&buffer[..read]);
4686+
writer
4687+
.write_all(&buffer[..read])
4688+
.map_err(|err| format!("write {}: {err}", dst.display()))?;
4689+
}
4690+
writer
4691+
.flush()
4692+
.map_err(|err| format!("flush {}: {err}", dst.display()))?;
4693+
Ok(format!("{:x}", hasher.finalize()))
4694+
}
4695+
4696+
/// Cache identity for a rootfs tar archive, derived from its contents.
4697+
///
4698+
/// Deliberately not path- or mtime-derived: staging directories are unique per
4699+
/// request, so a path-based key would never hit the cache, and a
4700+
/// seconds-truncated mtime cannot distinguish two writes within the same
4701+
/// second. A fixed-length digest also keeps the cache directory name inside
4702+
/// filesystem component limits regardless of how long the source path was.
4703+
fn rootfs_tar_cache_identity(digest: &str) -> String {
4704+
prepared_image_cache_identity(&format!("rootfs-tar:sha256:{digest}"))
4705+
}
4706+
46314707
fn extract_layer_blob_to_dir(
46324708
blob_path: &Path,
46334709
media_type: &str,
@@ -8724,6 +8800,238 @@ mod tests {
87248800
};
87258801
use crate::runtime::VmBackend;
87268802

8803+
/// Driver whose rootfs tar staging root is an isolated temp directory.
8804+
fn rootfs_tar_test_driver(staging_root: &Path, max_bytes: Option<u64>) -> VmDriver {
8805+
let (events, _) = broadcast::channel(WATCH_BUFFER);
8806+
VmDriver {
8807+
config: VmDriverConfig {
8808+
rootfs_tar_staging_dir: Some(staging_root.to_path_buf()),
8809+
rootfs_tar_max_bytes: max_bytes,
8810+
..Default::default()
8811+
},
8812+
launcher_bin: PathBuf::from("openshell-driver-vm"),
8813+
registry: Arc::new(Mutex::new(HashMap::new())),
8814+
image_cache_lock: Arc::new(Mutex::new(())),
8815+
events,
8816+
gpu_inventory: None,
8817+
subnet_allocator: Arc::new(std::sync::Mutex::new(SubnetAllocator::new(
8818+
Ipv4Addr::new(10, 0, 128, 0),
8819+
17,
8820+
))),
8821+
lifecycle_extensions: Arc::new(LifecycleExtensionRegistry::new()),
8822+
}
8823+
}
8824+
8825+
/// `<staging_root>/req-<name>/<file>` with `contents`, the shape the
8826+
/// gateway allocates for one create request.
8827+
fn staged_rootfs_tar(staging_root: &Path, request: &str, contents: &[u8]) -> PathBuf {
8828+
let request_dir = staging_root.join(format!("req-{request}"));
8829+
std::fs::create_dir_all(&request_dir).expect("create request dir");
8830+
let archive = request_dir.join("rootfs.tar");
8831+
std::fs::write(&archive, contents).expect("write archive");
8832+
archive
8833+
}
8834+
8835+
#[tokio::test]
8836+
async fn validate_rootfs_tar_path_accepts_staged_archive() {
8837+
let root = unique_temp_dir();
8838+
std::fs::create_dir_all(&root).expect("create staging root");
8839+
let archive = staged_rootfs_tar(&root, "a", b"payload");
8840+
let driver = rootfs_tar_test_driver(&root, None);
8841+
8842+
let resolved = driver
8843+
.validate_rootfs_tar_path(&archive)
8844+
.await
8845+
.expect("a correctly staged archive is accepted");
8846+
8847+
assert_eq!(
8848+
resolved,
8849+
archive.canonicalize().expect("canonicalize archive")
8850+
);
8851+
let _ = std::fs::remove_dir_all(&root);
8852+
}
8853+
8854+
/// The core of the fix: a caller-named host path must never reach
8855+
/// privileged driver I/O, even if the caller is authenticated.
8856+
#[tokio::test]
8857+
async fn validate_rootfs_tar_path_rejects_arbitrary_host_paths() {
8858+
let root = unique_temp_dir();
8859+
std::fs::create_dir_all(&root).expect("create staging root");
8860+
let driver = rootfs_tar_test_driver(&root, None);
8861+
8862+
for candidate in ["/etc/passwd", "/dev/zero"] {
8863+
let path = Path::new(candidate);
8864+
if !path.exists() {
8865+
continue;
8866+
}
8867+
let Err(err) = driver.validate_rootfs_tar_path(path).await else {
8868+
panic!("{candidate} must be rejected");
8869+
};
8870+
assert_eq!(
8871+
err.code(),
8872+
Code::PermissionDenied,
8873+
"{candidate} should be denied, got: {err}"
8874+
);
8875+
}
8876+
let _ = std::fs::remove_dir_all(&root);
8877+
}
8878+
8879+
#[tokio::test]
8880+
async fn validate_rootfs_tar_path_rejects_symlink_escape() {
8881+
let root = unique_temp_dir();
8882+
let request_dir = root.join("req-a");
8883+
std::fs::create_dir_all(&request_dir).expect("create request dir");
8884+
let target = unique_temp_dir();
8885+
std::fs::create_dir_all(&target).expect("create escape target dir");
8886+
let secret = target.join("secret.tar");
8887+
std::fs::write(&secret, b"not yours").expect("write escape target");
8888+
let link = request_dir.join("rootfs.tar");
8889+
std::os::unix::fs::symlink(&secret, &link).expect("create symlink");
8890+
let driver = rootfs_tar_test_driver(&root, None);
8891+
8892+
let err = driver
8893+
.validate_rootfs_tar_path(&link)
8894+
.await
8895+
.expect_err("a symlink out of the staging root must be rejected");
8896+
8897+
assert_eq!(err.code(), Code::PermissionDenied, "{err}");
8898+
let _ = std::fs::remove_dir_all(&root);
8899+
let _ = std::fs::remove_dir_all(&target);
8900+
}
8901+
8902+
#[tokio::test]
8903+
async fn validate_rootfs_tar_path_rejects_wrong_depth() {
8904+
let root = unique_temp_dir();
8905+
std::fs::create_dir_all(&root).expect("create staging root");
8906+
let shallow = root.join("rootfs.tar");
8907+
std::fs::write(&shallow, b"payload").expect("write shallow archive");
8908+
let deep_dir = root.join("req-a").join("nested");
8909+
std::fs::create_dir_all(&deep_dir).expect("create deep dir");
8910+
let deep = deep_dir.join("rootfs.tar");
8911+
std::fs::write(&deep, b"payload").expect("write deep archive");
8912+
let driver = rootfs_tar_test_driver(&root, None);
8913+
8914+
for path in [&shallow, &deep] {
8915+
let err = driver
8916+
.validate_rootfs_tar_path(path)
8917+
.await
8918+
.expect_err("only request-directory depth is accepted");
8919+
assert_eq!(err.code(), Code::PermissionDenied, "{err}");
8920+
}
8921+
let _ = std::fs::remove_dir_all(&root);
8922+
}
8923+
8924+
#[tokio::test]
8925+
async fn validate_rootfs_tar_path_rejects_directory() {
8926+
let root = unique_temp_dir();
8927+
let request_dir = root.join("req-a");
8928+
let not_a_file = request_dir.join("rootfs.tar");
8929+
std::fs::create_dir_all(&not_a_file).expect("create directory in archive position");
8930+
let driver = rootfs_tar_test_driver(&root, None);
8931+
8932+
let err = driver
8933+
.validate_rootfs_tar_path(&not_a_file)
8934+
.await
8935+
.expect_err("a directory is not a rootfs tar");
8936+
8937+
assert_eq!(err.code(), Code::InvalidArgument, "{err}");
8938+
let _ = std::fs::remove_dir_all(&root);
8939+
}
8940+
8941+
#[tokio::test]
8942+
async fn validate_rootfs_tar_path_enforces_max_bytes() {
8943+
let root = unique_temp_dir();
8944+
std::fs::create_dir_all(&root).expect("create staging root");
8945+
let archive = staged_rootfs_tar(&root, "a", &[0_u8; 64]);
8946+
let driver = rootfs_tar_test_driver(&root, Some(16));
8947+
8948+
let err = driver
8949+
.validate_rootfs_tar_path(&archive)
8950+
.await
8951+
.expect_err("an oversized archive must be rejected");
8952+
8953+
assert_eq!(err.code(), Code::InvalidArgument, "{err}");
8954+
let _ = std::fs::remove_dir_all(&root);
8955+
}
8956+
8957+
/// Identity must follow the bytes, not the path. The gateway hands every
8958+
/// request its own staging directory, so a path-derived key would miss the
8959+
/// cache on every single create.
8960+
#[test]
8961+
fn rootfs_tar_cache_identity_tracks_contents_not_path() {
8962+
let same_a = rootfs_tar_cache_identity(&compute_bytes_sha256_hex(b"rootfs-bytes"));
8963+
let same_b = rootfs_tar_cache_identity(&compute_bytes_sha256_hex(b"rootfs-bytes"));
8964+
let different = rootfs_tar_cache_identity(&compute_bytes_sha256_hex(b"other-bytes"));
8965+
8966+
assert_eq!(
8967+
same_a, same_b,
8968+
"identical contents must share one prepared disk"
8969+
);
8970+
assert_ne!(
8971+
same_a, different,
8972+
"different contents must not collide on one prepared disk"
8973+
);
8974+
}
8975+
8976+
/// The old key was `path + seconds-truncated mtime` run through a
8977+
/// punctuation sanitizer, so `/tmp/a/b.tar` and `/tmp/a-b.tar` collided and
8978+
/// a long path could blow past filesystem component limits.
8979+
#[test]
8980+
fn rootfs_tar_cache_identity_is_bounded_and_separator_safe() {
8981+
let long_path_digest = compute_bytes_sha256_hex(&vec![7_u8; 4096]);
8982+
let identity = rootfs_tar_cache_identity(&long_path_digest);
8983+
let sanitized = sanitize_image_identity(&identity);
8984+
8985+
assert!(
8986+
sanitized.len() < 255,
8987+
"cache directory component must stay within filesystem limits, got {}",
8988+
sanitized.len()
8989+
);
8990+
assert_ne!(
8991+
rootfs_tar_cache_identity(&compute_bytes_sha256_hex(b"/tmp/a/b.tar")),
8992+
rootfs_tar_cache_identity(&compute_bytes_sha256_hex(b"/tmp/a-b.tar")),
8993+
"separator-colliding inputs must not share an identity"
8994+
);
8995+
}
8996+
8997+
#[test]
8998+
fn copy_file_sha256_hex_matches_source_digest() {
8999+
let base = unique_temp_dir();
9000+
std::fs::create_dir_all(&base).expect("create base dir");
9001+
let src = base.join("src.tar");
9002+
let dst = base.join("dst.tar");
9003+
let payload = vec![3_u8; 200 * 1024];
9004+
std::fs::write(&src, &payload).expect("write source");
9005+
9006+
let copied = copy_file_sha256_hex(&src, &dst).expect("copy should succeed");
9007+
9008+
assert_eq!(copied, compute_file_sha256_hex(&src).expect("hash source"));
9009+
assert_eq!(copied, compute_bytes_sha256_hex(&payload));
9010+
assert_eq!(std::fs::read(&dst).expect("read copy"), payload);
9011+
let _ = std::fs::remove_dir_all(&base);
9012+
}
9013+
9014+
/// An archive rewritten between the hash pass and the copy pass yields a
9015+
/// different digest, which is what lets the caller reject it instead of
9016+
/// caching a disk under an identity that does not describe it.
9017+
#[test]
9018+
fn copy_file_sha256_hex_detects_content_change_between_passes() {
9019+
let base = unique_temp_dir();
9020+
std::fs::create_dir_all(&base).expect("create base dir");
9021+
let src = base.join("src.tar");
9022+
std::fs::write(&src, b"original").expect("write source");
9023+
let first = compute_file_sha256_hex(&src).expect("hash source");
9024+
9025+
std::fs::write(&src, b"replaced").expect("rewrite source");
9026+
let second = copy_file_sha256_hex(&src, &base.join("dst.tar")).expect("copy");
9027+
9028+
assert_ne!(
9029+
first, second,
9030+
"a mid-staging rewrite must produce a different digest"
9031+
);
9032+
let _ = std::fs::remove_dir_all(&base);
9033+
}
9034+
87279035
fn test_driver_with_extensions(extensions: LifecycleExtensionRegistry) -> VmDriver {
87289036
let (events, _) = broadcast::channel(WATCH_BUFFER);
87299037
VmDriver {

0 commit comments

Comments
 (0)