Confine image preparation beneath retained storage authority
This commit is contained in:
parent
fbd332c31f
commit
c0b139256e
1 changed files with 694 additions and 103 deletions
|
|
@ -1,12 +1,21 @@
|
|||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::{fs::OpenOptions, io::Write, os::unix::fs::PermissionsExt};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use lazaret_core::configuration::ConfinedPathPlan;
|
||||
use oci_client::Reference;
|
||||
use oci_client::client::{Client, ClientConfig};
|
||||
use oci_client::manifest::{IMAGE_LAYER_GZIP_MEDIA_TYPE, IMAGE_LAYER_MEDIA_TYPE, OciImageManifest};
|
||||
use oci_client::secrets::RegistryAuth;
|
||||
use rustix::fs::{
|
||||
AtFlags, Mode, OFlags, RenameFlags, ResolveFlags, fstat, mkdirat, openat2, renameat_with,
|
||||
unlinkat,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
|
|
@ -56,11 +65,50 @@ impl std::fmt::Debug for RegistryCredentials {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Debug)]
|
||||
pub struct TransportedImage {
|
||||
pub layout_path: PathBuf,
|
||||
pub source_identity: Vec<u8>,
|
||||
pub selected_manifest_digest: [u8; 32],
|
||||
retained_layout: Option<RetainedLayout>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RetainedLayout {
|
||||
root: Arc<File>,
|
||||
parent: Arc<File>,
|
||||
name: String,
|
||||
identity: (u64, u64),
|
||||
cleanup_armed: bool,
|
||||
}
|
||||
|
||||
impl RetainedLayout {
|
||||
pub(crate) fn root(&self) -> &File {
|
||||
&self.root
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportedImage {
|
||||
pub(crate) fn retained_layout(&self) -> Option<&RetainedLayout> {
|
||||
self.retained_layout.as_ref()
|
||||
}
|
||||
|
||||
/// Deletes only the exact descriptor-retained transport staging leaf.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses a replacement path and reports any descriptor-relative cleanup failure.
|
||||
pub fn cleanup(mut self) -> Result<()> {
|
||||
if let Some(layout) = &mut self.retained_layout {
|
||||
cleanup_retained_layout(layout)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RetainedLayout {
|
||||
fn drop(&mut self) {
|
||||
let _ = cleanup_retained_layout(self);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
|
@ -90,12 +138,56 @@ impl OciRegistryTransport {
|
|||
.await
|
||||
}
|
||||
|
||||
/// Pulls an image into an exact descriptor-retained child of Storage Root.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses changed Storage Root authority, fixed-child symlinks, staging collisions,
|
||||
/// authentication failures, corrupt content, and unsupported images.
|
||||
pub async fn pull_confined(
|
||||
&self,
|
||||
reference: &str,
|
||||
credentials: &RegistryCredentials,
|
||||
platform: &Platform,
|
||||
storage_root: &ConfinedPathPlan,
|
||||
staging_id: [u8; 32],
|
||||
) -> Result<TransportedImage> {
|
||||
self.pull_to(
|
||||
reference,
|
||||
credentials,
|
||||
platform,
|
||||
LayoutDestination::Confined {
|
||||
storage_root,
|
||||
staging_id,
|
||||
},
|
||||
ClientConfig::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn pull_with_config(
|
||||
&self,
|
||||
reference: &str,
|
||||
credentials: &RegistryCredentials,
|
||||
platform: &Platform,
|
||||
layout_path: &Path,
|
||||
config: ClientConfig,
|
||||
) -> Result<TransportedImage> {
|
||||
self.pull_to(
|
||||
reference,
|
||||
credentials,
|
||||
platform,
|
||||
LayoutDestination::Ambient(layout_path),
|
||||
config,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn pull_to(
|
||||
&self,
|
||||
reference: &str,
|
||||
credentials: &RegistryCredentials,
|
||||
platform: &Platform,
|
||||
destination: LayoutDestination<'_>,
|
||||
mut config: ClientConfig,
|
||||
) -> Result<TransportedImage> {
|
||||
validate_credentials(credentials)?;
|
||||
|
|
@ -131,39 +223,35 @@ impl OciRegistryTransport {
|
|||
}
|
||||
verify_platform(&config_json, platform)?;
|
||||
validate_manifest(&manifest)?;
|
||||
create_layout(layout_path)?;
|
||||
let blobs = layout_path.join("blobs/sha256");
|
||||
write_verified_blob(&blobs, &manifest.config.digest, config_json.as_bytes())?;
|
||||
let layout = LayoutWriter::create(destination)?;
|
||||
layout.write_verified_blob(&manifest.config.digest, config_json.as_bytes())?;
|
||||
for descriptor in &manifest.layers {
|
||||
let size = u64::try_from(descriptor.size).context("negative OCI layer size")?;
|
||||
if size > MAX_LAYER_BYTES {
|
||||
bail!("OCI layer exceeds {MAX_LAYER_BYTES} bytes");
|
||||
}
|
||||
let digest_component = digest_component(&descriptor.digest)?;
|
||||
let path = blobs.join(digest_component);
|
||||
if path.exists() {
|
||||
require_existing_blob(&path, &descriptor.digest, size)?;
|
||||
if layout.blob_exists(digest_component)? {
|
||||
layout.require_existing_blob(&descriptor.digest, size)?;
|
||||
continue;
|
||||
}
|
||||
let file = tokio::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.await?;
|
||||
let (file, temporary) = layout.create_blob(digest_component)?;
|
||||
let file = tokio::fs::File::from_std(file);
|
||||
if let Err(error) = client.pull_blob(&reference, descriptor, file).await {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
layout.remove_temporary_blob(&temporary);
|
||||
return Err(error)
|
||||
.with_context(|| format!("pulling OCI layer {}", descriptor.digest));
|
||||
}
|
||||
let observed = std::fs::metadata(&path)?.len();
|
||||
let observed = layout.temporary_blob_size(&temporary)?;
|
||||
if observed != size {
|
||||
bail!("OCI layer {} size mismatch", descriptor.digest);
|
||||
}
|
||||
layout.publish_blob(&temporary, digest_component)?;
|
||||
}
|
||||
let local_manifest = serde_json::to_vec(&manifest)?;
|
||||
let local_digest = format!("sha256:{}", hex(&Sha256::digest(&local_manifest)));
|
||||
write_verified_blob(&blobs, &local_digest, &local_manifest)?;
|
||||
write_layout_index(layout_path, &local_digest, local_manifest.len())?;
|
||||
layout.write_verified_blob(&local_digest, &local_manifest)?;
|
||||
layout.write_layout_index(&local_digest, local_manifest.len())?;
|
||||
let selected_manifest_digest = parse_sha256(&local_digest)?;
|
||||
let source_identity = format!(
|
||||
"oci-v1\0{}\0{}\0{manifest_digest}",
|
||||
|
|
@ -172,9 +260,10 @@ impl OciRegistryTransport {
|
|||
)
|
||||
.into_bytes();
|
||||
Ok(TransportedImage {
|
||||
layout_path: layout_path.to_owned(),
|
||||
layout_path: layout.label().to_owned(),
|
||||
source_identity,
|
||||
selected_manifest_digest,
|
||||
retained_layout: layout.into_retained(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -237,6 +326,297 @@ fn validate_manifest(manifest: &OciImageManifest) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum LayoutDestination<'a> {
|
||||
Ambient(&'a Path),
|
||||
Confined {
|
||||
storage_root: &'a ConfinedPathPlan,
|
||||
staging_id: [u8; 32],
|
||||
},
|
||||
}
|
||||
|
||||
enum LayoutWriter {
|
||||
Ambient(PathBuf),
|
||||
Confined {
|
||||
layout: RetainedLayout,
|
||||
label: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
impl LayoutWriter {
|
||||
fn create(destination: LayoutDestination<'_>) -> Result<Self> {
|
||||
match destination {
|
||||
LayoutDestination::Ambient(path) => {
|
||||
create_layout(path)?;
|
||||
Ok(Self::Ambient(path.to_owned()))
|
||||
}
|
||||
LayoutDestination::Confined {
|
||||
storage_root,
|
||||
staging_id,
|
||||
} => {
|
||||
let storage = File::from(storage_root.open_authority()?);
|
||||
create_directory(&storage, "image-transports")?;
|
||||
let parent = Arc::new(open_directory(&storage, Path::new("image-transports"))?);
|
||||
let name = hex(&staging_id);
|
||||
mkdirat(&parent, name.as_str(), Mode::from_raw_mode(0o700))?;
|
||||
let root = Arc::new(open_directory(&parent, Path::new(&name))?);
|
||||
let stat = fstat(&root)?;
|
||||
create_directory(&root, "blobs")?;
|
||||
let blobs = open_directory(&root, Path::new("blobs"))?;
|
||||
create_directory(&blobs, "sha256")?;
|
||||
write_relative_atomic(
|
||||
&root,
|
||||
Path::new("oci-layout"),
|
||||
b"{\"imageLayoutVersion\":\"1.0.0\"}\n",
|
||||
)?;
|
||||
let label = PathBuf::from(std::ffi::OsString::from_vec(
|
||||
storage_root.canonical_label().as_bytes().to_vec(),
|
||||
))
|
||||
.join("image-transports")
|
||||
.join(&name);
|
||||
Ok(Self::Confined {
|
||||
layout: RetainedLayout {
|
||||
root,
|
||||
parent,
|
||||
name,
|
||||
identity: (stat.st_dev, stat.st_ino),
|
||||
cleanup_armed: true,
|
||||
},
|
||||
label,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn label(&self) -> &Path {
|
||||
match self {
|
||||
Self::Ambient(path) => path,
|
||||
Self::Confined { label, .. } => label,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_retained(self) -> Option<RetainedLayout> {
|
||||
match self {
|
||||
Self::Ambient(_) => None,
|
||||
Self::Confined { layout, .. } => Some(layout),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_verified_blob(&self, digest: &str, bytes: &[u8]) -> Result<()> {
|
||||
if parse_sha256(digest)? != Sha256::digest(bytes).as_slice() {
|
||||
bail!("OCI descriptor digest mismatch for {digest}");
|
||||
}
|
||||
let component = digest_component(digest)?;
|
||||
if self.blob_exists(component)? {
|
||||
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()?;
|
||||
self.publish_blob(&temporary, component)
|
||||
}
|
||||
|
||||
fn blob_exists(&self, component: &str) -> Result<bool> {
|
||||
match self {
|
||||
Self::Ambient(path) => Ok(path.join("blobs/sha256").join(component).exists()),
|
||||
Self::Confined { layout, .. } => match open_relative_file(
|
||||
layout.root(),
|
||||
&Path::new("blobs/sha256").join(component),
|
||||
OFlags::RDONLY,
|
||||
) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(error) => Err(error.into()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn require_existing_blob(&self, digest: &str, size: u64) -> Result<()> {
|
||||
let mut file = self.open_blob(digest_component(digest)?)?;
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)?;
|
||||
if bytes.len() as u64 != size || parse_sha256(digest)? != Sha256::digest(&bytes).as_slice()
|
||||
{
|
||||
bail!("existing OCI blob does not match its descriptor");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_blob(&self, component: &str) -> Result<File> {
|
||||
match self {
|
||||
Self::Ambient(path) => Ok(File::open(path.join("blobs/sha256").join(component))?),
|
||||
Self::Confined { layout, .. } => Ok(open_relative_file(
|
||||
layout.root(),
|
||||
&Path::new("blobs/sha256").join(component),
|
||||
OFlags::RDONLY,
|
||||
)?),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_blob(&self, component: &str) -> Result<(File, String)> {
|
||||
let temporary = format!(".{component}.partial");
|
||||
match self {
|
||||
Self::Ambient(path) => Ok((
|
||||
OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(path.join("blobs/sha256").join(&temporary))?,
|
||||
temporary,
|
||||
)),
|
||||
Self::Confined { layout, .. } => Ok((
|
||||
open_relative_file(
|
||||
layout.root(),
|
||||
&Path::new("blobs/sha256").join(&temporary),
|
||||
OFlags::CREATE | OFlags::EXCL | OFlags::WRONLY,
|
||||
)?,
|
||||
temporary,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn temporary_blob_size(&self, temporary: &str) -> Result<u64> {
|
||||
Ok(fstat(&self.open_blob(temporary)?)?.st_size.cast_unsigned())
|
||||
}
|
||||
|
||||
fn publish_blob(&self, temporary: &str, component: &str) -> Result<()> {
|
||||
match self {
|
||||
Self::Ambient(path) => {
|
||||
std::fs::rename(
|
||||
path.join("blobs/sha256").join(temporary),
|
||||
path.join("blobs/sha256").join(component),
|
||||
)?;
|
||||
}
|
||||
Self::Confined { layout, .. } => {
|
||||
let blobs = open_directory(layout.root(), Path::new("blobs/sha256"))?;
|
||||
renameat_with(&blobs, temporary, &blobs, component, RenameFlags::NOREPLACE)?;
|
||||
blobs.sync_all()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_temporary_blob(&self, temporary: &str) {
|
||||
match self {
|
||||
Self::Ambient(path) => {
|
||||
let _ = std::fs::remove_file(path.join("blobs/sha256").join(temporary));
|
||||
}
|
||||
Self::Confined { layout, .. } => {
|
||||
let _ = unlinkat(
|
||||
layout.root(),
|
||||
Path::new("blobs/sha256").join(temporary),
|
||||
AtFlags::empty(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_layout_index(&self, digest: &str, size: usize) -> Result<()> {
|
||||
let bytes = layout_index(digest, size)?;
|
||||
match self {
|
||||
Self::Ambient(path) => std::fs::write(path.join("index.json"), bytes)?,
|
||||
Self::Confined { layout, .. } => {
|
||||
write_relative_atomic(layout.root(), Path::new("index.json"), &bytes)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn create_directory(parent: &File, path: &str) -> Result<()> {
|
||||
match mkdirat(parent, path, Mode::from_raw_mode(0o700)) {
|
||||
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
drop(open_directory(parent, Path::new(path))?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_directory(parent: &File, path: &Path) -> std::io::Result<File> {
|
||||
openat2(
|
||||
parent,
|
||||
path,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
confined_resolve(),
|
||||
)
|
||||
.map(File::from)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
fn open_relative_file(parent: &File, path: &Path, flags: OFlags) -> std::io::Result<File> {
|
||||
openat2(
|
||||
parent,
|
||||
path,
|
||||
flags | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::from_raw_mode(0o600),
|
||||
confined_resolve(),
|
||||
)
|
||||
.map(File::from)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
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 temporary = format!(".{}.partial", name.to_string_lossy());
|
||||
let mut file = open_relative_file(
|
||||
&parent,
|
||||
Path::new(&temporary),
|
||||
OFlags::CREATE | OFlags::EXCL | OFlags::WRONLY,
|
||||
)?;
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()?;
|
||||
renameat_with(&parent, temporary, &parent, name, RenameFlags::NOREPLACE)?;
|
||||
parent.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn confined_resolve() -> ResolveFlags {
|
||||
ResolveFlags::BENEATH
|
||||
| ResolveFlags::NO_MAGICLINKS
|
||||
| ResolveFlags::NO_SYMLINKS
|
||||
| ResolveFlags::NO_XDEV
|
||||
}
|
||||
|
||||
fn cleanup_retained_layout(layout: &mut RetainedLayout) -> Result<()> {
|
||||
if !layout.cleanup_armed {
|
||||
return Ok(());
|
||||
}
|
||||
let current = open_directory(&layout.parent, Path::new(&layout.name))?;
|
||||
let stat = fstat(¤t)?;
|
||||
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)?;
|
||||
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;
|
||||
}
|
||||
if entry.file_type() == rustix::fs::FileType::Directory {
|
||||
let child = open_directory(
|
||||
directory,
|
||||
Path::new(std::ffi::OsStr::from_bytes(name.to_bytes())),
|
||||
)?;
|
||||
remove_layout_children(&child)?;
|
||||
unlinkat(directory, name, AtFlags::REMOVEDIR)?;
|
||||
} else {
|
||||
unlinkat(directory, name, AtFlags::empty())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_layout(path: &Path) -> Result<()> {
|
||||
std::fs::create_dir(path).context("creating private OCI layout")?;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
|
||||
|
|
@ -253,29 +633,6 @@ fn create_layout(path: &Path) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn write_verified_blob(blobs: &Path, digest: &str, bytes: &[u8]) -> Result<()> {
|
||||
if parse_sha256(digest)? != Sha256::digest(bytes).as_slice() {
|
||||
bail!("OCI descriptor digest mismatch for {digest}");
|
||||
}
|
||||
let path = blobs.join(digest_component(digest)?);
|
||||
match OpenOptions::new().create_new(true).write(true).open(&path) {
|
||||
Ok(mut file) => file.write_all(bytes)?,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
require_existing_blob(&path, digest, bytes.len() as u64)?;
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_existing_blob(path: &Path, digest: &str, size: u64) -> Result<()> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
if bytes.len() as u64 != size || parse_sha256(digest)? != Sha256::digest(&bytes).as_slice() {
|
||||
bail!("existing OCI blob does not match its descriptor");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LayoutIndex<'a> {
|
||||
|
|
@ -291,7 +648,7 @@ struct LayoutDescriptor<'a> {
|
|||
size: usize,
|
||||
}
|
||||
|
||||
fn write_layout_index(path: &Path, digest: &str, size: usize) -> Result<()> {
|
||||
fn layout_index(digest: &str, size: usize) -> Result<Vec<u8>> {
|
||||
let index = LayoutIndex {
|
||||
schema_version: 2,
|
||||
manifests: [LayoutDescriptor {
|
||||
|
|
@ -300,8 +657,7 @@ fn write_layout_index(path: &Path, digest: &str, size: usize) -> Result<()> {
|
|||
size,
|
||||
}],
|
||||
};
|
||||
std::fs::write(path.join("index.json"), serde_json::to_vec(&index)?)?;
|
||||
Ok(())
|
||||
Ok(serde_json::to_vec(&index)?)
|
||||
}
|
||||
|
||||
fn digest_component(digest: &str) -> Result<&str> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue