Expose typed Prepared Lower failures
This commit is contained in:
parent
ee9201b057
commit
30cd36dcdd
1 changed files with 694 additions and 249 deletions
|
|
@ -10,6 +10,7 @@ use anyhow::{Context, Result, bail};
|
|||
use lazaret_core::configuration::ConfinedPathPlan;
|
||||
use oci_client::Reference;
|
||||
use oci_client::client::{Client, ClientConfig};
|
||||
use oci_client::errors::{OciDistributionError, OciErrorCode};
|
||||
use oci_client::manifest::{IMAGE_LAYER_GZIP_MEDIA_TYPE, IMAGE_LAYER_MEDIA_TYPE, OciImageManifest};
|
||||
use oci_client::secrets::RegistryAuth;
|
||||
use rustix::fs::{
|
||||
|
|
@ -19,6 +20,8 @@ use rustix::fs::{
|
|||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{PreparedLowerError, PreparedLowerResult};
|
||||
|
||||
const OCI_LAYER_ZSTD: &str = "application/vnd.oci.image.layer.v1.tar+zstd";
|
||||
const MAX_CONFIG_BYTES: usize = 16 * 1024 * 1024;
|
||||
const MAX_LAYER_BYTES: u64 = 32 * 1024 * 1024 * 1024;
|
||||
|
|
@ -97,11 +100,15 @@ impl TransportedImage {
|
|||
///
|
||||
/// # 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(())
|
||||
pub fn cleanup(mut self) -> PreparedLowerResult<()> {
|
||||
self.cleanup_inner()
|
||||
.map_err(PreparedLowerError::publication)
|
||||
}
|
||||
|
||||
fn cleanup_inner(&mut self) -> Result<()> {
|
||||
self.retained_layout
|
||||
.as_mut()
|
||||
.map_or(Ok(()), cleanup_retained_layout)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -127,7 +134,7 @@ impl OciRegistryTransport {
|
|||
credentials: &RegistryCredentials,
|
||||
platform: &Platform,
|
||||
layout_path: &Path,
|
||||
) -> Result<TransportedImage> {
|
||||
) -> PreparedLowerResult<TransportedImage> {
|
||||
self.pull_with_config(
|
||||
reference,
|
||||
credentials,
|
||||
|
|
@ -150,7 +157,7 @@ impl OciRegistryTransport {
|
|||
platform: &Platform,
|
||||
storage_root: &ConfinedPathPlan,
|
||||
staging_id: [u8; 32],
|
||||
) -> Result<TransportedImage> {
|
||||
) -> PreparedLowerResult<TransportedImage> {
|
||||
self.pull_to(
|
||||
reference,
|
||||
credentials,
|
||||
|
|
@ -171,7 +178,7 @@ impl OciRegistryTransport {
|
|||
platform: &Platform,
|
||||
layout_path: &Path,
|
||||
config: ClientConfig,
|
||||
) -> Result<TransportedImage> {
|
||||
) -> PreparedLowerResult<TransportedImage> {
|
||||
self.pull_to(
|
||||
reference,
|
||||
credentials,
|
||||
|
|
@ -189,9 +196,11 @@ impl OciRegistryTransport {
|
|||
platform: &Platform,
|
||||
destination: LayoutDestination<'_>,
|
||||
mut config: ClientConfig,
|
||||
) -> Result<TransportedImage> {
|
||||
validate_credentials(credentials)?;
|
||||
let reference = Reference::from_str(reference).context("invalid OCI image reference")?;
|
||||
) -> PreparedLowerResult<TransportedImage> {
|
||||
validate_credentials(credentials).map_err(PreparedLowerError::authentication)?;
|
||||
let reference = Reference::from_str(reference)
|
||||
.context("invalid OCI image reference")
|
||||
.map_err(PreparedLowerError::transport)?;
|
||||
let selected_platform = platform.clone();
|
||||
config.max_concurrent_download = 1;
|
||||
config.platform_resolver = Some(Box::new(move |entries| {
|
||||
|
|
@ -217,54 +226,37 @@ impl OciRegistryTransport {
|
|||
let (manifest, manifest_digest, config_json) = client
|
||||
.pull_manifest_and_config(&reference, &auth)
|
||||
.await
|
||||
.context("pulling selected OCI manifest and config")?;
|
||||
.map_err(|error| {
|
||||
distribution_error(error, "pulling selected OCI manifest and config")
|
||||
})?;
|
||||
if config_json.len() > MAX_CONFIG_BYTES {
|
||||
bail!("OCI config exceeds {MAX_CONFIG_BYTES} bytes");
|
||||
return Err(PreparedLowerError::manifest_selection(anyhow::anyhow!(
|
||||
"OCI config exceeds {MAX_CONFIG_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
verify_platform(&config_json, platform)?;
|
||||
validate_manifest(&manifest)?;
|
||||
let layout = LayoutWriter::create(destination).context("creating private OCI layout")?;
|
||||
verify_platform(&config_json, platform).map_err(PreparedLowerError::platform_selection)?;
|
||||
validate_manifest(&manifest).map_err(PreparedLowerError::manifest_selection)?;
|
||||
let layout = LayoutWriter::create(destination)
|
||||
.context("creating private OCI layout")
|
||||
.map_err(PreparedLowerError::confinement)?;
|
||||
layout
|
||||
.write_verified_blob(&manifest.config.digest, config_json.as_bytes())
|
||||
.context("writing verified OCI config")?;
|
||||
for descriptor in &manifest.layers {
|
||||
let size = u64::try_from(descriptor.size).context("negative OCI layer size")?;
|
||||
if size > MAX_LAYER_BYTES {
|
||||
bail!("OCI layer exceeds {MAX_LAYER_BYTES} bytes");
|
||||
}
|
||||
let digest_component = digest_component(&descriptor.digest)?;
|
||||
if layout.blob_exists(digest_component)? {
|
||||
layout.require_existing_blob(&descriptor.digest, size)?;
|
||||
continue;
|
||||
}
|
||||
let (file, temporary) = layout
|
||||
.create_blob(digest_component)
|
||||
.with_context(|| format!("creating OCI layer {}", descriptor.digest))?;
|
||||
let file = tokio::fs::File::from_std(file);
|
||||
if let Err(error) = client.pull_blob(&reference, descriptor, file).await {
|
||||
layout.remove_temporary_blob(&temporary);
|
||||
return Err(error)
|
||||
.with_context(|| format!("pulling OCI layer {}", descriptor.digest));
|
||||
}
|
||||
let observed = layout
|
||||
.temporary_blob_size(&temporary)
|
||||
.with_context(|| format!("measuring OCI layer {}", descriptor.digest))?;
|
||||
if observed != size {
|
||||
bail!("OCI layer {} size mismatch", descriptor.digest);
|
||||
}
|
||||
layout
|
||||
.publish_blob(&temporary, digest_component)
|
||||
.with_context(|| format!("publishing OCI layer {}", descriptor.digest))?;
|
||||
}
|
||||
let local_manifest = serde_json::to_vec(&manifest)?;
|
||||
.context("writing verified OCI config")
|
||||
.map_err(PreparedLowerError::publication)?;
|
||||
pull_layers(&client, &reference, &manifest, &layout).await?;
|
||||
let local_manifest =
|
||||
serde_json::to_vec(&manifest).map_err(PreparedLowerError::manifest_selection)?;
|
||||
let local_digest = format!("sha256:{}", hex(&Sha256::digest(&local_manifest)));
|
||||
layout
|
||||
.write_verified_blob(&local_digest, &local_manifest)
|
||||
.context("writing verified OCI manifest")?;
|
||||
.context("writing verified OCI manifest")
|
||||
.map_err(PreparedLowerError::publication)?;
|
||||
layout
|
||||
.write_layout_index(&local_digest, local_manifest.len())
|
||||
.context("writing OCI layout index")?;
|
||||
let selected_manifest_digest = parse_sha256(&local_digest)?;
|
||||
.context("writing OCI layout index")
|
||||
.map_err(PreparedLowerError::publication)?;
|
||||
let selected_manifest_digest =
|
||||
parse_sha256(&local_digest).map_err(PreparedLowerError::manifest_selection)?;
|
||||
let source_identity = format!(
|
||||
"oci-v1\0{}\0{}\0{manifest_digest}",
|
||||
reference.registry(),
|
||||
|
|
@ -280,6 +272,115 @@ impl OciRegistryTransport {
|
|||
}
|
||||
}
|
||||
|
||||
async fn pull_layers(
|
||||
client: &Client,
|
||||
reference: &Reference,
|
||||
manifest: &OciImageManifest,
|
||||
layout: &LayoutWriter,
|
||||
) -> PreparedLowerResult<()> {
|
||||
for descriptor in &manifest.layers {
|
||||
let size = u64::try_from(descriptor.size)
|
||||
.context("negative OCI layer size")
|
||||
.map_err(PreparedLowerError::manifest_selection)?;
|
||||
if size > MAX_LAYER_BYTES {
|
||||
return Err(PreparedLowerError::layer_validation(anyhow::anyhow!(
|
||||
"OCI layer exceeds {MAX_LAYER_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
let digest_component =
|
||||
digest_component(&descriptor.digest).map_err(PreparedLowerError::manifest_selection)?;
|
||||
if layout
|
||||
.blob_exists(digest_component)
|
||||
.map_err(PreparedLowerError::operating_system)?
|
||||
{
|
||||
layout
|
||||
.require_existing_blob(&descriptor.digest, size)
|
||||
.map_err(PreparedLowerError::layer_validation)?;
|
||||
continue;
|
||||
}
|
||||
let (file, temporary) = layout
|
||||
.create_blob(digest_component)
|
||||
.with_context(|| format!("creating OCI layer {}", descriptor.digest))
|
||||
.map_err(PreparedLowerError::materialization)?;
|
||||
let file = tokio::fs::File::from_std(file);
|
||||
if let Err(error) = client.pull_blob(reference, descriptor, file).await {
|
||||
layout.remove_temporary_blob(&temporary);
|
||||
return Err(distribution_error(
|
||||
error,
|
||||
format!("pulling OCI layer {}", descriptor.digest),
|
||||
));
|
||||
}
|
||||
let observed = layout
|
||||
.temporary_blob_size(&temporary)
|
||||
.with_context(|| format!("measuring OCI layer {}", descriptor.digest))
|
||||
.map_err(PreparedLowerError::operating_system)?;
|
||||
if observed != size {
|
||||
return Err(PreparedLowerError::layer_validation(anyhow::anyhow!(
|
||||
"OCI layer {} size mismatch",
|
||||
descriptor.digest
|
||||
)));
|
||||
}
|
||||
layout
|
||||
.publish_blob(&temporary, digest_component)
|
||||
.with_context(|| format!("publishing OCI layer {}", descriptor.digest))
|
||||
.map_err(PreparedLowerError::publication)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn distribution_error(
|
||||
error: OciDistributionError,
|
||||
context: impl Into<String>,
|
||||
) -> PreparedLowerError {
|
||||
let authentication = matches!(
|
||||
&error,
|
||||
OciDistributionError::AuthenticationFailure(_)
|
||||
| OciDistributionError::RegistryTokenDecodeError(_)
|
||||
| OciDistributionError::UnauthorizedError { .. }
|
||||
| OciDistributionError::ServerError {
|
||||
code: 401 | 403,
|
||||
..
|
||||
}
|
||||
) || matches!(
|
||||
&error,
|
||||
OciDistributionError::RegistryError { envelope, .. }
|
||||
if envelope.errors.iter().any(|error| {
|
||||
matches!(error.code, OciErrorCode::Unauthorized | OciErrorCode::Denied)
|
||||
})
|
||||
) || matches!(
|
||||
&error,
|
||||
OciDistributionError::RequestError(error)
|
||||
if error.status().is_some_and(|status| matches!(status.as_u16(), 401 | 403))
|
||||
);
|
||||
let platform = matches!(
|
||||
&error,
|
||||
OciDistributionError::ImageIndexParsingNoPlatformResolverError
|
||||
);
|
||||
let manifest = matches!(
|
||||
&error,
|
||||
OciDistributionError::ConfigConversionError(_)
|
||||
| OciDistributionError::ImageManifestNotFoundError(_)
|
||||
| OciDistributionError::IncompatibleLayerMediaTypeError(_)
|
||||
| OciDistributionError::JsonError(_)
|
||||
| OciDistributionError::ManifestEncodingError(_)
|
||||
| OciDistributionError::ManifestParsingError(_)
|
||||
| OciDistributionError::PullNoLayersError
|
||||
| OciDistributionError::UnsupportedMediaTypeError(_)
|
||||
| OciDistributionError::UnsupportedSchemaVersionError(_)
|
||||
| OciDistributionError::VersionedParsingError(_)
|
||||
);
|
||||
let source = anyhow::Error::new(error).context(context.into());
|
||||
if authentication {
|
||||
PreparedLowerError::authentication(source)
|
||||
} else if platform {
|
||||
PreparedLowerError::platform_selection(source)
|
||||
} else if manifest {
|
||||
PreparedLowerError::manifest_selection(source)
|
||||
} else {
|
||||
PreparedLowerError::transport(source)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ImageConfigPlatform {
|
||||
os: String,
|
||||
|
|
@ -726,146 +827,4 @@ fn hex(bytes: &[u8]) -> String {
|
|||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use oci_client::client::ClientProtocol;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn atomic_layout_write_accepts_a_root_level_destination() {
|
||||
let tree = tempfile::tempdir().unwrap();
|
||||
let root = File::open(tree.path()).unwrap();
|
||||
write_relative_atomic(&root, Path::new("oci-layout"), b"layout").unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(tree.path().join("oci-layout")).unwrap(),
|
||||
b"layout"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confined_read_open_uses_an_empty_creation_mode() {
|
||||
let tree = tempfile::tempdir().unwrap();
|
||||
let root = File::open(tree.path()).unwrap();
|
||||
let error = open_relative_file(&root, Path::new("absent"), OFlags::RDONLY).unwrap_err();
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preexisting_transport_parents_require_exact_private_mode() {
|
||||
for name in ["image-transports", "blobs", "sha256"] {
|
||||
let tree = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(tree.path().join(name)).unwrap();
|
||||
std::fs::set_permissions(
|
||||
tree.path().join(name),
|
||||
std::fs::Permissions::from_mode(0o777),
|
||||
)
|
||||
.unwrap();
|
||||
let parent = File::open(tree.path()).unwrap();
|
||||
assert!(
|
||||
create_directory(&parent, name).is_err(),
|
||||
"accepted wrong mode for {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_mismatched_and_incomplete_platforms() {
|
||||
assert!(
|
||||
verify_platform(
|
||||
r#"{"os":"linux","architecture":"amd64"}"#,
|
||||
&Platform::linux("amd64")
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
verify_platform(
|
||||
r#"{"os":"linux","architecture":"arm64"}"#,
|
||||
&Platform::linux("amd64")
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
verify_platform(
|
||||
r#"{"os":"linux","architecture":"amd64","variant":"v2"}"#,
|
||||
&Platform::linux("amd64")
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(verify_platform(r#"{"architecture":"amd64"}"#, &Platform::linux("amd64")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_authentication_refuses_without_layout_or_secret_disclosure() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.unwrap();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let result = runtime.block_on(OciRegistryTransport.pull_with_config(
|
||||
"registry.invalid/private/image:latest",
|
||||
&RegistryCredentials::Basic {
|
||||
username: "developer".to_owned(),
|
||||
password: String::new(),
|
||||
},
|
||||
&Platform::linux("amd64"),
|
||||
&temp.path().join("layout"),
|
||||
ClientConfig::default(),
|
||||
));
|
||||
let error = result.unwrap_err().to_string();
|
||||
assert!(error.contains("credentials are incomplete"));
|
||||
assert!(!error.contains("developer"));
|
||||
assert!(!temp.path().join("layout").exists());
|
||||
assert_eq!(
|
||||
format!(
|
||||
"{:?}",
|
||||
RegistryCredentials::Bearer("not-a-real-token".to_owned())
|
||||
),
|
||||
"Bearer(\"[redacted]\")"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_authentication_failure_refuses_before_layout_creation() {
|
||||
let listener = match TcpListener::bind("127.0.0.1:0") {
|
||||
Ok(listener) => listener,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return,
|
||||
Err(error) => panic!("binding test registry: {error}"),
|
||||
};
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = [0; 4096];
|
||||
assert!(stream.read(&mut request).unwrap() > 0);
|
||||
stream
|
||||
.write_all(
|
||||
b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let result = runtime.block_on(OciRegistryTransport.pull_with_config(
|
||||
&format!("{address}/private/image:latest"),
|
||||
&RegistryCredentials::Basic {
|
||||
username: "wrong".to_owned(),
|
||||
password: "wrong".to_owned(),
|
||||
},
|
||||
&Platform::linux("amd64"),
|
||||
&temp.path().join("layout"),
|
||||
ClientConfig {
|
||||
protocol: ClientProtocol::Http,
|
||||
..ClientConfig::default()
|
||||
},
|
||||
));
|
||||
assert!(result.is_err());
|
||||
assert!(!temp.path().join("layout").exists());
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
include!("transport/tests.rs");
|
||||
|
|
|
|||
Loading…
Reference in a new issue