systemprompt_loader/bundle/source/oci/
pull.rs1use std::path::Path;
15
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18use systemprompt_models::services::bundle::BUNDLE_MEDIA_TYPE;
19
20use super::RegistryClient;
21use crate::bundle::error::{BundleError, BundleResult};
22use crate::bundle::source::FetchedBundle;
23use crate::bundle::source::stream::stream_to_file;
24use systemprompt_models::net::{trusted_http_hosts_from_env, validate_outbound_url_with_trust};
25
26pub const OCI_MANIFEST_MEDIA_TYPE: &str = "application/vnd.oci.image.manifest.v1+json";
27pub const DOCKER_MANIFEST_MEDIA_TYPE: &str = "application/vnd.docker.distribution.manifest.v2+json";
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct OciDescriptor {
31 #[serde(rename = "mediaType")]
32 pub media_type: String,
33
34 pub digest: String,
35
36 pub size: u64,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct OciManifest {
41 #[serde(rename = "schemaVersion")]
42 pub schema_version: u32,
43
44 #[serde(rename = "mediaType", default, skip_serializing_if = "Option::is_none")]
45 pub media_type: Option<String>,
46
47 #[serde(
48 rename = "artifactType",
49 default,
50 skip_serializing_if = "Option::is_none"
51 )]
52 pub artifact_type: Option<String>,
53
54 pub config: OciDescriptor,
55
56 #[serde(default)]
57 pub layers: Vec<OciDescriptor>,
58}
59
60pub async fn get_manifest(registry: &RegistryClient) -> BundleResult<(String, OciManifest)> {
61 let url = registry.url(&format!("/manifests/{}", registry.manifest_ref()))?;
62 let accept = format!("{OCI_MANIFEST_MEDIA_TYPE}, {DOCKER_MANIFEST_MEDIA_TYPE}");
63 let response = registry
64 .send(move |client| {
65 client
66 .get(url.clone())
67 .header(reqwest::header::ACCEPT, accept.clone())
68 })
69 .await?;
70
71 let status = response.status();
72 if !status.is_success() {
73 return Err(BundleError::fetch(
74 ®istry.name,
75 format!("manifest request failed: {status}"),
76 ));
77 }
78
79 let header_digest = response
80 .headers()
81 .get("docker-content-digest")
82 .and_then(|v| v.to_str().ok())
83 .map(str::to_owned);
84 let body = response
85 .bytes()
86 .await
87 .map_err(|e| BundleError::fetch(®istry.name, e))?;
88 let digest =
89 header_digest.unwrap_or_else(|| format!("sha256:{}", hex::encode(Sha256::digest(&body))));
90
91 let manifest: OciManifest = serde_json::from_slice(&body)
92 .map_err(|e| BundleError::fetch(®istry.name, format!("manifest does not parse: {e}")))?;
93 Ok((digest, manifest))
94}
95
96pub async fn pull_bundle_layer(
97 registry: &RegistryClient,
98 manifest: &OciManifest,
99 into: &Path,
100 max_bytes: u64,
101) -> BundleResult<FetchedBundle> {
102 let matching: Vec<&OciDescriptor> = manifest
103 .layers
104 .iter()
105 .filter(|l| l.media_type == BUNDLE_MEDIA_TYPE)
106 .collect();
107 let [layer] = matching.as_slice() else {
108 return Err(BundleError::fetch(
109 ®istry.name,
110 format!(
111 "manifest carries {} layers of {BUNDLE_MEDIA_TYPE}, expected exactly one",
112 matching.len()
113 ),
114 ));
115 };
116
117 if layer.size > max_bytes {
118 return Err(BundleError::TooLarge { bytes: max_bytes });
119 }
120
121 let url = registry.url(&format!("/blobs/{}", layer.digest))?;
122 let response = registry.send(move |client| client.get(url.clone())).await?;
123 let response = follow_blob_redirects(registry, response).await?;
124 let status = response.status();
125 if !status.is_success() {
126 return Err(BundleError::fetch(
127 ®istry.name,
128 format!("blob request failed: {status}"),
129 ));
130 }
131
132 let digest = stream_to_file(response, into, ®istry.name, max_bytes).await?;
133 let expected = layer
134 .digest
135 .strip_prefix("sha256:")
136 .unwrap_or(&layer.digest);
137 if !digest.eq_ignore_ascii_case(expected) {
138 return Err(BundleError::fetch(
139 ®istry.name,
140 format!(
141 "blob digest is sha256:{digest}, manifest declares {}",
142 layer.digest
143 ),
144 ));
145 }
146
147 Ok(FetchedBundle {
148 archive: into.to_path_buf(),
149 digest: format!("sha256:{digest}"),
150 })
151}
152
153const MAX_BLOB_REDIRECTS: usize = 3;
156
157async fn follow_blob_redirects(
158 registry: &RegistryClient,
159 mut response: reqwest::Response,
160) -> BundleResult<reqwest::Response> {
161 for _ in 0..MAX_BLOB_REDIRECTS {
162 if !response.status().is_redirection() {
163 return Ok(response);
164 }
165 let location = response
166 .headers()
167 .get(reqwest::header::LOCATION)
168 .and_then(|v| v.to_str().ok())
169 .ok_or_else(|| {
170 BundleError::fetch(
171 ®istry.name,
172 format!("blob redirect ({}) without a Location", response.status()),
173 )
174 })?;
175 let target = match url::Url::parse(location) {
176 Ok(absolute) => absolute,
177 Err(_) => response
178 .url()
179 .join(location)
180 .map_err(|e| BundleError::fetch(®istry.name, format!("blob redirect: {e}")))?,
181 };
182 let target =
183 validate_outbound_url_with_trust(target.as_str(), &trusted_http_hosts_from_env())
184 .map_err(|e| BundleError::fetch(®istry.name, e))?;
185 response = registry
186 .client
187 .get(target)
188 .send()
189 .await
190 .map_err(|e| BundleError::fetch(®istry.name, e))?;
191 }
192 Err(BundleError::fetch(
193 ®istry.name,
194 format!("blob redirected more than {MAX_BLOB_REDIRECTS} times"),
195 ))
196}