From 8d1e16f48396a68b63db02cf76242cf55663a9be Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Thu, 24 Sep 2026 11:54:39 -0400 Subject: [PATCH] Make it work - add www-authenticate support to request auth tokens - follow redirects to CDNs - add media type hints for config, as they often don't contain their own type - track nest path to problematic fields in json parsing - add basic tests Signed-off-by: Scott Andrews --- components/client/src/lib.rs | 1287 ++++++++++++++--- .../componentized-oci-0.0.0-dev/package.wit | 82 +- wit/client.wit | 25 +- 3 files changed, 1136 insertions(+), 258 deletions(-) diff --git a/components/client/src/lib.rs b/components/client/src/lib.rs index eb7662f..17d0746 100644 --- a/components/client/src/lib.rs +++ b/components/client/src/lib.rs @@ -9,7 +9,7 @@ use url::Url; use wit_bindgen::StreamReader; use crate::{ - componentized::http::client as http, + componentized::http::client::{self as http, HttpResponse}, exports::componentized::oci::client::{ Config, Digest, ErrorCode, Guest, Instant, Manifest, MediaType::{self, Other}, @@ -60,7 +60,7 @@ impl Guest for OCIClient { headers, body, .. - } = http::get(url, vec![], None).await?; + } = Self::get(url, vec![]).await?; match status { 200 => Self::compute_digest("sha256", &body.collect().await), @@ -86,7 +86,7 @@ impl Guest for OCIClient { headers, body, .. - } = http::get(url, vec![], None).await?; + } = Self::get(url, vec![]).await?; let raw = match status { 200 => Ok(body.collect().await), @@ -99,23 +99,20 @@ impl Guest for OCIClient { } #[allow(async_fn_in_trait)] - async fn get_config(reference: Reference) -> Result { + async fn get_config( + reference: Reference, + default_media_type: Option, + ) -> Result { let blob = Self::get_blob(reference).await?; - let parsed: Value = serde_json::from_slice(&blob)?; - let media_type = Self::required( - Self::parse_media_type(&parsed["mediaType"], "mediaType"), - "mediaType", - )?; - match media_type { - MediaType::ApplicationVndOciImageConfigV1(MediaTypeSuffix::Json) => { - Self::required(Self::parse_oci_image_config_v1(&parsed, ""), "") - } - MediaType::ApplicationVndWasmConfigV0(MediaTypeSuffix::Json) => { - Self::required(Self::parse_wasm_config_v0(&parsed, ""), "") - } - _ => Ok(Config::Other(blob)), - } + Self::required( + Self::parse_config( + &serde_json::from_slice(&blob)?, + "$config", + default_media_type, + ), + "$config", + ) } #[allow(async_fn_in_trait)] @@ -139,7 +136,9 @@ impl Guest for OCIClient { headers, body, .. - } = http::get(url, vec![], None).await?; + } = Self::get(url, vec![ + ("Accept".to_string(), "application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json".to_string()) + ]).await?; let raw = match status { 200 => body.collect().await, @@ -149,25 +148,166 @@ impl Guest for OCIClient { // check if manifest was requested by digest, ignore if requested by tag Self::assert_digest(digest, &raw)? } - let parsed: Value = serde_json::from_slice(&raw)?; + Self::required( + Self::parse_manifest(&serde_json::from_slice(&raw)?, "$manifest"), + "$manifest", + ) + } +} - let media_type = Self::required( - Self::parse_media_type(&parsed["mediaType"], "mediaType"), - "mediaType", - )?; - match media_type { - MediaType::ApplicationVndOciImageIndexV1(MediaTypeSuffix::Json) => { - Self::required(Self::parse_oci_image_index_v1(&parsed, ""), "") +impl OCIClient { + async fn get( + url: String, + mut headers: Vec<(String, String)>, + ) -> Result { + let response = Self::get_with_redirects(url.clone(), headers.clone()).await?; + if response.status != 401 { + return Ok(response); + } + + let challenge = response + .headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("www-authenticate")) + .and_then(|(_, v)| Self::parse_www_authenticate(v)); + let Some((scheme, params)) = challenge else { + return Ok(response); + }; + if !scheme.eq_ignore_ascii_case("bearer") { + // other schemes (e.g. basic) require credentials, which are not supported yet + return Ok(response); + } + + // https://distribution.github.io/distribution/spec/auth/token/ + let token = Self::fetch_token(¶ms).await?; + headers.retain(|(k, _)| !k.eq_ignore_ascii_case("authorization")); + headers.push(("Authorization".to_string(), format!("Bearer {token}"))); + + // a second 401 is returned to the caller and decoded as a transport error + Self::get_with_redirects(url, headers).await + } + + /// Issues a GET request, following redirects (e.g. registries redirecting + /// blob downloads to a CDN). The `Authorization` header is dropped when a + /// redirect leaves the original origin, as pre-signed storage URLs reject + /// unexpected credentials and the token must not leak to other hosts. + async fn get_with_redirects( + url: String, + mut headers: Vec<(String, String)>, + ) -> Result { + const MAX_REDIRECTS: usize = 10; + + let mut url = Url::parse(&url) + .map_err(|e| ErrorCode::Other(Some(format!("invalid url {url}: {e}"))))?; + for _ in 0..=MAX_REDIRECTS { + let response = http::get(url.to_string(), headers.clone(), None).await?; + if !matches!(response.status, 301 | 302 | 303 | 307 | 308) { + return Ok(response); } - MediaType::ApplicationVndOciImageManifestV1(MediaTypeSuffix::Json) => { - Self::required(Self::parse_oci_image_manifest_v1(&parsed, ""), "") + let Some(location) = response + .headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("location")) + .map(|(_, v)| v.clone()) + else { + return Ok(response); + }; + + let next = url.join(&location).map_err(|e| { + ErrorCode::Other(Some(format!("invalid redirect location {location}: {e}"))) + })?; + if next.origin() != url.origin() { + headers.retain(|(k, _)| !k.eq_ignore_ascii_case("authorization")); } - _ => Ok(Manifest::Other(raw)), + url = next; } + + Err(ErrorCode::Other(Some(format!( + "too many redirects, stopped at {url}" + )))) + } + + async fn fetch_token(params: &BTreeMap) -> Result { + let realm = params.get("realm").ok_or_else(|| { + ErrorCode::Unauthorized("bearer challenge is missing realm".to_string()) + })?; + let mut url = Url::parse(realm) + .map_err(|e| ErrorCode::Unauthorized(format!("invalid token realm {realm}: {e}")))?; + { + let mut query = url.query_pairs_mut(); + for key in ["service", "scope"] { + if let Some(value) = params.get(key) { + query.append_pair(key, value); + } + } + } + + let http::HttpResponse { status, body, .. } = + Self::get_with_redirects(url.to_string(), vec![]).await?; + let body = body.collect().await; + if status != 200 { + return Err(ErrorCode::Unauthorized(format!( + "token request to {realm} failed with status {status}" + ))); + } + + let TokenResponse { + token, + access_token, + } = serde_json::from_slice(&body)?; + token.or(access_token).ok_or_else(|| { + ErrorCode::Unauthorized(format!("token response from {realm} is missing token")) + }) + } + + /// Parses a single `WWW-Authenticate` challenge into its scheme and + /// lower-cased auth-params, e.g. + /// `Bearer realm="https://auth.example/token",service="example",scope="repository:foo:pull"` + fn parse_www_authenticate(value: &str) -> Option<(String, BTreeMap)> { + let value = value.trim(); + let (scheme, rest) = value.split_once(char::is_whitespace).unwrap_or((value, "")); + if scheme.is_empty() { + return None; + } + + let mut params = BTreeMap::new(); + let mut chars = rest.chars().peekable(); + loop { + while chars.next_if(|c| c.is_whitespace() || *c == ',').is_some() {} + if chars.peek().is_none() { + break; + } + + let mut key = String::new(); + while let Some(c) = chars.next_if(|c| *c != '=' && *c != ',') { + key.push(c); + } + if chars.next_if_eq(&'=').is_none() { + // token68 or malformed param, not used by registries + continue; + } + while chars.next_if(|c| c.is_whitespace()).is_some() {} + + let mut val = String::new(); + if chars.next_if_eq(&'"').is_some() { + while let Some(c) = chars.next() { + match c { + '\\' => val.extend(chars.next()), + '"' => break, + _ => val.push(c), + } + } + } else { + while let Some(c) = chars.next_if(|c| *c != ',') { + val.push(c); + } + } + params.insert(key.trim().to_ascii_lowercase(), val.trim().to_string()); + } + + Some((scheme.to_string(), params)) } -} -impl OCIClient { fn tag_reference(reference: String) -> Result { let mut base = reference.clone(); let mut tag = String::from(""); @@ -200,7 +340,9 @@ impl OCIClient { // Split on "@" let parts: Vec<&str> = reference.split('@').collect(); if parts.len() != 2 { - return Err(ErrorCode::Other(Some(format!("a digest must contain exactly one '@' separator (e.g. registry/repository@digest) saw: {reference}")))); + return Err(ErrorCode::Other(Some(format!( + "a digest must contain exactly one '@' separator (e.g. registry/repository@digest) saw: {reference}" + )))); } let base = parts.get(0).unwrap().to_string(); let digest = parts.get(1).unwrap().to_string(); @@ -384,11 +526,16 @@ impl OCIClient { let transport_errors: serde_json::Result = serde_json::from_slice(&body); if transport_errors.is_err() { - return ErrorCode::Other(Some("unknown transport error".to_string())); + return ErrorCode::Other(Some(format!( + "transport error with http status {status}: {}", + transport_errors.unwrap_err() + ))); } let transport_errors = transport_errors.unwrap(); if transport_errors.errors.len() == 0 { - return ErrorCode::Other(Some("unknown transport error".to_string())); + return ErrorCode::Other(Some( + "transport error with http status {status}: unspecified errors".to_string(), + )); } let error = transport_errors.errors.get(0).unwrap(); @@ -458,26 +605,55 @@ impl OCIClient { } } + fn parse_manifest(v: &Value, field: &str) -> Result, ErrorCode> { + let media_type = Self::required( + Self::parse_media_type(&v["mediaType"], &format!("{field}.mediaType")), + "mediaType", + )?; + match media_type { + MediaType::ApplicationVndOciImageIndexV1(MediaTypeSuffix::Json) => { + Self::parse_oci_image_index_v1(&v, field) + } + MediaType::ApplicationVndOciImageManifestV1(MediaTypeSuffix::Json) => { + Self::parse_oci_image_manifest_v1(&v, field) + } + _ => Ok(Some(Manifest::Other(serde_json::to_vec(v)?))), + } + } + fn parse_oci_image_index_v1(v: &Value, field: &str) -> Result, ErrorCode> { Self::parse_object(v, field, |v, _field| { Ok(Manifest::OciImageIndexV1(OciImageIndexManifestV1 { schema_version: Self::required( - Self::parse_schema_version(&v["schemaVersion"], "schemaVersion"), + Self::parse_schema_version( + &v["schemaVersion"], + &format!("{field}.schemaVersion"), + ), "schemaVersion", )?, media_type: Self::required( - Self::parse_media_type(&v["mediaType"], "mediaType"), + Self::parse_media_type(&v["mediaType"], &format!("{field}.mediaType")), "mediaType", )?, - artifact_type: Self::parse_media_type(&v["artifactType"], "artifactType")?, + artifact_type: Self::parse_media_type( + &v["artifactType"], + &format!("{field}.artifactType"), + )?, manifests: Self::required( - Self::parse_list(&v["manifests"], "manifests", |v, field| { - Self::required(Self::parse_oci_image_index_v1_manifest(v, field), field) - }), + Self::parse_list( + &v["manifests"], + &format!("{field}.manifests"), + |v, field| { + Self::required(Self::parse_oci_image_index_v1_manifest(v, field), field) + }, + ), "manifest", )?, - subject: Self::parse_oci_descriptor_v1(&v["subject"], "subject")?, - annotations: Self::parse_string_map(&v["annotations"], "annotations")?, + subject: Self::parse_oci_descriptor_v1(&v["subject"], &format!("{field}.subject"))?, + annotations: Self::parse_string_map( + &v["annotations"], + &format!("{field}.annotations"), + )?, })) }) } @@ -486,18 +662,35 @@ impl OCIClient { v: &Value, field: &str, ) -> Result, ErrorCode> { - Self::parse_object(v, field, |v, _field| { + Self::parse_object(v, field, |v, field| { Ok(OciImageIndexManifestV1Manifest { media_type: Self::required( - Self::parse_media_type(&v["mediaType"], "mediaType"), + Self::parse_media_type(&v["mediaType"], &format!("{field}.mediaType")), "mediaType", )?, platform: Self::parse_oci_image_index_v1_manifest_platform( &v["platform"], - "platform", + &format!("{field}.platform"), + )?, + subject: Self::parse_oci_descriptor_v1(&v["subject"], &format!("{field}.subject"))?, + annotations: Self::parse_string_map( + &v["annotations"], + &format!("{field}.annotations"), + )?, + artifact_type: Self::parse_media_type( + &v["artifactType"], + &format!("{field}.artifactType"), )?, - subject: Self::parse_oci_descriptor_v1(&v["subject"], "subject")?, - annotations: Self::parse_string_map(&v["annotations"], "annotations")?, + data: Self::parse_string(&v["data"], &format!("{field}.data"))?, + digest: Self::required( + Self::parse_digest(&v["digest"], &format!("{field}.digest")), + &format!("{field}.digest"), + )?, + size: Self::required( + Self::parse_u64(&v["size"], &format!("{field}.size")), + &format!("{field}.size"), + )?, + urls: Self::parse_string_list(&v["urls"], &format!("{field}.urls"))?, }) }) } @@ -509,13 +702,19 @@ impl OCIClient { Self::parse_object(v, field, |v, _field| { Ok(OciImageIndexManifestV1ManifestPlatform { architecture: Self::required( - Self::parse_string(&v["architecture"], "architecture"), + Self::parse_string(&v["architecture"], &format!("{field}.architecture")), "architecture", )?, - os: Self::required(Self::parse_string(&v["os"], "os"), "os")?, - os_version: Self::parse_string(&v["os.version"], "os.version")?, - os_features: Self::parse_string_list(&v["os.features"], "os.features")?, - variant: Self::parse_string(&v["variant"], "variant")?, + os: Self::required( + Self::parse_string(&v["os"], &format!("{field}.os")), + &format!("{field}.os"), + )?, + os_version: Self::parse_string(&v["os.version"], &format!("{field}.os.version"))?, + os_features: Self::parse_string_list( + &v["os.features"], + &format!("{field}.os.features"), + )?, + variant: Self::parse_string(&v["variant"], &format!("{field}.variant"))?, }) }) } @@ -527,15 +726,29 @@ impl OCIClient { Self::parse_object(v, field, |v, _field| { Ok(OciDescriptorV1 { media_type: Self::required( - Self::parse_media_type(&v["mediaType"], "mediaType"), + Self::parse_media_type(&v["mediaType"], &format!("{field}.mediaType")), "mediaType", )?, - digest: Self::required(Self::parse_digest(&v["digest"], "digest"), "digest")?, - size: Self::required(Self::parse_u64(&v["size"], "size"), "size")?, - urls: Self::parse_list(&v["urls"], "urls", |v, field| { + digest: Self::required( + Self::parse_digest(&v["digest"], &format!("{field}.digest")), + &format!("{field}.digest"), + )?, + size: Self::required( + Self::parse_u64(&v["size"], &format!("{field}.size")), + &format!("{field}.size"), + )?, + urls: Self::parse_list(&v["urls"], &format!("{field}.urls"), |v, field| { Self::required(Self::parse_string(v, field), field) })?, - annotations: Self::parse_string_map(&v["annotations"], "annotations")?, + annotations: Self::parse_string_map( + &v["annotations"], + &format!("{field}.annotations"), + )?, + artifact_type: Self::parse_media_type( + &v["artifactType"], + &format!("{field}.artifactType"), + )?, + data: Self::parse_string(&v["data"], &format!("{field}.data"))?, }) }) } @@ -544,107 +757,170 @@ impl OCIClient { Self::parse_object(v, field, |v, _field| { Ok(Manifest::OciImageV1(OciImageManifestV1 { schema_version: Self::required( - Self::parse_schema_version(&v["schemaVersion"], "schemaVersion"), - "schemaVersion", + Self::parse_schema_version( + &v["schemaVersion"], + &format!("{field}.schemaVersion"), + ), + &format!("{field}.schemaVersion"), )?, media_type: Self::required( - Self::parse_media_type(&v["mediaType"], "mediaType"), - "mediaType", + Self::parse_media_type(&v["mediaType"], &format!("{field}.mediaType")), + &format!("{field}.mediaType"), + )?, + artifact_type: Self::parse_media_type( + &v["artifactType"], + &format!("{field}.artifactType"), )?, - artifact_type: Self::parse_media_type(&v["artifactType"], "artifactType")?, config: Self::required( - Self::parse_oci_descriptor_v1(&v["config"], "config"), - "config", + Self::parse_oci_descriptor_v1(&v["config"], &format!("{field}.config")), + &format!("{field}.config"), )?, layers: Self::required( - Self::parse_list(&v["layers"], "layers", |v, field| { + Self::parse_list(&v["layers"], &format!("{field}.layers"), |v, field| { Self::required(Self::parse_oci_descriptor_v1(v, field), field) }), - "layers", + &format!("{field}.layers"), + )?, + subject: Self::parse_oci_descriptor_v1(&v["subject"], &format!("{field}.subject"))?, + annotations: Self::parse_string_map( + &v["annotations"], + &format!("{field}.annotations"), )?, - subject: Self::parse_oci_descriptor_v1(&v["subject"], "subject")?, - annotations: Self::parse_string_map(&v["annotations"], "annotations")?, })) }) } + fn parse_config( + v: &Value, + field: &str, + default_media_type: Option, + ) -> Result, ErrorCode> { + let media_type = Self::required( + Self::parse_media_type(&v["mediaType"], &format!("{field}.mediaType")) + .map(|mt| mt.or(default_media_type)), + &format!("{field}.mediaType"), + )?; + match media_type { + MediaType::ApplicationVndOciImageConfigV1(MediaTypeSuffix::Json) => { + Self::parse_oci_image_config_v1(&v, field) + } + MediaType::ApplicationVndWasmConfigV0(MediaTypeSuffix::Json) => { + Self::parse_wasm_config_v0(&v, field) + } + _ => Ok(Some(Config::Other(serde_json::to_vec(v)?))), + } + } + fn parse_oci_image_config_v1(v: &Value, field: &str) -> Result, ErrorCode> { Self::parse_object(v, field, |v, _field| { Ok(Config::OciImageV1(OciImageConfigV1 { - created: Self::parse_instant(&v["created"], "crated")?, - author: Self::parse_string(&v["author"], "author")?, + created: Self::parse_instant(&v["created"], &format!("{field}.crated"))?, + author: Self::parse_string(&v["author"], &format!("{field}.author"))?, architecture: Self::required( - Self::parse_string(&v["architecture"], "architecture"), - "architecture", + Self::parse_string(&v["architecture"], &format!("{field}.architecture")), + &format!("{field}.architecture"), + )?, + os: Self::required( + Self::parse_string(&v["os"], &format!("{field}.os")), + &format!("{field}.os"), + )?, + os_version: Self::parse_string(&v["os.version"], &format!("{field}.os.version"))?, + os_features: Self::parse_string_list( + &v["os.features"], + &format!("{field}.os.features"), + )?, + variant: Self::parse_string(&v["variant"], &format!("{field}.variant"))?, + config: Self::parse_object( + &v["config"], + &format!("{field}.config"), + |v, field| { + Ok(OciImageConfigV1Config { + user: Self::parse_string(&v["User"], &format!("{field}.User"))?, + exposed_ports: Self::parse_string_set( + &v["ExposedPorts"], + &format!("{field}.ExposedPorts"), + )?, + env: Self::parse_string_list(&v["Env"], &format!("{field}.Env"))?, + entrypoint: Self::parse_string_list( + &v["Entrypoint"], + &format!("{field}.Entrypoint"), + )?, + cmd: Self::parse_string_list(&v["Cmd"], &format!("{field}.Cmd"))?, + volumes: Self::parse_string_set( + &v["Volumes"], + &format!("{field}.Volumes"), + )?, + working_dir: Self::parse_string( + &v["WorkingDir"], + &format!("{field}.WorkingDir"), + )?, + labels: Self::parse_string_map( + &v["Labels"], + &format!("{field}.Labels"), + )?, + stop_signal: Self::parse_string( + &v["StopSignal"], + &format!("{field}.StopSignal"), + )?, + args_escaped: Self::parse_bool( + &v["ArgsEscaped"], + &format!("{field}.ArgsEscaped"), + )?, + }) + }, )?, - os: Self::required(Self::parse_string(&v["os"], "os"), "os")?, - os_version: Self::parse_string(&v["os.version"], "os.version")?, - os_features: Self::parse_string_list(&v["os.features"], "os.features")?, - variant: Self::parse_string(&v["variant"], "variant")?, - config: match &v["config"] { - Value::Object(v) => Some(OciImageConfigV1Config { - user: Self::parse_string(&v["User"], "User")?, - exposed_ports: Self::parse_string_set(&v["ExposedPorts"], "ExposedPorts")?, - env: Self::parse_string_list(&v["Env"], "Env")?, - entrypoint: Self::parse_string_list(&v["Entrypoint"], "Entrypoint")?, - cmd: Self::parse_string_list(&v["Cmd"], "Cmd")?, - volumes: Self::parse_string_set(&v["Volumes"], "Volumes")?, - working_dir: Self::parse_string(&v["WorkingDir"], "WorkingDir")?, - labels: Self::parse_string_map(&v["Labels"], "Labels")?, - stop_signal: Self::parse_string(&v["StopSignal"], "StopSignal")?, - args_escaped: Self::parse_bool(&v["ArgsEscaped"], "ArgsEscaped")?, + rootfs: Self::required( + Self::parse_object(&v["rootfs"], &format!("{field}.rootfs"), |v, field| { + Ok(OciImageConfigV1ContentAddresses { + type_: Self::required( + Self::parse_string(&v["type"], &format!("{field}.type")), + &format!("{field}.type"), + )?, + diff_ids: Self::required( + Self::parse_list( + &v["diff_ids"], + &format!("{field}.diff_ids"), + |v, field| Self::required(Self::parse_digest(v, field), field), + ), + &format!("{field}.diff_ids"), + )?, + }) }), - Value::Null => None, - _ => Err(ErrorCode::Other(Some( - "expected an object for field: config".to_string(), - )))?, - }, - rootfs: match &v["rootfs"] { - Value::Object(v) => OciImageConfigV1ContentAddresses { - type_: Self::required(Self::parse_string(&v["type"], "type"), "type")?, - diff_ids: match &v["diff_ids"] { - Value::Array(values) => { - let mut diff_ids = vec![]; - for (i, v) in values.iter().enumerate() { - let field = &format!("diff_ids[{i}]"); - diff_ids - .push(Self::required(Self::parse_string(v, field), field)?); - } - diff_ids - } - Value::Null => Err(ErrorCode::Other(Some( - "missing required field: diff_ids".to_string(), - )))?, - _ => Err(ErrorCode::Other(Some( - "unexpected type for field: diff_ids".to_string(), - )))?, - }, + &format!("{field}.rootfs"), + )?, + history: Self::parse_list( + &v["history"], + &format!("{field}.history"), + |v, field| { + Self::required( + Self::parse_object(v, field, |v, field| { + Ok(OciImageConfigV1HistoryEntry { + created: Self::parse_instant( + &v["created"], + &format!("{field}.created"), + )?, + author: Self::parse_string( + &v["author"], + &format!("{field}.author"), + )?, + created_by: Self::parse_string( + &v["created_by"], + &format!("{field}.created_by"), + )?, + comment: Self::parse_string( + &v["comment"], + &format!("{field}.comment"), + )?, + empty_layer: Self::parse_bool( + &v["empty_layer"], + &format!("{field}.empty_layer"), + )?, + }) + }), + field, + ) }, - Value::Null => Err(ErrorCode::Other(Some( - "missing required field: rootfs".to_string(), - )))?, - _ => Err(ErrorCode::Other(Some( - "unknown config type, expected object".to_string(), - )))?, - }, - history: Self::parse_list(&v["history"], "history", |v, field| { - Self::required( - match v { - Value::Object(v) => Ok(Some(OciImageConfigV1HistoryEntry { - created: Self::parse_instant(&v["created"], "created")?, - author: Self::parse_string(&v["author"], "author")?, - created_by: Self::parse_string(&v["created_by"], "created_by")?, - comment: Self::parse_string(&v["comment"], "comment")?, - empty_layer: Self::parse_bool(&v["empty_layer"], "empty_layer")?, - })), - Value::Null => Ok(None), - _ => Err(ErrorCode::Other(Some( - "unknown item in history array, expected object".to_string(), - ))), - }, - field, - ) - })?, + )?, })) }) } @@ -652,71 +928,43 @@ impl OCIClient { fn parse_wasm_config_v0(v: &Value, field: &str) -> Result, ErrorCode> { Self::parse_object(v, field, |v, _field| { Ok(Config::WasmV0(WasmConfigV0 { - created: Self::parse_instant(&v["created"], "created")?, - author: Self::parse_string(&v["author"], "author")?, + created: Self::parse_instant(&v["created"], &format!("{field}.created"))?, + author: Self::parse_string(&v["author"], &format!("{field}.author"))?, architecture: Self::required( - Self::parse_string(&v["architecture"], "architecture"), - "architecture", + Self::parse_string(&v["architecture"], &format!("{field}.architecture")), + &format!("{field}.architecture"), + )?, + os: Self::required( + Self::parse_string(&v["os"], &format!("{field}.os")), + &format!("{field}.os"), + )?, + layer_digests: Self::required( + Self::parse_list( + &v["layerDigests"], + &format!("{field}.layerDigests"), + |v, field| Self::required(Self::parse_digest(v, field), field), + ), + &format!("{field}.layerDigests"), + )?, + component: Self::parse_object( + &v["component"], + &format!("{field}.component"), + |v, field| { + Ok(WasmConfigV0Component { + exports: Self::parse_string_list( + &v["exports"], + &format!("{field}.exports"), + )? + .unwrap_or(vec![]), + imports: Self::parse_string_list( + &v["imports"], + &format!("{field}.imports"), + )? + .unwrap_or(vec![]), + target: Self::parse_string(&v["target"], &format!("{field}.target"))?, + }) + }, )?, - os: Self::required(Self::parse_string(&v["os"], "os"), "os")?, - layer_digests: match &v["layerDigests"] { - Value::Array(values) => { - let mut digests = vec![]; - for (i, v) in values.iter().enumerate() { - let field = &format!("layerDigests[{i}]"); - digests.push(Self::digest(Self::required( - Self::parse_string(v, field), - field, - )?)?); - } - digests - } - Value::Null => Err(ErrorCode::Other(Some( - "missing required field: layerDigests".to_string(), - )))?, - _ => Err(ErrorCode::Other(Some( - "unknown layerDigests type, expected array".to_string(), - )))?, - }, - component: match &v["component"] { - Value::Object(component) => Some(WasmConfigV0Component { - exports: match &component["exports"] { - Value::Array(values) => { - let mut exports = vec![]; - for (i, v) in values.iter().enumerate() { - let field = &format!("exports[{i}])"); - exports - .push(Self::required(Self::parse_string(v, field), field)?); - } - exports - } - Value::Null => vec![], - _ => Err(ErrorCode::Other(Some( - "unknown exports type, expected array".to_string(), - )))?, - }, - imports: match &component["imports"] { - Value::Array(values) => { - let mut imports = vec![]; - for (i, v) in values.iter().enumerate() { - let field = &format!("imports[{i}])"); - imports - .push(Self::required(Self::parse_string(v, field), field)?); - } - imports - } - Value::Null => vec![], - _ => Err(ErrorCode::Other(Some( - "unknown imports type, expected array".to_string(), - )))?, - }, - target: Self::parse_string(&v["target"], "target")?, - }), - Value::Null => None, - _ => Err(ErrorCode::Other(Some( - "unknown component type, expected object".to_string(), - )))?, - }, })) }) } @@ -800,16 +1048,17 @@ impl OCIClient { fn parse_string_set(v: &Value, field: &str) -> Result>, ErrorCode> { Self::parse_object(v, field, |v, _field| { let mut set = vec![]; - for (k, _) in v { - set.push(k.to_string()); + if let Value::Object(v) = v { + for (k, _) in v { + set.push(k.to_string()); + } } Ok(set) }) } fn parse_media_type(v: &Value, field: &str) -> Result, ErrorCode> { - let media_type = Self::required(Self::parse_string(v, field), field)?; - Ok(Some(Self::normalize_media_type(&media_type))) + Ok(Self::parse_string(v, field)?.map(|v| Self::normalize_media_type(&v))) } fn parse_instant(v: &Value, field: &str) -> Result, ErrorCode> { @@ -874,10 +1123,10 @@ impl OCIClient { fn parse_object( v: &Value, field: &str, - mapper: impl Fn(&serde_json::Map, &str) -> Result, + mapper: impl Fn(&Value, &str) -> Result, ) -> Result, ErrorCode> { match v { - Value::Object(v) => Ok(Some(mapper(v, field)?)), + Value::Object(_) => Ok(Some(mapper(v, field)?)), Value::Null => Ok(None), _ => Err(ErrorCode::Other(Some(format!( "expected an object for field: {field}" @@ -886,6 +1135,12 @@ impl OCIClient { } } +#[derive(Deserialize, Debug)] +struct TokenResponse { + token: Option, + access_token: Option, +} + #[derive(Deserialize, Debug)] struct TransportErrors { errors: Vec, @@ -895,7 +1150,7 @@ struct TransportErrors { struct TransportError { code: String, message: String, - _detail: String, + // detail: Option, } impl From for ErrorCode { @@ -927,7 +1182,10 @@ impl Display for Digest { impl PartialEq for Reference { fn eq(&self, other: &Self) -> bool { - self.registry == other.registry && self.repository == other.repository + self.registry == other.registry + && self.repository == other.repository + && self.tag == other.tag + && self.digest == other.digest } } @@ -982,6 +1240,220 @@ impl PartialEq for Instant { } } +impl PartialEq for MediaType { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + ( + MediaType::ApplicationVndOciDescriptorV1(this), + MediaType::ApplicationVndOciDescriptorV1(other), + ) => this == other, + ( + MediaType::ApplicationVndOciLayoutHeaderV1(this), + MediaType::ApplicationVndOciLayoutHeaderV1(other), + ) => this == other, + ( + MediaType::ApplicationVndOciImageIndexV1(this), + MediaType::ApplicationVndOciImageIndexV1(other), + ) => this == other, + ( + MediaType::ApplicationVndOciImageManifestV1(this), + MediaType::ApplicationVndOciImageManifestV1(other), + ) => this == other, + ( + MediaType::ApplicationVndOciImageConfigV1(this), + MediaType::ApplicationVndOciImageConfigV1(other), + ) => this == other, + ( + MediaType::ApplicationVndOciImageLayerV1Tar(this), + MediaType::ApplicationVndOciImageLayerV1Tar(other), + ) => this == other, + ( + MediaType::ApplicationVndOciEmptyV1(this), + MediaType::ApplicationVndOciEmptyV1(other), + ) => this == other, + ( + MediaType::ApplicationVndOciImageLayerNondistributableV1Tar(this), + MediaType::ApplicationVndOciImageLayerNondistributableV1Tar(other), + ) => this == other, + ( + MediaType::ApplicationVndWasmConfigV0(this), + MediaType::ApplicationVndWasmConfigV0(other), + ) => this == other, + (MediaType::ApplicationWasm, MediaType::ApplicationWasm) => true, + (Other(this), Other(other)) => this == other, + _ => false, + } + } +} + +impl PartialEq for MediaTypeSuffix { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (MediaTypeSuffix::Json, MediaTypeSuffix::Json) => true, + (MediaTypeSuffix::Gzip, MediaTypeSuffix::Gzip) => true, + (MediaTypeSuffix::Zstd, MediaTypeSuffix::Zstd) => true, + (MediaTypeSuffix::Other(this), MediaTypeSuffix::Other(other)) => this == other, + _ => false, + } + } +} + +impl PartialEq for SchemaVersion { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (SchemaVersion::V2, SchemaVersion::V2) => true, + (SchemaVersion::Other(this), SchemaVersion::Other(other)) => this == other, + _ => false, + } + } +} + +impl PartialEq for Manifest { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Manifest::OciImageV1(this), Manifest::OciImageV1(other)) => this == other, + (Manifest::OciImageIndexV1(this), Manifest::OciImageIndexV1(other)) => this == other, + (Manifest::Other(this), Manifest::Other(other)) => this == other, + _ => false, + } + } +} + +impl PartialEq for OciImageManifestV1 { + fn eq(&self, other: &Self) -> bool { + self.annotations == other.annotations + && self.artifact_type == other.artifact_type + && self.config == other.config + && self.layers == other.layers + && self.media_type == other.media_type + && self.schema_version == other.schema_version + && self.subject == other.subject + } +} + +impl PartialEq for OciImageIndexManifestV1 { + fn eq(&self, other: &Self) -> bool { + self.annotations == other.annotations + && self.artifact_type == other.artifact_type + && self.manifests == other.manifests + && self.media_type == other.media_type + && self.schema_version == other.schema_version + && self.subject == other.subject + } +} + +impl PartialEq for OciImageIndexManifestV1Manifest { + fn eq(&self, other: &Self) -> bool { + self.annotations == other.annotations + && self.digest == other.digest + && self.media_type == other.media_type + && self.size == other.size + && self.urls == other.urls + && self.artifact_type == other.artifact_type + && self.data == other.data + && self.platform == other.platform + && self.subject == other.subject + } +} + +impl PartialEq for OciImageIndexManifestV1ManifestPlatform { + fn eq(&self, other: &Self) -> bool { + self.architecture == other.architecture + && self.os == other.os + && self.os_version == other.os_version + && self.os_features == other.os_features + && self.variant == other.variant + } +} + +impl PartialEq for OciDescriptorV1 { + fn eq(&self, other: &Self) -> bool { + self.annotations == other.annotations + && self.digest == other.digest + && self.media_type == other.media_type + && self.size == other.size + && self.urls == other.urls + && self.artifact_type == other.artifact_type + && self.data == other.data + } +} + +impl PartialEq for Config { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Config::OciImageV1(this), Config::OciImageV1(other)) => this == other, + (Config::WasmV0(this), Config::WasmV0(other)) => this == other, + (Config::Other(this), Config::Other(other)) => this == other, + _ => false, + } + } +} + +impl PartialEq for OciImageConfigV1 { + fn eq(&self, other: &Self) -> bool { + self.created == other.created + && self.author == other.author + && self.architecture == other.architecture + && self.os == other.os + && self.os_version == other.os_version + && self.os_features == other.os_features + && self.variant == other.variant + && self.config == other.config + && self.rootfs == other.rootfs + && self.history == other.history + } +} + +impl PartialEq for OciImageConfigV1Config { + fn eq(&self, other: &Self) -> bool { + self.user == other.user + && self.exposed_ports == other.exposed_ports + && self.env == other.env + && self.entrypoint == other.entrypoint + && self.cmd == other.cmd + && self.volumes == other.volumes + && self.working_dir == other.working_dir + && self.labels == other.labels + && self.stop_signal == other.stop_signal + && self.args_escaped == other.args_escaped + } +} + +impl PartialEq for OciImageConfigV1ContentAddresses { + fn eq(&self, other: &Self) -> bool { + self.type_ == other.type_ && self.diff_ids == other.diff_ids + } +} + +impl PartialEq for OciImageConfigV1HistoryEntry { + fn eq(&self, other: &Self) -> bool { + self.created == other.created + && self.author == other.author + && self.created_by == other.created_by + && self.comment == other.comment + && self.empty_layer == other.empty_layer + } +} + +impl PartialEq for WasmConfigV0 { + fn eq(&self, other: &Self) -> bool { + self.created == other.created + && self.author == other.author + && self.architecture == other.architecture + && self.os == other.os + && self.layer_digests == other.layer_digests + && self.component == other.component + } +} + +impl PartialEq for WasmConfigV0Component { + fn eq(&self, other: &Self) -> bool { + self.exports == other.exports + && self.imports == other.imports + && self.target == other.target + } +} + wit_bindgen::generate!({ path: "../wit", world: "client", @@ -994,6 +1466,7 @@ export!(OCIClient); #[cfg(test)] mod tests { use super::*; + use serde_json::json; fn make_ref( registry: &str, @@ -1085,7 +1558,12 @@ mod tests { }, TestCase { input: "[2001:0db8:85a3:0000:0000:8a2e:0370:7334]/foo/bar", - expected: make_ref("[2001:0db8:85a3:0000:0000:8a2e:0370:7334]", "foo/bar", None, None), + expected: make_ref( + "[2001:0db8:85a3:0000:0000:8a2e:0370:7334]", + "foo/bar", + None, + None, + ), description: "IPv6 literal registry", }, TestCase { @@ -1105,9 +1583,7 @@ mod tests { }, TestCase { input: "[2001:db8:1111:2222:3333:4444:5555:6666:7777]/foo/bar", - expected: make_err( - "[2001:db8:1111:2222:3333:4444:5555:6666:7777]/foo/bar", - ), + expected: make_err("[2001:db8:1111:2222:3333:4444:5555:6666:7777]/foo/bar"), description: "invalid IPv6 literal registry", }, TestCase { @@ -1137,7 +1613,12 @@ mod tests { }, TestCase { input: "foo/bar:1.0.0-alpha_1", - expected: make_ref("index.docker.io", "foo/bar", make_tag("1.0.0-alpha_1"), None), + expected: make_ref( + "index.docker.io", + "foo/bar", + make_tag("1.0.0-alpha_1"), + None, + ), description: "tag with dots, dashes and underscores", }, TestCase { @@ -1157,17 +1638,32 @@ mod tests { }, TestCase { input: "foo/bar@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - expected: make_ref("index.docker.io", "foo/bar", None, make_digest("sha256", sha256)), + expected: make_ref( + "index.docker.io", + "foo/bar", + None, + make_digest("sha256", sha256), + ), description: "valid sha256 digest", }, TestCase { input: "foo/bar@sha512:cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e", - expected: make_ref("index.docker.io", "foo/bar", None, make_digest("sha512", sha512)), + expected: make_ref( + "index.docker.io", + "foo/bar", + None, + make_digest("sha512", sha512), + ), description: "valid sha512 digest", }, TestCase { input: "foo/bar:v1@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - expected: make_ref("index.docker.io", "foo/bar", make_tag("v1"), make_digest("sha256", sha256)), + expected: make_ref( + "index.docker.io", + "foo/bar", + make_tag("v1"), + make_digest("sha256", sha256), + ), description: "valid tag and digest", }, TestCase { @@ -1201,7 +1697,12 @@ mod tests { }, TestCase { input: "foo/bar:v1@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - expected: make_ref("index.docker.io", "foo/bar", None, make_digest("sha256", sha256)), + expected: make_ref( + "index.docker.io", + "foo/bar", + make_tag("v1"), + make_digest("sha256", sha256), + ), description: "when both a tag and a digest are present, the digest wins and the tag is dropped", }, ]; @@ -1215,4 +1716,352 @@ mod tests { ); } } + + #[test] + fn test_parse_www_authenticate() { + let params = |pairs: &[(&str, &str)]| { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect::>() + }; + + assert_eq!( + OCIClient::parse_www_authenticate( + r#"Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/alpine:pull,push""# + ), + Some(( + "Bearer".to_string(), + params(&[ + ("realm", "https://auth.docker.io/token"), + ("service", "registry.docker.io"), + ("scope", "repository:library/alpine:pull,push"), + ]) + )), + ); + assert_eq!( + OCIClient::parse_www_authenticate( + r#"Basic Realm = "a \"quoted\" realm" , charset=UTF-8"# + ), + Some(( + "Basic".to_string(), + params(&[("realm", r#"a "quoted" realm"#), ("charset", "UTF-8")]) + )), + ); + assert_eq!( + OCIClient::parse_www_authenticate("Bearer"), + Some(("Bearer".to_string(), params(&[]))), + ); + assert_eq!(OCIClient::parse_www_authenticate(" "), None); + } + + #[test] + fn test_parse_manifest() { + assert_eq!( + OCIClient::parse_manifest(&json!({ + "annotations":{ + "org.opencontainers.image.description": "OCI image client component.", + "org.opencontainers.image.licenses": "Apache-2.0", + "org.opencontainers.image.revision": "23f1b1de0b55b4d3a1d0b417f0c2492263323e2e", + "org.opencontainers.image.source": "https://github.com/componentized/oci.git", + "org.opencontainers.image.title": "client", + "org.opencontainers.image.version": "0.0.0-dev" + }, + "config": { + "digest": "sha256:80d83bbdaa82cff96584c99217c29fd17bc7e5f0424c0cb26aa3831ea18132b4", + "mediaType": "application/vnd.wasm.config.v0+json", + "size": 345 + }, + "layers": [ + { + "annotations": { + "org.opencontainers.image.title": "client.wasm" + }, + "digest": "sha256:ee7ff5c9588e997b4a54b6d351b52a5ca4f6980377f59e48c778f48a23b483db", + "mediaType": "application/wasm", + "size": 1894585 + } + ], + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "schemaVersion": 2 + }), "").unwrap(), + Some(Manifest::OciImageV1(OciImageManifestV1 { + annotations: Some(BTreeMap::from([ + ("org.opencontainers.image.description".to_string(), "OCI image client component.".to_string()), + ("org.opencontainers.image.licenses".to_string(), "Apache-2.0".to_string()), + ("org.opencontainers.image.revision".to_string(), "23f1b1de0b55b4d3a1d0b417f0c2492263323e2e".to_string()), + ("org.opencontainers.image.source".to_string(), "https://github.com/componentized/oci.git".to_string()), + ("org.opencontainers.image.title".to_string(), "client".to_string()), + ("org.opencontainers.image.version".to_string(), "0.0.0-dev".to_string()), + ])), + artifact_type: None, + config: OciDescriptorV1 { + annotations: None, + digest: Digest { algorithm: "sha256".to_string(), encoded: "80d83bbdaa82cff96584c99217c29fd17bc7e5f0424c0cb26aa3831ea18132b4".to_string() }, + media_type: MediaType::ApplicationVndWasmConfigV0(MediaTypeSuffix::Json), + size: 345, + urls: None, + artifact_type: None, + data: None, + }, + layers: vec![OciDescriptorV1{ + annotations: Some(BTreeMap::from([ + ("org.opencontainers.image.title".to_string(), "client.wasm".to_string()), + ])), + digest: Digest { algorithm: "sha256".to_string(), encoded: "ee7ff5c9588e997b4a54b6d351b52a5ca4f6980377f59e48c778f48a23b483db".to_string() }, + media_type: MediaType::ApplicationWasm, + size: 1894585, + urls: None, + artifact_type: None, + data: None, + }], + media_type: MediaType::ApplicationVndOciImageManifestV1(MediaTypeSuffix::Json), + schema_version: SchemaVersion::V2, + subject: None, + })), + ); + + assert_eq!( + OCIClient::parse_manifest(&json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "size": 743, + "digest": "sha256:9434033b4008b51c0c9270dda9315ea4229901fee28a7980085091e9fd4b62b8", + "platform": { + "architecture": "amd64", + "os": "linux" + }, + "artifactType": "application/vnd.docker.container.image.v1+json" + }, + { + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "size": 743, + "digest": "sha256:eac7a2bcae76b2bc5b5fed23033ffba56283462e315c2035b6ab5b2c8c80bd34", + "platform": { + "architecture": "arm64", + "os": "linux" + }, + "artifactType": "application/vnd.docker.container.image.v1+json" + } + ] + }), "").unwrap(), + Some(Manifest::OciImageIndexV1(OciImageIndexManifestV1 { + schema_version: SchemaVersion::V2, + media_type: MediaType::ApplicationVndOciImageIndexV1(MediaTypeSuffix::Json), + artifact_type: None, + manifests: vec![ + OciImageIndexManifestV1Manifest{ + annotations:None, + media_type:MediaType::Other("application/vnd.docker.distribution.manifest.v2+json".to_string()), + size:743, + platform:Some(OciImageIndexManifestV1ManifestPlatform{ + architecture:"amd64".to_string(), + os:"linux".to_string(), + os_features:None, + os_version:None, + variant:None, + }), + subject:None, + digest: Digest { algorithm: "sha256".to_string(), encoded: "9434033b4008b51c0c9270dda9315ea4229901fee28a7980085091e9fd4b62b8".to_string() }, + urls: None, + data: None, + artifact_type: Some(MediaType::Other("application/vnd.docker.container.image.v1+json".to_string())) + }, + OciImageIndexManifestV1Manifest{ + annotations:None, + media_type:MediaType::Other("application/vnd.docker.distribution.manifest.v2+json".to_string()), + size:743, + platform:Some(OciImageIndexManifestV1ManifestPlatform{ + architecture:"arm64".to_string(), + os:"linux".to_string(), + os_features:None,os_version:None,variant:None, + }), + subject:None, + digest: Digest { algorithm: "sha256".to_string(), encoded: "eac7a2bcae76b2bc5b5fed23033ffba56283462e315c2035b6ab5b2c8c80bd34".to_string() }, + urls: None, + data: None, + artifact_type: Some(MediaType::Other("application/vnd.docker.container.image.v1+json".to_string())), + }, + ], + subject: None, + annotations: None, + })) + ); + } + + #[test] + fn test_parse_config() { + assert_eq!( + OCIClient::parse_config(&json!({ + "architecture": "amd64", + "author": "github.com/ko-build/ko", + "created": "2026-07-08T23:06:13Z", + "history":[ + { + "author": "apko", + "created": "2026-07-08T23:06:13Z", + "created_by": "apko", + "comment": "static by Chainguard" + }, + { + "author": "ko", + "created": "0001-01-01T00:00:00Z", + "created_by": "ko build ko://github.com/servicebinding/runtime", + "comment": "kodata contents, at $KO_DATA_PATH" + }, + { + "author": "ko", + "created": "0001-01-01T00:00:00Z", + "created_by": "ko build ko://github.com/servicebinding/runtime", + "comment": "go build output, at /ko-app/runtime" + } + ], + "os": "linux", + "rootfs":{ + "type": "layers", + "diff_ids":[ + "sha256:458136df58646e7146e8240b685e4e6bfffa019ba10d3c221ff59b3928f54d8c", + "sha256:ffe56a1c5f3878e9b5f803842adb9e2ce81584b6bd027e8599582aefe14a975b", + "sha256:38217aaa0c148dcb7f3af90a384d30ccb4a7736a9f15b75a5d6a9f28c586964b" + ] + }, + "config":{ + "Entrypoint":["/ko-app/runtime"], + "Env":[ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin:/ko-app", + "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt", + "KO_DATA_PATH=/var/run/ko" + ], + "Labels":{ + "dev.chainguard.image.title": "static", + "dev.chainguard.package.main": "", + "org.opencontainers.image.authors": "Chainguard Team https://www.chainguard.dev/", + "org.opencontainers.image.created": "2026-07-08T23:06:13Z", + "org.opencontainers.image.source": "https://github.com/chainguard-images/images/tree/main/images/static", + "org.opencontainers.image.title": "static", + "org.opencontainers.image.url": "https://images.chainguard.dev/directory/image/static/overview", + "org.opencontainers.image.vendor": "Chainguard" + }, + "User": "65532" + } + }), "", Some(MediaType::ApplicationVndOciImageConfigV1(MediaTypeSuffix::Json))).unwrap(), + Some(Config::OciImageV1(OciImageConfigV1 { + architecture: "amd64".to_string(), + author: Some("github.com/ko-build/ko".to_string()), + created: Some(Instant{seconds:1783551973, nanoseconds:0}), // "2026-07-08T23:06:13Z" + history: Some(vec![ + OciImageConfigV1HistoryEntry{ + author: Some("apko".to_string()), + created: Some(Instant{seconds:1783551973, nanoseconds:0}), // "2026-07-08T23:06:13Z" + created_by: Some("apko".to_string()), + comment: Some("static by Chainguard".to_string()), + empty_layer: None, + }, + OciImageConfigV1HistoryEntry{ + author: Some("ko".to_string()), + created: Some(Instant{seconds:-62135596800, nanoseconds:0}), // "0001-01-01T00:00:00Z" + created_by: Some("ko build ko://github.com/servicebinding/runtime".to_string()), + comment:Some( "kodata contents, at $KO_DATA_PATH".to_string()), + empty_layer: None, + }, + OciImageConfigV1HistoryEntry{ + author: Some("ko".to_string()), + created: Some(Instant{seconds:-62135596800, nanoseconds:0}), // "0001-01-01T00:00:00Z" + created_by: Some("ko build ko://github.com/servicebinding/runtime".to_string()), + comment: Some("go build output, at /ko-app/runtime".to_string()), + empty_layer: None, + } + ]), + os: "linux".to_string(), + os_features: None, + os_version: None, + variant: None, + rootfs: OciImageConfigV1ContentAddresses { + type_: "layers".to_string(), + diff_ids: vec![ + Digest{algorithm:"sha256".to_string(), encoded:"458136df58646e7146e8240b685e4e6bfffa019ba10d3c221ff59b3928f54d8c".to_string()}, + Digest{algorithm:"sha256".to_string(), encoded:"ffe56a1c5f3878e9b5f803842adb9e2ce81584b6bd027e8599582aefe14a975b".to_string()}, + Digest{algorithm:"sha256".to_string(), encoded:"38217aaa0c148dcb7f3af90a384d30ccb4a7736a9f15b75a5d6a9f28c586964b".to_string()}, + ] + }, + config: Some(OciImageConfigV1Config{ + entrypoint: Some(vec!["/ko-app/runtime".to_string()]), + env:Some(vec![ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin:/ko-app".to_string(), + "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt".to_string(), + "KO_DATA_PATH=/var/run/ko".to_string() + ]), + labels: Some(BTreeMap::from([ + ("dev.chainguard.image.title".to_string(), "static".to_string()), + ("dev.chainguard.package.main".to_string(), "".to_string()), + ("org.opencontainers.image.authors".to_string(), "Chainguard Team https://www.chainguard.dev/".to_string()), + ("org.opencontainers.image.created".to_string(), "2026-07-08T23:06:13Z".to_string()), + ("org.opencontainers.image.source".to_string(), "https://github.com/chainguard-images/images/tree/main/images/static".to_string()), + ("org.opencontainers.image.title".to_string(), "static".to_string()), + ("org.opencontainers.image.url".to_string(), "https://images.chainguard.dev/directory/image/static/overview".to_string()), + ("org.opencontainers.image.vendor".to_string(), "Chainguard".to_string()) + ])), + user: Some("65532".to_string()), + args_escaped: None, + cmd: None, + exposed_ports: None, + stop_signal: None, + volumes: None, + working_dir: None, + }) + })), + ); + + assert_eq!( + OCIClient::parse_config( + &json!({ + "created": "2026-09-22T14:58:42.504222730Z", + "author": null, + "architecture": "wasm", + "os": "wasip2", + "layerDigests": [ + "sha256:ee7ff5c9588e997b4a54b6d351b52a5ca4f6980377f59e48c778f48a23b483db" + ], + "component": { + "exports": [ + "componentized:oci/client@0.0.0-dev" + ], + "imports":[ + "componentized:http/client@0.1.0-dev", + "wasi:clocks/system-clock@0.3.0" + ], + "target": null + } + }), + "", + Some(MediaType::ApplicationVndWasmConfigV0(MediaTypeSuffix::Json)) + ) + .unwrap(), + Some(Config::WasmV0(WasmConfigV0 { + created: Some(Instant { + seconds: 1790089122, + nanoseconds: 504222730, + }), + author: None, + architecture: "wasm".to_string(), + os: "wasip2".to_string(), + layer_digests: vec![Digest { + algorithm: "sha256".to_string(), + encoded: "ee7ff5c9588e997b4a54b6d351b52a5ca4f6980377f59e48c778f48a23b483db" + .to_string() + }], + component: Some(WasmConfigV0Component { + exports: vec!["componentized:oci/client@0.0.0-dev".to_string(),], + imports: vec![ + "componentized:http/client@0.1.0-dev".to_string(), + "wasi:clocks/system-clock@0.3.0".to_string() + ], + target: None, + }), + })), + ); + } } diff --git a/components/wit/deps/componentized-oci-0.0.0-dev/package.wit b/components/wit/deps/componentized-oci-0.0.0-dev/package.wit index fae6a8e..2630638 100644 --- a/components/wit/deps/componentized-oci-0.0.0-dev/package.wit +++ b/components/wit/deps/componentized-oci-0.0.0-dev/package.wit @@ -119,13 +119,6 @@ interface client { args-escaped: option, } - record oci-image-config-v1-content-addresses { - /// must be 'layers' - %type: string, - /// layer content hashes, in order from first to last - diff-ids: list, - } - record oci-image-config-v1-history-entry { /// combined date and time at which the layer was created created: option, @@ -139,29 +132,6 @@ interface client { empty-layer: option, } - record oci-image-config-v1 { - /// combined date and time at which the image was created - created: option, - /// name and/or email address of the person or entity which created and is responsible for maintaining the image - author: option, - /// CPU architecture which the binaries in this image are built to run on - architecture: string, - /// name of the operating system which the image is built to run on - os: string, - /// version of the operating system - os-version: option, - /// mandatory OS features - os-features: option>, - /// variant of the specified CPU architecture - %variant: option, - /// execution parameters which should be used as a base when running a container using the image - config: option, - /// layer content addresses used by the image - rootfs: oci-image-config-v1-content-addresses, - /// history of each layer. The array is ordered from first to last - history: option>, - } - record wasm-config-v0-component { exports: list, imports: list, @@ -184,6 +154,10 @@ interface client { urls: option>, /// arbitrary metadata for this descriptor annotations: option>, + /// embedded representation of the referenced content + data: option, + /// type of an artifact when the descriptor points to an artifact + artifact-type: option, } record oci-image-manifest-v1 { @@ -204,14 +178,24 @@ interface client { } record oci-image-index-manifest-v1-manifest { - /// media type of the subject manifest + /// media type of the referenced content media-type: media-type, + /// digest of the targeted content + digest: digest, + /// size, in bytes, of the raw content + size: u64, + /// list of URIs from which this object MAY be downloaded + urls: option>, + /// arbitrary metadata for this descriptor + annotations: option>, + /// embedded representation of the referenced content + data: option, + /// type of an artifact when the descriptor points to an artifact + artifact-type: option, /// minimum runtime requirements of the image platform: option, /// descriptor of another manifest subject: option, - /// arbitrary metadata for the image manifest - annotations: option>, } record oci-image-index-manifest-v1 { @@ -238,6 +222,36 @@ interface client { other(list), } + record oci-image-config-v1-content-addresses { + /// must be 'layers' + %type: string, + /// layer content hashes, in order from first to last + diff-ids: list, + } + + record oci-image-config-v1 { + /// combined date and time at which the image was created + created: option, + /// name and/or email address of the person or entity which created and is responsible for maintaining the image + author: option, + /// CPU architecture which the binaries in this image are built to run on + architecture: string, + /// name of the operating system which the image is built to run on + os: string, + /// version of the operating system + os-version: option, + /// mandatory OS features + os-features: option>, + /// variant of the specified CPU architecture + %variant: option, + /// execution parameters which should be used as a base when running a container using the image + config: option, + /// layer content addresses used by the image + rootfs: oci-image-config-v1-content-addresses, + /// history of each layer. The array is ordered from first to last + history: option>, + } + record wasm-config-v0 { /// combined date and time at which the image was created created: option, @@ -275,7 +289,7 @@ interface client { get-blob: async func(reference: reference) -> result, error-code>; - get-config: async func(reference: reference) -> result; + get-config: async func(reference: reference, default-media-type: option) -> result; get-manifest: async func(reference: reference) -> result; } diff --git a/wit/client.wit b/wit/client.wit index 43e3fad..9ca060f 100644 --- a/wit/client.wit +++ b/wit/client.wit @@ -93,6 +93,10 @@ interface client { urls: option>, // arbitrary metadata for this descriptor annotations: option>, + // embedded representation of the referenced content + data: option, + // type of an artifact when the descriptor points to an artifact + artifact-type: option, } variant manifest { @@ -137,14 +141,25 @@ interface client { } record oci-image-index-manifest-v1-manifest { - // media type of the subject manifest + // media type of the referenced content media-type: media-type, + // digest of the targeted content + digest: digest, + // size, in bytes, of the raw content + size: u64, + // list of URIs from which this object MAY be downloaded + urls: option>, + // arbitrary metadata for this descriptor + annotations: option>, + // embedded representation of the referenced content + data: option, + // type of an artifact when the descriptor points to an artifact + artifact-type: option, + // minimum runtime requirements of the image platform: option, // descriptor of another manifest subject: option, - // arbitrary metadata for the image manifest - annotations: option>, } record oci-image-index-manifest-v1-manifest-platform { @@ -219,7 +234,7 @@ interface client { // must be 'layers' %type: string, // layer content hashes, in order from first to last - diff-ids: list, + diff-ids: list, } record oci-image-config-v1-history-entry { @@ -271,7 +286,7 @@ interface client { parse-reference: func(reference: string) -> result; resolve-digest: async func(reference: reference) -> result; get-blob: async func(reference: reference) -> result, error-code>; - get-config: async func(reference: reference) -> result; + get-config: async func(reference: reference, default-media-type: option) -> result; get-manifest: async func(reference: reference) -> result; }