2026-07-15 15:22:02 +09:00
|
|
|
use std::fs::File;
|
|
|
|
|
use std::io;
|
|
|
|
|
use std::os::fd::AsFd;
|
|
|
|
|
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
|
|
|
|
use std::path::{Path, PathBuf};
|
2026-07-16 20:41:26 +09:00
|
|
|
use std::sync::Arc;
|
2026-07-15 15:22:02 +09:00
|
|
|
use std::thread;
|
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
|
|
|
|
|
|
use anyhow::{Context, Result, bail};
|
2026-07-16 20:41:26 +09:00
|
|
|
use lazaret_core::configuration::ConfinedPathPlan;
|
2026-07-15 15:22:02 +09:00
|
|
|
use lazaret_core::identity::{
|
|
|
|
|
DeviceId, HostPath, IdMapDigest, InodeId, PolicyId, PrepareOperationId, PreparedArtifactId,
|
|
|
|
|
SelectedManifestDigest, SourceIdentity,
|
|
|
|
|
};
|
|
|
|
|
use lazaret_core::records::{ArtifactState, PrepareOperation, PrepareStageState, PreparedArtifact};
|
|
|
|
|
use lazaret_linux::filesystem::{ExclusiveFileLock, try_exclusive_flock};
|
|
|
|
|
use lazaret_storage_catalog::{Catalog, CatalogError, CatalogRow};
|
2026-07-15 15:39:22 +09:00
|
|
|
use rustix::fs::{
|
|
|
|
|
AtFlags, Mode, OFlags, RenameFlags, ResolveFlags, mkdirat, openat2, renameat, renameat_with,
|
|
|
|
|
unlinkat,
|
|
|
|
|
};
|
2026-07-15 15:22:02 +09:00
|
|
|
use sha2::{Digest, Sha256};
|
|
|
|
|
|
|
|
|
|
use crate::layer::{
|
2026-07-16 20:41:26 +09:00
|
|
|
IdMap, LayoutSource, POLICY_NAME, PreparationLimits, apply_layout, remove_children,
|
|
|
|
|
verify_layout,
|
2026-07-15 15:22:02 +09:00
|
|
|
};
|
2026-07-16 20:41:26 +09:00
|
|
|
use crate::transport::TransportedImage;
|
2026-07-15 15:22:02 +09:00
|
|
|
|
|
|
|
|
const PHYSICAL_LOCK_WAIT: Duration = Duration::from_secs(5);
|
|
|
|
|
const LOCK_RETRY: Duration = Duration::from_millis(2);
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
pub struct PrepareRequest {
|
|
|
|
|
pub operation_id: PrepareOperationId,
|
|
|
|
|
pub layout_path: PathBuf,
|
|
|
|
|
pub source_identity: Vec<u8>,
|
|
|
|
|
pub selected_manifest_digest: [u8; 32],
|
|
|
|
|
pub id_map: IdMap,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
pub enum PrepareRecovery {
|
|
|
|
|
ClearedUnmaterializedIntent,
|
|
|
|
|
RemovedOwnedStage,
|
|
|
|
|
PublishedCompletedStage,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
pub enum ReclaimOutcome {
|
|
|
|
|
Removed,
|
|
|
|
|
ReplacementPreserved,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
pub struct PreparedLowerStore {
|
2026-07-16 20:41:26 +09:00
|
|
|
root: Arc<File>,
|
2026-07-15 15:22:02 +09:00
|
|
|
catalog: Catalog,
|
|
|
|
|
limits: PreparationLimits,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PreparedLowerStore {
|
|
|
|
|
/// Creates the fixed prepared-lower directories beneath an existing Storage Root.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// 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> {
|
2026-07-16 20:41:26 +09:00
|
|
|
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"))?;
|
2026-07-16 21:06:02 +09:00
|
|
|
require_owned_directory(&root)?;
|
2026-07-16 20:41:26 +09:00
|
|
|
Self::initialize_retained(root, catalog)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn initialize_retained(root: File, catalog: Catalog) -> Result<Self> {
|
2026-07-15 15:22:02 +09:00
|
|
|
let store = Self {
|
2026-07-16 20:41:26 +09:00
|
|
|
root: Arc::new(root),
|
2026-07-15 15:22:02 +09:00
|
|
|
catalog,
|
|
|
|
|
limits: PreparationLimits::default(),
|
|
|
|
|
};
|
|
|
|
|
for name in ["artifacts", "stages", "quarantine"] {
|
2026-07-16 20:41:26 +09:00
|
|
|
match mkdirat(&store.root, name, Mode::from_raw_mode(0o700)) {
|
2026-07-15 15:22:02 +09:00
|
|
|
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
|
|
|
|
|
Err(error) => return Err(error.into()),
|
|
|
|
|
}
|
2026-07-16 21:06:02 +09:00
|
|
|
let directory = open_directory_at(&store.root, Path::new(name))?;
|
|
|
|
|
require_owned_directory(&directory)?;
|
2026-07-15 15:22:02 +09:00
|
|
|
}
|
2026-07-16 20:41:26 +09:00
|
|
|
open_lock_at(&store.root)?;
|
2026-07-15 15:22:02 +09:00
|
|
|
Ok(store)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn with_limits(mut self, limits: PreparationLimits) -> Self {
|
|
|
|
|
self.limits = limits;
|
|
|
|
|
self
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Materializes and atomically publishes one verified immutable prepared lower.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Refuses corrupt or unsupported OCI input, forged physical ownership, catalog conflicts,
|
|
|
|
|
/// resource limits, and publication failures.
|
|
|
|
|
pub fn prepare(&self, request: &PrepareRequest) -> Result<PreparedArtifact> {
|
2026-07-16 20:41:26 +09:00
|
|
|
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> {
|
2026-07-15 15:22:02 +09:00
|
|
|
if request.source_identity.is_empty() || request.source_identity.len() > 4096 {
|
|
|
|
|
bail!("source identity is empty or exceeds 4096 bytes");
|
|
|
|
|
}
|
2026-07-16 20:41:26 +09:00
|
|
|
let verified = verify_layout(layout, self.limits)?;
|
2026-07-15 15:39:22 +09:00
|
|
|
if request.selected_manifest_digest != verified.manifest_digest {
|
|
|
|
|
bail!("selected manifest identity does not match verified OCI layout");
|
|
|
|
|
}
|
2026-07-15 15:22:02 +09:00
|
|
|
let identities = Identities::new(request)?;
|
|
|
|
|
if let Some(existing) = self.available_artifact(identities.artifact_id)? {
|
|
|
|
|
identities.require_equivalent(&existing)?;
|
|
|
|
|
self.require_artifact_identity(&existing)?;
|
|
|
|
|
return Ok(existing);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let stage_relative = stage_path(request.operation_id);
|
|
|
|
|
let intent = identities.intent(request.operation_id, &stage_relative);
|
|
|
|
|
self.catalog.record_prepare(&intent)?;
|
|
|
|
|
let stage = match self.create_stage(&stage_relative) {
|
|
|
|
|
Ok(stage) => stage,
|
|
|
|
|
Err(error) => {
|
|
|
|
|
let _ = self.catalog.remove_prepare(&intent);
|
|
|
|
|
return Err(error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let (device, inode) = file_identity(&stage)?;
|
|
|
|
|
let owned = PrepareOperation {
|
|
|
|
|
stage_state: PrepareStageState::Owned,
|
|
|
|
|
device: Some(device),
|
|
|
|
|
inode: Some(inode),
|
|
|
|
|
..intent.clone()
|
|
|
|
|
};
|
|
|
|
|
if let Err(error) = self.catalog.bind_prepare_stage(&intent, &owned) {
|
|
|
|
|
if self
|
|
|
|
|
.remove_exact_path(&stage_relative, device, inode)
|
|
|
|
|
.is_ok()
|
|
|
|
|
{
|
|
|
|
|
let _ = self.catalog.remove_prepare(&intent);
|
|
|
|
|
}
|
|
|
|
|
return Err(error.into());
|
|
|
|
|
}
|
2026-07-16 20:41:26 +09:00
|
|
|
if let Err(error) = apply_layout(layout, &stage, &verified, &request.id_map, self.limits)
|
|
|
|
|
.and_then(|()| sync_tree(&stage))
|
2026-07-15 15:22:02 +09:00
|
|
|
{
|
|
|
|
|
if self
|
|
|
|
|
.remove_exact_path(&stage_relative, device, inode)
|
|
|
|
|
.is_ok()
|
|
|
|
|
{
|
|
|
|
|
let _ = self.catalog.remove_prepare(&owned);
|
|
|
|
|
}
|
|
|
|
|
return Err(error);
|
|
|
|
|
}
|
|
|
|
|
self.publish_owned(&owned, &identities)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Recovers one exact durable prepare operation without adopting foreign paths.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Refuses unexpected paths, missing ownership evidence, identity mismatch, and catalog or
|
|
|
|
|
/// filesystem failures.
|
|
|
|
|
pub fn recover_prepare(&self, operation: &PrepareOperation) -> Result<PrepareRecovery> {
|
|
|
|
|
let expected_stage = stage_path(operation.operation_id);
|
|
|
|
|
if operation.stage_path.as_bytes() != path_bytes(&expected_stage) {
|
|
|
|
|
bail!("prepare operation stage path is not canonical");
|
|
|
|
|
}
|
|
|
|
|
match operation.stage_state {
|
|
|
|
|
PrepareStageState::Intent => {
|
|
|
|
|
if self.open_relative(&expected_stage)?.is_some() {
|
|
|
|
|
bail!("unowned stage path is present");
|
|
|
|
|
}
|
|
|
|
|
self.catalog.remove_prepare(operation)?;
|
|
|
|
|
Ok(PrepareRecovery::ClearedUnmaterializedIntent)
|
|
|
|
|
}
|
|
|
|
|
PrepareStageState::Owned => {
|
|
|
|
|
let device = operation.device.context("owned prepare lacks device")?;
|
|
|
|
|
let inode = operation.inode.context("owned prepare lacks inode")?;
|
|
|
|
|
if self.open_relative(&expected_stage)?.is_some() {
|
|
|
|
|
self.remove_exact_path(&expected_stage, device, inode)?;
|
|
|
|
|
self.catalog.remove_prepare(operation)?;
|
|
|
|
|
return Ok(PrepareRecovery::RemovedOwnedStage);
|
|
|
|
|
}
|
|
|
|
|
let artifact_relative = artifact_path(operation.expected_artifact_id);
|
|
|
|
|
let Some(artifact_fd) = self.open_relative(&artifact_relative)? else {
|
|
|
|
|
bail!("owned preparation path is absent without exact recovery evidence");
|
|
|
|
|
};
|
|
|
|
|
if file_identity(&artifact_fd)? != (device, inode) {
|
|
|
|
|
bail!("published path does not match preparation ownership");
|
|
|
|
|
}
|
|
|
|
|
let artifact =
|
|
|
|
|
artifact_from_operation(operation, &artifact_relative, device, inode);
|
|
|
|
|
self.publish_catalog_row(operation, &artifact)?;
|
|
|
|
|
Ok(PrepareRecovery::PublishedCompletedStage)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Moves an exact zero-lease artifact to quarantine, deletes it, and closes reclaim intent.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Refuses leases, forged catalog rows, physical identity mismatch, foreign paths, and
|
|
|
|
|
/// filesystem or catalog failures.
|
|
|
|
|
pub fn reclaim(&self, expected: &PreparedArtifact) -> Result<ReclaimOutcome> {
|
|
|
|
|
let reclaiming = self.catalog.begin_reclaim(expected)?;
|
|
|
|
|
self.resume_reclaim(&reclaiming)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Resumes an exact durable reclaim after interruption.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Refuses non-reclaiming state, path/identity mismatch, missing physical evidence, and
|
|
|
|
|
/// catalog or filesystem failures.
|
|
|
|
|
pub fn resume_reclaim(&self, reclaiming: &PreparedArtifact) -> Result<ReclaimOutcome> {
|
|
|
|
|
if reclaiming.state != ArtifactState::Reclaiming {
|
|
|
|
|
bail!("artifact is not reclaiming");
|
|
|
|
|
}
|
|
|
|
|
let expected_artifact = artifact_path(reclaiming.prepared_artifact_id);
|
|
|
|
|
if reclaiming.path.as_bytes() != path_bytes(&expected_artifact) {
|
|
|
|
|
bail!("artifact path is not canonical");
|
|
|
|
|
}
|
|
|
|
|
let quarantine = quarantine_path(reclaiming);
|
|
|
|
|
{
|
|
|
|
|
let _lock = self.physical_lock()?;
|
|
|
|
|
if let Some(canonical) = self.open_relative(&expected_artifact)? {
|
2026-07-15 15:39:22 +09:00
|
|
|
if file_identity(&canonical)? == (reclaiming.device, reclaiming.inode) {
|
|
|
|
|
self.rename_relative_noreplace(&expected_artifact, &quarantine)?;
|
|
|
|
|
self.sync_fixed_directory("artifacts")?;
|
|
|
|
|
self.sync_fixed_directory("quarantine")?;
|
|
|
|
|
} else {
|
|
|
|
|
let quarantined = self
|
|
|
|
|
.open_relative(&quarantine)?
|
|
|
|
|
.context("canonical replacement exists without quarantined old artifact")?;
|
|
|
|
|
require_identity(&quarantined, reclaiming.device, reclaiming.inode)?;
|
|
|
|
|
let replacement = self
|
2026-07-15 15:43:44 +09:00
|
|
|
.catalog_artifact(reclaiming.prepared_artifact_id)?
|
|
|
|
|
.context("canonical replacement exists without catalog ownership")?;
|
2026-07-15 15:39:22 +09:00
|
|
|
require_identity(&canonical, replacement.device, replacement.inode)?;
|
|
|
|
|
}
|
2026-07-15 15:22:02 +09:00
|
|
|
} else {
|
|
|
|
|
let existing = self
|
|
|
|
|
.open_relative(&quarantine)?
|
|
|
|
|
.context("reclaiming artifact and quarantine are both absent")?;
|
|
|
|
|
require_identity(&existing, reclaiming.device, reclaiming.inode)?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
self.remove_exact_path(&quarantine, reclaiming.device, reclaiming.inode)?;
|
|
|
|
|
match self.catalog.finish_reclaim(reclaiming) {
|
|
|
|
|
Ok(()) => Ok(ReclaimOutcome::Removed),
|
|
|
|
|
Err(CatalogError::OwnershipMismatch {
|
|
|
|
|
row: CatalogRow::PreparedArtifact,
|
|
|
|
|
}) => {
|
|
|
|
|
let replacement = self
|
2026-07-15 15:43:44 +09:00
|
|
|
.catalog_artifact(reclaiming.prepared_artifact_id)?
|
|
|
|
|
.context("reclaim row changed without an owned replacement")?;
|
2026-07-15 15:22:02 +09:00
|
|
|
self.require_artifact_identity(&replacement)?;
|
|
|
|
|
Ok(ReclaimOutcome::ReplacementPreserved)
|
|
|
|
|
}
|
|
|
|
|
Err(error) => Err(error.into()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn publish_owned(
|
|
|
|
|
&self,
|
|
|
|
|
operation: &PrepareOperation,
|
|
|
|
|
identities: &Identities,
|
|
|
|
|
) -> Result<PreparedArtifact> {
|
|
|
|
|
let stage = stage_path(operation.operation_id);
|
|
|
|
|
let artifact_path = artifact_path(operation.expected_artifact_id);
|
|
|
|
|
let _lock = self.physical_lock()?;
|
|
|
|
|
if let Some(existing) = self.open_relative(&artifact_path)? {
|
|
|
|
|
if let Some(row) = self.available_artifact(operation.expected_artifact_id)? {
|
|
|
|
|
identities.require_equivalent(&row)?;
|
|
|
|
|
require_identity(&existing, row.device, row.inode)?;
|
|
|
|
|
let device = operation.device.context("owned prepare lacks device")?;
|
|
|
|
|
let inode = operation.inode.context("owned prepare lacks inode")?;
|
|
|
|
|
self.remove_exact_path(&stage, device, inode)?;
|
|
|
|
|
self.catalog.remove_prepare(operation)?;
|
|
|
|
|
return Ok(row);
|
|
|
|
|
}
|
|
|
|
|
let reclaiming = self
|
|
|
|
|
.catalog
|
|
|
|
|
.snapshot()?
|
|
|
|
|
.prepared_artifacts
|
|
|
|
|
.into_iter()
|
|
|
|
|
.find(|candidate| {
|
|
|
|
|
candidate.prepared_artifact_id == operation.expected_artifact_id
|
|
|
|
|
&& candidate.state == ArtifactState::Reclaiming
|
|
|
|
|
})
|
|
|
|
|
.context("canonical artifact path exists without matching catalog ownership")?;
|
|
|
|
|
require_identity(&existing, reclaiming.device, reclaiming.inode)?;
|
|
|
|
|
let quarantine = quarantine_path(&reclaiming);
|
2026-07-15 15:39:22 +09:00
|
|
|
self.rename_relative_noreplace(&artifact_path, &quarantine)?;
|
2026-07-15 15:22:02 +09:00
|
|
|
self.sync_fixed_directory("quarantine")?;
|
|
|
|
|
}
|
|
|
|
|
self.rename_relative(&stage, &artifact_path)?;
|
|
|
|
|
self.sync_fixed_directory("artifacts")?;
|
|
|
|
|
let artifact_fd = self
|
|
|
|
|
.open_relative(&artifact_path)?
|
|
|
|
|
.context("published artifact disappeared")?;
|
|
|
|
|
let (device, inode) = file_identity(&artifact_fd)?;
|
|
|
|
|
let artifact = identities.artifact(&artifact_path, device, inode);
|
|
|
|
|
self.publish_catalog_row(operation, &artifact)?;
|
|
|
|
|
Ok(artifact)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn publish_catalog_row(
|
|
|
|
|
&self,
|
|
|
|
|
operation: &PrepareOperation,
|
|
|
|
|
artifact: &PreparedArtifact,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
let snapshot = self.catalog.snapshot()?;
|
|
|
|
|
if let Some(reclaiming) = snapshot.prepared_artifacts.into_iter().find(|candidate| {
|
|
|
|
|
candidate.prepared_artifact_id == artifact.prepared_artifact_id
|
|
|
|
|
&& candidate.state == ArtifactState::Reclaiming
|
|
|
|
|
}) {
|
|
|
|
|
self.catalog
|
|
|
|
|
.publish_replacement(operation, &reclaiming, artifact)?;
|
|
|
|
|
} else {
|
|
|
|
|
self.catalog.publish(operation, artifact)?;
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn available_artifact(&self, id: PreparedArtifactId) -> Result<Option<PreparedArtifact>> {
|
|
|
|
|
Ok(self
|
|
|
|
|
.catalog
|
|
|
|
|
.snapshot()?
|
|
|
|
|
.prepared_artifacts
|
|
|
|
|
.into_iter()
|
|
|
|
|
.find(|artifact| {
|
|
|
|
|
artifact.prepared_artifact_id == id && artifact.state == ArtifactState::Available
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 15:43:44 +09:00
|
|
|
fn catalog_artifact(&self, id: PreparedArtifactId) -> Result<Option<PreparedArtifact>> {
|
|
|
|
|
Ok(self
|
|
|
|
|
.catalog
|
|
|
|
|
.snapshot()?
|
|
|
|
|
.prepared_artifacts
|
|
|
|
|
.into_iter()
|
|
|
|
|
.find(|artifact| artifact.prepared_artifact_id == id))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 15:22:02 +09:00
|
|
|
fn require_artifact_identity(&self, artifact: &PreparedArtifact) -> Result<()> {
|
|
|
|
|
let path = PathBuf::from(std::ffi::OsString::from_vec(
|
|
|
|
|
artifact.path.as_bytes().to_vec(),
|
|
|
|
|
));
|
|
|
|
|
let expected = artifact_path(artifact.prepared_artifact_id);
|
|
|
|
|
if path != expected {
|
|
|
|
|
bail!("artifact path is not canonical");
|
|
|
|
|
}
|
|
|
|
|
let file = self
|
|
|
|
|
.open_relative(&path)?
|
|
|
|
|
.context("artifact path is absent")?;
|
|
|
|
|
require_identity(&file, artifact.device, artifact.inode)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn create_stage(&self, relative: &Path) -> Result<File> {
|
2026-07-16 20:41:26 +09:00
|
|
|
let stages = open_directory_at(&self.root, Path::new("stages"))?;
|
2026-07-15 15:22:02 +09:00
|
|
|
let name = relative.file_name().context("stage has no name")?;
|
|
|
|
|
mkdirat(&stages, name, Mode::from_raw_mode(0o700))?;
|
|
|
|
|
open_directory_at(&stages, Path::new(name))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn open_relative(&self, relative: &Path) -> Result<Option<File>> {
|
|
|
|
|
match openat2(
|
2026-07-16 20:41:26 +09:00
|
|
|
&self.root,
|
2026-07-15 15:22:02 +09:00
|
|
|
relative,
|
|
|
|
|
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
|
|
|
|
Mode::empty(),
|
|
|
|
|
secure_resolve(),
|
|
|
|
|
) {
|
|
|
|
|
Ok(fd) => Ok(Some(File::from(fd))),
|
|
|
|
|
Err(rustix::io::Errno::NOENT) => Ok(None),
|
|
|
|
|
Err(error) => Err(error.into()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn rename_relative(&self, from: &Path, to: &Path) -> Result<()> {
|
2026-07-16 20:41:26 +09:00
|
|
|
renameat(&self.root, from, &self.root, to)?;
|
2026-07-15 15:22:02 +09:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 15:39:22 +09:00
|
|
|
fn rename_relative_noreplace(&self, from: &Path, to: &Path) -> Result<()> {
|
2026-07-16 20:41:26 +09:00
|
|
|
renameat_with(&self.root, from, &self.root, to, RenameFlags::NOREPLACE)?;
|
2026-07-15 15:39:22 +09:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 15:22:02 +09:00
|
|
|
fn remove_exact_path(&self, relative: &Path, device: DeviceId, inode: InodeId) -> Result<()> {
|
2026-07-16 20:41:26 +09:00
|
|
|
let file = open_directory_at(&self.root, relative)?;
|
2026-07-15 15:22:02 +09:00
|
|
|
require_identity(&file, device, inode)?;
|
|
|
|
|
remove_children(&file)?;
|
2026-07-16 20:41:26 +09:00
|
|
|
unlinkat(&self.root, relative, AtFlags::REMOVEDIR)?;
|
2026-07-15 15:22:02 +09:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn physical_lock(&self) -> Result<ExclusiveFileLock> {
|
|
|
|
|
let deadline = Instant::now() + PHYSICAL_LOCK_WAIT;
|
|
|
|
|
loop {
|
2026-07-16 20:41:26 +09:00
|
|
|
let file = open_lock_at(&self.root)?;
|
2026-07-15 15:22:02 +09:00
|
|
|
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 {
|
|
|
|
|
bail!("prepared-lower physical lock wait expired");
|
|
|
|
|
}
|
|
|
|
|
thread::sleep(LOCK_RETRY.min(deadline.saturating_duration_since(now)));
|
|
|
|
|
}
|
|
|
|
|
Err(error) => return Err(error.into()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn sync_fixed_directory(&self, name: &str) -> Result<()> {
|
2026-07-16 20:41:26 +09:00
|
|
|
open_directory_at(&self.root, Path::new(name))?.sync_all()?;
|
2026-07-15 15:22:02 +09:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-16 21:06:02 +09:00
|
|
|
fn require_owned_directory(directory: &File) -> Result<()> {
|
|
|
|
|
let stat = rustix::fs::fstat(directory)?;
|
|
|
|
|
if stat.st_uid != rustix::process::getuid().as_raw() || stat.st_mode & 0o7777 != 0o700 {
|
|
|
|
|
bail!("fixed prepared-lower directory must be owned by the invoking user with mode 0700");
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 15:22:02 +09:00
|
|
|
struct Identities {
|
|
|
|
|
artifact_id: PreparedArtifactId,
|
|
|
|
|
source: SourceIdentity,
|
|
|
|
|
manifest: SelectedManifestDigest,
|
|
|
|
|
policy: PolicyId,
|
|
|
|
|
id_map: IdMapDigest,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 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(&[
|
|
|
|
|
b"lazaret-prepared-artifact-v1",
|
|
|
|
|
source.as_bytes(),
|
|
|
|
|
manifest.as_bytes(),
|
|
|
|
|
policy.as_bytes(),
|
|
|
|
|
id_map.as_bytes(),
|
|
|
|
|
]));
|
|
|
|
|
Ok(Self {
|
|
|
|
|
artifact_id,
|
|
|
|
|
source,
|
|
|
|
|
manifest,
|
|
|
|
|
policy,
|
|
|
|
|
id_map,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn intent(&self, operation_id: PrepareOperationId, stage: &Path) -> PrepareOperation {
|
|
|
|
|
PrepareOperation {
|
|
|
|
|
operation_id,
|
|
|
|
|
expected_artifact_id: self.artifact_id,
|
|
|
|
|
source_identity: self.source.clone(),
|
|
|
|
|
selected_manifest_digest: self.manifest,
|
|
|
|
|
policy_id: self.policy,
|
|
|
|
|
id_map_digest: self.id_map,
|
|
|
|
|
stage_path: HostPath::new(path_bytes(stage).to_vec()),
|
|
|
|
|
stage_state: PrepareStageState::Intent,
|
|
|
|
|
device: None,
|
|
|
|
|
inode: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn artifact(&self, path: &Path, device: DeviceId, inode: InodeId) -> PreparedArtifact {
|
|
|
|
|
PreparedArtifact {
|
|
|
|
|
prepared_artifact_id: self.artifact_id,
|
|
|
|
|
source_identity: self.source.clone(),
|
|
|
|
|
selected_manifest_digest: self.manifest,
|
|
|
|
|
policy_id: self.policy,
|
|
|
|
|
id_map_digest: self.id_map,
|
|
|
|
|
path: HostPath::new(path_bytes(path).to_vec()),
|
|
|
|
|
device,
|
|
|
|
|
inode,
|
|
|
|
|
state: ArtifactState::Available,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn require_equivalent(&self, artifact: &PreparedArtifact) -> Result<()> {
|
|
|
|
|
if artifact.prepared_artifact_id != self.artifact_id
|
|
|
|
|
|| artifact.source_identity != self.source
|
|
|
|
|
|| artifact.selected_manifest_digest != self.manifest
|
|
|
|
|
|| artifact.policy_id != self.policy
|
|
|
|
|
|| artifact.id_map_digest != self.id_map
|
|
|
|
|
{
|
|
|
|
|
bail!("prepared artifact identity inputs differ");
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn artifact_from_operation(
|
|
|
|
|
operation: &PrepareOperation,
|
|
|
|
|
path: &Path,
|
|
|
|
|
device: DeviceId,
|
|
|
|
|
inode: InodeId,
|
|
|
|
|
) -> PreparedArtifact {
|
|
|
|
|
PreparedArtifact {
|
|
|
|
|
prepared_artifact_id: operation.expected_artifact_id,
|
|
|
|
|
source_identity: operation.source_identity.clone(),
|
|
|
|
|
selected_manifest_digest: operation.selected_manifest_digest,
|
|
|
|
|
policy_id: operation.policy_id,
|
|
|
|
|
id_map_digest: operation.id_map_digest,
|
|
|
|
|
path: HostPath::new(path_bytes(path).to_vec()),
|
|
|
|
|
device,
|
|
|
|
|
inode,
|
|
|
|
|
state: ArtifactState::Available,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn file_identity(file: &File) -> Result<(DeviceId, InodeId)> {
|
|
|
|
|
let stat = rustix::fs::fstat(file)?;
|
|
|
|
|
Ok((DeviceId::new(stat.st_dev), InodeId::new(stat.st_ino)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn require_identity(file: &File, device: DeviceId, inode: InodeId) -> Result<()> {
|
|
|
|
|
if file_identity(file)? == (device, inode) {
|
|
|
|
|
Ok(())
|
|
|
|
|
} else {
|
|
|
|
|
bail!("physical path identity mismatch")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn open_directory_at(parent: impl AsFd, path: &Path) -> Result<File> {
|
|
|
|
|
Ok(File::from(openat2(
|
|
|
|
|
parent,
|
|
|
|
|
path,
|
|
|
|
|
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
|
|
|
|
Mode::empty(),
|
|
|
|
|
secure_resolve(),
|
|
|
|
|
)?))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn open_lock_at(root: impl AsFd) -> Result<File> {
|
|
|
|
|
Ok(File::from(openat2(
|
|
|
|
|
root,
|
|
|
|
|
"prepared-lower.lock",
|
|
|
|
|
OFlags::RDWR | OFlags::CREATE | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
|
|
|
|
Mode::from_raw_mode(0o600),
|
|
|
|
|
secure_resolve(),
|
|
|
|
|
)?))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn sync_tree(directory: &File) -> Result<()> {
|
|
|
|
|
let mut buffer = [std::mem::MaybeUninit::uninit(); 8192];
|
|
|
|
|
let names = {
|
|
|
|
|
let mut names = Vec::new();
|
|
|
|
|
let mut entries = rustix::fs::RawDir::new(directory.as_fd(), &mut buffer);
|
|
|
|
|
while let Some(entry) = entries.next() {
|
|
|
|
|
let entry = entry?;
|
|
|
|
|
if entry.file_name().to_bytes() != b"." && entry.file_name().to_bytes() != b".." {
|
|
|
|
|
names.push(entry.file_name().to_owned());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
names
|
|
|
|
|
};
|
|
|
|
|
for name in names {
|
|
|
|
|
let path_fd = openat2(
|
|
|
|
|
directory,
|
|
|
|
|
&name,
|
|
|
|
|
OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
|
|
|
|
Mode::empty(),
|
|
|
|
|
secure_resolve(),
|
|
|
|
|
)?;
|
|
|
|
|
let kind = rustix::fs::FileType::from_raw_mode(rustix::fs::fstat(&path_fd)?.st_mode);
|
|
|
|
|
if kind == rustix::fs::FileType::Directory {
|
|
|
|
|
let child = open_directory_at(
|
|
|
|
|
directory,
|
|
|
|
|
Path::new(std::ffi::OsStr::from_bytes(name.to_bytes())),
|
|
|
|
|
)?;
|
|
|
|
|
sync_tree(&child)?;
|
|
|
|
|
} else if kind == rustix::fs::FileType::RegularFile {
|
|
|
|
|
File::from(openat2(
|
|
|
|
|
directory,
|
|
|
|
|
&name,
|
|
|
|
|
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
|
|
|
|
Mode::empty(),
|
|
|
|
|
secure_resolve(),
|
|
|
|
|
)?)
|
|
|
|
|
.sync_all()?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
directory.sync_all()?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn hash_parts(parts: &[&[u8]]) -> [u8; 32] {
|
|
|
|
|
let mut hasher = Sha256::new();
|
|
|
|
|
for part in parts {
|
|
|
|
|
hasher.update((part.len() as u64).to_be_bytes());
|
|
|
|
|
hasher.update(part);
|
|
|
|
|
}
|
|
|
|
|
hasher.finalize().into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn stage_path(id: PrepareOperationId) -> PathBuf {
|
|
|
|
|
Path::new("stages").join(hex(id.as_bytes()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn artifact_path(id: PreparedArtifactId) -> PathBuf {
|
|
|
|
|
Path::new("artifacts").join(hex(id.as_bytes()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn quarantine_path(artifact: &PreparedArtifact) -> PathBuf {
|
|
|
|
|
Path::new("quarantine").join(format!(
|
|
|
|
|
"{}-{}-{}",
|
|
|
|
|
hex(artifact.prepared_artifact_id.as_bytes()),
|
|
|
|
|
artifact.device.get(),
|
|
|
|
|
artifact.inode.get()
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn path_bytes(path: &Path) -> &[u8] {
|
|
|
|
|
std::os::unix::ffi::OsStrExt::as_bytes(path.as_os_str())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn hex(bytes: &[u8]) -> String {
|
|
|
|
|
use std::fmt::Write as _;
|
|
|
|
|
|
|
|
|
|
bytes.iter().fold(
|
|
|
|
|
String::with_capacity(bytes.len() * 2),
|
|
|
|
|
|mut output, byte| {
|
|
|
|
|
write!(output, "{byte:02x}").expect("writing to String cannot fail");
|
|
|
|
|
output
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn secure_resolve() -> ResolveFlags {
|
2026-07-16 21:09:48 +09:00
|
|
|
ResolveFlags::BENEATH
|
|
|
|
|
| ResolveFlags::NO_SYMLINKS
|
|
|
|
|
| ResolveFlags::NO_MAGICLINKS
|
|
|
|
|
| ResolveFlags::NO_XDEV
|
2026-07-15 15:22:02 +09:00
|
|
|
}
|
2026-07-16 21:06:02 +09:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod fixed_directory_tests {
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn prepared_lower_root_requires_invoking_owner_and_exact_private_mode() {
|
|
|
|
|
let directory = tempfile::tempdir().unwrap();
|
|
|
|
|
std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o777)).unwrap();
|
|
|
|
|
let root = File::open(directory.path()).unwrap();
|
|
|
|
|
assert!(require_owned_directory(&root).is_err());
|
|
|
|
|
std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
|
|
|
assert!(require_owned_directory(&root).is_ok());
|
|
|
|
|
}
|
2026-07-16 21:09:48 +09:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn retained_store_resolution_refuses_mount_crossing() {
|
|
|
|
|
assert!(secure_resolve().contains(ResolveFlags::NO_XDEV));
|
|
|
|
|
}
|
2026-07-16 21:06:02 +09:00
|
|
|
}
|