Fix rootless launches with subordinate ID maps

Resolve and verify canonical caller subordinate ranges, launch preparation and runtime workers through mapped causal acquisition, and keep runsc in the existing user namespace. Repair descriptor-relative OCI cleanup and absent-mount recovery so failed launches can progress to the corrected path.
This commit is contained in:
ms2k 2026-07-19 19:51:47 +09:00
commit 73e3e9fdee
4 changed files with 1424 additions and 172 deletions

View file

@ -32,13 +32,13 @@ pub struct IdMap {
pub gid: Vec<IdMapRange>,
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
pub enum PreparedDestinationKind {
Directory,
RegularFile,
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
pub struct PreparedDestination {
pub path: PathBuf,
pub kind: PreparedDestinationKind,

View file

@ -20,6 +20,7 @@ use rustix::fs::{
AtFlags, Mode, OFlags, RenameFlags, ResolveFlags, mkdirat, openat2, renameat, renameat_with,
unlinkat,
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::layer::{
@ -33,7 +34,7 @@ use crate::{PreparedLowerError, PreparedLowerResult};
const PHYSICAL_LOCK_WAIT: Duration = Duration::from_secs(5);
const LOCK_RETRY: Duration = Duration::from_millis(2);
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PrepareRequest {
pub operation_id: PrepareOperationId,
pub layout_path: PathBuf,
@ -103,11 +104,27 @@ impl PreparedLowerStore {
.open_authority()
.context("opening validated Storage Root")?,
);
match mkdirat(&storage_root, "prepared-lower", Mode::from_raw_mode(0o700)) {
Self::under_storage_root_authority_inner(&storage_root, catalog)
}
/// Creates the prepared-lower store from an exact inherited Storage Root authority.
///
/// # Errors
/// Refuses fixed-child conflicts, changed ownership, or catalog initialization failure.
pub fn under_storage_root_authority(
storage_root: &File,
catalog: Catalog,
) -> PreparedLowerResult<Self> {
Self::under_storage_root_authority_inner(storage_root, catalog)
.map_err(PreparedLowerError::confinement)
}
fn under_storage_root_authority_inner(storage_root: &File, catalog: Catalog) -> Result<Self> {
match mkdirat(storage_root, "prepared-lower", Mode::from_raw_mode(0o700)) {
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
Err(error) => return Err(error.into()),
}
let root = open_directory_at(&storage_root, Path::new("prepared-lower"))?;
let root = open_directory_at(storage_root, Path::new("prepared-lower"))?;
require_owned_directory(&root)?;
Self::initialize_retained(root, catalog)
}
@ -167,7 +184,32 @@ impl PreparedLowerStore {
/// Refuses corrupt or unsupported OCI input, forged physical ownership, catalog conflicts,
/// resource limits, and publication failures.
pub fn prepare(&self, request: &PrepareRequest) -> PreparedLowerResult<PreparedArtifact> {
self.prepare_from_source(request, &LayoutSource::Ambient(&request.layout_path))
self.prepare_from_source(request, &LayoutSource::Ambient(&request.layout_path), false)
}
/// Materializes from an exact inherited OCI-layout directory authority.
///
/// # Errors
/// Refuses invalid layout content, mapped ownership, catalog state, or publication.
pub fn prepare_from_layout_authority(
&self,
request: &PrepareRequest,
layout: &File,
) -> PreparedLowerResult<PreparedArtifact> {
self.prepare_from_source(request, &LayoutSource::Retained(layout), false)
}
/// Materializes from an exact OCI layout while executing inside the target ID-map namespace.
///
/// # Errors
/// Refuses invalid layout content, logical ownership outside the target map, catalog state,
/// or publication.
pub fn prepare_from_layout_authority_in_target_namespace(
&self,
request: &PrepareRequest,
layout: &File,
) -> PreparedLowerResult<PreparedArtifact> {
self.prepare_from_source(request, &LayoutSource::Retained(layout), true)
}
/// Verifies and prepares an image through its retained transport-layout authority.
@ -203,13 +245,14 @@ impl PreparedLowerStore {
id_map,
destinations,
};
self.prepare_from_source(&request, &LayoutSource::Retained(layout.root()))
self.prepare_from_source(&request, &LayoutSource::Retained(layout.root()), false)
}
fn prepare_from_source(
&self,
request: &PrepareRequest,
layout: &LayoutSource<'_>,
materialize_logical_owners: bool,
) -> PreparedLowerResult<PreparedArtifact> {
if request.source_identity.is_empty() || request.source_identity.len() > 4096 {
return Err(PreparedLowerError::layer_validation(anyhow::anyhow!(
@ -265,9 +308,17 @@ impl PreparedLowerStore {
}
return Err(PreparedLowerError::catalog(error));
}
if let Err(error) = apply_layout(layout, &stage, &verified, &request.id_map, self.limits)
.and_then(|()| materialize_destinations(&stage, &request.destinations, &request.id_map))
.and_then(|()| sync_tree(&stage))
let materialization_map = if materialize_logical_owners {
logical_id_map(&request.id_map)
} else {
request.id_map.clone()
};
if let Err(error) =
apply_layout(layout, &stage, &verified, &materialization_map, self.limits)
.and_then(|()| {
materialize_destinations(&stage, &request.destinations, &materialization_map)
})
.and_then(|()| sync_tree(&stage))
{
if self
.remove_exact_path(&stage_relative, device, inode)
@ -596,12 +647,21 @@ impl PreparedLowerStore {
}
fn rename_relative(&self, from: &Path, to: &Path) -> Result<()> {
renameat(&self.root, from, &self.root, to)?;
renameat(&self.root, from, &self.root, to)
.with_context(|| format!("renaming {} to {}", from.display(), to.display()))?;
Ok(())
}
fn rename_relative_noreplace(&self, from: &Path, to: &Path) -> Result<()> {
renameat_with(&self.root, from, &self.root, to, RenameFlags::NOREPLACE)?;
renameat_with(&self.root, from, &self.root, to, RenameFlags::NOREPLACE).with_context(
|| {
format!(
"renaming {} to {} without replacement",
from.display(),
to.display()
)
},
)?;
Ok(())
}
@ -639,6 +699,29 @@ impl PreparedLowerStore {
}
}
fn logical_id_map(target: &IdMap) -> IdMap {
IdMap {
uid: target
.uid
.iter()
.map(|range| crate::layer::IdMapRange {
container_id: range.container_id,
namespace_id: range.container_id,
size: range.size,
})
.collect(),
gid: target
.gid
.iter()
.map(|range| crate::layer::IdMapRange {
container_id: range.container_id,
namespace_id: range.container_id,
size: range.size,
})
.collect(),
}
}
fn require_owned_directory(directory: &File) -> Result<()> {
let stat = rustix::fs::fstat(directory)?;
if stat.st_uid != rustix::process::getuid().as_raw() || stat.st_mode & 0o7777 != 0o700 {

View file

@ -96,6 +96,23 @@ impl TransportedImage {
self.retained_layout.as_ref()
}
/// Duplicates the exact retained OCI-layout directory for a preparation worker.
///
/// # Errors
/// Refuses an ambient-only transport or descriptor duplication failure.
pub fn layout_authority(&self) -> PreparedLowerResult<File> {
self.retained_layout
.as_ref()
.context("transported image has no retained layout authority")
.and_then(|layout| {
layout
.root
.try_clone()
.context("duplicating OCI layout authority")
})
.map_err(PreparedLowerError::confinement)
}
/// Deletes only the exact descriptor-retained transport staging leaf.
///
/// # Errors
@ -724,30 +741,39 @@ fn cleanup_retained_layout(layout: &mut RetainedLayout) -> Result<()> {
if (stat.st_dev, stat.st_ino) != layout.identity {
bail!("transport staging path no longer names the retained layout");
}
remove_layout_children(&layout.root)?;
unlinkat(&layout.parent, layout.name.as_str(), AtFlags::REMOVEDIR)?;
remove_layout_children(&layout.root).context("removing OCI layout children")?;
unlinkat(&layout.parent, layout.name.as_str(), AtFlags::REMOVEDIR)
.with_context(|| format!("removing OCI layout {}", layout.name))?;
layout.cleanup_armed = false;
Ok(())
}
fn remove_layout_children(directory: &File) -> Result<()> {
let mut buffer = [std::mem::MaybeUninit::<u8>::uninit(); 4096];
let mut entries = rustix::fs::RawDir::new(rustix::io::dup(directory)?, &mut buffer);
while let Some(entry) = entries.next() {
let entry = entry?;
let name = entry.file_name();
if name.to_bytes() == b"." || name.to_bytes() == b".." {
continue;
let entries = {
let mut buffer = [std::mem::MaybeUninit::<u8>::uninit(); 4096];
let mut directory_entries =
rustix::fs::RawDir::new(open_directory(directory, Path::new("."))?, &mut buffer);
let mut entries = Vec::new();
while let Some(entry) = directory_entries.next() {
let entry = entry?;
let name = entry.file_name().to_bytes();
if name == b"." || name == b".." {
continue;
}
entries.push((name.to_vec(), entry.file_type()));
}
if entry.file_type() == rustix::fs::FileType::Directory {
let child = open_directory(
directory,
Path::new(std::ffi::OsStr::from_bytes(name.to_bytes())),
)?;
entries
};
for (name, file_type) in entries {
let name = std::ffi::OsStr::from_bytes(&name);
if file_type == rustix::fs::FileType::Directory {
let child = open_directory(directory, Path::new(name))?;
remove_layout_children(&child)?;
unlinkat(directory, name, AtFlags::REMOVEDIR)?;
unlinkat(directory, name, AtFlags::REMOVEDIR)
.with_context(|| format!("removing OCI layout directory {}", name.display()))?;
} else {
unlinkat(directory, name, AtFlags::empty())?;
unlinkat(directory, name, AtFlags::empty())
.with_context(|| format!("removing OCI layout file {}", name.display()))?;
}
}
Ok(())

View file

@ -28,6 +28,23 @@ fn confined_read_open_uses_an_empty_creation_mode() {
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
}
#[test]
fn retained_layout_cleanup_removes_a_large_directory_snapshot() {
let tree = tempfile::tempdir().unwrap();
let nested = tree.path().join("blobs/sha256");
std::fs::create_dir_all(&nested).unwrap();
for index in 0..512 {
std::fs::write(nested.join(format!("{index:04}")), b"blob").unwrap();
}
let root = File::open(tree.path()).unwrap();
let mut buffer = [std::mem::MaybeUninit::<u8>::uninit(); 4096];
let mut prior_reader = rustix::fs::RawDir::new(rustix::io::dup(&root).unwrap(), &mut buffer);
while prior_reader.next().is_some() {}
drop(prior_reader);
remove_layout_children(&root).unwrap();
assert_eq!(std::fs::read_dir(tree.path()).unwrap().count(), 0);
}
#[test]
fn preexisting_transport_parents_require_exact_private_mode() {
for name in ["image-transports", "blobs", "sha256"] {