feat(snix-build/oci): Use user's subordinate ids in oci builds.

subuid/subgids used to be hardcoded, which resulted in build failures
if those did not match the ones of the effective user.

fixes #86

Change-Id: I3b0c3e9ef710aa9e3de998891abe10fd1a893189
Reviewed-on: https://cl.snix.dev/c/snix/+/30301
Tested-by: besadii
Reviewed-by: Florian Klink <flokli@flokli.de>
This commit is contained in:
Vova Kryachko 2025-04-06 15:21:18 +00:00
parent acf614e884
commit 6118142b21
7 changed files with 316 additions and 74 deletions

15
snix/Cargo.lock generated
View file

@ -2574,6 +2574,18 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.6.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]] [[package]]
name = "nix-compat" name = "nix-compat"
version = "0.1.0" version = "0.1.0"
@ -4147,6 +4159,7 @@ dependencies = [
"futures", "futures",
"itertools 0.12.1", "itertools 0.12.1",
"mimalloc", "mimalloc",
"nix 0.29.0",
"oci-spec", "oci-spec",
"prost", "prost",
"prost-build", "prost-build",
@ -5275,7 +5288,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69fff37da548239c3bf9e64a12193d261e8b22b660991c6fd2df057c168f435f" checksum = "69fff37da548239c3bf9e64a12193d261e8b22b660991c6fd2df057c168f435f"
dependencies = [ dependencies = [
"cc", "cc",
"windows-targets 0.48.5", "windows-targets 0.52.6",
] ]
[[package]] [[package]]

View file

@ -8153,6 +8153,53 @@ rec {
}; };
resolvedDefaultFeatures = [ "default" "fs" ]; resolvedDefaultFeatures = [ "default" "fs" ];
}; };
"nix 0.29.0" = rec {
crateName = "nix";
version = "0.29.0";
edition = "2021";
sha256 = "0ikvn7s9r2lrfdm3mx1h7nbfjvcc6s9vxdzw7j5xfkd2qdnp9qki";
authors = [
"The nix-rust Project Developers"
];
dependencies = [
{
name = "bitflags";
packageId = "bitflags 2.6.0";
}
{
name = "cfg-if";
packageId = "cfg-if";
}
{
name = "libc";
packageId = "libc";
features = [ "extra_traits" ];
}
];
buildDependencies = [
{
name = "cfg_aliases";
packageId = "cfg_aliases";
}
];
features = {
"aio" = [ "pin-utils" ];
"dir" = [ "fs" ];
"memoffset" = [ "dep:memoffset" ];
"mount" = [ "uio" ];
"mqueue" = [ "fs" ];
"net" = [ "socket" ];
"pin-utils" = [ "dep:pin-utils" ];
"ptrace" = [ "process" ];
"sched" = [ "process" ];
"signal" = [ "process" ];
"socket" = [ "memoffset" ];
"ucontext" = [ "signal" ];
"user" = [ "feature" ];
"zerocopy" = [ "fs" "uio" ];
};
resolvedDefaultFeatures = [ "default" "feature" "user" ];
};
"nix-compat" = rec { "nix-compat" = rec {
crateName = "nix-compat"; crateName = "nix-compat";
version = "0.1.0"; version = "0.1.0";
@ -13483,6 +13530,11 @@ rec {
name = "mimalloc"; name = "mimalloc";
packageId = "mimalloc"; packageId = "mimalloc";
} }
{
name = "nix";
packageId = "nix 0.29.0";
features = [ "user" ];
}
{ {
name = "oci-spec"; name = "oci-spec";
packageId = "oci-spec"; packageId = "oci-spec";
@ -17658,7 +17710,7 @@ rec {
dependencies = [ dependencies = [
{ {
name = "windows-targets"; name = "windows-targets";
packageId = "windows-targets 0.48.5"; packageId = "windows-targets 0.52.6";
target = { target, features }: (target."windows" or false); target = { target, features }: (target."windows" or false);
} }
]; ];

View file

@ -25,6 +25,7 @@ bstr = "1.6.0"
data-encoding = "2.5.0" data-encoding = "2.5.0"
futures = "0.3.30" futures = "0.3.30"
oci-spec = "0.7.0" oci-spec = "0.7.0"
nix = { version = "0.29.0", features = ["user"] }
serde_json = "1.0.111" serde_json = "1.0.111"
snix-tracing = { path = "../tracing" } snix-tracing = { path = "../tracing" }
uuid = { version = "1.7.0", features = ["v4"] } uuid = { version = "1.7.0", features = ["v4"] }

View file

@ -1,6 +1,5 @@
use anyhow::Context; use anyhow::Context;
use bstr::BStr; use bstr::BStr;
use oci_spec::runtime::{LinuxIdMapping, LinuxIdMappingBuilder};
use snix_castore::{ use snix_castore::{
blobservice::BlobService, blobservice::BlobService,
directoryservice::DirectoryService, directoryservice::DirectoryService,
@ -29,11 +28,6 @@ pub struct OCIBuildService<BS, DS> {
/// Root path in which all bundles are created in /// Root path in which all bundles are created in
bundle_root: PathBuf, bundle_root: PathBuf,
/// uid mappings to set up for the workloads
uid_mappings: Vec<LinuxIdMapping>,
/// uid mappings to set up for the workloads
gid_mappings: Vec<LinuxIdMapping>,
/// Handle to a [BlobService], used by filesystems spawned during builds. /// Handle to a [BlobService], used by filesystems spawned during builds.
blob_service: BS, blob_service: BS,
/// Handle to a [DirectoryService], used by filesystems spawned during builds. /// Handle to a [DirectoryService], used by filesystems spawned during builds.
@ -49,40 +43,11 @@ impl<BS, DS> OCIBuildService<BS, DS> {
// We map root inside the container to the uid/gid this is running at, // We map root inside the container to the uid/gid this is running at,
// and allocate one for uid 1000 into the container from the range we // and allocate one for uid 1000 into the container from the range we
// got in /etc/sub{u,g}id. // got in /etc/sub{u,g}id.
// TODO: actually read uid, and /etc/subuid. Maybe only when we try to build?
// FUTUREWORK: use different uids? // FUTUREWORK: use different uids?
Self { Self {
bundle_root, bundle_root,
blob_service, blob_service,
directory_service, directory_service,
uid_mappings: vec![
LinuxIdMappingBuilder::default()
.host_id(1000_u32)
.container_id(0_u32)
.size(1_u32)
.build()
.unwrap(),
LinuxIdMappingBuilder::default()
.host_id(100000_u32)
.container_id(1000_u32)
.size(1_u32)
.build()
.unwrap(),
],
gid_mappings: vec![
LinuxIdMappingBuilder::default()
.host_id(100_u32)
.container_id(0_u32)
.size(1_u32)
.build()
.unwrap(),
LinuxIdMappingBuilder::default()
.host_id(100000_u32)
.container_id(100_u32)
.size(1_u32)
.build()
.unwrap(),
],
concurrent_builds: tokio::sync::Semaphore::new(MAX_CONCURRENT_BUILDS), concurrent_builds: tokio::sync::Semaphore::new(MAX_CONCURRENT_BUILDS),
} }
} }
@ -108,11 +73,7 @@ where
.context("failed to create spec") .context("failed to create spec")
.map_err(std::io::Error::other)?; .map_err(std::io::Error::other)?;
let mut linux = runtime_spec.linux().clone().unwrap(); let linux = runtime_spec.linux().clone().unwrap();
// edit the spec, we need to setup uid/gid mappings.
linux.set_uid_mappings(Some(self.uid_mappings.clone()));
linux.set_gid_mappings(Some(self.gid_mappings.clone()));
runtime_spec.set_linux(Some(linux)); runtime_spec.set_linux(Some(linux));

View file

@ -1,5 +1,6 @@
mod bundle; mod bundle;
mod spec; mod spec;
pub(crate) mod subuid;
pub(crate) use bundle::get_host_output_paths; pub(crate) use bundle::get_host_output_paths;
pub(crate) use bundle::make_bundle; pub(crate) use bundle::make_bundle;

View file

@ -1,12 +1,23 @@
//! Module to create a OCI runtime spec for a given [BuildRequest]. //! Module to create a OCI runtime spec for a given [BuildRequest].
use crate::buildservice::{BuildConstraints, BuildRequest}; use crate::buildservice::{BuildConstraints, BuildRequest};
use oci_spec::{ use oci_spec::runtime::{
runtime::{Capability, LinuxNamespace, LinuxNamespaceBuilder, LinuxNamespaceType}, Capability, LinuxIdMappingBuilder, LinuxNamespace, LinuxNamespaceBuilder, LinuxNamespaceType,
OciSpecError,
}; };
use std::{collections::HashSet, path::Path}; use std::{collections::HashSet, path::Path};
use super::scratch_name; use super::{
scratch_name,
subuid::{SubordinateError, SubordinateInfo},
};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SpecError {
#[error("oci error: {0}")]
OciError(oci_spec::OciSpecError),
#[error("subordinate error: {0}")]
SubordinateError(SubordinateError),
}
/// For a given [BuildRequest], return an OCI runtime spec. /// For a given [BuildRequest], return an OCI runtime spec.
/// ///
@ -33,7 +44,7 @@ pub(crate) fn make_spec(
request: &BuildRequest, request: &BuildRequest,
rootless: bool, rootless: bool,
sandbox_shell: &str, sandbox_shell: &str,
) -> Result<oci_spec::runtime::Spec, oci_spec::OciSpecError> { ) -> Result<oci_spec::runtime::Spec, SpecError> {
let allow_network = request let allow_network = request
.constraints .constraints
.contains(&BuildConstraints::NetworkAccess); .contains(&BuildConstraints::NetworkAccess);
@ -57,7 +68,8 @@ pub(crate) fn make_spec(
} }
oci_spec::runtime::SpecBuilder::default() oci_spec::runtime::SpecBuilder::default()
.process(configure_process( .process(
configure_process(
&request.command_args, &request.command_args,
&request.working_dir, &request.working_dir,
request request
@ -72,24 +84,31 @@ pub(crate) fn make_spec(
}) })
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
rootless, rootless,
)?) )
.map_err(SpecError::OciError)?,
)
.linux(configure_linux(allow_network, rootless)?) .linux(configure_linux(allow_network, rootless)?)
.root( .root(
oci_spec::runtime::RootBuilder::default() oci_spec::runtime::RootBuilder::default()
.path("root") .path("root")
.readonly(true) .readonly(true)
.build()?, .build()
.map_err(SpecError::OciError)?,
) )
.hostname("localhost") .hostname("localhost")
.mounts(configure_mounts( .mounts(
configure_mounts(
rootless, rootless,
allow_network, allow_network,
request.scratch_paths.iter().map(|e| e.as_path()), request.scratch_paths.iter().map(|e| e.as_path()),
request.inputs.iter(), request.inputs.iter(),
&request.inputs_dir, &request.inputs_dir,
ro_host_mounts, ro_host_mounts,
)?) )
.map_err(SpecError::OciError)?,
)
.build() .build()
.map_err(SpecError::OciError)
} }
/// Return the Process part of the OCI Runtime spec. /// Return the Process part of the OCI Runtime spec.
@ -162,7 +181,7 @@ fn configure_process<'a>(
fn configure_linux( fn configure_linux(
allow_network: bool, allow_network: bool,
rootless: bool, rootless: bool,
) -> Result<oci_spec::runtime::Linux, OciSpecError> { ) -> Result<oci_spec::runtime::Linux, SpecError> {
let mut linux = oci_spec::runtime::Linux::default(); let mut linux = oci_spec::runtime::Linux::default();
// explicitly set namespaces, depending on allow_network. // explicitly set namespaces, depending on allow_network.
@ -187,7 +206,8 @@ fn configure_linux(
namespace_types namespace_types
.into_iter() .into_iter()
.map(|e| LinuxNamespaceBuilder::default().typ(e).build()) .map(|e| LinuxNamespaceBuilder::default().typ(e).build())
.collect::<Result<Vec<LinuxNamespace>, _>>()? .collect::<Result<Vec<LinuxNamespace>, _>>()
.map_err(SpecError::OciError)?
})); }));
linux.set_masked_paths(Some( linux.set_masked_paths(Some(
@ -217,6 +237,35 @@ fn configure_linux(
.map(|e| e.to_string()) .map(|e| e.to_string())
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
)); ));
let info = SubordinateInfo::for_effective_user().map_err(SpecError::SubordinateError)?;
linux.set_uid_mappings(Some(vec![
LinuxIdMappingBuilder::default()
.host_id(info.uid)
.container_id(0_u32)
.size(1_u32)
.build()
.unwrap(),
LinuxIdMappingBuilder::default()
.host_id(info.subuid)
.container_id(1000_u32)
.size(1_u32)
.build()
.unwrap(),
]));
linux.set_gid_mappings(Some(vec![
LinuxIdMappingBuilder::default()
.host_id(info.gid)
.container_id(0_u32)
.size(1_u32)
.build()
.unwrap(),
LinuxIdMappingBuilder::default()
.host_id(info.subgid)
.container_id(100_u32)
.size(1_u32)
.build()
.unwrap(),
]));
Ok(linux) Ok(linux)
} }

View file

@ -0,0 +1,165 @@
use std::{
fs::File,
io::{BufRead, BufReader},
num::ParseIntError,
path::PathBuf,
};
use nix::{
errno::Errno,
unistd::{Gid, Group, Uid, User},
};
use thiserror::Error;
#[derive(Debug, Error)]
pub(crate) enum SubordinateError {
#[error("can't determine user {0}")]
UidError(Errno),
#[error("user entry for {0} does not exist")]
NoPasswdEntry(Uid),
#[error("can't determine group {0}")]
GidError(Errno),
#[error("group entry for {0} does not exist")]
NoGroupEntry(Gid),
#[error("io error {0:?}, file {1}")]
IoError(std::io::Error, PathBuf),
#[error("failed to parse {0} line '{1}', error {2}")]
ParseError(PathBuf, String, ParseIntError),
#[error("Missing entry in {0}, for {1}({2})")]
MissingEntry(PathBuf, String, u32),
}
/// Represents a single (subuid,subgid) pair for a user and their group.
///
/// In practice there are usually many more subordinate ids than just one, but
/// for oci builds we only need one. If we ever need more, we can improve this
/// implementation.
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct SubordinateInfo {
pub uid: u32,
pub gid: u32,
pub subuid: u32,
pub subgid: u32,
}
impl SubordinateInfo {
/// Parses /etc/subuid and /etc/subgid and returns a single [SubordinateInfo] for the effective user.
pub(crate) fn for_effective_user() -> Result<SubordinateInfo, SubordinateError> {
let (user, group) = user_info()?;
let subuid =
first_subordinate_id(&PathBuf::from("/etc/subuid"), user.uid.as_raw(), &user.name)?;
let subgid = first_subordinate_id(
&PathBuf::from("/etc/subgid"),
group.gid.as_raw(),
&group.name,
)?;
Ok(SubordinateInfo {
uid: user.uid.as_raw(),
gid: group.gid.as_raw(),
subuid,
subgid,
})
}
}
/// Returns user and group entries for current effective user.
fn user_info() -> Result<(User, Group), SubordinateError> {
let u = Uid::effective();
let user = User::from_uid(u)
.map_err(SubordinateError::UidError)?
.ok_or(SubordinateError::NoPasswdEntry(u))?;
let g = Gid::effective();
let group = Group::from_gid(g)
.map_err(SubordinateError::GidError)?
.ok_or(SubordinateError::NoGroupEntry(g))?;
Ok((user, group))
}
fn first_subordinate_id(file: &PathBuf, id: u32, name: &str) -> Result<u32, SubordinateError> {
let f = File::open(file).map_err(|e| SubordinateError::IoError(e, file.clone()))?;
let reader = BufReader::new(f).lines();
for line in reader {
let line = line.map_err(|e| SubordinateError::IoError(e, file.clone()))?;
let line = line.trim();
let parts: Vec<&str> = line.split(':').collect();
if parts.len() == 3 && (parts[0] == name || id.to_string() == parts[0]) {
let subuid = parts[1]
.parse::<u32>()
.map_err(|e| SubordinateError::ParseError(file.clone(), line.into(), e))?;
let range = parts[2]
.parse::<u32>()
.map_err(|e| SubordinateError::ParseError(file.clone(), line.into(), e))?;
if range > 0 {
return Ok(subuid);
}
}
}
Err(SubordinateError::MissingEntry(
file.clone(),
name.into(),
id,
))
}
#[cfg(test)]
mod tests {
use crate::oci::subuid::SubordinateError;
fn create_fixture<'a>(content: impl IntoIterator<Item = &'a str>) -> tempfile::NamedTempFile {
use std::io::Write;
let mut file = tempfile::NamedTempFile::new().expect("Could not create tempfile");
for line in content.into_iter() {
writeln!(file, "{}", line).expect("");
}
file
}
#[test]
fn test_parse_uid_file_with_name_should_return_first_match() {
let file = create_fixture(["nobody:10000:65", "root:1000:2", "0:2:2"]);
let id = super::first_subordinate_id(&file.path().into(), 0, "root")
.expect("Faild to look up subordinate id.");
assert_eq!(id, 1000);
}
#[test]
fn test_parse_uid_file_with_uid_should_return_first_match() {
let file = create_fixture(["nobody:10000:65", "0:2:2"]);
let id = super::first_subordinate_id(&file.path().into(), 0, "root")
.expect("Failed to look up subordinate id.");
assert_eq!(id, 2);
}
#[test]
fn test_missing() {
let file = create_fixture(["roots:1000:2", "1000:2:2"]);
let id = super::first_subordinate_id(&file.path().into(), 0, "root")
.expect_err("Expected not to find a matching subordinate entry.");
assert!(matches!(id, SubordinateError::MissingEntry(_, _, _)));
}
#[test]
fn test_parse_error() {
let file = create_fixture(["root:hello:2", "1000:2:2"]);
let id = super::first_subordinate_id(&file.path().into(), 0, "root")
.expect_err("Expected parsing to fail.");
assert!(matches!(id, SubordinateError::ParseError(_, _, _)));
}
#[test]
fn test_parse_errors_in_other_users_files_are_ignored() {
let file = create_fixture(["root:hello:2", "1000:2:2"]);
let id = super::first_subordinate_id(&file.path().into(), 1000, "user")
.expect("Failed to look up subordinate id.");
assert_eq!(id, 2);
}
}