mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-11-13 11:45:58 +08:00
* refactor(config): normalize compute driver field names Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * refactor(config): introduce canonical gateway fields Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * refactor(config): enforce gateway schema version 2 Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(config): preserve compute driver runtime guarantees Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(config): address schema v2 review regressions Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(config): complete schema v2 migration safeguards Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(config): expand schema v2 regression coverage Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(config): add schema v2 parity manifest Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(config): correct parity manifest inventory Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * docs(config): record schema v2 intentional changes Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * docs(config): disposition schema v2 parity gaps Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(e2e): add dual schema parity harness Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(e2e): establish compute lifecycle parity baseline Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(config): preserve gateway option compatibility Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(e2e): record gateway option parity Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * docs(config): close gateway-wide parity gaps Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(podman): apply configured pids limit Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(e2e): validate Podman option parity Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(e2e): add Kubernetes option parity harness Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(e2e): record Kubernetes option parity Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(e2e): disposition VM parity lanes Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(e2e): add external driver parity lane Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(e2e): preserve external driver pull policy Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(e2e): attest parity artifacts and launches Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(e2e): require clean parity build sources Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(e2e): bind parity runtime artifacts Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(e2e): use isolated supervisor tags Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(e2e): qualify parity image tags Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(e2e): serve parity supervisor locally Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(e2e): isolate parity podman services Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(e2e): harden parity evidence provenance Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(e2e): pin parity sandbox artifacts Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(e2e): attest parity runtime inputs Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(e2e): bind parity runtime evidence Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(e2e): record compute boundary parity Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(e2e): disposition cross-cutting parity lanes Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(packaging): preflight gateway config upgrades Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(config): preserve rebase integration guarantees Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(ci): isolate temporary git signing config Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(config): update remaining schema v2 consumers Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(ci): provide e2fs tools to VM tests Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(config): align preflight with gateway startup Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(vm): preserve rootfs tar configuration Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * chore(config): adopt duration unit constructors Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(packaging): preflight RPM gateway config Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(config): address driver review findings Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(e2e): require fresh semantic parity evidence Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(docker): update tests for renamed sandbox label Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * test(gateway): preserve selective driver coverage after rebase Signed-off-by: Drew Newberry <anewberry@nvidia.com> --------- Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> Signed-off-by: Drew Newberry <anewberry@nvidia.com> Co-authored-by: Drew Newberry <anewberry@nvidia.com>
1454 lines
50 KiB
Rust
1454 lines
50 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
use flate2::read::MultiGzDecoder;
|
|
use std::fs;
|
|
use std::fs::File;
|
|
#[cfg(test)]
|
|
use std::io::BufWriter;
|
|
use std::io::{BufRead, BufReader, Cursor, Read, Seek, SeekFrom, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
const SUPERVISOR: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/openshell-sandbox.zst"));
|
|
const UMOCI: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/umoci.zst"));
|
|
const ROOTFS_VARIANT_MARKER: &str = ".openshell-rootfs-variant";
|
|
/// Leading bytes of a gzip stream, used to recognize `.tar.gz`/`.tgz` input
|
|
/// without trusting the file name.
|
|
pub const GZIP_MAGIC: [u8; 2] = [0x1f, 0x8b];
|
|
const SANDBOX_GUEST_INIT_PATH: &str = "/srv/openshell-vm-sandbox-init.sh";
|
|
const SANDBOX_SUPERVISOR_PATH: &str = openshell_core::driver_utils::SUPERVISOR_CONTAINER_BINARY;
|
|
const SANDBOX_UMOCI_PATH: &str = openshell_core::container_paths::VM_UMOCI_PATH;
|
|
const DEFAULT_SANDBOX_UID: u32 = 1000;
|
|
const ROOTFS_IMAGE_MIN_SIZE_BYTES: u64 = 512 * 1024 * 1024;
|
|
const ROOTFS_IMAGE_MIN_HEADROOM_BYTES: u64 = 256 * 1024 * 1024;
|
|
const EXT4_IMAGE_MIN_HEADROOM_BYTES: u64 = 16 * 1024 * 1024;
|
|
static INJECTION_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
|
|
pub const fn sandbox_guest_init_path() -> &'static str {
|
|
SANDBOX_GUEST_INIT_PATH
|
|
}
|
|
|
|
#[allow(clippy::similar_names)]
|
|
pub fn prepare_sandbox_rootfs_from_image_root(
|
|
rootfs: &Path,
|
|
image_identity: &str,
|
|
sandbox_uid: Option<u32>,
|
|
sandbox_gid: Option<u32>,
|
|
) -> Result<(), String> {
|
|
prepare_sandbox_rootfs(rootfs, sandbox_uid, sandbox_gid)?;
|
|
validate_sandbox_rootfs(rootfs)?;
|
|
fs::write(
|
|
rootfs.join(ROOTFS_VARIANT_MARKER),
|
|
format!("{}:image:{image_identity}\n", env!("CARGO_PKG_VERSION")),
|
|
)
|
|
.map_err(|e| format!("write rootfs variant marker: {e}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Extract a rootfs tarball, transparently decompressing gzip archives.
|
|
///
|
|
/// Compression is detected from the magic bytes rather than the file name:
|
|
/// `--from` accepts `.tar.gz` and `.tgz`, but nothing guarantees a caller's
|
|
/// extension matches the bytes, and the archives this crate stages internally
|
|
/// carry no extension at all.
|
|
pub fn extract_rootfs_archive_to(archive_path: &Path, dest: &Path) -> Result<(), String> {
|
|
if dest.exists() {
|
|
fs::remove_dir_all(dest)
|
|
.map_err(|e| format!("remove old rootfs {}: {e}", dest.display()))?;
|
|
}
|
|
|
|
fs::create_dir_all(dest).map_err(|e| format!("create rootfs dir {}: {e}", dest.display()))?;
|
|
let file =
|
|
File::open(archive_path).map_err(|e| format!("open {}: {e}", archive_path.display()))?;
|
|
let mut reader = BufReader::new(file);
|
|
let compressed = reader
|
|
.fill_buf()
|
|
.map_err(|e| format!("read {}: {e}", archive_path.display()))?
|
|
.starts_with(&GZIP_MAGIC);
|
|
if compressed {
|
|
unpack_tar_reader(MultiGzDecoder::new(reader), dest)
|
|
} else {
|
|
unpack_tar_reader(reader, dest)
|
|
}
|
|
}
|
|
|
|
fn unpack_tar_reader(reader: impl Read, dest: &Path) -> Result<(), String> {
|
|
tar::Archive::new(reader)
|
|
.unpack(dest)
|
|
.map_err(|e| format!("extract rootfs tarball into {}: {e}", dest.display()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn create_rootfs_archive_from_dir(source: &Path, archive_path: &Path) -> Result<(), String> {
|
|
if let Some(parent) = archive_path.parent() {
|
|
fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
|
|
}
|
|
|
|
let file = File::create(archive_path)
|
|
.map_err(|e| format!("create {}: {e}", archive_path.display()))?;
|
|
let writer = BufWriter::new(file);
|
|
let mut builder = tar::Builder::new(writer);
|
|
append_rootfs_tree_to_archive(&mut builder, source, Path::new("")).map_err(|e| {
|
|
format!(
|
|
"archive {} into {}: {e}",
|
|
source.display(),
|
|
archive_path.display()
|
|
)
|
|
})?;
|
|
builder
|
|
.finish()
|
|
.map_err(|e| format!("finalize {}: {e}", archive_path.display()))
|
|
}
|
|
|
|
pub fn create_rootfs_image_from_dir(source: &Path, image_path: &Path) -> Result<(), String> {
|
|
let image_size = rootfs_image_size_bytes(source)?;
|
|
create_ext4_image_from_dir_with_size(source, image_path, image_size)?;
|
|
if let Err(err) = normalize_sandbox_owner_in_rootfs_image(source, image_path) {
|
|
let _ = fs::remove_file(image_path);
|
|
return Err(err);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn create_ext4_image_from_dir_with_size(
|
|
source: &Path,
|
|
image_path: &Path,
|
|
image_size: u64,
|
|
) -> Result<(), String> {
|
|
if let Some(parent) = image_path.parent() {
|
|
fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
|
|
}
|
|
if image_path.exists() {
|
|
fs::remove_file(image_path)
|
|
.map_err(|e| format!("remove old rootfs image {}: {e}", image_path.display()))?;
|
|
}
|
|
|
|
let required_size = ext4_image_min_size_bytes(source)?;
|
|
if image_size < required_size {
|
|
return Err(format!(
|
|
"ext4 image size {} bytes is too small for {} (requires at least {} bytes)",
|
|
image_size,
|
|
source.display(),
|
|
required_size
|
|
));
|
|
}
|
|
|
|
let image = File::create(image_path)
|
|
.map_err(|e| format!("create rootfs image {}: {e}", image_path.display()))?;
|
|
image
|
|
.set_len(image_size)
|
|
.map_err(|e| format!("size rootfs image {}: {e}", image_path.display()))?;
|
|
drop(image);
|
|
|
|
if let Err(err) = format_ext4_image_from_dir(source, image_path) {
|
|
let _ = fs::remove_file(image_path);
|
|
return Err(err);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn clone_or_copy_sparse_file(source: &Path, dest: &Path) -> Result<(), String> {
|
|
if let Some(parent) = dest.parent() {
|
|
fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
|
|
}
|
|
if dest.exists() {
|
|
fs::remove_file(dest).map_err(|e| format!("remove old file {}: {e}", dest.display()))?;
|
|
}
|
|
|
|
let clone_error = match try_clone_file(source, dest) {
|
|
Ok(()) => return Ok(()),
|
|
Err(err) => {
|
|
let _ = fs::remove_file(dest);
|
|
err
|
|
}
|
|
};
|
|
|
|
copy_sparse_file(source, dest).map_err(|copy_error| {
|
|
format!(
|
|
"clone {} to {} failed ({clone_error}); sparse copy failed: {copy_error}",
|
|
source.display(),
|
|
dest.display()
|
|
)
|
|
})
|
|
}
|
|
|
|
pub fn write_rootfs_image_file(
|
|
image_path: &Path,
|
|
guest_path: &str,
|
|
contents: &[u8],
|
|
) -> Result<(), String> {
|
|
ensure_rootfs_image_parent_dirs(image_path, guest_path);
|
|
|
|
let tmp_path = temporary_injection_path(image_path);
|
|
fs::write(&tmp_path, contents).map_err(|e| format!("write {}: {e}", tmp_path.display()))?;
|
|
let Some(quoted_guest_path) = debugfs_quote_absolute_path(guest_path) else {
|
|
let _ = fs::remove_file(&tmp_path);
|
|
return Err(format!("invalid debugfs guest path '{guest_path}'"));
|
|
};
|
|
let Some(quoted_tmp_path) = debugfs_quote_argument(&tmp_path.to_string_lossy()) else {
|
|
let _ = fs::remove_file(&tmp_path);
|
|
return Err(format!(
|
|
"invalid debugfs injection path '{}'",
|
|
tmp_path.display()
|
|
));
|
|
};
|
|
let _ = run_debugfs(image_path, &format!("rm {quoted_guest_path}"));
|
|
let result = run_debugfs(
|
|
image_path,
|
|
&format!("write {quoted_tmp_path} {quoted_guest_path}"),
|
|
);
|
|
let _ = fs::remove_file(&tmp_path);
|
|
result
|
|
}
|
|
|
|
pub fn set_rootfs_image_file_mode(
|
|
image_path: &Path,
|
|
guest_path: &str,
|
|
mode: u32,
|
|
) -> Result<(), String> {
|
|
let regular_file_mode = 0o100_000 | (mode & 0o7777);
|
|
let Some(quoted_guest_path) = debugfs_quote_absolute_path(guest_path) else {
|
|
return Err(format!("invalid debugfs guest path '{guest_path}'"));
|
|
};
|
|
run_debugfs(
|
|
image_path,
|
|
&format!("set_inode_field {quoted_guest_path} mode 0{regular_file_mode:o}"),
|
|
)
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn try_clone_file(source: &Path, dest: &Path) -> Result<(), String> {
|
|
let output = Command::new("cp")
|
|
.arg("-c")
|
|
.arg(source)
|
|
.arg(dest)
|
|
.output()
|
|
.map_err(|e| format!("run cp -c: {e}"))?;
|
|
if output.status.success() {
|
|
return Ok(());
|
|
}
|
|
Err(format!(
|
|
"cp -c failed with status {}\nstdout: {}\nstderr: {}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
))
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn try_clone_file(source: &Path, dest: &Path) -> Result<(), String> {
|
|
let output = Command::new("cp")
|
|
.arg("--reflink=auto")
|
|
.arg("--sparse=always")
|
|
.arg(source)
|
|
.arg(dest)
|
|
.output()
|
|
.map_err(|e| format!("run cp --reflink=auto: {e}"))?;
|
|
if output.status.success() {
|
|
return Ok(());
|
|
}
|
|
Err(format!(
|
|
"cp --reflink=auto --sparse=always failed with status {}\nstdout: {}\nstderr: {}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
))
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
|
fn try_clone_file(_source: &Path, _dest: &Path) -> Result<(), String> {
|
|
Err("no platform clone command available".to_string())
|
|
}
|
|
|
|
fn copy_sparse_file(source: &Path, dest: &Path) -> Result<(), String> {
|
|
const BUFFER_SIZE: usize = 1024 * 1024;
|
|
|
|
let mut source_file =
|
|
File::open(source).map_err(|e| format!("open {}: {e}", source.display()))?;
|
|
let mut dest_file =
|
|
File::create(dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
|
|
let mut buffer = vec![0_u8; BUFFER_SIZE];
|
|
let mut size = 0_u64;
|
|
|
|
loop {
|
|
let read = source_file
|
|
.read(&mut buffer)
|
|
.map_err(|e| format!("read {}: {e}", source.display()))?;
|
|
if read == 0 {
|
|
break;
|
|
}
|
|
|
|
if buffer[..read].iter().all(|byte| *byte == 0) {
|
|
let skip =
|
|
i64::try_from(read).map_err(|_| format!("sparse copy chunk too large: {read}"))?;
|
|
dest_file
|
|
.seek(SeekFrom::Current(skip))
|
|
.map_err(|e| format!("seek {}: {e}", dest.display()))?;
|
|
} else {
|
|
dest_file
|
|
.write_all(&buffer[..read])
|
|
.map_err(|e| format!("write {}: {e}", dest.display()))?;
|
|
}
|
|
size += read as u64;
|
|
}
|
|
|
|
dest_file
|
|
.set_len(size)
|
|
.map_err(|e| format!("size {}: {e}", dest.display()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn append_rootfs_tree_to_archive(
|
|
builder: &mut tar::Builder<BufWriter<File>>,
|
|
source: &Path,
|
|
archive_prefix: &Path,
|
|
) -> Result<(), String> {
|
|
let mut entries = fs::read_dir(source)
|
|
.map_err(|e| format!("read {}: {e}", source.display()))?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|e| format!("read {}: {e}", source.display()))?;
|
|
entries.sort_by_key(fs::DirEntry::file_name);
|
|
|
|
for entry in entries {
|
|
let entry_name = entry.file_name();
|
|
let source_path = entry.path();
|
|
let archive_path = if archive_prefix.as_os_str().is_empty() {
|
|
entry_name.into()
|
|
} else {
|
|
archive_prefix.join(entry_name)
|
|
};
|
|
let metadata = fs::symlink_metadata(&source_path)
|
|
.map_err(|e| format!("stat {}: {e}", source_path.display()))?;
|
|
let file_type = metadata.file_type();
|
|
|
|
if file_type.is_dir() {
|
|
builder
|
|
.append_dir(&archive_path, &source_path)
|
|
.map_err(|e| format!("append dir {}: {e}", source_path.display()))?;
|
|
append_rootfs_tree_to_archive(builder, &source_path, &archive_path)?;
|
|
continue;
|
|
}
|
|
|
|
if file_type.is_file() {
|
|
let mut file = File::open(&source_path)
|
|
.map_err(|e| format!("open {}: {e}", source_path.display()))?;
|
|
builder
|
|
.append_file(&archive_path, &mut file)
|
|
.map_err(|e| format!("append file {}: {e}", source_path.display()))?;
|
|
continue;
|
|
}
|
|
|
|
if file_type.is_symlink() {
|
|
append_symlink_to_archive(builder, &source_path, &archive_path, &metadata)?;
|
|
continue;
|
|
}
|
|
|
|
return Err(format!(
|
|
"unsupported rootfs entry type at {}",
|
|
source_path.display()
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn append_symlink_to_archive(
|
|
builder: &mut tar::Builder<BufWriter<File>>,
|
|
source_path: &Path,
|
|
archive_path: &Path,
|
|
metadata: &fs::Metadata,
|
|
) -> Result<(), String> {
|
|
let target = fs::read_link(source_path)
|
|
.map_err(|e| format!("readlink {}: {e}", source_path.display()))?;
|
|
let mut header = tar::Header::new_gnu();
|
|
header.set_metadata(metadata);
|
|
header.set_size(0);
|
|
header.set_cksum();
|
|
builder
|
|
.append_link(&mut header, archive_path, target)
|
|
.map_err(|e| format!("append symlink {}: {e}", source_path.display()))
|
|
}
|
|
|
|
#[allow(clippy::similar_names)]
|
|
fn prepare_sandbox_rootfs(
|
|
rootfs: &Path,
|
|
sandbox_uid: Option<u32>,
|
|
sandbox_gid: Option<u32>,
|
|
) -> Result<(), String> {
|
|
for relative in ["opt/openshell/.initialized", "opt/openshell/.rootfs-type"] {
|
|
remove_rootfs_path(rootfs, relative)?;
|
|
}
|
|
|
|
let init_path = rootfs.join("srv/openshell-vm-sandbox-init.sh");
|
|
if let Some(parent) = init_path.parent() {
|
|
fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
|
|
}
|
|
fs::write(
|
|
&init_path,
|
|
include_str!("../scripts/openshell-vm-sandbox-init.sh"),
|
|
)
|
|
.map_err(|e| format!("write {}: {e}", init_path.display()))?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
|
|
fs::set_permissions(&init_path, fs::Permissions::from_mode(0o755))
|
|
.map_err(|e| format!("chmod {}: {e}", init_path.display()))?;
|
|
}
|
|
|
|
ensure_supervisor_binary(rootfs)?;
|
|
ensure_umoci_binary(rootfs)?;
|
|
|
|
let opt_dir = rootfs.join("opt/openshell");
|
|
fs::create_dir_all(&opt_dir).map_err(|e| format!("create {}: {e}", opt_dir.display()))?;
|
|
fs::write(opt_dir.join(".rootfs-type"), "sandbox\n")
|
|
.map_err(|e| format!("write sandbox rootfs marker: {e}"))?;
|
|
ensure_sandbox_guest_user(rootfs, sandbox_uid, sandbox_gid)?;
|
|
create_sandbox_mountpoint(&rootfs.join("sandbox"))?;
|
|
create_sandbox_mountpoint(&rootfs.join("image-cache"))?;
|
|
create_sandbox_mountpoint(&rootfs.join("lower"))?;
|
|
create_sandbox_mountpoint(&rootfs.join("overlay"))?;
|
|
create_sandbox_mountpoint(&rootfs.join("newroot"))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn validate_sandbox_rootfs(rootfs: &Path) -> Result<(), String> {
|
|
require_rootfs_path(rootfs, SANDBOX_GUEST_INIT_PATH)?;
|
|
require_rootfs_path(rootfs, SANDBOX_SUPERVISOR_PATH)?;
|
|
require_rootfs_path(rootfs, SANDBOX_UMOCI_PATH)?;
|
|
require_any_rootfs_path(rootfs, &["/bin/bash"])?;
|
|
require_any_rootfs_path(rootfs, &["/bin/mount", "/usr/bin/mount"])?;
|
|
require_any_rootfs_path(
|
|
rootfs,
|
|
&[
|
|
"/usr/sbin/chroot",
|
|
"/usr/bin/chroot",
|
|
"/sbin/chroot",
|
|
"/bin/chroot",
|
|
],
|
|
)?;
|
|
require_any_rootfs_path(
|
|
rootfs,
|
|
&["/sbin/ip", "/usr/sbin/ip", "/bin/ip", "/usr/bin/ip"],
|
|
)?;
|
|
require_any_rootfs_path(rootfs, &["/bin/sed", "/usr/bin/sed"])?;
|
|
Ok(())
|
|
}
|
|
|
|
fn create_sandbox_mountpoint(path: &Path) -> Result<(), String> {
|
|
fs::create_dir_all(path).map_err(|e| format!("create {}: {e}", path.display()))?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
|
|
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
|
|
.map_err(|e| format!("chmod {}: {e}", path.display()))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn rootfs_image_size_bytes(source: &Path) -> Result<u64, String> {
|
|
let used = directory_size_bytes(source)?;
|
|
let headroom = (used / 4).max(ROOTFS_IMAGE_MIN_HEADROOM_BYTES);
|
|
let size = (used + headroom).max(ROOTFS_IMAGE_MIN_SIZE_BYTES);
|
|
Ok(round_up_to_mib(size))
|
|
}
|
|
|
|
fn ext4_image_min_size_bytes(source: &Path) -> Result<u64, String> {
|
|
let used = directory_size_bytes(source)?;
|
|
Ok(round_up_to_mib(used + EXT4_IMAGE_MIN_HEADROOM_BYTES))
|
|
}
|
|
|
|
fn directory_size_bytes(path: &Path) -> Result<u64, String> {
|
|
let metadata =
|
|
fs::symlink_metadata(path).map_err(|e| format!("stat {}: {e}", path.display()))?;
|
|
if metadata.file_type().is_file() || metadata.file_type().is_symlink() {
|
|
return Ok(metadata.len());
|
|
}
|
|
if !metadata.file_type().is_dir() {
|
|
return Ok(0);
|
|
}
|
|
|
|
let mut size = 4096;
|
|
for entry in fs::read_dir(path).map_err(|e| format!("read {}: {e}", path.display()))? {
|
|
let entry = entry.map_err(|e| format!("read {}: {e}", path.display()))?;
|
|
size += directory_size_bytes(&entry.path())?;
|
|
}
|
|
Ok(size)
|
|
}
|
|
|
|
fn round_up_to_mib(bytes: u64) -> u64 {
|
|
const MIB: u64 = 1024 * 1024;
|
|
bytes.div_ceil(MIB) * MIB
|
|
}
|
|
|
|
enum FormatterAttempt {
|
|
Succeeded,
|
|
Failed(String),
|
|
Unavailable(String),
|
|
}
|
|
|
|
fn format_ext4_image_from_dir(source: &Path, image_path: &Path) -> Result<(), String> {
|
|
let candidates = ["mke2fs", "mkfs.ext4"]
|
|
.into_iter()
|
|
.flat_map(e2fs_tool_candidates);
|
|
run_ext4_formatter_candidates(candidates, |candidate| {
|
|
let label = candidate.display().to_string();
|
|
let output = Command::new(candidate)
|
|
.arg("-q")
|
|
.arg("-F")
|
|
.arg("-t")
|
|
.arg("ext4")
|
|
.arg("-E")
|
|
.arg("root_owner=0:0")
|
|
.arg("-d")
|
|
.arg(source)
|
|
.arg(image_path)
|
|
.output();
|
|
match output {
|
|
Ok(output) if output.status.success() => FormatterAttempt::Succeeded,
|
|
Ok(output) => FormatterAttempt::Failed(format!(
|
|
"{label} failed with status {}\nstdout: {}\nstderr: {}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
)),
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
|
FormatterAttempt::Unavailable(format!("{label} not found"))
|
|
}
|
|
Err(err) => FormatterAttempt::Failed(format!("run {label}: {err}")),
|
|
}
|
|
})
|
|
.map_err(|details| {
|
|
format!(
|
|
"failed to create ext4 rootfs image from {}: {details}. Install e2fsprogs (mke2fs/mkfs.ext4) and retry",
|
|
source.display()
|
|
)
|
|
})
|
|
}
|
|
|
|
fn run_ext4_formatter_candidates(
|
|
candidates: impl IntoIterator<Item = PathBuf>,
|
|
mut run: impl FnMut(&Path) -> FormatterAttempt,
|
|
) -> Result<(), String> {
|
|
let mut failures = Vec::new();
|
|
let mut unavailable = Vec::new();
|
|
|
|
for candidate in candidates {
|
|
match run(&candidate) {
|
|
FormatterAttempt::Succeeded => return Ok(()),
|
|
FormatterAttempt::Failed(error) => failures.push(error),
|
|
FormatterAttempt::Unavailable(error) => unavailable.push(error),
|
|
}
|
|
}
|
|
|
|
if failures.is_empty() {
|
|
Err(if unavailable.is_empty() {
|
|
"no ext4 formatter candidates configured".to_string()
|
|
} else {
|
|
unavailable.join("\n")
|
|
})
|
|
} else {
|
|
Err(failures.join("\n"))
|
|
}
|
|
}
|
|
|
|
fn ensure_rootfs_image_parent_dirs(image_path: &Path, guest_path: &str) {
|
|
let Some(parent) = Path::new(guest_path).parent() else {
|
|
return;
|
|
};
|
|
let mut current = String::new();
|
|
for component in parent.components() {
|
|
let part = component.as_os_str().to_string_lossy();
|
|
if part == "/" || part.is_empty() {
|
|
continue;
|
|
}
|
|
current.push('/');
|
|
current.push_str(&part);
|
|
let _ = run_debugfs(image_path, &format!("mkdir {current}"));
|
|
}
|
|
}
|
|
|
|
fn normalize_sandbox_owner_in_rootfs_image(source: &Path, image_path: &Path) -> Result<(), String> {
|
|
let sandbox_dir = source.join("sandbox");
|
|
if !sandbox_dir.exists() {
|
|
return Ok(());
|
|
}
|
|
|
|
let Some((uid, gid)) = sandbox_guest_user_ids(source)? else {
|
|
return Ok(());
|
|
};
|
|
|
|
let mut commands = Vec::new();
|
|
if !collect_sandbox_owner_commands(&sandbox_dir, "/sandbox", uid, gid, &mut commands)? {
|
|
return Ok(());
|
|
}
|
|
if commands.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
run_debugfs_batch(image_path, &commands)
|
|
}
|
|
|
|
fn collect_sandbox_owner_commands(
|
|
source_path: &Path,
|
|
guest_path: &str,
|
|
uid: u32,
|
|
gid: u32,
|
|
commands: &mut Vec<String>,
|
|
) -> Result<bool, String> {
|
|
let metadata = fs::symlink_metadata(source_path).map_err(|e| {
|
|
format!(
|
|
"stat {} for rootfs ownership normalization: {e}",
|
|
source_path.display()
|
|
)
|
|
})?;
|
|
if metadata.file_type().is_symlink() {
|
|
return Ok(true);
|
|
}
|
|
|
|
let Some(quoted_guest_path) = debugfs_quote_absolute_path(guest_path) else {
|
|
return Ok(false);
|
|
};
|
|
commands.push(format!("set_inode_field {quoted_guest_path} uid {uid}"));
|
|
commands.push(format!("set_inode_field {quoted_guest_path} gid {gid}"));
|
|
|
|
if !metadata.is_dir() {
|
|
return Ok(true);
|
|
}
|
|
|
|
let mut entries = fs::read_dir(source_path)
|
|
.map_err(|e| {
|
|
format!(
|
|
"read {} for rootfs ownership normalization: {e}",
|
|
source_path.display()
|
|
)
|
|
})?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|e| {
|
|
format!(
|
|
"read {} entry for rootfs ownership normalization: {e}",
|
|
source_path.display()
|
|
)
|
|
})?;
|
|
entries.sort_by_key(fs::DirEntry::file_name);
|
|
|
|
for entry in entries {
|
|
let file_name = entry.file_name();
|
|
let Some(file_name) = file_name.to_str() else {
|
|
return Ok(false);
|
|
};
|
|
let child_guest_path = format!("{guest_path}/{file_name}");
|
|
if !collect_sandbox_owner_commands(&entry.path(), &child_guest_path, uid, gid, commands)? {
|
|
return Ok(false);
|
|
}
|
|
}
|
|
|
|
Ok(true)
|
|
}
|
|
|
|
fn debugfs_quote_absolute_path(path: &str) -> Option<String> {
|
|
if path.is_empty() || !path.starts_with('/') {
|
|
return None;
|
|
}
|
|
|
|
debugfs_quote_argument(path)
|
|
}
|
|
|
|
fn debugfs_quote_argument(argument: &str) -> Option<String> {
|
|
if argument.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let mut quoted = String::with_capacity(argument.len() + 2);
|
|
quoted.push('"');
|
|
for ch in argument.chars() {
|
|
match ch {
|
|
'\0' | '\n' | '\r' => return None,
|
|
'\\' => quoted.push_str("\\\\"),
|
|
'"' => quoted.push_str("\\\""),
|
|
_ => quoted.push(ch),
|
|
}
|
|
}
|
|
quoted.push('"');
|
|
Some(quoted)
|
|
}
|
|
|
|
/// Read the sandbox account identity directly from an ext4 rootfs image.
|
|
///
|
|
/// Persisted VM overlays may outlive the prepared-image cache layout that
|
|
/// created them. Reading the matching old lower disk lets the driver preserve
|
|
/// that overlay's real ownership contract during an upgrade.
|
|
pub fn sandbox_guest_user_ids_from_image(image_path: &Path) -> Result<Option<(u32, u32)>, String> {
|
|
sandbox_guest_user_ids_from_image_path(image_path, "/etc/passwd")
|
|
}
|
|
|
|
/// Read a sandbox account copied into an overlay upper layer.
|
|
///
|
|
/// An upper-layer passwd file is the most direct evidence of the identity an
|
|
/// existing overlay observed, so migration consults it before any lower image.
|
|
pub fn sandbox_guest_user_ids_from_overlay_image(
|
|
image_path: &Path,
|
|
) -> Result<Option<(u32, u32)>, String> {
|
|
sandbox_guest_user_ids_from_image_path(image_path, "/upper/etc/passwd")
|
|
}
|
|
|
|
fn sandbox_guest_user_ids_from_image_path(
|
|
image_path: &Path,
|
|
guest_path: &str,
|
|
) -> Result<Option<(u32, u32)>, String> {
|
|
let metadata = fs::metadata(image_path)
|
|
.map_err(|error| format!("stat rootfs image {}: {error}", image_path.display()))?;
|
|
if !metadata.is_file() {
|
|
return Err(format!(
|
|
"rootfs image {} is not a regular file",
|
|
image_path.display()
|
|
));
|
|
}
|
|
|
|
let quoted_path = debugfs_quote_absolute_path(guest_path)
|
|
.expect("the static passwd path is a valid debugfs path");
|
|
let command = format!("cat {quoted_path}");
|
|
let mut last_error = None;
|
|
|
|
for candidate in e2fs_tool_candidates("debugfs") {
|
|
let label = candidate.display().to_string();
|
|
match Command::new(&candidate)
|
|
.arg("-R")
|
|
.arg(&command)
|
|
.arg(image_path)
|
|
.output()
|
|
{
|
|
Ok(output) if output.status.success() => {
|
|
let passwd = String::from_utf8(output.stdout).map_err(|error| {
|
|
format!(
|
|
"read {guest_path} from {} as UTF-8: {error}",
|
|
image_path.display()
|
|
)
|
|
})?;
|
|
return parse_sandbox_guest_user_ids(&passwd, &image_path.display().to_string());
|
|
}
|
|
Ok(output) => {
|
|
last_error = Some(format!(
|
|
"{label} failed with status {}\nstdout: {}\nstderr: {}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
));
|
|
}
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
last_error = Some(format!("{label} not found"));
|
|
}
|
|
Err(error) => last_error = Some(format!("run {label}: {error}")),
|
|
}
|
|
}
|
|
|
|
Err(format!(
|
|
"debugfs command '{command}' failed for {}: {}. Install e2fsprogs (debugfs) and retry",
|
|
image_path.display(),
|
|
last_error.unwrap_or_else(|| "debugfs not found".to_string())
|
|
))
|
|
}
|
|
|
|
fn sandbox_guest_user_ids(rootfs: &Path) -> Result<Option<(u32, u32)>, String> {
|
|
let passwd_path = rootfs.join("etc/passwd");
|
|
if !passwd_path.exists() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let passwd = fs::read_to_string(&passwd_path)
|
|
.map_err(|e| format!("read {}: {e}", passwd_path.display()))?;
|
|
parse_sandbox_guest_user_ids(&passwd, &passwd_path.display().to_string())
|
|
}
|
|
|
|
fn parse_sandbox_guest_user_ids(passwd: &str, source: &str) -> Result<Option<(u32, u32)>, String> {
|
|
for line in passwd.lines() {
|
|
let mut parts = line.split(':');
|
|
if parts.next() != Some("sandbox") {
|
|
continue;
|
|
}
|
|
let _password = parts.next();
|
|
let uid = parts
|
|
.next()
|
|
.ok_or_else(|| format!("sandbox entry in {source} is missing uid"))?
|
|
.parse::<u32>()
|
|
.map_err(|e| format!("sandbox uid in {source} is invalid: {e}"))?;
|
|
let gid = parts
|
|
.next()
|
|
.ok_or_else(|| format!("sandbox entry in {source} is missing gid"))?
|
|
.parse::<u32>()
|
|
.map_err(|e| format!("sandbox gid in {source} is invalid: {e}"))?;
|
|
return Ok(Some((uid, gid)));
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
fn run_debugfs_batch(image_path: &Path, commands: &[String]) -> Result<(), String> {
|
|
let command_path = temporary_injection_path(image_path);
|
|
let mut contents = commands.join("\n");
|
|
contents.push('\n');
|
|
fs::write(&command_path, contents)
|
|
.map_err(|e| format!("write {}: {e}", command_path.display()))?;
|
|
|
|
let result = run_debugfs_batch_file(image_path, &command_path);
|
|
let _ = fs::remove_file(&command_path);
|
|
result
|
|
}
|
|
|
|
fn run_debugfs_batch_file(image_path: &Path, command_path: &Path) -> Result<(), String> {
|
|
let mut last_error = None;
|
|
for candidate in e2fs_tool_candidates("debugfs") {
|
|
let label = candidate.display().to_string();
|
|
let output = Command::new(&candidate)
|
|
.arg("-w")
|
|
.arg("-f")
|
|
.arg(command_path)
|
|
.arg(image_path)
|
|
.output();
|
|
match output {
|
|
Ok(output) if output.status.success() => return Ok(()),
|
|
Ok(output) => {
|
|
last_error = Some(format!(
|
|
"{label} failed with status {}\nstdout: {}\nstderr: {}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
));
|
|
}
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
|
last_error = Some(format!("{label} not found"));
|
|
}
|
|
Err(err) => {
|
|
last_error = Some(format!("run {label}: {err}"));
|
|
}
|
|
}
|
|
}
|
|
Err(format!(
|
|
"debugfs batch {} failed for {}: {}. Install e2fsprogs (debugfs) and retry",
|
|
command_path.display(),
|
|
image_path.display(),
|
|
last_error.unwrap_or_else(|| "debugfs not found".to_string())
|
|
))
|
|
}
|
|
|
|
fn run_debugfs(image_path: &Path, command: &str) -> Result<(), String> {
|
|
let mut last_error = None;
|
|
for candidate in e2fs_tool_candidates("debugfs") {
|
|
let label = candidate.display().to_string();
|
|
let output = Command::new(&candidate)
|
|
.arg("-w")
|
|
.arg("-R")
|
|
.arg(command)
|
|
.arg(image_path)
|
|
.output();
|
|
match output {
|
|
Ok(output) if output.status.success() => return Ok(()),
|
|
Ok(output) => {
|
|
last_error = Some(format!(
|
|
"{label} failed with status {}\nstdout: {}\nstderr: {}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
));
|
|
}
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
|
last_error = Some(format!("{label} not found"));
|
|
}
|
|
Err(err) => {
|
|
last_error = Some(format!("run {label}: {err}"));
|
|
}
|
|
}
|
|
}
|
|
Err(format!(
|
|
"debugfs command '{command}' failed for {}: {}. Install e2fsprogs (debugfs) and retry",
|
|
image_path.display(),
|
|
last_error.unwrap_or_else(|| "debugfs not found".to_string())
|
|
))
|
|
}
|
|
|
|
fn e2fs_tool_candidates(tool: &str) -> Vec<PathBuf> {
|
|
let mut candidates = vec![PathBuf::from(tool)];
|
|
for root in ["/opt/homebrew/opt/e2fsprogs", "/usr/local/opt/e2fsprogs"] {
|
|
candidates.push(Path::new(root).join("sbin").join(tool));
|
|
candidates.push(Path::new(root).join("bin").join(tool));
|
|
}
|
|
candidates
|
|
}
|
|
|
|
fn temporary_injection_path(image_path: &Path) -> PathBuf {
|
|
let n = INJECTION_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
let parent = image_path.parent().unwrap_or_else(|| Path::new("."));
|
|
parent.join(format!(
|
|
".openshell-rootfs-inject-{}-{n}",
|
|
std::process::id()
|
|
))
|
|
}
|
|
|
|
#[allow(clippy::similar_names)]
|
|
fn ensure_sandbox_guest_user(
|
|
rootfs: &Path,
|
|
sandbox_uid: Option<u32>,
|
|
sandbox_gid: Option<u32>,
|
|
) -> Result<(), String> {
|
|
// An image's sandbox account is part of its filesystem contract. Leave it
|
|
// intact unless an operator explicitly configured either side of the
|
|
// identity. A missing account still gets the OpenShell default.
|
|
if sandbox_uid.is_none() && sandbox_gid.is_none() && sandbox_guest_user_ids(rootfs)?.is_some() {
|
|
return Ok(());
|
|
}
|
|
|
|
let sandbox_uid = sandbox_uid.unwrap_or(DEFAULT_SANDBOX_UID);
|
|
let sandbox_gid = sandbox_gid.unwrap_or(sandbox_uid);
|
|
let etc_dir = rootfs.join("etc");
|
|
fs::create_dir_all(&etc_dir).map_err(|e| format!("create {}: {e}", etc_dir.display()))?;
|
|
|
|
ensure_line_in_file(
|
|
&etc_dir.join("group"),
|
|
&format!("sandbox:x:{sandbox_gid}:"),
|
|
|line| line.starts_with("sandbox:"),
|
|
)?;
|
|
ensure_line_in_file(&etc_dir.join("gshadow"), "sandbox:!::", |line| {
|
|
line.starts_with("sandbox:")
|
|
})?;
|
|
ensure_line_in_file(
|
|
&etc_dir.join("passwd"),
|
|
&format!("sandbox:x:{sandbox_uid}:{sandbox_gid}:OpenShell Sandbox:/sandbox:/bin/bash"),
|
|
|line| line.starts_with("sandbox:"),
|
|
)?;
|
|
ensure_line_in_file(
|
|
&etc_dir.join("shadow"),
|
|
"sandbox:!:20123:0:99999:7:::",
|
|
|line| line.starts_with("sandbox:"),
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_line_in_file(
|
|
path: &Path,
|
|
line: &str,
|
|
exists: impl Fn(&str) -> bool,
|
|
) -> Result<(), String> {
|
|
let contents = if path.exists() {
|
|
fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
let mut replaced = false;
|
|
let mut updated = String::new();
|
|
for existing in contents.lines() {
|
|
if exists(existing) {
|
|
if !replaced {
|
|
updated.push_str(line);
|
|
updated.push('\n');
|
|
replaced = true;
|
|
}
|
|
} else {
|
|
updated.push_str(existing);
|
|
updated.push('\n');
|
|
}
|
|
}
|
|
if !replaced {
|
|
updated.push_str(line);
|
|
updated.push('\n');
|
|
}
|
|
|
|
fs::write(path, updated).map_err(|e| format!("write {}: {e}", path.display()))
|
|
}
|
|
|
|
fn ensure_supervisor_binary(rootfs: &Path) -> Result<(), String> {
|
|
let path = rootfs.join(SANDBOX_SUPERVISOR_PATH.trim_start_matches('/'));
|
|
if SUPERVISOR.is_empty() {
|
|
if !path.exists() {
|
|
return Err(
|
|
"sandbox supervisor not embedded. Build openshell-driver-vm with OPENSHELL_VM_RUNTIME_COMPRESSED_DIR set and run `mise run vm:setup && mise run vm:supervisor` first"
|
|
.to_string(),
|
|
);
|
|
}
|
|
} else {
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
|
|
}
|
|
|
|
let supervisor = zstd::decode_all(Cursor::new(SUPERVISOR))
|
|
.map_err(|e| format!("decompress supervisor: {e}"))?;
|
|
fs::write(&path, supervisor).map_err(|e| format!("write {}: {e}", path.display()))?;
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
|
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o755))
|
|
.map_err(|e| format!("chmod {}: {e}", path.display()))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_umoci_binary(rootfs: &Path) -> Result<(), String> {
|
|
let path = rootfs.join(SANDBOX_UMOCI_PATH.trim_start_matches('/'));
|
|
if UMOCI.is_empty() {
|
|
if !path.exists() {
|
|
return Err(
|
|
"umoci not embedded. Build openshell-driver-vm with OPENSHELL_VM_RUNTIME_COMPRESSED_DIR set and run `mise run vm:setup` first"
|
|
.to_string(),
|
|
);
|
|
}
|
|
} else {
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
|
|
}
|
|
|
|
let umoci =
|
|
zstd::decode_all(Cursor::new(UMOCI)).map_err(|e| format!("decompress umoci: {e}"))?;
|
|
fs::write(&path, umoci).map_err(|e| format!("write {}: {e}", path.display()))?;
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
|
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o755))
|
|
.map_err(|e| format!("chmod {}: {e}", path.display()))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn require_rootfs_path(rootfs: &Path, relative: &str) -> Result<(), String> {
|
|
let candidate = rootfs.join(relative.trim_start_matches('/'));
|
|
if candidate.exists() {
|
|
Ok(())
|
|
} else {
|
|
Err(format!(
|
|
"prepared rootfs is missing {}",
|
|
candidate.display()
|
|
))
|
|
}
|
|
}
|
|
|
|
fn require_any_rootfs_path(rootfs: &Path, candidates: &[&str]) -> Result<(), String> {
|
|
if candidates
|
|
.iter()
|
|
.any(|candidate| rootfs.join(candidate.trim_start_matches('/')).exists())
|
|
{
|
|
Ok(())
|
|
} else {
|
|
Err(format!(
|
|
"prepared rootfs is missing one of: {}",
|
|
candidates.join(", ")
|
|
))
|
|
}
|
|
}
|
|
|
|
fn remove_rootfs_path(rootfs: &Path, relative: &str) -> Result<(), String> {
|
|
let path = rootfs.join(relative);
|
|
if !path.exists() {
|
|
return Ok(());
|
|
}
|
|
|
|
let result = if path.is_dir() {
|
|
fs::remove_dir_all(&path)
|
|
} else {
|
|
fs::remove_file(&path)
|
|
};
|
|
result.map_err(|e| format!("remove {}: {e}", path.display()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
#[test]
|
|
fn prepare_sandbox_rootfs_rewrites_guest_layout() {
|
|
let dir = unique_temp_dir();
|
|
let rootfs = dir.join("rootfs");
|
|
|
|
fs::create_dir_all(rootfs.join("etc")).expect("create etc");
|
|
fs::create_dir_all(rootfs.join("opt/openshell/bin")).expect("create openshell bin");
|
|
fs::write(rootfs.join("opt/openshell/.initialized"), b"yes").expect("write initialized");
|
|
write_fake_runtime_binaries(&rootfs);
|
|
fs::write(
|
|
rootfs.join("etc/passwd"),
|
|
"root:x:0:0:root:/root:/bin/bash\nsandbox:x:10001:10001:OpenShell Sandbox:/sandbox:/bin/sh\n",
|
|
)
|
|
.expect("write passwd");
|
|
fs::write(rootfs.join("etc/group"), "root:x:0:\nsandbox:x:10001:\n").expect("write group");
|
|
fs::write(rootfs.join("etc/hosts"), "127.0.0.1 localhost\n").expect("write hosts");
|
|
fs::create_dir_all(rootfs.join("bin")).expect("create bin");
|
|
fs::create_dir_all(rootfs.join("sbin")).expect("create sbin");
|
|
fs::write(rootfs.join("bin/bash"), b"bash").expect("write bash");
|
|
fs::write(rootfs.join("bin/mount"), b"mount").expect("write mount");
|
|
fs::write(rootfs.join("bin/chroot"), b"chroot").expect("write chroot");
|
|
fs::write(rootfs.join("bin/sed"), b"sed").expect("write sed");
|
|
fs::write(rootfs.join("sbin/ip"), b"ip").expect("write ip");
|
|
|
|
// Use a non-standard UID so the test doesn't collide with the default.
|
|
let uid = 20001;
|
|
prepare_sandbox_rootfs(&rootfs, Some(uid), Some(uid)).expect("prepare sandbox rootfs");
|
|
validate_sandbox_rootfs(&rootfs).expect("validate sandbox rootfs");
|
|
|
|
assert!(rootfs.join("srv/openshell-vm-sandbox-init.sh").is_file());
|
|
assert!(rootfs.join("opt/openshell/bin/umoci").is_file());
|
|
assert!(rootfs.join("sandbox").is_dir());
|
|
assert!(rootfs.join("image-cache").is_dir());
|
|
assert!(rootfs.join("lower").is_dir());
|
|
assert!(rootfs.join("overlay").is_dir());
|
|
assert!(rootfs.join("newroot").is_dir());
|
|
assert!(
|
|
fs::read_dir(rootfs.join("sandbox"))
|
|
.expect("read sandbox")
|
|
.next()
|
|
.is_none()
|
|
);
|
|
assert!(
|
|
fs::read_to_string(rootfs.join("etc/passwd"))
|
|
.expect("read passwd")
|
|
.contains(&format!(
|
|
"sandbox:x:{uid}:{uid}:OpenShell Sandbox:/sandbox:/bin/bash"
|
|
))
|
|
);
|
|
assert!(
|
|
fs::read_to_string(rootfs.join("etc/group"))
|
|
.expect("read group")
|
|
.contains(&format!("sandbox:x:{uid}:"))
|
|
);
|
|
assert!(
|
|
!fs::read_to_string(rootfs.join("etc/passwd"))
|
|
.expect("read passwd")
|
|
.contains("sandbox:x:10001:"),
|
|
"newly prepared rootfs must replace the legacy sandbox account"
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(rootfs.join("etc/hosts")).expect("read hosts"),
|
|
"127.0.0.1 localhost\n"
|
|
);
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn prepare_sandbox_rootfs_preserves_image_workdir_contents_in_rootfs() {
|
|
let dir = unique_temp_dir();
|
|
let rootfs = dir.join("rootfs");
|
|
|
|
fs::create_dir_all(rootfs.join("opt/openshell/bin")).expect("create openshell bin");
|
|
write_fake_runtime_binaries(&rootfs);
|
|
fs::create_dir_all(rootfs.join("sandbox")).expect("create sandbox workdir");
|
|
fs::write(rootfs.join("sandbox/app.py"), "print('hello')\n").expect("write app");
|
|
|
|
prepare_sandbox_rootfs(&rootfs, Some(10001), Some(10001)).expect("prepare sandbox rootfs");
|
|
|
|
assert!(rootfs.join("sandbox").is_dir());
|
|
assert_eq!(
|
|
fs::read_to_string(rootfs.join("sandbox/app.py")).expect("read app"),
|
|
"print('hello')\n"
|
|
);
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// `--from` accepts `.tar.gz` and `.tgz`, so extraction must recognize a
|
|
/// gzip stream instead of handing compressed bytes to the tar reader.
|
|
#[test]
|
|
fn extract_rootfs_archive_accepts_gzip_archives() {
|
|
let dir = unique_temp_dir();
|
|
let rootfs = dir.join("rootfs");
|
|
let extracted = dir.join("extracted");
|
|
let archive = dir.join("rootfs.tar");
|
|
let gz_archive = dir.join("rootfs.tar.gz");
|
|
|
|
fs::create_dir_all(rootfs.join("etc")).expect("create etc");
|
|
fs::write(rootfs.join("etc/marker.txt"), "gzip-rootfs\n").expect("write marker");
|
|
create_rootfs_archive_from_dir(&rootfs, &archive).expect("archive rootfs");
|
|
|
|
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
|
encoder
|
|
.write_all(&fs::read(&archive).expect("read archive"))
|
|
.expect("gzip archive");
|
|
fs::write(&gz_archive, encoder.finish().expect("finish gzip")).expect("write gzip archive");
|
|
|
|
extract_rootfs_archive_to(&gz_archive, &extracted).expect("extract gzip rootfs");
|
|
|
|
assert_eq!(
|
|
fs::read_to_string(extracted.join("etc/marker.txt")).expect("read extracted marker"),
|
|
"gzip-rootfs\n"
|
|
);
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn create_rootfs_archive_preserves_broken_symlinks() {
|
|
let dir = unique_temp_dir();
|
|
let rootfs = dir.join("rootfs");
|
|
let extracted = dir.join("extracted");
|
|
let archive = dir.join("rootfs.tar");
|
|
|
|
fs::create_dir_all(rootfs.join("etc")).expect("create etc");
|
|
fs::write(rootfs.join("etc/hosts"), "127.0.0.1 localhost\n").expect("write hosts");
|
|
std::os::unix::fs::symlink("/proc/self/mounts", rootfs.join("etc/mtab"))
|
|
.expect("create symlink");
|
|
|
|
create_rootfs_archive_from_dir(&rootfs, &archive).expect("archive rootfs");
|
|
extract_rootfs_archive_to(&archive, &extracted).expect("extract rootfs");
|
|
|
|
let extracted_link = extracted.join("etc/mtab");
|
|
assert!(
|
|
fs::symlink_metadata(&extracted_link)
|
|
.unwrap()
|
|
.file_type()
|
|
.is_symlink()
|
|
);
|
|
assert_eq!(
|
|
fs::read_link(&extracted_link).expect("read extracted symlink"),
|
|
PathBuf::from("/proc/self/mounts")
|
|
);
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn clone_or_copy_sparse_file_preserves_size_and_contents() {
|
|
let dir = unique_temp_dir();
|
|
fs::create_dir_all(&dir).expect("create temp dir");
|
|
let source = dir.join("source.bin");
|
|
let dest = dir.join("dest.bin");
|
|
|
|
let mut source_file = File::create(&source).expect("create source");
|
|
source_file.write_all(b"head").expect("write head");
|
|
source_file
|
|
.seek(SeekFrom::Start(1024 * 1024 + 7))
|
|
.expect("seek source");
|
|
source_file.write_all(b"tail").expect("write tail");
|
|
source_file
|
|
.set_len(2 * 1024 * 1024 + 3)
|
|
.expect("size source");
|
|
drop(source_file);
|
|
|
|
clone_or_copy_sparse_file(&source, &dest).expect("copy sparse file");
|
|
|
|
assert_eq!(
|
|
fs::metadata(&dest).expect("stat dest").len(),
|
|
2 * 1024 * 1024 + 3
|
|
);
|
|
let mut dest_file = File::open(&dest).expect("open dest");
|
|
let mut head = [0_u8; 4];
|
|
dest_file.read_exact(&mut head).expect("read head");
|
|
assert_eq!(&head, b"head");
|
|
dest_file
|
|
.seek(SeekFrom::Start(1024 * 1024 + 7))
|
|
.expect("seek dest");
|
|
let mut tail = [0_u8; 4];
|
|
dest_file.read_exact(&mut tail).expect("read tail");
|
|
assert_eq!(&tail, b"tail");
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_user_preserves_image_account_when_identity_is_omitted() {
|
|
let dir = unique_temp_dir();
|
|
let rootfs = dir.join("rootfs");
|
|
fs::create_dir_all(rootfs.join("etc")).unwrap();
|
|
fs::write(
|
|
rootfs.join("etc/passwd"),
|
|
"sandbox:x:4242:4343:Image:/image-home:/bin/false\n",
|
|
)
|
|
.unwrap();
|
|
fs::write(rootfs.join("etc/group"), "sandbox:x:4343:\n").unwrap();
|
|
|
|
ensure_sandbox_guest_user(&rootfs, None, None).unwrap();
|
|
|
|
assert_eq!(sandbox_guest_user_ids(&rootfs).unwrap(), Some((4242, 4343)));
|
|
assert!(
|
|
fs::read_to_string(rootfs.join("etc/passwd"))
|
|
.unwrap()
|
|
.contains("Image:/image-home:/bin/false")
|
|
);
|
|
let _ = fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_user_defaults_to_1000_when_image_has_no_account() {
|
|
let dir = unique_temp_dir();
|
|
let rootfs = dir.join("rootfs");
|
|
ensure_sandbox_guest_user(&rootfs, None, None).unwrap();
|
|
assert_eq!(sandbox_guest_user_ids(&rootfs).unwrap(), Some((1000, 1000)));
|
|
let _ = fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_user_explicit_identity_overrides_image_account() {
|
|
let dir = unique_temp_dir();
|
|
let rootfs = dir.join("rootfs");
|
|
fs::create_dir_all(rootfs.join("etc")).unwrap();
|
|
fs::write(
|
|
rootfs.join("etc/passwd"),
|
|
"sandbox:x:4242:4343:Image:/image-home:/bin/false\n",
|
|
)
|
|
.unwrap();
|
|
fs::write(rootfs.join("etc/group"), "sandbox:x:4343:\n").unwrap();
|
|
|
|
ensure_sandbox_guest_user(&rootfs, Some(2000), Some(3000)).unwrap();
|
|
|
|
assert_eq!(sandbox_guest_user_ids(&rootfs).unwrap(), Some((2000, 3000)));
|
|
assert!(
|
|
fs::read_to_string(rootfs.join("etc/group"))
|
|
.unwrap()
|
|
.contains("sandbox:x:3000:")
|
|
);
|
|
let _ = fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_guest_user_ids_reads_existing_sandbox_user() {
|
|
let dir = unique_temp_dir();
|
|
let rootfs = dir.join("rootfs");
|
|
fs::create_dir_all(rootfs.join("etc")).expect("create etc");
|
|
fs::write(
|
|
rootfs.join("etc/passwd"),
|
|
"root:x:0:0:root:/root:/bin/bash\nsandbox:x:998:997:Sandbox:/sandbox:/bin/sh\n",
|
|
)
|
|
.expect("write passwd");
|
|
|
|
assert_eq!(
|
|
sandbox_guest_user_ids(&rootfs).expect("read sandbox user"),
|
|
Some((998, 997))
|
|
);
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn collect_sandbox_owner_commands_quotes_guest_paths() {
|
|
let dir = unique_temp_dir();
|
|
let sandbox_dir = dir.join("sandbox");
|
|
fs::create_dir_all(sandbox_dir.join("dir with space")).expect("create sandbox tree");
|
|
fs::write(sandbox_dir.join("dir with space/file.txt"), "hello\n").expect("write file");
|
|
|
|
let mut commands = Vec::new();
|
|
assert!(
|
|
collect_sandbox_owner_commands(&sandbox_dir, "/sandbox", 998, 997, &mut commands)
|
|
.expect("collect commands")
|
|
);
|
|
|
|
assert!(commands.contains(&"set_inode_field \"/sandbox\" uid 998".to_string()));
|
|
assert!(commands.contains(&"set_inode_field \"/sandbox\" gid 997".to_string()));
|
|
assert!(
|
|
commands.contains(
|
|
&"set_inode_field \"/sandbox/dir with space/file.txt\" uid 998".to_string()
|
|
)
|
|
);
|
|
assert!(
|
|
commands.contains(
|
|
&"set_inode_field \"/sandbox/dir with space/file.txt\" gid 997".to_string()
|
|
)
|
|
);
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn debugfs_quote_argument_quotes_source_paths_with_spaces() {
|
|
assert_eq!(
|
|
debugfs_quote_argument("/tmp/openshell state/.openshell-rootfs-inject-123-0"),
|
|
Some("\"/tmp/openshell state/.openshell-rootfs-inject-123-0\"".to_string())
|
|
);
|
|
assert_eq!(
|
|
debugfs_quote_argument("/tmp/path/with\\backslash/and\"quote"),
|
|
Some("\"/tmp/path/with\\\\backslash/and\\\"quote\"".to_string())
|
|
);
|
|
assert_eq!(debugfs_quote_argument("/tmp/bad\npath"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn formatter_candidates_preserve_executed_failure_over_missing_fallback() {
|
|
let candidates = vec![PathBuf::from("mke2fs"), PathBuf::from("missing")];
|
|
|
|
let err = run_ext4_formatter_candidates(candidates, |candidate| {
|
|
if candidate == Path::new("mke2fs") {
|
|
FormatterAttempt::Failed(
|
|
"mke2fs failed with status 1\nstdout: formatter output\nstderr: no space left"
|
|
.to_string(),
|
|
)
|
|
} else {
|
|
FormatterAttempt::Unavailable("missing not found".to_string())
|
|
}
|
|
})
|
|
.expect_err("formatter should fail");
|
|
|
|
assert!(err.contains("mke2fs failed with status 1"));
|
|
assert!(err.contains("no space left"));
|
|
assert!(!err.contains("missing not found"));
|
|
}
|
|
|
|
#[test]
|
|
fn formatter_candidates_report_all_missing_tools() {
|
|
let candidates = vec![PathBuf::from("mke2fs"), PathBuf::from("mkfs.ext4")];
|
|
|
|
let err = run_ext4_formatter_candidates(candidates, |candidate| {
|
|
FormatterAttempt::Unavailable(format!("{} not found", candidate.display()))
|
|
})
|
|
.expect_err("formatter should be unavailable");
|
|
|
|
assert!(err.contains("mke2fs not found"));
|
|
assert!(err.contains("mkfs.ext4 not found"));
|
|
}
|
|
|
|
#[test]
|
|
fn formatter_candidates_accept_successful_fallback() {
|
|
let candidates = vec![PathBuf::from("first"), PathBuf::from("second")];
|
|
let mut attempted = Vec::new();
|
|
|
|
run_ext4_formatter_candidates(candidates, |candidate| {
|
|
attempted.push(candidate.to_path_buf());
|
|
if candidate == Path::new("second") {
|
|
FormatterAttempt::Succeeded
|
|
} else {
|
|
FormatterAttempt::Failed("first failed".to_string())
|
|
}
|
|
})
|
|
.expect("fallback should succeed");
|
|
|
|
assert_eq!(
|
|
attempted,
|
|
vec![PathBuf::from("first"), PathBuf::from("second")]
|
|
);
|
|
}
|
|
|
|
fn unique_temp_dir() -> PathBuf {
|
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("time went backwards")
|
|
.as_nanos();
|
|
let suffix = COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
std::env::temp_dir().join(format!(
|
|
"openshell-driver-vm-rootfs-test-{}-{nanos}-{suffix}",
|
|
std::process::id()
|
|
))
|
|
}
|
|
|
|
fn write_fake_runtime_binaries(rootfs: &Path) {
|
|
fs::write(
|
|
rootfs.join("opt/openshell/bin/openshell-sandbox"),
|
|
b"sandbox",
|
|
)
|
|
.expect("write openshell-sandbox");
|
|
fs::write(rootfs.join("opt/openshell/bin/umoci"), b"umoci").expect("write umoci");
|
|
}
|
|
}
|