Confine image preparation beneath retained storage authority
This commit is contained in:
parent
fbd332c31f
commit
c0b139256e
5 changed files with 694 additions and 103 deletions
|
|
@ -100,6 +100,26 @@ pub(crate) struct VerifiedLayout {
|
||||||
diff_ids: Vec<String>,
|
diff_ids: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) enum LayoutSource<'a> {
|
||||||
|
Ambient(&'a Path),
|
||||||
|
Retained(&'a File),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LayoutSource<'_> {
|
||||||
|
fn open(&self, relative: &Path) -> Result<File> {
|
||||||
|
match self {
|
||||||
|
Self::Ambient(root) => Ok(File::open(root.join(relative))?),
|
||||||
|
Self::Retained(root) => Ok(File::from(openat2(
|
||||||
|
root,
|
||||||
|
relative,
|
||||||
|
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||||
|
Mode::empty(),
|
||||||
|
secure_resolve(),
|
||||||
|
)?)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Usage {
|
struct Usage {
|
||||||
entries: u64,
|
entries: u64,
|
||||||
|
|
@ -107,8 +127,11 @@ struct Usage {
|
||||||
total_bytes: u64,
|
total_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn verify_layout(layout: &Path, limits: PreparationLimits) -> Result<VerifiedLayout> {
|
pub(crate) fn verify_layout(
|
||||||
let index: LayoutIndex = read_json(&layout.join("index.json"), limits.max_json_bytes)?;
|
layout: &LayoutSource<'_>,
|
||||||
|
limits: PreparationLimits,
|
||||||
|
) -> Result<VerifiedLayout> {
|
||||||
|
let index: LayoutIndex = read_json(layout, Path::new("index.json"), limits.max_json_bytes)?;
|
||||||
if index.manifests.len() != 1 {
|
if index.manifests.len() != 1 {
|
||||||
bail!("selected OCI layout must contain exactly one manifest");
|
bail!("selected OCI layout must contain exactly one manifest");
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +161,7 @@ pub(crate) fn verify_layout(layout: &Path, limits: PreparationLimits) -> Result<
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn apply_layout(
|
pub(crate) fn apply_layout(
|
||||||
layout: &Path,
|
layout: &LayoutSource<'_>,
|
||||||
rootfs: &File,
|
rootfs: &File,
|
||||||
verified: &VerifiedLayout,
|
verified: &VerifiedLayout,
|
||||||
id_map: &IdMap,
|
id_map: &IdMap,
|
||||||
|
|
@ -157,7 +180,7 @@ pub(crate) fn apply_layout(
|
||||||
secure_resolve(),
|
secure_resolve(),
|
||||||
)?);
|
)?);
|
||||||
let observed = decompress_layer(
|
let observed = decompress_layer(
|
||||||
&blob_path(layout, &descriptor.digest)?,
|
layout.open(&blob_path(&descriptor.digest)?)?,
|
||||||
&descriptor.media_type,
|
&descriptor.media_type,
|
||||||
spool.try_clone()?,
|
spool.try_clone()?,
|
||||||
limits.max_uncompressed_layer_bytes,
|
limits.max_uncompressed_layer_bytes,
|
||||||
|
|
@ -171,48 +194,64 @@ pub(crate) fn apply_layout(
|
||||||
state.finish(rootfs)
|
state.finish(rootfs)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_json<T: serde::de::DeserializeOwned>(path: &Path, limit: u64) -> Result<T> {
|
fn read_json<T: serde::de::DeserializeOwned>(
|
||||||
let metadata = std::fs::metadata(path)?;
|
layout: &LayoutSource<'_>,
|
||||||
|
path: &Path,
|
||||||
|
limit: u64,
|
||||||
|
) -> Result<T> {
|
||||||
|
let mut file = layout.open(path)?;
|
||||||
|
let metadata = file.metadata()?;
|
||||||
if metadata.len() > limit {
|
if metadata.len() > limit {
|
||||||
bail!("OCI JSON exceeds {limit} bytes");
|
bail!("OCI JSON exceeds {limit} bytes");
|
||||||
}
|
}
|
||||||
Ok(serde_json::from_slice(&std::fs::read(path)?)?)
|
let mut bytes = Vec::new();
|
||||||
|
file.read_to_end(&mut bytes)?;
|
||||||
|
Ok(serde_json::from_slice(&bytes)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn verified_blob(layout: &Path, descriptor: &Descriptor, limit: u64) -> Result<Vec<u8>> {
|
fn verified_blob(
|
||||||
let path = blob_path(layout, &descriptor.digest)?;
|
layout: &LayoutSource<'_>,
|
||||||
let metadata = std::fs::metadata(&path)?;
|
descriptor: &Descriptor,
|
||||||
|
limit: u64,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
let mut file = layout.open(&blob_path(&descriptor.digest)?)?;
|
||||||
|
let metadata = file.metadata()?;
|
||||||
if metadata.len() != descriptor.size || metadata.len() > limit {
|
if metadata.len() != descriptor.size || metadata.len() > limit {
|
||||||
bail!("OCI descriptor size or resource limit mismatch");
|
bail!("OCI descriptor size or resource limit mismatch");
|
||||||
}
|
}
|
||||||
let bytes = std::fs::read(path)?;
|
let mut bytes = Vec::new();
|
||||||
|
file.read_to_end(&mut bytes)?;
|
||||||
if Sha256::digest(&bytes).as_slice() != parse_sha256(&descriptor.digest)? {
|
if Sha256::digest(&bytes).as_slice() != parse_sha256(&descriptor.digest)? {
|
||||||
bail!("OCI descriptor digest mismatch");
|
bail!("OCI descriptor digest mismatch");
|
||||||
}
|
}
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn verify_blob_digest(layout: &Path, descriptor: &Descriptor, limit: u64) -> Result<()> {
|
fn verify_blob_digest(
|
||||||
let path = blob_path(layout, &descriptor.digest)?;
|
layout: &LayoutSource<'_>,
|
||||||
let metadata = std::fs::metadata(&path)?;
|
descriptor: &Descriptor,
|
||||||
|
limit: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let file = layout.open(&blob_path(&descriptor.digest)?)?;
|
||||||
|
let metadata = file.metadata()?;
|
||||||
if metadata.len() != descriptor.size || metadata.len() > limit {
|
if metadata.len() != descriptor.size || metadata.len() > limit {
|
||||||
bail!("OCI layer size or resource limit mismatch");
|
bail!("OCI layer size or resource limit mismatch");
|
||||||
}
|
}
|
||||||
let observed = digest_reader(File::open(path)?, limit, None)?;
|
let observed = digest_reader(file, limit, None)?;
|
||||||
if observed != descriptor.digest {
|
if observed != descriptor.digest {
|
||||||
bail!("OCI layer digest mismatch");
|
bail!("OCI layer digest mismatch");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn blob_path(layout: &Path, digest: &str) -> Result<PathBuf> {
|
fn blob_path(digest: &str) -> Result<PathBuf> {
|
||||||
let component = digest
|
let component = digest
|
||||||
.strip_prefix("sha256:")
|
.strip_prefix("sha256:")
|
||||||
.context("only sha256 OCI descriptors are supported")?;
|
.context("only sha256 OCI descriptors are supported")?;
|
||||||
if component.len() != 64 || !component.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
if component.len() != 64 || !component.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||||
bail!("invalid sha256 OCI descriptor");
|
bail!("invalid sha256 OCI descriptor");
|
||||||
}
|
}
|
||||||
Ok(layout.join("blobs/sha256").join(component))
|
Ok(Path::new("blobs/sha256").join(component))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_sha256(digest: &str) -> Result<[u8; 32]> {
|
fn parse_sha256(digest: &str) -> Result<[u8; 32]> {
|
||||||
|
|
@ -229,8 +268,7 @@ fn parse_sha256(digest: &str) -> Result<[u8; 32]> {
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decompress_layer(source: &Path, media_type: &str, output: File, limit: u64) -> Result<String> {
|
fn decompress_layer(input: File, media_type: &str, output: File, limit: u64) -> Result<String> {
|
||||||
let input = File::open(source)?;
|
|
||||||
let reader: Box<dyn Read> = match media_type {
|
let reader: Box<dyn Read> = match media_type {
|
||||||
"application/vnd.oci.image.layer.v1.tar" => Box::new(input),
|
"application/vnd.oci.image.layer.v1.tar" => Box::new(input),
|
||||||
"application/vnd.oci.image.layer.v1.tar+gzip" => {
|
"application/vnd.oci.image.layer.v1.tar+gzip" => {
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@ use std::io;
|
||||||
use std::os::fd::AsFd;
|
use std::os::fd::AsFd;
|
||||||
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
|
use lazaret_core::configuration::ConfinedPathPlan;
|
||||||
use lazaret_core::identity::{
|
use lazaret_core::identity::{
|
||||||
DeviceId, HostPath, IdMapDigest, InodeId, PolicyId, PrepareOperationId, PreparedArtifactId,
|
DeviceId, HostPath, IdMapDigest, InodeId, PolicyId, PrepareOperationId, PreparedArtifactId,
|
||||||
SelectedManifestDigest, SourceIdentity,
|
SelectedManifestDigest, SourceIdentity,
|
||||||
|
|
@ -21,8 +23,10 @@ use rustix::fs::{
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::layer::{
|
use crate::layer::{
|
||||||
IdMap, POLICY_NAME, PreparationLimits, apply_layout, remove_children, verify_layout,
|
IdMap, LayoutSource, POLICY_NAME, PreparationLimits, apply_layout, remove_children,
|
||||||
|
verify_layout,
|
||||||
};
|
};
|
||||||
|
use crate::transport::TransportedImage;
|
||||||
|
|
||||||
const PHYSICAL_LOCK_WAIT: Duration = Duration::from_secs(5);
|
const PHYSICAL_LOCK_WAIT: Duration = Duration::from_secs(5);
|
||||||
const LOCK_RETRY: Duration = Duration::from_millis(2);
|
const LOCK_RETRY: Duration = Duration::from_millis(2);
|
||||||
|
|
@ -51,7 +55,7 @@ pub enum ReclaimOutcome {
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct PreparedLowerStore {
|
pub struct PreparedLowerStore {
|
||||||
root: PathBuf,
|
root: Arc<File>,
|
||||||
catalog: Catalog,
|
catalog: Catalog,
|
||||||
limits: PreparationLimits,
|
limits: PreparationLimits,
|
||||||
}
|
}
|
||||||
|
|
@ -63,20 +67,43 @@ impl PreparedLowerStore {
|
||||||
///
|
///
|
||||||
/// Returns an error if the root cannot be opened or any fixed path is not a real directory.
|
/// Returns an error if the root cannot be opened or any fixed path is not a real directory.
|
||||||
pub fn initialize(root: impl Into<PathBuf>, catalog: Catalog) -> Result<Self> {
|
pub fn initialize(root: impl Into<PathBuf>, catalog: Catalog) -> Result<Self> {
|
||||||
|
let root = root.into();
|
||||||
|
let root = File::open(&root).context("opening Storage Root")?;
|
||||||
|
Self::initialize_retained(root, catalog)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retains the exact validated Storage Root and creates only fixed children beneath it.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if Storage Root authority drifts or a fixed child is not a real directory.
|
||||||
|
pub fn under_storage_root(storage_root: &ConfinedPathPlan, catalog: Catalog) -> Result<Self> {
|
||||||
|
let storage_root = File::from(
|
||||||
|
storage_root
|
||||||
|
.open_authority()
|
||||||
|
.context("opening validated Storage Root")?,
|
||||||
|
);
|
||||||
|
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"))?;
|
||||||
|
Self::initialize_retained(root, catalog)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initialize_retained(root: File, catalog: Catalog) -> Result<Self> {
|
||||||
let store = Self {
|
let store = Self {
|
||||||
root: root.into(),
|
root: Arc::new(root),
|
||||||
catalog,
|
catalog,
|
||||||
limits: PreparationLimits::default(),
|
limits: PreparationLimits::default(),
|
||||||
};
|
};
|
||||||
let root = File::open(&store.root).context("opening Storage Root")?;
|
|
||||||
for name in ["artifacts", "stages", "quarantine"] {
|
for name in ["artifacts", "stages", "quarantine"] {
|
||||||
match mkdirat(&root, name, Mode::from_raw_mode(0o700)) {
|
match mkdirat(&store.root, name, Mode::from_raw_mode(0o700)) {
|
||||||
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
|
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
|
||||||
Err(error) => return Err(error.into()),
|
Err(error) => return Err(error.into()),
|
||||||
}
|
}
|
||||||
open_directory_at(&root, Path::new(name))?;
|
open_directory_at(&store.root, Path::new(name))?;
|
||||||
}
|
}
|
||||||
open_lock_at(&root)?;
|
open_lock_at(&store.root)?;
|
||||||
Ok(store)
|
Ok(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,10 +120,41 @@ impl PreparedLowerStore {
|
||||||
/// Refuses corrupt or unsupported OCI input, forged physical ownership, catalog conflicts,
|
/// Refuses corrupt or unsupported OCI input, forged physical ownership, catalog conflicts,
|
||||||
/// resource limits, and publication failures.
|
/// resource limits, and publication failures.
|
||||||
pub fn prepare(&self, request: &PrepareRequest) -> Result<PreparedArtifact> {
|
pub fn prepare(&self, request: &PrepareRequest) -> Result<PreparedArtifact> {
|
||||||
|
self.prepare_from_source(request, &LayoutSource::Ambient(&request.layout_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies and prepares an image through its retained transport-layout authority.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses ambient-only transported images and all normal preparation validation failures.
|
||||||
|
pub fn prepare_transported(
|
||||||
|
&self,
|
||||||
|
operation_id: PrepareOperationId,
|
||||||
|
image: &TransportedImage,
|
||||||
|
id_map: IdMap,
|
||||||
|
) -> Result<PreparedArtifact> {
|
||||||
|
let layout = image
|
||||||
|
.retained_layout()
|
||||||
|
.context("transported image has no retained layout authority")?;
|
||||||
|
let request = PrepareRequest {
|
||||||
|
operation_id,
|
||||||
|
layout_path: image.layout_path.clone(),
|
||||||
|
source_identity: image.source_identity.clone(),
|
||||||
|
selected_manifest_digest: image.selected_manifest_digest,
|
||||||
|
id_map,
|
||||||
|
};
|
||||||
|
self.prepare_from_source(&request, &LayoutSource::Retained(layout.root()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_from_source(
|
||||||
|
&self,
|
||||||
|
request: &PrepareRequest,
|
||||||
|
layout: &LayoutSource<'_>,
|
||||||
|
) -> Result<PreparedArtifact> {
|
||||||
if request.source_identity.is_empty() || request.source_identity.len() > 4096 {
|
if request.source_identity.is_empty() || request.source_identity.len() > 4096 {
|
||||||
bail!("source identity is empty or exceeds 4096 bytes");
|
bail!("source identity is empty or exceeds 4096 bytes");
|
||||||
}
|
}
|
||||||
let verified = verify_layout(&request.layout_path, self.limits)?;
|
let verified = verify_layout(layout, self.limits)?;
|
||||||
if request.selected_manifest_digest != verified.manifest_digest {
|
if request.selected_manifest_digest != verified.manifest_digest {
|
||||||
bail!("selected manifest identity does not match verified OCI layout");
|
bail!("selected manifest identity does not match verified OCI layout");
|
||||||
}
|
}
|
||||||
|
|
@ -133,14 +191,8 @@ impl PreparedLowerStore {
|
||||||
}
|
}
|
||||||
return Err(error.into());
|
return Err(error.into());
|
||||||
}
|
}
|
||||||
if let Err(error) = apply_layout(
|
if let Err(error) = apply_layout(layout, &stage, &verified, &request.id_map, self.limits)
|
||||||
&request.layout_path,
|
.and_then(|()| sync_tree(&stage))
|
||||||
&stage,
|
|
||||||
&verified,
|
|
||||||
&request.id_map,
|
|
||||||
self.limits,
|
|
||||||
)
|
|
||||||
.and_then(|()| sync_tree(&stage))
|
|
||||||
{
|
{
|
||||||
if self
|
if self
|
||||||
.remove_exact_path(&stage_relative, device, inode)
|
.remove_exact_path(&stage_relative, device, inode)
|
||||||
|
|
@ -358,16 +410,15 @@ impl PreparedLowerStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_stage(&self, relative: &Path) -> Result<File> {
|
fn create_stage(&self, relative: &Path) -> Result<File> {
|
||||||
let stages = open_directory_at(&File::open(&self.root)?, Path::new("stages"))?;
|
let stages = open_directory_at(&self.root, Path::new("stages"))?;
|
||||||
let name = relative.file_name().context("stage has no name")?;
|
let name = relative.file_name().context("stage has no name")?;
|
||||||
mkdirat(&stages, name, Mode::from_raw_mode(0o700))?;
|
mkdirat(&stages, name, Mode::from_raw_mode(0o700))?;
|
||||||
open_directory_at(&stages, Path::new(name))
|
open_directory_at(&stages, Path::new(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_relative(&self, relative: &Path) -> Result<Option<File>> {
|
fn open_relative(&self, relative: &Path) -> Result<Option<File>> {
|
||||||
let root = File::open(&self.root)?;
|
|
||||||
match openat2(
|
match openat2(
|
||||||
&root,
|
&self.root,
|
||||||
relative,
|
relative,
|
||||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
Mode::empty(),
|
Mode::empty(),
|
||||||
|
|
@ -380,30 +431,27 @@ impl PreparedLowerStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rename_relative(&self, from: &Path, to: &Path) -> Result<()> {
|
fn rename_relative(&self, from: &Path, to: &Path) -> Result<()> {
|
||||||
let root = File::open(&self.root)?;
|
renameat(&self.root, from, &self.root, to)?;
|
||||||
renameat(&root, from, &root, to)?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rename_relative_noreplace(&self, from: &Path, to: &Path) -> Result<()> {
|
fn rename_relative_noreplace(&self, from: &Path, to: &Path) -> Result<()> {
|
||||||
let root = File::open(&self.root)?;
|
renameat_with(&self.root, from, &self.root, to, RenameFlags::NOREPLACE)?;
|
||||||
renameat_with(&root, from, &root, to, RenameFlags::NOREPLACE)?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_exact_path(&self, relative: &Path, device: DeviceId, inode: InodeId) -> Result<()> {
|
fn remove_exact_path(&self, relative: &Path, device: DeviceId, inode: InodeId) -> Result<()> {
|
||||||
let root = File::open(&self.root)?;
|
let file = open_directory_at(&self.root, relative)?;
|
||||||
let file = open_directory_at(&root, relative)?;
|
|
||||||
require_identity(&file, device, inode)?;
|
require_identity(&file, device, inode)?;
|
||||||
remove_children(&file)?;
|
remove_children(&file)?;
|
||||||
unlinkat(&root, relative, AtFlags::REMOVEDIR)?;
|
unlinkat(&self.root, relative, AtFlags::REMOVEDIR)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn physical_lock(&self) -> Result<ExclusiveFileLock> {
|
fn physical_lock(&self) -> Result<ExclusiveFileLock> {
|
||||||
let deadline = Instant::now() + PHYSICAL_LOCK_WAIT;
|
let deadline = Instant::now() + PHYSICAL_LOCK_WAIT;
|
||||||
loop {
|
loop {
|
||||||
let file = open_lock_at(&File::open(&self.root)?)?;
|
let file = open_lock_at(&self.root)?;
|
||||||
match try_exclusive_flock(file) {
|
match try_exclusive_flock(file) {
|
||||||
Ok(lock) => return Ok(lock),
|
Ok(lock) => return Ok(lock),
|
||||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||||
|
|
@ -419,7 +467,7 @@ impl PreparedLowerStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_fixed_directory(&self, name: &str) -> Result<()> {
|
fn sync_fixed_directory(&self, name: &str) -> Result<()> {
|
||||||
open_directory_at(&File::open(&self.root)?, Path::new(name))?.sync_all()?;
|
open_directory_at(&self.root, Path::new(name))?.sync_all()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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::path::{Path, PathBuf};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::{fs::OpenOptions, io::Write, os::unix::fs::PermissionsExt};
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
|
use lazaret_core::configuration::ConfinedPathPlan;
|
||||||
use oci_client::Reference;
|
use oci_client::Reference;
|
||||||
use oci_client::client::{Client, ClientConfig};
|
use oci_client::client::{Client, ClientConfig};
|
||||||
use oci_client::manifest::{IMAGE_LAYER_GZIP_MEDIA_TYPE, IMAGE_LAYER_MEDIA_TYPE, OciImageManifest};
|
use oci_client::manifest::{IMAGE_LAYER_GZIP_MEDIA_TYPE, IMAGE_LAYER_MEDIA_TYPE, OciImageManifest};
|
||||||
use oci_client::secrets::RegistryAuth;
|
use oci_client::secrets::RegistryAuth;
|
||||||
|
use rustix::fs::{
|
||||||
|
AtFlags, Mode, OFlags, RenameFlags, ResolveFlags, fstat, mkdirat, openat2, renameat_with,
|
||||||
|
unlinkat,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
|
@ -56,11 +65,50 @@ impl std::fmt::Debug for RegistryCredentials {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Debug)]
|
||||||
pub struct TransportedImage {
|
pub struct TransportedImage {
|
||||||
pub layout_path: PathBuf,
|
pub layout_path: PathBuf,
|
||||||
pub source_identity: Vec<u8>,
|
pub source_identity: Vec<u8>,
|
||||||
pub selected_manifest_digest: [u8; 32],
|
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)]
|
#[derive(Clone, Debug, Default)]
|
||||||
|
|
@ -90,12 +138,56 @@ impl OciRegistryTransport {
|
||||||
.await
|
.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(
|
async fn pull_with_config(
|
||||||
&self,
|
&self,
|
||||||
reference: &str,
|
reference: &str,
|
||||||
credentials: &RegistryCredentials,
|
credentials: &RegistryCredentials,
|
||||||
platform: &Platform,
|
platform: &Platform,
|
||||||
layout_path: &Path,
|
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,
|
mut config: ClientConfig,
|
||||||
) -> Result<TransportedImage> {
|
) -> Result<TransportedImage> {
|
||||||
validate_credentials(credentials)?;
|
validate_credentials(credentials)?;
|
||||||
|
|
@ -131,39 +223,35 @@ impl OciRegistryTransport {
|
||||||
}
|
}
|
||||||
verify_platform(&config_json, platform)?;
|
verify_platform(&config_json, platform)?;
|
||||||
validate_manifest(&manifest)?;
|
validate_manifest(&manifest)?;
|
||||||
create_layout(layout_path)?;
|
let layout = LayoutWriter::create(destination)?;
|
||||||
let blobs = layout_path.join("blobs/sha256");
|
layout.write_verified_blob(&manifest.config.digest, config_json.as_bytes())?;
|
||||||
write_verified_blob(&blobs, &manifest.config.digest, config_json.as_bytes())?;
|
|
||||||
for descriptor in &manifest.layers {
|
for descriptor in &manifest.layers {
|
||||||
let size = u64::try_from(descriptor.size).context("negative OCI layer size")?;
|
let size = u64::try_from(descriptor.size).context("negative OCI layer size")?;
|
||||||
if size > MAX_LAYER_BYTES {
|
if size > MAX_LAYER_BYTES {
|
||||||
bail!("OCI layer exceeds {MAX_LAYER_BYTES} bytes");
|
bail!("OCI layer exceeds {MAX_LAYER_BYTES} bytes");
|
||||||
}
|
}
|
||||||
let digest_component = digest_component(&descriptor.digest)?;
|
let digest_component = digest_component(&descriptor.digest)?;
|
||||||
let path = blobs.join(digest_component);
|
if layout.blob_exists(digest_component)? {
|
||||||
if path.exists() {
|
layout.require_existing_blob(&descriptor.digest, size)?;
|
||||||
require_existing_blob(&path, &descriptor.digest, size)?;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let file = tokio::fs::OpenOptions::new()
|
let (file, temporary) = layout.create_blob(digest_component)?;
|
||||||
.create_new(true)
|
let file = tokio::fs::File::from_std(file);
|
||||||
.write(true)
|
|
||||||
.open(&path)
|
|
||||||
.await?;
|
|
||||||
if let Err(error) = client.pull_blob(&reference, descriptor, file).await {
|
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)
|
return Err(error)
|
||||||
.with_context(|| format!("pulling OCI layer {}", descriptor.digest));
|
.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 {
|
if observed != size {
|
||||||
bail!("OCI layer {} size mismatch", descriptor.digest);
|
bail!("OCI layer {} size mismatch", descriptor.digest);
|
||||||
}
|
}
|
||||||
|
layout.publish_blob(&temporary, digest_component)?;
|
||||||
}
|
}
|
||||||
let local_manifest = serde_json::to_vec(&manifest)?;
|
let local_manifest = serde_json::to_vec(&manifest)?;
|
||||||
let local_digest = format!("sha256:{}", hex(&Sha256::digest(&local_manifest)));
|
let local_digest = format!("sha256:{}", hex(&Sha256::digest(&local_manifest)));
|
||||||
write_verified_blob(&blobs, &local_digest, &local_manifest)?;
|
layout.write_verified_blob(&local_digest, &local_manifest)?;
|
||||||
write_layout_index(layout_path, &local_digest, local_manifest.len())?;
|
layout.write_layout_index(&local_digest, local_manifest.len())?;
|
||||||
let selected_manifest_digest = parse_sha256(&local_digest)?;
|
let selected_manifest_digest = parse_sha256(&local_digest)?;
|
||||||
let source_identity = format!(
|
let source_identity = format!(
|
||||||
"oci-v1\0{}\0{}\0{manifest_digest}",
|
"oci-v1\0{}\0{}\0{manifest_digest}",
|
||||||
|
|
@ -172,9 +260,10 @@ impl OciRegistryTransport {
|
||||||
)
|
)
|
||||||
.into_bytes();
|
.into_bytes();
|
||||||
Ok(TransportedImage {
|
Ok(TransportedImage {
|
||||||
layout_path: layout_path.to_owned(),
|
layout_path: layout.label().to_owned(),
|
||||||
source_identity,
|
source_identity,
|
||||||
selected_manifest_digest,
|
selected_manifest_digest,
|
||||||
|
retained_layout: layout.into_retained(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -237,6 +326,297 @@ fn validate_manifest(manifest: &OciImageManifest) -> Result<()> {
|
||||||
Ok(())
|
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<()> {
|
fn create_layout(path: &Path) -> Result<()> {
|
||||||
std::fs::create_dir(path).context("creating private OCI layout")?;
|
std::fs::create_dir(path).context("creating private OCI layout")?;
|
||||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
|
||||||
|
|
@ -253,29 +633,6 @@ fn create_layout(path: &Path) -> Result<()> {
|
||||||
Ok(())
|
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)]
|
#[derive(Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct LayoutIndex<'a> {
|
struct LayoutIndex<'a> {
|
||||||
|
|
@ -291,7 +648,7 @@ struct LayoutDescriptor<'a> {
|
||||||
size: usize,
|
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 {
|
let index = LayoutIndex {
|
||||||
schema_version: 2,
|
schema_version: 2,
|
||||||
manifests: [LayoutDescriptor {
|
manifests: [LayoutDescriptor {
|
||||||
|
|
@ -300,8 +657,7 @@ fn write_layout_index(path: &Path, digest: &str, size: usize) -> Result<()> {
|
||||||
size,
|
size,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
std::fs::write(path.join("index.json"), serde_json::to_vec(&index)?)?;
|
Ok(serde_json::to_vec(&index)?)
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn digest_component(digest: &str) -> Result<&str> {
|
fn digest_component(digest: &str) -> Result<&str> {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ license.workspace = true
|
||||||
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
|
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
|
||||||
lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" }
|
lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" }
|
||||||
redb.workspace = true
|
redb.workspace = true
|
||||||
|
rustix.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,27 @@
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::fs::{File, OpenOptions};
|
use std::fs::{File, OpenOptions};
|
||||||
use std::io;
|
use std::io;
|
||||||
|
use std::os::fd::AsFd;
|
||||||
|
use std::os::unix::ffi::OsStringExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use lazaret_core::codec::{self, CodecError};
|
use lazaret_core::codec::{self, CodecError};
|
||||||
use lazaret_core::identity::{PrepareOperationId, PreparedArtifactId};
|
use lazaret_core::configuration::ConfinedPathPlan;
|
||||||
|
use lazaret_core::identity::{MountIdentity, PrepareOperationId, PreparedArtifactId};
|
||||||
use lazaret_core::records::{
|
use lazaret_core::records::{
|
||||||
ArtifactLease, ArtifactState, PrepareOperation, PrepareStageState, PreparedArtifact,
|
ArtifactLease, ArtifactState, PrepareOperation, PrepareStageState, PreparedArtifact,
|
||||||
StorageCatalog, StorageCatalogMetadata,
|
StorageCatalog, StorageCatalogMetadata,
|
||||||
};
|
};
|
||||||
use lazaret_linux::filesystem::{ExclusiveFileLock, try_exclusive_flock};
|
use lazaret_linux::filesystem::{
|
||||||
|
ExclusiveFileLock, mount_identity, require_mount_identity, try_exclusive_flock,
|
||||||
|
};
|
||||||
use redb::{
|
use redb::{
|
||||||
Database, ReadTransaction, ReadableDatabase, ReadableTable, TableDefinition, WriteTransaction,
|
Database, ReadTransaction, ReadableDatabase, ReadableTable, TableDefinition, WriteTransaction,
|
||||||
};
|
};
|
||||||
|
use rustix::fs::{Mode, OFlags, ResolveFlags, openat2};
|
||||||
|
|
||||||
const METADATA: TableDefinition<u8, &[u8]> = TableDefinition::new("catalog_metadata");
|
const METADATA: TableDefinition<u8, &[u8]> = TableDefinition::new("catalog_metadata");
|
||||||
const ARTIFACTS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("prepared_artifacts");
|
const ARTIFACTS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("prepared_artifacts");
|
||||||
|
|
@ -26,23 +33,77 @@ const METADATA_KEY: u8 = 0;
|
||||||
const DEFAULT_LOCK_WAIT: Duration = Duration::from_secs(5);
|
const DEFAULT_LOCK_WAIT: Duration = Duration::from_secs(5);
|
||||||
const LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(2);
|
const LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(2);
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone)]
|
||||||
pub struct Catalog {
|
pub struct Catalog {
|
||||||
database_path: PathBuf,
|
location: CatalogLocation,
|
||||||
lock_path: PathBuf,
|
|
||||||
lock_wait: Duration,
|
lock_wait: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum CatalogLocation {
|
||||||
|
Ambient {
|
||||||
|
database_path: PathBuf,
|
||||||
|
lock_path: PathBuf,
|
||||||
|
},
|
||||||
|
StorageRoot(Arc<StorageRootCatalog>),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StorageRootCatalog {
|
||||||
|
root: File,
|
||||||
|
root_label: PathBuf,
|
||||||
|
identity: MountIdentity,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for Catalog {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let location = match &self.location {
|
||||||
|
CatalogLocation::Ambient { .. } => "ambient",
|
||||||
|
CatalogLocation::StorageRoot(_) => "storage-root",
|
||||||
|
};
|
||||||
|
formatter
|
||||||
|
.debug_struct("Catalog")
|
||||||
|
.field("location", &location)
|
||||||
|
.field("lock_wait", &self.lock_wait)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Catalog {
|
impl Catalog {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(database_path: impl Into<PathBuf>, lock_path: impl Into<PathBuf>) -> Self {
|
pub fn new(database_path: impl Into<PathBuf>, lock_path: impl Into<PathBuf>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
database_path: database_path.into(),
|
location: CatalogLocation::Ambient {
|
||||||
lock_path: lock_path.into(),
|
database_path: database_path.into(),
|
||||||
|
lock_path: lock_path.into(),
|
||||||
|
},
|
||||||
lock_wait: DEFAULT_LOCK_WAIT,
|
lock_wait: DEFAULT_LOCK_WAIT,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Binds the fixed catalog children to the validated Storage Root authority.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error when the exact Storage Root cannot be retained and identified.
|
||||||
|
pub fn under_storage_root(storage_root: &ConfinedPathPlan) -> Result<Self, CatalogError> {
|
||||||
|
let root = File::from(
|
||||||
|
storage_root
|
||||||
|
.open_authority()
|
||||||
|
.map_err(|error| CatalogError::Io(io::Error::other(error.to_string())))?,
|
||||||
|
);
|
||||||
|
let identity = mount_identity(&root).map_err(CatalogError::Io)?;
|
||||||
|
let root_label = PathBuf::from(std::ffi::OsString::from_vec(
|
||||||
|
storage_root.canonical_label().as_bytes().to_vec(),
|
||||||
|
));
|
||||||
|
Ok(Self {
|
||||||
|
location: CatalogLocation::StorageRoot(Arc::new(StorageRootCatalog {
|
||||||
|
root,
|
||||||
|
root_label,
|
||||||
|
identity,
|
||||||
|
})),
|
||||||
|
lock_wait: DEFAULT_LOCK_WAIT,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_lock_wait(mut self, lock_wait: Duration) -> Self {
|
pub fn with_lock_wait(mut self, lock_wait: Duration) -> Self {
|
||||||
self.lock_wait = lock_wait;
|
self.lock_wait = lock_wait;
|
||||||
|
|
@ -56,10 +117,11 @@ impl Catalog {
|
||||||
/// Returns an error if locking, creation, or the initialization transaction fails.
|
/// Returns an error if locking, creation, or the initialization transaction fails.
|
||||||
pub fn initialize(&self) -> Result<(), CatalogError> {
|
pub fn initialize(&self) -> Result<(), CatalogError> {
|
||||||
self.with_lock(|| {
|
self.with_lock(|| {
|
||||||
if self.database_path.exists() {
|
if self.database_exists()? {
|
||||||
return Err(CatalogError::AlreadyInitialized);
|
return Err(CatalogError::AlreadyInitialized);
|
||||||
}
|
}
|
||||||
let database = Database::create(&self.database_path).map_err(CatalogError::redb)?;
|
let database_path = self.database_path_for_handoff()?;
|
||||||
|
let database = Database::create(database_path).map_err(CatalogError::redb)?;
|
||||||
let transaction = database.begin_write().map_err(CatalogError::redb)?;
|
let transaction = database.begin_write().map_err(CatalogError::redb)?;
|
||||||
{
|
{
|
||||||
let mut metadata = transaction
|
let mut metadata = transaction
|
||||||
|
|
@ -538,7 +600,8 @@ impl Catalog {
|
||||||
operation: impl FnOnce(&ReadTransaction) -> Result<T, CatalogError>,
|
operation: impl FnOnce(&ReadTransaction) -> Result<T, CatalogError>,
|
||||||
) -> Result<T, CatalogError> {
|
) -> Result<T, CatalogError> {
|
||||||
self.with_lock(|| {
|
self.with_lock(|| {
|
||||||
let database = Database::open(&self.database_path).map_err(CatalogError::redb)?;
|
let database_path = self.database_path_for_handoff()?;
|
||||||
|
let database = Database::open(database_path).map_err(CatalogError::redb)?;
|
||||||
let transaction = database.begin_read().map_err(CatalogError::redb)?;
|
let transaction = database.begin_read().map_err(CatalogError::redb)?;
|
||||||
validate_read_metadata(&transaction)?;
|
validate_read_metadata(&transaction)?;
|
||||||
operation(&transaction)
|
operation(&transaction)
|
||||||
|
|
@ -550,7 +613,8 @@ impl Catalog {
|
||||||
operation: impl FnOnce(&WriteTransaction) -> Result<T, CatalogError>,
|
operation: impl FnOnce(&WriteTransaction) -> Result<T, CatalogError>,
|
||||||
) -> Result<T, CatalogError> {
|
) -> Result<T, CatalogError> {
|
||||||
self.with_lock(|| {
|
self.with_lock(|| {
|
||||||
let database = Database::open(&self.database_path).map_err(CatalogError::redb)?;
|
let database_path = self.database_path_for_handoff()?;
|
||||||
|
let database = Database::open(database_path).map_err(CatalogError::redb)?;
|
||||||
let transaction = database.begin_write().map_err(CatalogError::redb)?;
|
let transaction = database.begin_write().map_err(CatalogError::redb)?;
|
||||||
validate_write_metadata(&transaction)?;
|
validate_write_metadata(&transaction)?;
|
||||||
let result = operation(&transaction)?;
|
let result = operation(&transaction)?;
|
||||||
|
|
@ -563,9 +627,43 @@ impl Catalog {
|
||||||
&self,
|
&self,
|
||||||
operation: impl FnOnce() -> Result<T, CatalogError>,
|
operation: impl FnOnce() -> Result<T, CatalogError>,
|
||||||
) -> Result<T, CatalogError> {
|
) -> Result<T, CatalogError> {
|
||||||
let _lock = acquire_lock(&self.lock_path, self.lock_wait)?;
|
let _lock = match &self.location {
|
||||||
|
CatalogLocation::Ambient { lock_path, .. } => acquire_lock(lock_path, self.lock_wait)?,
|
||||||
|
CatalogLocation::StorageRoot(location) => {
|
||||||
|
acquire_storage_root_lock(location, self.lock_wait)?
|
||||||
|
}
|
||||||
|
};
|
||||||
operation()
|
operation()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn database_exists(&self) -> Result<bool, CatalogError> {
|
||||||
|
match &self.location {
|
||||||
|
CatalogLocation::Ambient { database_path, .. } => Ok(database_path.exists()),
|
||||||
|
CatalogLocation::StorageRoot(location) => match open_storage_root_child(
|
||||||
|
location,
|
||||||
|
"catalog.redb",
|
||||||
|
OFlags::RDONLY | OFlags::NOFOLLOW,
|
||||||
|
Mode::empty(),
|
||||||
|
) {
|
||||||
|
Ok(_) => Ok(true),
|
||||||
|
Err(CatalogError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn database_path_for_handoff(&self) -> Result<PathBuf, CatalogError> {
|
||||||
|
match &self.location {
|
||||||
|
CatalogLocation::Ambient { database_path, .. } => Ok(database_path.clone()),
|
||||||
|
CatalogLocation::StorageRoot(location) => {
|
||||||
|
require_mount_identity(&location.root_label, location.identity)
|
||||||
|
.map_err(CatalogError::Io)?;
|
||||||
|
Ok(location.root_label.join("catalog.redb"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn acquire_lock(path: &Path, wait: Duration) -> Result<ExclusiveFileLock, CatalogError> {
|
fn acquire_lock(path: &Path, wait: Duration) -> Result<ExclusiveFileLock, CatalogError> {
|
||||||
|
|
@ -586,6 +684,56 @@ fn acquire_lock(path: &Path, wait: Duration) -> Result<ExclusiveFileLock, Catalo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn acquire_storage_root_lock(
|
||||||
|
location: &StorageRootCatalog,
|
||||||
|
wait: Duration,
|
||||||
|
) -> Result<ExclusiveFileLock, CatalogError> {
|
||||||
|
let deadline = Instant::now() + wait;
|
||||||
|
loop {
|
||||||
|
let file = File::from(open_storage_root_child(
|
||||||
|
location,
|
||||||
|
"storage-catalog.lock",
|
||||||
|
OFlags::CREATE | OFlags::RDWR | OFlags::NOFOLLOW,
|
||||||
|
Mode::from_raw_mode(0o600),
|
||||||
|
)?);
|
||||||
|
match try_exclusive_flock(file) {
|
||||||
|
Ok(lock) => return Ok(lock),
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||||
|
let now = Instant::now();
|
||||||
|
if now >= deadline {
|
||||||
|
return Err(CatalogError::LockTimeout);
|
||||||
|
}
|
||||||
|
thread::sleep(LOCK_RETRY_INTERVAL.min(deadline.saturating_duration_since(now)));
|
||||||
|
}
|
||||||
|
Err(error) => return Err(CatalogError::Io(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_storage_root_child(
|
||||||
|
location: &StorageRootCatalog,
|
||||||
|
name: &str,
|
||||||
|
flags: OFlags,
|
||||||
|
mode: Mode,
|
||||||
|
) -> Result<std::os::fd::OwnedFd, CatalogError> {
|
||||||
|
if mount_identity(&location.root).map_err(CatalogError::Io)? != location.identity {
|
||||||
|
return Err(CatalogError::Io(io::Error::other(
|
||||||
|
"retained Storage Root identity changed",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
openat2(
|
||||||
|
location.root.as_fd(),
|
||||||
|
name,
|
||||||
|
flags | OFlags::CLOEXEC,
|
||||||
|
mode,
|
||||||
|
ResolveFlags::BENEATH
|
||||||
|
| ResolveFlags::NO_MAGICLINKS
|
||||||
|
| ResolveFlags::NO_SYMLINKS
|
||||||
|
| ResolveFlags::NO_XDEV,
|
||||||
|
)
|
||||||
|
.map_err(|error| CatalogError::Io(error.into()))
|
||||||
|
}
|
||||||
|
|
||||||
fn open_lock(path: &Path) -> Result<File, CatalogError> {
|
fn open_lock(path: &Path) -> Result<File, CatalogError> {
|
||||||
OpenOptions::new()
|
OpenOptions::new()
|
||||||
.create(true)
|
.create(true)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue