Compose selected State Anchor paths over prepared roots

This commit is contained in:
ms2k 2026-07-17 03:58:27 +09:00
commit 4dd2b150d2
4 changed files with 1575 additions and 163 deletions

View file

@ -32,6 +32,18 @@ pub struct IdMap {
pub gid: Vec<IdMapRange>,
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum PreparedDestinationKind {
Directory,
RegularFile,
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct PreparedDestination {
pub path: PathBuf,
pub kind: PreparedDestinationKind,
}
#[derive(Clone, Copy, Debug)]
pub struct PreparationLimits {
pub max_layers: usize,
@ -189,11 +201,91 @@ pub(crate) fn apply_layout(
bail!("layer {index} diff-ID mismatch");
}
validate_tar(&spool, id_map, limits, &mut usage)?;
state.apply(&spool, rootfs)?;
state.apply(&spool, rootfs, id_map)?;
}
state.finish(rootfs)
}
pub(crate) fn materialize_destinations(
root: &File,
destinations: &[PreparedDestination],
id_map: &IdMap,
) -> Result<()> {
if destinations.is_empty() {
return Ok(());
}
let uid = map_owner(0, &id_map.uid, "uid")?;
let gid = map_owner(0, &id_map.gid, "gid")?;
for destination in destinations {
let relative = destination
.path
.strip_prefix("/")
.context("prepared destination is not absolute")?;
let relative = normalize_relative(relative)?;
let (parent, leaf) = materialize_parent(root, &relative, uid, gid)?;
let expected = match destination.kind {
PreparedDestinationKind::Directory => FileType::Directory,
PreparedDestinationKind::RegularFile => FileType::RegularFile,
};
match path_type(&parent, Path::new(&leaf))? {
Some(actual) if actual == expected => {}
Some(_) => bail!("prepared destination conflicts with the image layout"),
None => match destination.kind {
PreparedDestinationKind::Directory => {
mkdirat(&parent, &leaf, Mode::from_raw_mode(0o755))?;
set_materialized_metadata(open_component(&parent, &leaf)?, uid, gid)?;
}
PreparedDestinationKind::RegularFile => {
let file = openat2(
&parent,
&leaf,
OFlags::WRONLY
| OFlags::CREATE
| OFlags::EXCL
| OFlags::CLOEXEC
| OFlags::NOFOLLOW,
Mode::from_raw_mode(0o755),
secure_resolve(),
)?;
set_materialized_metadata(file, uid, gid)?;
}
},
}
}
Ok(())
}
fn materialize_parent(root: &File, path: &Path, uid: u32, gid: u32) -> Result<(OwnedFd, OsString)> {
let leaf = path
.file_name()
.context("prepared destination has no final component")?
.to_os_string();
let mut current = dup(root)?;
for component in path.parent().into_iter().flat_map(Path::components) {
let Component::Normal(name) = component else {
bail!("prepared destination is not normalized");
};
match mkdirat(&current, name, Mode::from_raw_mode(0o755)) {
Ok(()) => set_materialized_metadata(open_component(&current, name)?, uid, gid)?,
Err(Errno::EXIST) => {}
Err(error) => return Err(error.into()),
}
current = open_component(&current, name)?;
}
Ok((current, leaf))
}
fn set_materialized_metadata(descriptor: impl AsFd, uid: u32, gid: u32) -> Result<()> {
fchown(
&descriptor,
Some(Uid::from_raw(uid)),
Some(Gid::from_raw(gid)),
)?;
fchmod(&descriptor, Mode::from_raw_mode(0o755))?;
futimens(&descriptor, &timestamps(0))?;
Ok(())
}
fn read_json<T: serde::de::DeserializeOwned>(
layout: &LayoutSource<'_>,
path: &Path,
@ -315,7 +407,8 @@ fn validate_tar(
let mut paths = HashSet::new();
for entry in archive.entries()? {
let mut entry = entry?;
let path = normalize_relative(&entry.path()?)?;
let entry_type = entry.header().entry_type();
let path = normalize_layer_path(&entry.path()?, entry_type)?;
if !paths.insert(path.clone()) {
bail!("policy {POLICY_NAME} rejects duplicate layer path");
}
@ -353,9 +446,8 @@ fn validate_tar(
bail!("policy {POLICY_NAME} rejects PAX metadata");
}
}
validate_owner(entry.header().uid()?, &id_map.uid, "uid")?;
validate_owner(entry.header().gid()?, &id_map.gid, "gid")?;
let entry_type = entry.header().entry_type();
map_owner(entry.header().uid()?, &id_map.uid, "uid")?;
map_owner(entry.header().gid()?, &id_map.gid, "gid")?;
if !matches!(
entry_type,
EntryType::Regular
@ -407,17 +499,35 @@ fn normalize_relative(path: &Path) -> Result<PathBuf> {
Ok(normalized)
}
fn validate_owner(value: u64, ranges: &[IdMapRange], name: &str) -> Result<()> {
fn normalize_layer_path(path: &Path, entry_type: EntryType) -> Result<PathBuf> {
match normalize_relative(path) {
Ok(path) => Ok(path),
Err(_)
if entry_type == EntryType::Directory
&& path.components().next().is_some()
&& path
.components()
.all(|component| component == Component::CurDir) =>
{
Ok(PathBuf::from("."))
}
Err(error) => Err(error),
}
}
fn map_owner(value: u64, ranges: &[IdMapRange], name: &str) -> Result<u32> {
let Ok(value) = u32::try_from(value) else {
bail!("{name} {value} is outside the target ID map");
};
if !ranges
.iter()
.any(|range| value >= range.container_id && value - range.container_id < range.size)
{
bail!("{name} {value} is outside the target ID map");
for range in ranges {
if value >= range.container_id && value - range.container_id < range.size {
return range
.namespace_id
.checked_add(value - range.container_id)
.context("mapped owner overflows");
}
}
Ok(())
bail!("{name} {value} is outside the target ID map")
}
fn validate_id_map(id_map: &IdMap) -> Result<()> {
@ -483,7 +593,7 @@ struct LayerState {
}
impl LayerState {
fn apply(&mut self, tar_source: &File, root: &File) -> Result<()> {
fn apply(&mut self, tar_source: &File, root: &File, id_map: &IdMap) -> Result<()> {
apply_whiteouts(tar_source, root, &mut self.directories)?;
let mut archive = tar::Archive::new(rewound(tar_source)?);
let mut hardlinks = Vec::new();
@ -497,7 +607,7 @@ impl LayerState {
if name == ".wh..wh..opq" || name.starts_with(".wh.") {
continue;
}
let metadata = EntryMetadata::from_header(path.clone(), entry.header())?;
let metadata = EntryMetadata::from_header(path.clone(), entry.header(), id_map)?;
match entry.header().entry_type() {
EntryType::Regular | EntryType::Continuous => {
forget_directory_tree(&mut self.directories, &path);
@ -557,11 +667,11 @@ struct EntryMetadata {
}
impl EntryMetadata {
fn from_header(path: PathBuf, header: &Header) -> Result<Self> {
fn from_header(path: PathBuf, header: &Header, id_map: &IdMap) -> Result<Self> {
Ok(Self {
path,
uid: u32::try_from(header.uid()?).context("entry uid exceeds u32")?,
gid: u32::try_from(header.gid()?).context("entry gid exceeds u32")?,
uid: map_owner(header.uid()?, &id_map.uid, "uid")?,
gid: map_owner(header.gid()?, &id_map.gid, "gid")?,
mode: header.mode()?,
mtime: header.mtime()?,
})

View file

@ -4,6 +4,8 @@ mod layer;
mod store;
mod transport;
pub use layer::{IdMap, IdMapRange, PreparationLimits};
pub use layer::{
IdMap, IdMapRange, PreparationLimits, PreparedDestination, PreparedDestinationKind,
};
pub use store::{PrepareRecovery, PrepareRequest, PreparedLowerStore, ReclaimOutcome};
pub use transport::{OciRegistryTransport, Platform, RegistryCredentials, TransportedImage};

View file

@ -23,7 +23,8 @@ use rustix::fs::{
use sha2::{Digest, Sha256};
use crate::layer::{
IdMap, LayoutSource, POLICY_NAME, PreparationLimits, apply_layout, remove_children,
IdMap, LayoutSource, POLICY_NAME, PreparationLimits, PreparedDestination,
PreparedDestinationKind, apply_layout, materialize_destinations, remove_children,
verify_layout,
};
use crate::transport::TransportedImage;
@ -38,6 +39,7 @@ pub struct PrepareRequest {
pub source_identity: Vec<u8>,
pub selected_manifest_digest: [u8; 32],
pub id_map: IdMap,
pub destinations: Vec<PreparedDestination>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@ -115,6 +117,22 @@ impl PreparedLowerStore {
self
}
/// Opens the exact published artifact beneath the retained prepared-lower root.
///
/// # Errors
/// Refuses a non-canonical artifact record, catalog drift, or physical identity drift.
pub fn open_artifact_authority(&self, artifact: &PreparedArtifact) -> Result<File> {
let catalog_artifact = self
.catalog_artifact(artifact.prepared_artifact_id)?
.context("prepared artifact is absent from the catalog")?;
if catalog_artifact != *artifact {
bail!("prepared artifact does not match the catalog");
}
self.require_artifact_identity(artifact)?;
self.open_relative(&artifact_path(artifact.prepared_artifact_id))?
.context("prepared artifact path is absent")
}
/// Materializes and atomically publishes one verified immutable prepared lower.
///
/// # Errors
@ -134,6 +152,7 @@ impl PreparedLowerStore {
operation_id: PrepareOperationId,
image: &TransportedImage,
id_map: IdMap,
destinations: Vec<PreparedDestination>,
) -> Result<PreparedArtifact> {
let layout = image
.retained_layout()
@ -144,6 +163,7 @@ impl PreparedLowerStore {
source_identity: image.source_identity.clone(),
selected_manifest_digest: image.selected_manifest_digest,
id_map,
destinations,
};
self.prepare_from_source(&request, &LayoutSource::Retained(layout.root()))
}
@ -194,6 +214,7 @@ impl PreparedLowerStore {
return Err(error.into());
}
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))
{
if self
@ -494,7 +515,13 @@ impl Identities {
fn new(request: &PrepareRequest) -> Result<Self> {
let source = SourceIdentity::new(request.source_identity.clone());
let manifest = SelectedManifestDigest::new(request.selected_manifest_digest);
let policy = PolicyId::new(hash_parts(&[b"lazaret-policy-v1", POLICY_NAME.as_bytes()]));
let destinations = canonical_destinations(&request.destinations)?;
let destination_bytes = destination_policy_bytes(&destinations);
let policy = PolicyId::new(hash_parts(&[
b"lazaret-policy-v2",
POLICY_NAME.as_bytes(),
&destination_bytes,
]));
let id_map_bytes = postcard::to_stdvec(&request.id_map)?;
let id_map = IdMapDigest::new(hash_parts(&[b"lazaret-id-map-v1", &id_map_bytes]));
let artifact_id = PreparedArtifactId::new(hash_parts(&[
@ -555,6 +582,42 @@ impl Identities {
}
}
fn canonical_destinations(
destinations: &[PreparedDestination],
) -> Result<Vec<PreparedDestination>> {
let mut destinations = destinations.to_vec();
for destination in &destinations {
let mut components = destination.path.components();
if components.next() != Some(std::path::Component::RootDir)
|| components.any(|component| !matches!(component, std::path::Component::Normal(_)))
{
bail!("prepared destination is not an absolute normalized path");
}
}
destinations.sort();
destinations.dedup();
for pair in destinations.windows(2) {
if pair[0].path == pair[1].path && pair[0].kind != pair[1].kind {
bail!("prepared destination has conflicting types");
}
}
Ok(destinations)
}
fn destination_policy_bytes(destinations: &[PreparedDestination]) -> Vec<u8> {
let mut bytes = b"lazaret-prepared-layout-policy-v1\0".to_vec();
for destination in destinations {
bytes.extend_from_slice(destination.path.as_os_str().as_bytes());
bytes.push(0);
bytes.push(match destination.kind {
PreparedDestinationKind::Directory => b'd',
PreparedDestinationKind::RegularFile => b'f',
});
bytes.push(0);
}
bytes
}
fn artifact_from_operation(
operation: &PrepareOperation,
path: &Path,

View file

@ -223,8 +223,10 @@ impl OciRegistryTransport {
}
verify_platform(&config_json, platform)?;
validate_manifest(&manifest)?;
let layout = LayoutWriter::create(destination)?;
layout.write_verified_blob(&manifest.config.digest, config_json.as_bytes())?;
let layout = LayoutWriter::create(destination).context("creating private OCI layout")?;
layout
.write_verified_blob(&manifest.config.digest, config_json.as_bytes())
.context("writing verified OCI config")?;
for descriptor in &manifest.layers {
let size = u64::try_from(descriptor.size).context("negative OCI layer size")?;
if size > MAX_LAYER_BYTES {
@ -235,23 +237,33 @@ impl OciRegistryTransport {
layout.require_existing_blob(&descriptor.digest, size)?;
continue;
}
let (file, temporary) = layout.create_blob(digest_component)?;
let (file, temporary) = layout
.create_blob(digest_component)
.with_context(|| format!("creating OCI layer {}", descriptor.digest))?;
let file = tokio::fs::File::from_std(file);
if let Err(error) = client.pull_blob(&reference, descriptor, file).await {
layout.remove_temporary_blob(&temporary);
return Err(error)
.with_context(|| format!("pulling OCI layer {}", descriptor.digest));
}
let observed = layout.temporary_blob_size(&temporary)?;
let observed = layout
.temporary_blob_size(&temporary)
.with_context(|| format!("measuring OCI layer {}", descriptor.digest))?;
if observed != size {
bail!("OCI layer {} size mismatch", descriptor.digest);
}
layout.publish_blob(&temporary, digest_component)?;
layout
.publish_blob(&temporary, digest_component)
.with_context(|| format!("publishing OCI layer {}", descriptor.digest))?;
}
let local_manifest = serde_json::to_vec(&manifest)?;
let local_digest = format!("sha256:{}", hex(&Sha256::digest(&local_manifest)));
layout.write_verified_blob(&local_digest, &local_manifest)?;
layout.write_layout_index(&local_digest, local_manifest.len())?;
layout
.write_verified_blob(&local_digest, &local_manifest)
.context("writing verified OCI manifest")?;
layout
.write_layout_index(&local_digest, local_manifest.len())
.context("writing OCI layout index")?;
let selected_manifest_digest = parse_sha256(&local_digest)?;
let source_identity = format!(
"oci-v1\0{}\0{}\0{manifest_digest}",
@ -406,13 +418,19 @@ impl LayoutWriter {
bail!("OCI descriptor digest mismatch for {digest}");
}
let component = digest_component(digest)?;
if self.blob_exists(component)? {
if self
.blob_exists(component)
.context("checking verified OCI blob destination")?
{
return self.require_existing_blob(digest, bytes.len() as u64);
}
let (mut file, temporary) = self.create_blob(component)?;
file.write_all(bytes)?;
file.sync_all()?;
let (mut file, temporary) = self
.create_blob(component)
.context("creating verified OCI blob")?;
file.write_all(bytes).context("writing verified OCI blob")?;
file.sync_all().context("syncing verified OCI blob")?;
self.publish_blob(&temporary, component)
.context("publishing verified OCI blob")
}
fn blob_exists(&self, component: &str) -> Result<bool> {
@ -552,11 +570,16 @@ fn open_directory(parent: &File, path: &Path) -> std::io::Result<File> {
}
fn open_relative_file(parent: &File, path: &Path, flags: OFlags) -> std::io::Result<File> {
let mode = if flags.contains(OFlags::CREATE) {
Mode::from_raw_mode(0o600)
} else {
Mode::empty()
};
openat2(
parent,
path,
flags | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::from_raw_mode(0o600),
mode,
confined_resolve(),
)
.map(File::from)
@ -566,7 +589,11 @@ fn open_relative_file(parent: &File, path: &Path, flags: OFlags) -> std::io::Res
fn write_relative_atomic(root: &File, path: &Path, bytes: &[u8]) -> Result<()> {
let name = path.file_name().context("layout file has no name")?;
let parent_path = path.parent().unwrap_or(Path::new("."));
let parent = open_directory(root, parent_path)?;
let parent = if parent_path.as_os_str().is_empty() {
File::from(rustix::io::dup(root)?)
} else {
open_directory(root, parent_path)?
};
let temporary = format!(".{}.partial", name.to_string_lossy());
let mut file = open_relative_file(
&parent,
@ -709,6 +736,25 @@ mod tests {
use super::*;
#[test]
fn atomic_layout_write_accepts_a_root_level_destination() {
let tree = tempfile::tempdir().unwrap();
let root = File::open(tree.path()).unwrap();
write_relative_atomic(&root, Path::new("oci-layout"), b"layout").unwrap();
assert_eq!(
std::fs::read(tree.path().join("oci-layout")).unwrap(),
b"layout"
);
}
#[test]
fn confined_read_open_uses_an_empty_creation_mode() {
let tree = tempfile::tempdir().unwrap();
let root = File::open(tree.path()).unwrap();
let error = open_relative_file(&root, Path::new("absent"), OFlags::RDONLY).unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
}
#[test]
fn preexisting_transport_parents_require_exact_private_mode() {
for name in ["image-transports", "blobs", "sha256"] {