Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions crates/lib/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1047,7 +1047,7 @@ async fn upgrade(
crate::deploy::pull_unified(repo, imgref, None, opts.quiet, prog.clone(), storage)
.await?
} else {
crate::deploy::pull(repo, imgref, None, opts.quiet, prog.clone()).await?
crate::deploy::pull(repo, imgref, None, opts.quiet, prog.clone(), Some(storage)).await?
};
let staged_digest = staged_image.map(|s| s.digest().expect("valid digest in status"));
let fetched_digest = &fetched.manifest_digest;
Expand Down Expand Up @@ -1210,7 +1210,7 @@ async fn switch_ostree(
let fetched = if use_unified {
crate::deploy::pull_unified(repo, &target, None, opts.quiet, prog.clone(), storage).await?
} else {
crate::deploy::pull(repo, &target, None, opts.quiet, prog.clone()).await?
crate::deploy::pull(repo, &target, None, opts.quiet, prog.clone(), Some(storage)).await?
};

if !opts.retain {
Expand Down Expand Up @@ -1347,7 +1347,15 @@ async fn edit_ostree(
return crate::deploy::rollback(storage).await;
}

let fetched = crate::deploy::pull(repo, new_spec.image, None, opts.quiet, prog.clone()).await?;
let fetched = crate::deploy::pull(
repo,
new_spec.image,
None,
opts.quiet,
prog.clone(),
Some(storage),
)
.await?;

// TODO gc old layers here

Expand Down
13 changes: 12 additions & 1 deletion crates/lib/src/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,7 @@ pub(crate) async fn prepare_for_pull(
repo: &ostree::Repo,
imgref: &ImageReference,
target_imgref: Option<&OstreeImageReference>,
_store: Option<&Storage>,
) -> Result<PreparedPullResult> {
let imgref_canonicalized = imgref.clone().canonicalize()?;
tracing::debug!("Canonicalized image reference: {imgref_canonicalized:#}");
Expand All @@ -388,6 +389,13 @@ pub(crate) async fn prepare_for_pull(
if let Some(target) = target_imgref {
imp.set_target(target);
}

// Set storage root for direct access to the layer content when using containers-storage.
// For regular pulls, images come from the host's default container storage.
if ostree_imgref.imgref.transport == ostree_container::Transport::ContainerStorage {
let storage_path = format!("{}/storage", crate::podman::CONTAINER_STORAGE);
imp.set_storage_root(storage_path);
}
let prep = match imp.prepare().await? {
PrepareResult::AlreadyPresent(c) => {
println!("No changes in {imgref:#} => {}", c.manifest_digest);
Expand Down Expand Up @@ -484,6 +492,8 @@ pub(crate) async fn prepare_for_pull_unified(

// Use the preparation flow with the custom config
let mut imp = new_importer_with_config(repo, &ostree_imgref, config).await?;
// Set storage root for direct access to the layer content
imp.set_storage_root(&storage_path);
if let Some(target) = target_imgref {
imp.set_target(target);
}
Expand Down Expand Up @@ -642,8 +652,9 @@ pub(crate) async fn pull(
target_imgref: Option<&OstreeImageReference>,
quiet: bool,
prog: ProgressWriter,
store: Option<&Storage>,
) -> Result<Box<ImageState>> {
match prepare_for_pull(repo, imgref, target_imgref).await? {
match prepare_for_pull(repo, imgref, target_imgref, store).await? {
PreparedPullResult::AlreadyPresent(existing) => {
// Log that the image was already present (Debug level since it's not actionable)
const IMAGE_ALREADY_PRESENT_ID: &str = "5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9";
Expand Down
9 changes: 8 additions & 1 deletion crates/lib/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -969,7 +969,13 @@ async fn install_container(
)
.await?
} else {
prepare_for_pull(repo, &spec_imgref, Some(&state.target_imgref)).await?
prepare_for_pull(
repo,
&spec_imgref,
Some(&state.target_imgref),
Some(storage),
)
.await?
};

let pulled_image = match prepared {
Expand Down Expand Up @@ -2513,6 +2519,7 @@ pub(crate) async fn install_reset(opts: InstallResetOpts) -> Result<()> {
None,
opts.quiet,
prog.clone(),
Some(sysroot),
)
.await?;
(fetched, new_spec)
Expand Down
132 changes: 113 additions & 19 deletions crates/ostree-ext/src/container/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ pub struct ImageImporter {
offline: bool,
/// If true, we have ostree v2024.3 or newer.
ostree_v2024_3: bool,
/// Optional containers-storage root path for direct access to the layer content
storage_root: Option<Utf8PathBuf>,

layer_progress: Option<Sender<ImportProgress>>,
layer_byte_progress: Option<tokio::sync::watch::Sender<Option<LayerProgress>>>,
Expand Down Expand Up @@ -530,6 +532,7 @@ impl ImageImporter {
disable_gc: false,
require_bootable: false,
offline: false,
storage_root: None,
imgref: imgref.clone(),
layer_progress: None,
layer_byte_progress: None,
Expand Down Expand Up @@ -568,6 +571,13 @@ impl ImageImporter {
self.disable_gc = true;
}

/// Set the containers-storage root path for direct access to the layer content.
/// When set, layers will be imported directly from the layer diff directory
/// instead of being streamed through the proxy.
pub fn set_storage_root(&mut self, path: impl Into<Utf8PathBuf>) {
self.storage_root = Some(path.into());
}

/// Determine if there is a new manifest, and if so return its digest.
/// This will also serialize the new manifest and configuration into
/// metadata associated with the image, so that invocations of `[query_cached]`
Expand Down Expand Up @@ -1120,16 +1130,6 @@ impl ImageImporter {
p.send(ImportProgress::DerivedLayerStarted(layer.layer.clone()))
.await?;
}
let (blob, driver, media_type) = super::unencapsulate::fetch_layer(
&proxy,
&import.proxy_img,
&import.manifest,
&layer.layer,
self.layer_byte_progress.as_ref(),
des_layers.as_ref(),
self.imgref.imgref.transport,
)
.await?;
// An important aspect of this is that we SELinux label the derived layers using
// the base policy.
let opts = crate::tar::WriteTarOptions {
Expand All @@ -1138,16 +1138,61 @@ impl ImageImporter {
allow_nonusr: root_is_transient,
retain_var: self.ostree_v2024_3,
};
let r = crate::tar::write_tar(
&self.repo,
blob,
media_type,
layer.ostree_ref.as_str(),
Some(opts),

let layer_index = import
.manifest
.layers()
.iter()
.position(|x| x == &layer.layer)
.ok_or_else(|| {
anyhow!("Layer {} not found in manifest", layer.layer.digest())
})?;
tracing::debug!(
"Processing layer {}: digest={}, ostree_ref={}, transport={:?}",
layer_index,
layer.layer.digest(),
layer.ostree_ref,
self.imgref.imgref.transport
);
let r = super::unencapsulate::join_fetch(r, driver)
.await
.with_context(|| format!("Parsing layer blob {}", layer.layer.digest()))?;
let layer_diff_path = super::unencapsulate::try_get_layer_diff_path(
self.storage_root.as_deref(),
des_layers.as_ref(),
layer_index,
self.imgref.imgref.transport,
)?;

let r = if let Some(ref path) = layer_diff_path {
tracing::info!("Importing layer {} from filesystem: {}", layer_index, path);
Self::import_layer_from_filesystem(&self.repo, path, &layer.ostree_ref, &opts)
.await?
} else {
// Fall back to blob access
let (blob, driver, media_type) = super::unencapsulate::fetch_layer(
&proxy,
&import.proxy_img,
&import.manifest,
&layer.layer,
self.layer_byte_progress.as_ref(),
des_layers.as_ref(),
self.imgref.imgref.transport,
)
.await?;
tracing::debug!(
"Importing layer {} from tar stream, media_type={:?}",
layer_index,
media_type
);
let r = crate::tar::write_tar(
&self.repo,
blob,
media_type,
layer.ostree_ref.as_str(),
Some(opts),
);
super::unencapsulate::join_fetch(r, driver)
.await
.with_context(|| format!("Parsing layer blob {}", layer.layer.digest()))?
};
tracing::debug!("Imported layer: {}", r.commit.as_str());
layer_commits.push(r.commit);
let filtered_owned = HashMap::from_iter(r.filtered.clone());
Expand Down Expand Up @@ -1233,6 +1278,55 @@ impl ImageImporter {
state.filtered_files = layer_filtered_content;
Ok(state)
}

/// Import a layer directly from filesystem path instead of tar stream.
/// This directly walks the filesystem and writes content objects to OSTree,
/// applying path transformations (e.g., /etc -> /usr/etc).
async fn import_layer_from_filesystem(
repo: &ostree::Repo,
layer_path: &Utf8Path,
target_ref: &str,
options: &crate::tar::WriteTarOptions,
) -> Result<crate::tar::WriteTarResult> {
tracing::info!(
"import_layer_from_filesystem: layer_path={}, target_ref={}",
layer_path,
target_ref
);

let config = crate::filesystem::FilesystemFilterConfig::from_tar_options(options);
let repo = repo.clone();
let layer_path = layer_path.to_owned();
let target_ref = target_ref.to_string();

// Run the synchronous import in a blocking task
let result = {
let repo = repo.clone();
crate::tokio_util::spawn_blocking_flatten(move || {
crate::filesystem::import_filesystem_to_ostree(&repo, &layer_path, &config)
})
.await?
};

// Cache the layer by setting the ref (matching write_tar behavior)
{
let target_ref = target_ref.clone();
let commit = result.commit.clone();
crate::tokio_util::spawn_blocking_flatten(move || {
repo.set_ref_immediate(None, &target_ref, Some(&commit), gio::Cancellable::NONE)?;
Ok::<_, anyhow::Error>(())
})
.await?;
}

tracing::info!(
"Successfully imported layer from filesystem: commit={}, ref={}",
result.commit,
target_ref
);

Ok(result)
}
}

/// List all images stored
Expand Down
104 changes: 104 additions & 0 deletions crates/ostree-ext/src/container/unencapsulate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
use crate::container::store::LayerProgress;

use super::*;
use anyhow::Context as _;
use camino::{Utf8Path, Utf8PathBuf};
use containers_image_proxy::{ImageProxy, OpenedImage};
use fn_error_context::context;
use futures_util::{Future, FutureExt};
Expand Down Expand Up @@ -185,6 +187,108 @@ pub async fn unencapsulate(repo: &ostree::Repo, imgref: &OstreeImageReference) -
importer.unencapsulate().await
}

/// Try to get the diff path for a layer in containers-storage.
/// Returns Some(path) if the layer diff directory is available, None to use blob access.
///
/// The `storage_root` parameter specifies the containers-storage root directory.
/// If None, direct filesystem access is not attempted.
pub(crate) fn try_get_layer_diff_path(
storage_root: Option<&Utf8Path>,
layer_info: Option<&Vec<containers_image_proxy::ConvertedLayerInfo>>,
layer_index: usize,
transport_src: Transport,
) -> Result<Option<Utf8PathBuf>> {
match (transport_src, storage_root) {
(Transport::ContainerStorage, Some(storage_root)) => {
get_layer_diff_path(storage_root, layer_info, layer_index)
}
_ => Ok(None),
}
}

/// Entry in the overlay-layers/layers.json file
#[derive(serde::Deserialize, Debug)]
struct OverlayLayerEntry {
/// The directory name under overlay/
id: String,
/// Uncompressed diff digest (e.g., "sha256:...")
#[serde(rename = "diff-digest")]
diff_digest: Option<String>,
/// Compressed diff digest (e.g., "sha256:...")
#[serde(rename = "compressed-diff-digest")]
compressed_diff_digest: Option<String>,
}

/// Look up the overlay directory ID from layers.json given a digest
fn lookup_layer_id_from_digest(storage_root: &Utf8Path, digest: &str) -> Result<Option<String>> {
let layers_json_path = storage_root.join("overlay-layers/layers.json");

let file = match std::fs::File::open(&layers_json_path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return Ok(None);
}
Err(e) => return Err(anyhow::Error::new(e).context("Failed to open layers.json")),
};

let reader = std::io::BufReader::new(file);
let layers: Vec<OverlayLayerEntry> =
serde_json::from_reader(reader).context("Failed to parse layers.json")?;

// Search for matching digest in both diff-digest and compressed-diff-digest
for layer in &layers {
let matches = layer.diff_digest.as_deref() == Some(digest)
|| layer.compressed_diff_digest.as_deref() == Some(digest);
Comment on lines +240 to +241
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm this is interesting, I need to understand the distinction here, will look.

if matches {
return Ok(Some(layer.id.clone()));
}
}

Ok(None)
}

/// Get the diff directory path for a layer in containers-storage
fn get_layer_diff_path(
storage_root: &Utf8Path,
layer_info: Option<&Vec<containers_image_proxy::ConvertedLayerInfo>>,
layer_index: usize,
) -> Result<Option<Utf8PathBuf>> {
let Some(info) = layer_info else {
return Ok(None);
};

let Some(layer) = info.get(layer_index) else {
return Ok(None);
};

// Get the digest string (includes "sha256:" prefix)
let digest_str = layer.digest.to_string();

// Look up the layer ID from layers.json
let Some(layer_id) = lookup_layer_id_from_digest(storage_root, &digest_str)? else {
return Ok(None);
};

// Check if this is a composefs layer (not supported yet)
let composefs_blob_path = storage_root.join(format!(
"overlay/{}/composefs-data/composefs.blob",
layer_id
));
if composefs_blob_path.exists() {
return Ok(None);
}

// Construct the layer diff path: $STORAGE/overlay/$LAYER_ID/diff
let layer_diff_path = storage_root.join(format!("overlay/{}/diff", layer_id));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The layer_id obtained from layers.json is used to construct layer_diff_path using storage_root.join(). Since layer_id is not validated, it can contain path traversal sequences like .., allowing an attacker who can influence the contents of layers.json to point layer_diff_path to arbitrary directories on the host. This is a path traversal vulnerability that could lead to unauthorized access to host files during the container import process.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're effectively trusting the source storage so this is not a critical problem at all, but containers/composefs-rs#218 comprehensively uses cap_std Dir for this reason.


// Check if the layer diff directory exists and is a directory
if !layer_diff_path.is_dir() {
return Ok(None);
}

Ok(Some(layer_diff_path))
}

/// A wrapper for [`get_blob`] which fetches a layer and decompresses it.
pub(crate) async fn fetch_layer<'a>(
proxy: &'a ImageProxy,
Expand Down
Loading
Loading