1use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9
10use a3s_box_core::error::{BoxError, Result};
11use a3s_box_core::StoredImage;
12
13use super::image::OciImage;
14use super::reference::ImageReference;
15use super::registry::{PullProgressEventFn, RegistryAuth, RegistryPullPolicy, RegistryPuller};
16use super::store::ImageStore;
17
18type PullProgressFn = Arc<dyn Fn(usize, usize, &str, i64) + Send + Sync>;
20
21static PULL_TMP_SEQ: AtomicU64 = AtomicU64::new(0);
23
24pub struct ImagePuller {
26 store: Arc<ImageStore>,
27 puller: RegistryPuller,
28 metrics: Option<crate::prom::RuntimeMetrics>,
29 mirrors: std::collections::HashMap<String, String>,
35}
36
37fn parse_registry_mirrors() -> std::collections::HashMap<String, String> {
39 std::env::var("A3S_REGISTRY_MIRRORS")
40 .ok()
41 .map(|raw| {
42 raw.split(',')
43 .filter_map(|pair| pair.split_once('='))
44 .map(|(host, mirror)| (host.trim().to_string(), mirror.trim().to_string()))
45 .filter(|(host, mirror)| !host.is_empty() && !mirror.is_empty())
46 .collect()
47 })
48 .unwrap_or_default()
49}
50
51impl ImagePuller {
52 pub fn new(store: Arc<ImageStore>, auth: RegistryAuth) -> Self {
54 Self::with_platform(store, auth, None)
55 }
56
57 #[cfg(test)]
58 pub(crate) fn with_registry_puller(store: Arc<ImageStore>, puller: RegistryPuller) -> Self {
59 Self {
60 store,
61 puller,
62 metrics: None,
63 mirrors: std::collections::HashMap::new(),
64 }
65 }
66
67 pub fn with_platform(
71 store: Arc<ImageStore>,
72 auth: RegistryAuth,
73 platform: Option<String>,
74 ) -> Self {
75 Self {
76 store,
77 puller: RegistryPuller::with_auth_and_platform(auth, platform),
78 metrics: None,
79 mirrors: parse_registry_mirrors(),
80 }
81 }
82
83 fn mirror_reference(&self, reference: &ImageReference) -> ImageReference {
87 match self.mirrors.get(&reference.registry) {
88 Some(mirror) if !mirror.is_empty() => {
89 tracing::info!(
90 registry = %reference.registry,
91 mirror = %mirror,
92 "Pulling via configured registry mirror"
93 );
94 let mut mirrored = reference.clone();
95 mirror.clone_into(&mut mirrored.registry);
96 mirrored
97 }
98 _ => reference.clone(),
99 }
100 }
101
102 pub fn set_metrics(mut self, metrics: crate::prom::RuntimeMetrics) -> Self {
104 self.metrics = Some(metrics);
105 self
106 }
107
108 pub fn with_signature_policy(mut self, policy: super::signing::SignaturePolicy) -> Self {
110 self.puller = self.puller.with_signature_policy(policy);
111 self
112 }
113
114 pub fn with_progress_fn(mut self, f: PullProgressFn) -> Self {
116 self.puller = self.puller.with_progress_fn(f);
117 self
118 }
119
120 pub fn with_progress_event_fn(mut self, f: PullProgressEventFn) -> Self {
122 self.puller = self.puller.with_progress_event_fn(f);
123 self
124 }
125
126 pub fn with_pull_policy(mut self, policy: RegistryPullPolicy) -> Self {
128 self.puller = self.puller.with_pull_policy(policy);
129 self
130 }
131
132 pub async fn pull(&self, reference: &str) -> Result<OciImage> {
136 self.pull_resolved(reference).await.map(|(image, _)| image)
137 }
138
139 async fn pull_resolved(&self, reference: &str) -> Result<(OciImage, String)> {
140 let reference = reference.trim();
141 if is_digest_reference(reference) {
142 let Some((matched_reference, stored)) = self.cached_digest_image(reference).await?
143 else {
144 return Err(BoxError::OciImageError(format!(
145 "Image digest not found in local cache: {reference}"
146 )));
147 };
148 tracing::info!(
149 requested_reference = %reference,
150 matched_reference = %matched_reference,
151 digest = %stored.digest,
152 "Using cached image by digest"
153 );
154 return Ok((OciImage::from_path(&stored.path)?, matched_reference));
155 }
156
157 let parsed = ImageReference::parse(reference)?;
158
159 if let Some((matched_reference, stored)) = self.cached_image(reference, &parsed).await? {
160 tracing::info!(
161 requested_reference = %reference,
162 matched_reference = %matched_reference,
163 digest = %stored.digest,
164 "Using cached image"
165 );
166 return Ok((OciImage::from_path(&stored.path)?, matched_reference));
167 }
168
169 Ok((self.pull_and_store(&parsed).await?, parsed.full_reference()))
170 }
171
172 pub async fn force_pull(&self, reference: &str) -> Result<OciImage> {
174 let reference = reference.trim();
175 if is_digest_reference(reference) {
176 return Err(BoxError::OciImageError(format!(
177 "Cannot force-pull digest-only reference {reference}; use a tagged registry reference"
178 )));
179 }
180
181 let parsed = ImageReference::parse(reference)?;
182
183 for candidate in cache_reference_candidates(reference, &parsed) {
184 if self.store.get(&candidate).await.is_some() {
185 let _ = self.store.remove(&candidate).await;
186 }
187 }
188
189 self.pull_and_store(&parsed).await
190 }
191
192 pub async fn is_cached(&self, reference: &str) -> bool {
194 let reference = reference.trim();
195 if is_digest_reference(reference) {
196 return matches!(self.cached_digest_image(reference).await, Ok(Some(_)));
197 }
198
199 let parsed = match ImageReference::parse(reference) {
200 Ok(p) => p,
201 Err(_) => return false,
202 };
203 matches!(self.cached_image(reference, &parsed).await, Ok(Some(_)))
204 }
205
206 pub async fn remove_cached(&self, reference: &str) -> Result<bool> {
208 let reference = reference.trim();
209 if is_digest_reference(reference) {
210 if let Some((matched_reference, _)) = self.cached_digest_image(reference).await? {
211 self.store.remove(&matched_reference).await?;
212 return Ok(true);
213 }
214 return Ok(false);
215 }
216
217 let parsed = ImageReference::parse(reference)?;
218 if let Some((matched_reference, _)) = self.cached_image(reference, &parsed).await? {
219 self.store.remove(&matched_reference).await?;
220 Ok(true)
221 } else {
222 Ok(false)
223 }
224 }
225
226 pub async fn list_cached(&self) -> Result<Vec<String>> {
228 Ok(self
229 .store
230 .list()
231 .await
232 .into_iter()
233 .map(|img| img.reference)
234 .collect())
235 }
236
237 async fn pull_and_store(&self, reference: &ImageReference) -> Result<OciImage> {
239 let full_ref = reference.full_reference();
240
241 let fetch = self.mirror_reference(reference);
244
245 let digest = self.puller.pull_manifest_digest(&fetch).await?;
251 super::registry::validated_digest_hex(&digest)?;
252
253 if let Some(stored) = self.store.get_by_digest(&digest).await {
255 tracing::info!(
256 reference = %full_ref,
257 digest = %digest,
258 "Image content already cached under different reference"
259 );
260 self.store.put(&full_ref, &digest, &stored.path).await?;
262 return OciImage::from_path(&stored.path);
263 }
264
265 let digest_hex = digest.strip_prefix("sha256:").unwrap_or(&digest);
269 let tmp_dir = unique_pull_tmp_dir(self.store.store_dir(), digest_hex);
270
271 let pull_start = std::time::Instant::now();
272 if let Err(e) = self
277 .puller
278 .pull_with_store(&fetch, &tmp_dir, Some(&self.store))
279 .await
280 {
281 let _ = std::fs::remove_dir_all(&tmp_dir);
282 return Err(e);
283 }
284 if let Some(ref m) = self.metrics {
285 m.image_pull_total.inc();
286 m.image_pull_duration
287 .observe(pull_start.elapsed().as_secs_f64());
288 }
289
290 let stored = match self.store.put(&full_ref, &digest, &tmp_dir).await {
292 Ok(stored) => stored,
293 Err(e) => {
294 let _ = std::fs::remove_dir_all(&tmp_dir);
295 return Err(e);
296 }
297 };
298
299 if let Err(e) = std::fs::remove_dir_all(&tmp_dir) {
301 tracing::warn!(path = %tmp_dir.display(), error = %e, "Failed to remove temp dir after pull");
302 }
303
304 let evicted = self.store.evict().await?;
306 if !evicted.is_empty() {
307 tracing::info!(
308 count = evicted.len(),
309 references = ?evicted,
310 "Evicted images from cache"
311 );
312 }
313
314 OciImage::from_path(&stored.path)
315 }
316
317 async fn cached_image(
318 &self,
319 reference: &str,
320 parsed: &ImageReference,
321 ) -> Result<Option<(String, StoredImage)>> {
322 for candidate in cache_reference_candidates(reference, parsed) {
323 if let Some(stored) = self.store.get(&candidate).await {
324 return Ok(Some((candidate, stored)));
325 }
326 }
327 if let Some(digest) = parsed.digest.as_deref() {
328 return self.cached_digest_image(digest).await;
329 }
330 Ok(None)
331 }
332
333 async fn cached_digest_image(&self, digest: &str) -> Result<Option<(String, StoredImage)>> {
334 let images = self.store.list().await;
335 let mut matches = Vec::new();
336
337 for image in images {
338 let already_matched = matches
339 .iter()
340 .any(|matched: &StoredImage| matched.reference == image.reference);
341 if (digest_matches(&image.digest, digest) || image.reference == digest)
342 && !already_matched
343 {
344 matches.push(image);
345 }
346 }
347
348 match matches.len() {
349 0 => Ok(None),
350 1 => {
351 let stored = matches.pop().expect("checked one match");
352 let stored = self.store.get(&stored.reference).await.unwrap_or(stored);
353 Ok(Some((stored.reference.clone(), stored)))
354 }
355 _ => Err(BoxError::OciImageError(ambiguous_digest_error(
356 digest, &matches,
357 ))),
358 }
359 }
360}
361
362fn ambiguous_digest_error(query: &str, matches: &[StoredImage]) -> String {
363 let mut references: Vec<_> = matches
364 .iter()
365 .map(|image| image.reference.as_str())
366 .collect();
367 references.sort_unstable();
368 format!(
369 "Image digest '{query}' is ambiguous; it matches: {}",
370 references.join(", ")
371 )
372}
373
374fn is_digest_reference(reference: &str) -> bool {
375 reference.starts_with("sha256:")
376}
377
378fn digest_matches(stored_digest: &str, query: &str) -> bool {
379 if stored_digest == query {
380 return true;
381 }
382 let Some(query_hex) = query.strip_prefix("sha256:") else {
383 return false;
384 };
385 !query_hex.is_empty() && stored_digest.starts_with(query)
386}
387
388fn cache_reference_candidates(reference: &str, parsed: &ImageReference) -> Vec<String> {
389 let mut candidates = Vec::new();
390 push_unique(&mut candidates, reference.trim().to_string());
391 push_unique(&mut candidates, parsed.full_reference());
392
393 if parsed.registry == "docker.io" {
394 let repository = parsed
395 .repository
396 .strip_prefix("library/")
397 .unwrap_or(&parsed.repository);
398 push_unique(
399 &mut candidates,
400 reference_from_repository(repository, parsed.tag.as_deref(), parsed.digest.as_deref()),
401 );
402
403 if parsed.digest.is_none() && parsed.tag.as_deref() == Some("latest") {
404 push_unique(&mut candidates, repository.to_string());
405 }
406 }
407
408 if let Some(digest) = &parsed.digest {
409 push_unique(&mut candidates, digest.clone());
410 }
411
412 candidates
413}
414
415fn reference_from_repository(repository: &str, tag: Option<&str>, digest: Option<&str>) -> String {
416 let mut reference = repository.to_string();
417 if let Some(tag) = tag {
418 reference.push(':');
419 reference.push_str(tag);
420 }
421 if let Some(digest) = digest {
422 reference.push('@');
423 reference.push_str(digest);
424 }
425 reference
426}
427
428fn unique_pull_tmp_dir(store_dir: &std::path::Path, digest_hex: &str) -> std::path::PathBuf {
429 let seq = PULL_TMP_SEQ.fetch_add(1, Ordering::Relaxed);
430 store_dir
431 .join("tmp")
432 .join(format!("pull-{digest_hex}-{}-{seq}", std::process::id()))
433}
434
435fn push_unique(values: &mut Vec<String>, value: String) {
436 if !value.is_empty() && !values.iter().any(|existing| existing == &value) {
437 values.push(value);
438 }
439}
440
441#[async_trait::async_trait]
442impl a3s_box_core::traits::ImageRegistry for ImagePuller {
443 async fn pull(&self, reference: &str) -> Result<a3s_box_core::traits::PulledImage> {
444 let (image, resolved_reference) = self.pull_resolved(reference).await?;
445 Ok(a3s_box_core::traits::PulledImage {
446 path: image.root_dir().to_path_buf(),
447 digest: image.manifest_digest().to_string(),
448 reference: resolved_reference,
449 })
450 }
451
452 async fn force_pull(&self, reference: &str) -> Result<a3s_box_core::traits::PulledImage> {
453 let image = self.force_pull(reference).await?;
454 let parsed = ImageReference::parse(reference)?;
455 Ok(a3s_box_core::traits::PulledImage {
456 path: image.root_dir().to_path_buf(),
457 digest: image.manifest_digest().to_string(),
458 reference: parsed.full_reference(),
459 })
460 }
461
462 async fn is_cached(&self, reference: &str) -> bool {
463 self.is_cached(reference).await
464 }
465
466 async fn remove(&self, reference: &str) -> Result<bool> {
467 self.remove_cached(reference).await
468 }
469
470 async fn list_cached(&self) -> Result<Vec<String>> {
471 self.list_cached().await
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use crate::oci::store::ImageStore;
479 use std::path::Path;
480 use tempfile::TempDir;
481
482 #[test]
483 fn test_image_puller_creation() {
484 let tmp = TempDir::new().unwrap();
485 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
486 let _puller = ImagePuller::new(store, RegistryAuth::anonymous());
487 }
488
489 #[tokio::test]
490 async fn test_is_cached_empty_store() {
491 let tmp = TempDir::new().unwrap();
492 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
493 let puller = ImagePuller::new(store, RegistryAuth::anonymous());
494 assert!(!puller.is_cached("nginx:latest").await);
495 }
496
497 #[tokio::test]
498 async fn test_is_cached_invalid_reference() {
499 let tmp = TempDir::new().unwrap();
500 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
501 let puller = ImagePuller::new(store, RegistryAuth::anonymous());
502 assert!(!puller.is_cached("").await);
503 }
504
505 #[tokio::test]
506 async fn test_is_cached_matches_docker_hub_aliases() {
507 let tmp = TempDir::new().unwrap();
508 let source = TempDir::new().unwrap();
509 create_complete_oci_image(source.path());
510 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
511 store
512 .put(
513 "alpine:latest",
514 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
515 source.path(),
516 )
517 .await
518 .unwrap();
519
520 let puller = ImagePuller::new(store, RegistryAuth::anonymous());
521
522 assert!(puller.is_cached("alpine:latest").await);
523 assert!(puller.is_cached("docker.io/library/alpine:latest").await);
524 assert!(puller.is_cached("alpine").await);
525 }
526
527 #[tokio::test]
528 async fn test_pull_uses_cached_short_alias_for_full_reference() {
529 let tmp = TempDir::new().unwrap();
530 let source = TempDir::new().unwrap();
531 let manifest_digest = create_complete_oci_image(source.path());
532 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
533 store
534 .put(
535 "alpine:latest",
536 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
537 source.path(),
538 )
539 .await
540 .unwrap();
541 let puller = ImagePuller::new(store, RegistryAuth::anonymous());
542
543 let image = puller
544 .pull("docker.io/library/alpine:latest")
545 .await
546 .unwrap();
547
548 assert_eq!(image.manifest_digest(), manifest_digest);
549 }
550
551 #[tokio::test]
552 async fn test_pull_uses_cached_digest_reference_without_registry_parse() {
553 let tmp = TempDir::new().unwrap();
554 let source = TempDir::new().unwrap();
555 let manifest_digest = create_complete_oci_image(source.path());
556 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
557 store
558 .put(
559 "alpine:latest",
560 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
561 source.path(),
562 )
563 .await
564 .unwrap();
565 let puller = ImagePuller::new(store, RegistryAuth::anonymous());
566
567 let image = puller
568 .pull("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
569 .await
570 .unwrap();
571
572 assert_eq!(image.manifest_digest(), manifest_digest);
573 }
574
575 #[tokio::test]
576 async fn test_is_cached_matches_digest_prefix() {
577 let tmp = TempDir::new().unwrap();
578 let source = TempDir::new().unwrap();
579 create_complete_oci_image(source.path());
580 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
581 store
582 .put(
583 "alpine:latest",
584 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
585 source.path(),
586 )
587 .await
588 .unwrap();
589 let puller = ImagePuller::new(store, RegistryAuth::anonymous());
590
591 assert!(puller.is_cached("sha256:aaaaaaaa").await);
592 }
593
594 #[tokio::test]
595 async fn test_pull_reports_missing_digest_reference_as_local_cache_miss() {
596 let tmp = TempDir::new().unwrap();
597 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
598 let puller = ImagePuller::new(store, RegistryAuth::anonymous());
599
600 let error = puller.pull("sha256:notfound").await.unwrap_err();
601
602 assert!(error.to_string().contains("not found in local cache"));
603 }
604
605 #[tokio::test]
606 async fn test_pull_reports_ambiguous_digest_prefix() {
607 let tmp = TempDir::new().unwrap();
608 let source = TempDir::new().unwrap();
609 create_complete_oci_image(source.path());
610 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
611 store
612 .put(
613 "alpine:latest",
614 "sha256:ccccccccaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
615 source.path(),
616 )
617 .await
618 .unwrap();
619 store
620 .put(
621 "busybox:latest",
622 "sha256:ccccccccbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
623 source.path(),
624 )
625 .await
626 .unwrap();
627 let puller = ImagePuller::new(store, RegistryAuth::anonymous());
628
629 let error = puller.pull("sha256:cccccccc").await.unwrap_err();
630
631 assert!(error.to_string().contains("ambiguous"));
632 assert!(error.to_string().contains("alpine:latest"));
633 assert!(error.to_string().contains("busybox:latest"));
634 }
635
636 #[tokio::test]
637 async fn test_remove_cached_matches_docker_hub_alias() {
638 let tmp = TempDir::new().unwrap();
639 let source = TempDir::new().unwrap();
640 create_complete_oci_image(source.path());
641 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
642 store
643 .put(
644 "alpine:latest",
645 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
646 source.path(),
647 )
648 .await
649 .unwrap();
650 let puller = ImagePuller::new(store.clone(), RegistryAuth::anonymous());
651
652 assert!(puller
653 .remove_cached("docker.io/library/alpine:latest")
654 .await
655 .unwrap());
656 assert!(store.get("alpine:latest").await.is_none());
657 }
658
659 #[test]
660 fn test_set_metrics_attaches_to_puller() {
661 let tmp = TempDir::new().unwrap();
662 let store = Arc::new(ImageStore::new(tmp.path(), 10 * 1024 * 1024).unwrap());
663 let metrics = crate::prom::RuntimeMetrics::new();
664 let puller =
666 ImagePuller::new(store, RegistryAuth::anonymous()).set_metrics(metrics.clone());
667 assert!(puller.metrics.is_some());
668 assert_eq!(metrics.image_pull_total.get(), 0);
669 assert_eq!(metrics.image_pull_duration.get_sample_count(), 0);
670 }
671
672 #[test]
673 fn test_cache_reference_candidates_include_short_docker_hub_aliases() {
674 let parsed = ImageReference::parse("docker.io/library/alpine:latest").unwrap();
675 let candidates = cache_reference_candidates("docker.io/library/alpine:latest", &parsed);
676
677 assert_eq!(
678 candidates,
679 vec![
680 "docker.io/library/alpine:latest".to_string(),
681 "alpine:latest".to_string(),
682 "alpine".to_string(),
683 ]
684 );
685 }
686
687 #[test]
688 fn test_digest_matches_exact_and_prefix_queries() {
689 assert!(digest_matches("sha256:abcdef123456", "sha256:abcdef123456"));
690 assert!(digest_matches("sha256:abcdef123456", "sha256:abcdef"));
691 assert!(!digest_matches("sha256:abcdef123456", "sha256:"));
692 assert!(!digest_matches("sha256:abcdef123456", "abcdef"));
693 }
694
695 #[test]
696 fn test_unique_pull_tmp_dir_does_not_reuse_digest_path() {
697 let tmp = TempDir::new().unwrap();
698 let first = unique_pull_tmp_dir(tmp.path(), "abc123");
699 let second = unique_pull_tmp_dir(tmp.path(), "abc123");
700
701 assert_ne!(first, second);
702 assert_eq!(first.parent().unwrap(), tmp.path().join("tmp"));
703 assert_eq!(second.parent().unwrap(), tmp.path().join("tmp"));
704 assert!(first
705 .file_name()
706 .unwrap()
707 .to_string_lossy()
708 .contains("abc123"));
709 }
710
711 fn test_sha256_digest(bytes: &[u8]) -> String {
712 use sha2::{Digest, Sha256};
713
714 format!("sha256:{:x}", Sha256::digest(bytes))
715 }
716
717 fn write_test_blob(path: &Path, bytes: &[u8]) -> String {
718 let digest = test_sha256_digest(bytes);
719 std::fs::write(
720 path.join("blobs/sha256")
721 .join(digest.strip_prefix("sha256:").unwrap()),
722 bytes,
723 )
724 .unwrap();
725 digest
726 }
727
728 fn create_complete_oci_image(path: &Path) -> String {
729 std::fs::create_dir_all(path.join("blobs/sha256")).unwrap();
730 std::fs::write(path.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
731
732 let config_content = r#"{
733 "architecture": "amd64",
734 "os": "linux",
735 "config": {
736 "Entrypoint": ["/bin/sh"],
737 "Cmd": ["-c", "true"],
738 "Env": ["PATH=/usr/bin:/bin"],
739 "WorkingDir": "/"
740 },
741 "rootfs": {
742 "type": "layers",
743 "diff_ids": ["sha256:0000000000000000000000000000000000000000000000000000000000000000"]
744 },
745 "history": []
746 }"#;
747 let config_digest = write_test_blob(path, config_content.as_bytes());
748
749 let layer_content = b"layer";
750 let layer_digest = write_test_blob(path, layer_content);
751
752 let manifest_content = format!(
753 r#"{{
754 "schemaVersion": 2,
755 "mediaType": "application/vnd.oci.image.manifest.v1+json",
756 "config": {{
757 "mediaType": "application/vnd.oci.image.config.v1+json",
758 "digest": "{}",
759 "size": {}
760 }},
761 "layers": [
762 {{
763 "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
764 "digest": "{}",
765 "size": {}
766 }}
767 ]
768 }}"#,
769 config_digest,
770 config_content.len(),
771 layer_digest,
772 layer_content.len()
773 );
774 let manifest_digest = write_test_blob(path, manifest_content.as_bytes());
775
776 let index_content = format!(
777 r#"{{
778 "schemaVersion": 2,
779 "mediaType": "application/vnd.oci.image.index.v1+json",
780 "manifests": [
781 {{
782 "mediaType": "application/vnd.oci.image.manifest.v1+json",
783 "digest": "{}",
784 "size": {}
785 }}
786 ]
787 }}"#,
788 manifest_digest,
789 manifest_content.len()
790 );
791 std::fs::write(path.join("index.json"), index_content).unwrap();
792 manifest_digest
793 }
794}