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

View file

@ -0,0 +1,315 @@
use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::io;
use std::path::Path;
use rustix::process::{getgid, getuid};
use serde::{Deserialize, Serialize};
pub const REQUIRED_SUBORDINATE_IDS: u32 = 65_536;
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct UserNamespaceIdRange {
pub container_id: u32,
pub parent_id: u32,
pub size: u32,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct UserNamespaceMap {
pub uid: Vec<UserNamespaceIdRange>,
pub gid: Vec<UserNamespaceIdRange>,
}
impl UserNamespaceMap {
/// Resolves the invoking identity and the lowest sufficient configured subordinate ranges.
///
/// # Errors
/// Refuses missing, malformed, overlapping, or insufficient `/etc/subuid` and `/etc/subgid`
/// allocations.
pub fn resolve_current() -> io::Result<Self> {
let uid = getuid().as_raw();
let gid = getgid().as_raw();
let names = account_names(uid)?;
let subordinate_user_start = select_subordinate_range(Path::new("/etc/subuid"), &names)?;
let subordinate_group_start = select_subordinate_range(Path::new("/etc/subgid"), &names)?;
let map = Self {
uid: vec![
UserNamespaceIdRange {
container_id: 0,
parent_id: uid,
size: 1,
},
UserNamespaceIdRange {
container_id: 1,
parent_id: subordinate_user_start,
size: REQUIRED_SUBORDINATE_IDS,
},
],
gid: vec![
UserNamespaceIdRange {
container_id: 0,
parent_id: gid,
size: 1,
},
UserNamespaceIdRange {
container_id: 1,
parent_id: subordinate_group_start,
size: REQUIRED_SUBORDINATE_IDS,
},
],
};
map.validate()?;
Ok(map)
}
/// # Errors
/// Refuses empty, zero-sized, overflowing, overlapping, or rootless-incompatible maps.
pub fn validate(&self) -> io::Result<()> {
validate_ranges("UID", &self.uid, getuid().as_raw())?;
validate_ranges("GID", &self.gid, getgid().as_raw())
}
#[must_use]
pub fn uid_helper_arguments(&self, pid: u32) -> Vec<String> {
helper_arguments(pid, &self.uid)
}
#[must_use]
pub fn gid_helper_arguments(&self, pid: u32) -> Vec<String> {
helper_arguments(pid, &self.gid)
}
#[must_use]
pub fn uid_proc_text(&self) -> String {
proc_text(&self.uid)
}
#[must_use]
pub fn gid_proc_text(&self) -> String {
proc_text(&self.gid)
}
/// # Errors
/// Refuses an unreadable process or a live UID/GID map that differs from this map.
pub fn verify_process(&self, pid: u32) -> io::Result<()> {
verify_proc_map(&format!("/proc/{pid}/uid_map"), &self.uid, "UID")?;
verify_proc_map(&format!("/proc/{pid}/gid_map"), &self.gid, "GID")
}
}
fn account_names(uid: u32) -> io::Result<BTreeSet<String>> {
let mut names = BTreeSet::from([uid.to_string()]);
for line in std::fs::read_to_string("/etc/passwd")?.lines() {
let fields = line.split(':').collect::<Vec<_>>();
if fields.len() >= 3 && fields[2].parse::<u32>().ok() == Some(uid) {
names.insert(fields[0].to_owned());
}
}
Ok(names)
}
fn select_subordinate_range(path: &Path, names: &BTreeSet<String>) -> io::Result<u32> {
select_subordinate_range_contents(
&path.display().to_string(),
&std::fs::read_to_string(path)?,
names,
)
}
fn select_subordinate_range_contents(
label: &str,
contents: &str,
names: &BTreeSet<String>,
) -> io::Result<u32> {
let mut candidates = Vec::new();
for (line_number, line) in contents.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let fields = line.split(':').collect::<Vec<_>>();
if fields.len() != 3 {
return Err(io::Error::other(format!(
"{} row {} is malformed",
label,
line_number + 1
)));
}
if !names.contains(fields[0]) {
continue;
}
let start = fields[1].parse::<u32>().map_err(|_| {
io::Error::other(format!(
"{} row {} has an invalid start",
label,
line_number + 1
))
})?;
let count = fields[2].parse::<u32>().map_err(|_| {
io::Error::other(format!(
"{} row {} has an invalid count",
label,
line_number + 1
))
})?;
if count >= REQUIRED_SUBORDINATE_IDS
&& start.checked_add(REQUIRED_SUBORDINATE_IDS - 1).is_some()
{
candidates.push(start);
}
}
candidates.into_iter().min().ok_or_else(|| {
io::Error::other(format!(
"{label} has no contiguous {REQUIRED_SUBORDINATE_IDS}-ID allocation for the invoking user"
))
})
}
fn validate_ranges(
name: &str,
ranges: &[UserNamespaceIdRange],
invoking_id: u32,
) -> io::Result<()> {
if ranges.len() != 2
|| ranges[0]
!= (UserNamespaceIdRange {
container_id: 0,
parent_id: invoking_id,
size: 1,
})
|| ranges[1].container_id != 1
|| ranges[1].size != REQUIRED_SUBORDINATE_IDS
{
return Err(io::Error::other(format!(
"{name} map does not match the canonical rootless shape"
)));
}
for range in ranges {
range
.container_id
.checked_add(range.size - 1)
.and_then(|_| range.parent_id.checked_add(range.size - 1))
.ok_or_else(|| io::Error::other(format!("{name} map overflows")))?;
}
let subordinate = ranges[1];
if invoking_id >= subordinate.parent_id
&& invoking_id - subordinate.parent_id < subordinate.size
{
return Err(io::Error::other(format!(
"{name} subordinate range contains the invoking identity"
)));
}
Ok(())
}
fn helper_arguments(pid: u32, ranges: &[UserNamespaceIdRange]) -> Vec<String> {
std::iter::once(pid.to_string())
.chain(ranges.iter().flat_map(|range| {
[
range.container_id.to_string(),
range.parent_id.to_string(),
range.size.to_string(),
]
}))
.collect()
}
fn proc_text(ranges: &[UserNamespaceIdRange]) -> String {
ranges.iter().fold(String::new(), |mut text, range| {
writeln!(
text,
"{} {} {}",
range.container_id, range.parent_id, range.size
)
.expect("writing to String cannot fail");
text
})
}
fn verify_proc_map(path: &str, expected: &[UserNamespaceIdRange], name: &str) -> io::Result<()> {
let observed = std::fs::read_to_string(path)?
.lines()
.map(|line| {
let fields = line.split_whitespace().collect::<Vec<_>>();
if fields.len() != 3 {
return Err(io::Error::other(format!("{name} map row is malformed")));
}
Ok(UserNamespaceIdRange {
container_id: fields[0]
.parse()
.map_err(|_| io::Error::other(format!("{name} container ID is invalid")))?,
parent_id: fields[1]
.parse()
.map_err(|_| io::Error::other(format!("{name} parent ID is invalid")))?,
size: fields[2]
.parse()
.map_err(|_| io::Error::other(format!("{name} range size is invalid")))?,
})
})
.collect::<io::Result<Vec<_>>>()?;
if observed != expected {
return Err(io::Error::other(format!(
"live {name} map differs from the canonical map"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn helper_arguments_preserve_canonical_range_order() {
let map = UserNamespaceMap {
uid: vec![
UserNamespaceIdRange {
container_id: 0,
parent_id: 1000,
size: 1,
},
UserNamespaceIdRange {
container_id: 1,
parent_id: 524_288,
size: 65_536,
},
],
gid: Vec::new(),
};
assert_eq!(
map.uid_helper_arguments(42),
["42", "0", "1000", "1", "1", "524288", "65536"]
);
assert_eq!(map.uid_proc_text(), "0 1000 1\n1 524288 65536\n");
}
#[test]
fn subordinate_selection_uses_the_lowest_complete_allocation() {
let names = BTreeSet::from(["1000".to_owned(), "developer".to_owned()]);
assert_eq!(
select_subordinate_range_contents(
"/etc/subuid",
"other:100000:65536\ndeveloper:700000:131072\n1000:524288:65536\n",
&names,
)
.unwrap(),
524_288
);
}
#[test]
fn subordinate_selection_refuses_partial_and_overflowing_allocations() {
let names = BTreeSet::from(["developer".to_owned()]);
let error = select_subordinate_range_contents(
"/etc/subgid",
"developer:524288:65535\ndeveloper:4294967295:65536\n",
&names,
)
.unwrap_err();
assert!(
error
.to_string()
.contains("no contiguous 65536-ID allocation")
);
}
}