spin_oci/
client.rs

1//! Spin's client for distributing applications via OCI registries
2
3use std::collections::{BTreeMap, HashMap};
4use std::path::{Path, PathBuf};
5
6use anyhow::{bail, Context, Result};
7use docker_credential::DockerCredential;
8use futures_util::future;
9use futures_util::stream::{self, StreamExt, TryStreamExt};
10use itertools::Itertools;
11use oci_distribution::{
12    client::ImageLayer, config::ConfigFile, manifest::OciImageManifest, secrets::RegistryAuth,
13    token_cache::RegistryTokenType, Reference, RegistryOperation,
14};
15use reqwest::Url;
16use spin_common::sha256;
17use spin_common::ui::quoted_path;
18use spin_common::url::parse_file_url;
19use spin_compose::ComponentSourceLoaderFs;
20use spin_loader::cache::Cache;
21use spin_loader::FilesMountStrategy;
22use spin_locked_app::locked::{ContentPath, ContentRef, LockedApp, LockedComponent};
23use tokio::fs;
24use walkdir::WalkDir;
25
26use crate::auth::AuthConfig;
27use crate::validate;
28
29// TODO: the media types for application, data and archive layer are not final
30/// Media type for a layer representing a locked Spin application configuration
31pub const SPIN_APPLICATION_MEDIA_TYPE: &str = "application/vnd.fermyon.spin.application.v1+config";
32/// Media type for a layer representing a generic data file used by a Spin application
33pub const DATA_MEDIATYPE: &str = "application/vnd.wasm.content.layer.v1+data";
34/// Media type for a layer representing a compressed archive of one or more files used by a Spin application
35pub const ARCHIVE_MEDIATYPE: &str = "application/vnd.wasm.content.bundle.v1.tar+gzip";
36// Note: this will be updated with a canonical value once defined upstream
37const WASM_LAYER_MEDIA_TYPE: &str = "application/vnd.wasm.content.layer.v1+wasm";
38// Media type for a Wasm binary pushed by wkg
39const WASM_LAYER_MEDIA_TYPE_WKG: &str = "application/wasm";
40
41const CONFIG_FILE: &str = "config.json";
42const LATEST_TAG: &str = "latest";
43const MANIFEST_FILE: &str = "manifest.json";
44
45/// Env var to force use of archive layers when publishing a Spin app
46const SPIN_OCI_ARCHIVE_LAYERS_OPT: &str = "SPIN_OCI_ARCHIVE_LAYERS";
47
48const MAX_PARALLEL_PULL: usize = 16;
49/// Maximum layer count allowed per app, set in accordance to the lowest
50/// known maximum per image in well-known OCI registry implementations.
51/// (500 appears to be the limit for Elastic Container Registry)
52const MAX_LAYER_COUNT: usize = 500;
53
54/// Default maximum content size for inlining directly into config,
55/// rather than pushing as a separate layer
56const DEFAULT_CONTENT_REF_INLINE_MAX_SIZE: usize = 128;
57
58/// Default token expiration when pushing/pulling an image to/from a registry.
59/// This value is used by the underyling OCI client when the token expiration
60/// is unspecified on a claim.
61/// This essentially equates to a timeout for push/pull.
62const DEFAULT_TOKEN_EXPIRATION_SECS: usize = 300;
63
64/// Mode of assembly of a Spin application into an OCI image
65#[derive(Copy, Clone)]
66enum AssemblyMode {
67    /// Assemble the application as one layer per component and one layer for
68    /// every static asset included with a given component
69    Simple,
70    /// Assemble the application as one layer per component and one compressed
71    /// archive layer containing all static assets included with a given component
72    Archive,
73}
74
75/// Indicates whether to compose the components of a Spin application when pushing an image.
76#[derive(Copy, Clone)]
77pub enum ComposeMode {
78    /// Compose components before pushing the image.
79    All,
80    /// Skip composing components before pushing the image.
81    Skip,
82}
83
84/// Client for interacting with an OCI registry for Spin applications.
85pub struct Client {
86    /// Global cache for the metadata, Wasm modules, and static assets pulled from OCI registries.
87    pub cache: Cache,
88    /// Underlying OCI client.
89    oci: oci_distribution::Client,
90    /// Client options
91    pub opts: ClientOpts,
92}
93
94#[derive(Clone)]
95/// Options for configuring a Client
96pub struct ClientOpts {
97    /// Inline content into ContentRef iff < this size.
98    pub content_ref_inline_max_size: usize,
99}
100
101/// Controls whether predefined annotations are generated when pushing an application.
102/// If an explicit annotation has the same name as a predefined one, the explicit
103/// one takes precedence.
104#[derive(Debug, PartialEq)]
105pub enum InferPredefinedAnnotations {
106    /// Infer annotations for created, authors, version, name and description.
107    All,
108    /// Do not generate any annotations; use only explicitly supplied annotations.
109    None,
110}
111
112impl Client {
113    /// Create a new instance of an OCI client for distributing Spin applications.
114    pub async fn new(insecure: bool, cache_root: Option<PathBuf>) -> Result<Self> {
115        let client = oci_distribution::Client::new(Self::build_config(insecure));
116        let cache = Cache::new(cache_root).await?;
117        let opts = ClientOpts {
118            content_ref_inline_max_size: DEFAULT_CONTENT_REF_INLINE_MAX_SIZE,
119        };
120
121        Ok(Self {
122            oci: client,
123            cache,
124            opts,
125        })
126    }
127
128    /// Push a Spin application to an OCI registry and return the digest (or None
129    /// if the digest cannot be determined).
130    pub async fn push(
131        &mut self,
132        manifest_path: &Path,
133        reference: impl AsRef<str>,
134        annotations: Option<BTreeMap<String, String>>,
135        infer_annotations: InferPredefinedAnnotations,
136        compose_mode: ComposeMode,
137    ) -> Result<Option<String>> {
138        let reference: Reference = reference
139            .as_ref()
140            .parse()
141            .with_context(|| format!("cannot parse reference {}", reference.as_ref()))?;
142        let auth = Self::auth(&reference).await?;
143        let working_dir = tempfile::tempdir()?;
144
145        // Create a locked application from the application manifest.
146        // TODO: We don't need an extra copy here for each asset to prepare the application.
147        // We should be able to use assets::collect instead when constructing the locked app.
148        let locked = spin_loader::from_file(
149            manifest_path,
150            FilesMountStrategy::Copy(working_dir.path().into()),
151            None,
152        )
153        .await?;
154
155        // Ensure that all Spin components specify valid wasm binaries in both the `source`
156        // field and for each dependency.
157        for locked_component in &locked.components {
158            validate::ensure_wasms(locked_component).await?;
159        }
160
161        self.push_locked_core(
162            locked,
163            auth,
164            reference,
165            annotations,
166            infer_annotations,
167            compose_mode,
168        )
169        .await
170    }
171
172    /// Push a Spin application to an OCI registry and return the digest (or None
173    /// if the digest cannot be determined).
174    pub async fn push_locked(
175        &mut self,
176        locked: LockedApp,
177        reference: impl AsRef<str>,
178        annotations: Option<BTreeMap<String, String>>,
179        infer_annotations: InferPredefinedAnnotations,
180        compose_mode: ComposeMode,
181    ) -> Result<Option<String>> {
182        let reference: Reference = reference
183            .as_ref()
184            .parse()
185            .with_context(|| format!("cannot parse reference {}", reference.as_ref()))?;
186        let auth = Self::auth(&reference).await?;
187
188        self.push_locked_core(
189            locked,
190            auth,
191            reference,
192            annotations,
193            infer_annotations,
194            compose_mode,
195        )
196        .await
197    }
198
199    /// Push a Spin application to an OCI registry and return the digest (or None
200    /// if the digest cannot be determined).
201    async fn push_locked_core(
202        &mut self,
203        locked: LockedApp,
204        auth: RegistryAuth,
205        reference: Reference,
206        annotations: Option<BTreeMap<String, String>>,
207        infer_annotations: InferPredefinedAnnotations,
208        compose_mode: ComposeMode,
209    ) -> Result<Option<String>> {
210        let mut locked_app = locked.clone();
211        let mut layers = self
212            .assemble_layers(&mut locked_app, AssemblyMode::Simple, compose_mode)
213            .await
214            .context("could not assemble layers for locked application")?;
215
216        // If SPIN_OCI_ARCHIVE_LAYERS_OPT is set *or* if layer count exceeds MAX_LAYER_COUNT-1,
217        // assemble archive layers instead. (An additional layer to represent the locked
218        // application config is added.)
219        if std::env::var(SPIN_OCI_ARCHIVE_LAYERS_OPT).is_ok() || layers.len() > MAX_LAYER_COUNT - 1
220        {
221            locked_app = locked.clone();
222            layers = self
223                .assemble_layers(&mut locked_app, AssemblyMode::Archive, compose_mode)
224                .await
225                .context("could not assemble archive layers for locked application")?;
226        }
227
228        let annotations = all_annotations(&locked_app, annotations, infer_annotations);
229
230        // Push layer for locked spin application config
231        let locked_config_layer = ImageLayer::new(
232            serde_json::to_vec(&locked_app).context("could not serialize locked config")?,
233            SPIN_APPLICATION_MEDIA_TYPE.to_string(),
234            None,
235        );
236        let config_layer_digest = locked_config_layer.sha256_digest().clone();
237        layers.push(locked_config_layer);
238
239        let mut labels = HashMap::new();
240        labels.insert(
241            "com.fermyon.spin.lockedAppDigest".to_string(),
242            config_layer_digest,
243        );
244        let cfg = oci_distribution::config::Config {
245            labels: Some(labels),
246            ..Default::default()
247        };
248
249        // Construct empty/default OCI config file. Data may be parsed according to
250        // the expected config structure per the image spec, so we want to ensure it conforms.
251        // (See https://github.com/opencontainers/image-spec/blob/main/config.md)
252        // TODO: Explore adding data applicable to the Spin app being published.
253        let oci_config_file = ConfigFile {
254            architecture: oci_distribution::config::Architecture::Wasm,
255            os: oci_distribution::config::Os::Wasip1,
256            // We need to ensure that the image config for different content is updated.
257            // Without referencing the digest of the locked application in the OCI image config,
258            // all Spin applications would get the same image config digest, resulting in the same
259            // image ID in container runtimes.
260            config: Some(cfg),
261            ..Default::default()
262        };
263        let oci_config =
264            oci_distribution::client::Config::oci_v1_from_config_file(oci_config_file, None)?;
265        let manifest = OciImageManifest::build(&layers, &oci_config, annotations);
266
267        let response = self
268            .oci
269            .push(&reference, &layers, oci_config, &auth, Some(manifest))
270            .await
271            .map(|push_response| push_response.manifest_url)
272            .context("cannot push Spin application")?;
273
274        tracing::info!("Pushed {:?}", response);
275
276        let digest = digest_from_url(&response);
277        Ok(digest)
278    }
279
280    /// Assemble ImageLayers for a locked application using the provided
281    /// AssemblyMode and return the resulting Vec<ImageLayer>.
282    async fn assemble_layers(
283        &mut self,
284        locked: &mut LockedApp,
285        assembly_mode: AssemblyMode,
286        compose_mode: ComposeMode,
287    ) -> Result<Vec<ImageLayer>> {
288        let (mut layers, components) = match compose_mode {
289            ComposeMode::All => {
290                self.assemble_layers_composed(assembly_mode, locked.clone())
291                    .await?
292            }
293            ComposeMode::Skip => {
294                self.assemble_layers_uncomposed(assembly_mode, locked.clone())
295                    .await?
296            }
297        };
298
299        locked.components = components;
300        locked.metadata.remove("origin");
301
302        // Deduplicate layers
303        layers = layers.into_iter().unique().collect();
304
305        Ok(layers)
306    }
307
308    async fn assemble_layers_uncomposed(
309        &mut self,
310        assembly_mode: AssemblyMode,
311        locked: LockedApp,
312    ) -> Result<(Vec<ImageLayer>, Vec<LockedComponent>)> {
313        let mut components = Vec::new();
314        let mut layers = Vec::new();
315
316        for mut c in locked.components {
317            // Add the wasm module for the component as layers.
318            let source = c
319                .source
320                .content
321                .source
322                .as_ref()
323                .context("component loaded from disk should contain a file source")?;
324
325            let source = parse_file_url(source.as_str())?;
326            let layer = Self::wasm_layer(&source).await?;
327
328            // Update the module source with the content ref of the layer.
329            c.source.content = self.content_ref_for_layer(&layer);
330
331            layers.push(layer);
332
333            let mut deps = BTreeMap::default();
334            for (dep_name, mut dep) in c.dependencies {
335                let source = dep
336                    .source
337                    .content
338                    .source
339                    .context("dependency loaded from disk should contain a file source")?;
340                let source = parse_file_url(source.as_str())?;
341
342                let layer = Self::wasm_layer(&source).await?;
343
344                dep.source.content = self.content_ref_for_layer(&layer);
345                deps.insert(dep_name, dep);
346
347                layers.push(layer);
348            }
349            c.dependencies = deps;
350
351            c.files = self
352                .assemble_content_layers(assembly_mode, &mut layers, c.files.as_slice())
353                .await?;
354            components.push(c);
355        }
356
357        Ok((layers, components))
358    }
359
360    async fn assemble_layers_composed(
361        &mut self,
362        assembly_mode: AssemblyMode,
363        locked: LockedApp,
364    ) -> Result<(Vec<ImageLayer>, Vec<LockedComponent>)> {
365        let mut components = Vec::new();
366        let mut layers = Vec::new();
367
368        for mut c in locked.components {
369            let composed = spin_compose::compose(&ComponentSourceLoaderFs, &c)
370                .await
371                .with_context(|| {
372                    format!("failed to resolve dependencies for component {:?}", c.id)
373                })?;
374            let layer = ImageLayer::new(composed, WASM_LAYER_MEDIA_TYPE.to_string(), None);
375            c.source.content = self.content_ref_for_layer(&layer);
376            c.dependencies.clear();
377            layers.push(layer);
378
379            c.files = self
380                .assemble_content_layers(assembly_mode, &mut layers, c.files.as_slice())
381                .await?;
382            components.push(c);
383        }
384
385        Ok((layers, components))
386    }
387
388    async fn assemble_content_layers(
389        &mut self,
390        assembly_mode: AssemblyMode,
391        layers: &mut Vec<ImageLayer>,
392        contents: &[ContentPath],
393    ) -> Result<Vec<ContentPath>> {
394        let mut files = Vec::new();
395        for f in contents {
396            let source = f
397                .content
398                .source
399                .as_ref()
400                .context("file mount loaded from disk should contain a file source")?;
401            let source = parse_file_url(source.as_str())?;
402
403            match assembly_mode {
404                AssemblyMode::Archive => self
405                    .push_archive_layer(&source, &mut files, layers)
406                    .await
407                    .context(format!(
408                        "cannot push archive layer for source {}",
409                        quoted_path(&source)
410                    ))?,
411                AssemblyMode::Simple => self
412                    .push_file_layers(&source, &mut files, layers)
413                    .await
414                    .context(format!(
415                        "cannot push file layers for source {}",
416                        quoted_path(&source)
417                    ))?,
418            }
419        }
420        Ok(files)
421    }
422
423    /// Archive all of the files recursively under the source directory
424    /// and push as a compressed archive layer
425    async fn push_archive_layer(
426        &mut self,
427        source: &PathBuf,
428        files: &mut Vec<ContentPath>,
429        layers: &mut Vec<ImageLayer>,
430    ) -> Result<()> {
431        // Add all archived file entries to the locked app manifest
432        for entry in WalkDir::new(source) {
433            let entry = entry?;
434            if !entry.file_type().is_file() {
435                continue;
436            }
437            // Can unwrap because we got to 'entry' from walking 'source'
438            let rel_path = entry.path().strip_prefix(source).unwrap();
439            tracing::trace!("Adding asset {rel_path:?} to component files list");
440            // Add content/path to the locked component files list
441            let layer = Self::data_layer(entry.path(), DATA_MEDIATYPE.to_string()).await?;
442            let content = self.content_ref_for_layer(&layer);
443            files.push(ContentPath {
444                content,
445                path: rel_path.into(),
446            });
447        }
448
449        // Only add the archive layer to the OCI manifest
450        tracing::trace!("Adding archive layer for all files in source {:?}", &source);
451        let working_dir = tempfile::tempdir()?;
452        let archive_path = crate::utils::archive(source, &working_dir.keep())
453            .await
454            .context(format!(
455                "Unable to create compressed archive for source {source:?}"
456            ))?;
457        let layer = Self::data_layer(archive_path.as_path(), ARCHIVE_MEDIATYPE.to_string()).await?;
458        layers.push(layer);
459        Ok(())
460    }
461
462    /// Recursively traverse the source directory and add layers for each file.
463    async fn push_file_layers(
464        &mut self,
465        source: &PathBuf,
466        files: &mut Vec<ContentPath>,
467        layers: &mut Vec<ImageLayer>,
468    ) -> Result<()> {
469        // Traverse each mount directory, add all static assets as layers, then update the
470        // locked application file with the file digest.
471        tracing::trace!("Adding new layer per file under source {:?}", source);
472        for entry in WalkDir::new(source) {
473            let entry = entry?;
474            if !entry.file_type().is_file() {
475                continue;
476            }
477            // Can unwrap because we got to 'entry' from walking 'source'
478            let rel_path = entry.path().strip_prefix(source).unwrap();
479            // Paths must be in portable (forward slash) format in the registry,
480            // so that they can be placed correctly on any host system
481            let rel_path = portable_path(rel_path);
482
483            tracing::trace!("Adding new layer for asset {rel_path:?}");
484            // Construct and push layer, adding its digest to the locked component files Vec
485            let layer = Self::data_layer(entry.path(), DATA_MEDIATYPE.to_string()).await?;
486            let content = self.content_ref_for_layer(&layer);
487            let content_inline = content.inline.is_some();
488            files.push(ContentPath {
489                content,
490                path: rel_path,
491            });
492            // As a workaround for OCI implementations that don't support very small blobs,
493            // don't push very small content that has been inlined into the manifest:
494            // https://github.com/distribution/distribution/discussions/4029
495            let skip_layer = content_inline;
496            if !skip_layer {
497                layers.push(layer);
498            }
499        }
500        Ok(())
501    }
502
503    /// Pull a Spin application from an OCI registry.
504    pub async fn pull(&mut self, reference: &str) -> Result<OciImageManifest> {
505        let reference: Reference = reference.parse().context("cannot parse reference")?;
506        let auth = Self::auth(&reference).await?;
507
508        // Pull the manifest from the registry.
509        let (manifest, digest) = self.oci.pull_image_manifest(&reference, &auth).await?;
510
511        let manifest_json = serde_json::to_string(&manifest)?;
512        tracing::debug!("Pulled manifest: {}", manifest_json);
513
514        // Write the manifest in `<cache_root>/registry/oci/manifests/repository:<tag_or_latest>/manifest.json`
515        let m = self.manifest_path(&reference.to_string()).await?;
516        fs::write(&m, &manifest_json).await?;
517
518        // Older published Spin apps feature the locked app config *as* the OCI manifest config layer,
519        // while newer versions publish the locked app config as a generic layer alongside others.
520        // Assume that these bytes may represent the locked app config and write it as such.
521        let mut cfg_bytes = Vec::new();
522        self.oci
523            .pull_blob(&reference, &manifest.config, &mut cfg_bytes)
524            .await?;
525        self.write_locked_app_config(&reference.to_string(), &cfg_bytes)
526            .await
527            .context("unable to write locked app config to cache")?;
528
529        // If a layer is a Wasm module, write it in the Wasm directory.
530        // Otherwise, write it in the data directory (after unpacking if archive layer)
531        stream::iter(&manifest.layers)
532            .map(|layer| {
533                let this = &self;
534                let reference = reference.clone();
535                async move {
536                    // Skip pulling if the digest already exists in the wasm or data directories.
537                    if this.cache.wasm_file(&layer.digest).is_ok()
538                        || this.cache.data_file(&layer.digest).is_ok()
539                    {
540                        tracing::debug!("Layer {} already exists in cache", &layer.digest);
541                        return anyhow::Ok(());
542                    }
543
544                    tracing::debug!("Pulling layer {}", &layer.digest);
545                    let mut bytes = Vec::with_capacity(layer.size.try_into()?);
546                    this.oci.pull_blob(&reference, layer, &mut bytes).await?;
547                    match layer.media_type.as_str() {
548                        SPIN_APPLICATION_MEDIA_TYPE => {
549                            this.write_locked_app_config(&reference.to_string(), &bytes)
550                                .await
551                                .with_context(|| "unable to write locked app config to cache")?;
552                        }
553                        WASM_LAYER_MEDIA_TYPE | WASM_LAYER_MEDIA_TYPE_WKG => {
554                            this.cache.write_wasm(&bytes, &layer.digest).await?;
555                        }
556                        ARCHIVE_MEDIATYPE => {
557                            unpack_archive_layer(&this.cache, &bytes, &layer.digest).await?;
558                        }
559                        _ => {
560                            this.cache.write_data(&bytes, &layer.digest).await?;
561                        }
562                    }
563                    Ok(())
564                }
565            })
566            .buffer_unordered(MAX_PARALLEL_PULL)
567            .try_for_each(future::ok)
568            .await?;
569        tracing::info!("Pulled {}@{}", reference, digest);
570
571        Ok(manifest)
572    }
573
574    /// Get the file path to an OCI manifest given a reference.
575    /// If the directory for the manifest does not exist, this will create it.
576    async fn manifest_path(&self, reference: impl AsRef<str>) -> Result<PathBuf> {
577        let reference: Reference = reference
578            .as_ref()
579            .parse()
580            .context("cannot parse OCI reference")?;
581        let p = self
582            .cache
583            .manifests_dir()
584            .join(fs_safe_segment(reference.registry()))
585            .join(reference.repository())
586            .join(reference.tag().unwrap_or(LATEST_TAG));
587
588        if !p.is_dir() {
589            fs::create_dir_all(&p).await.with_context(|| {
590                format!("cannot create directory {} for OCI manifest", p.display())
591            })?;
592        }
593
594        Ok(p.join(MANIFEST_FILE))
595    }
596
597    /// Get the file path to the OCI configuration object given a reference.
598    pub async fn lockfile_path(&self, reference: impl AsRef<str>) -> Result<PathBuf> {
599        let reference: Reference = reference
600            .as_ref()
601            .parse()
602            .context("cannot parse reference")?;
603        let p = self
604            .cache
605            .manifests_dir()
606            .join(fs_safe_segment(reference.registry()))
607            .join(reference.repository())
608            .join(reference.tag().unwrap_or(LATEST_TAG));
609
610        if !p.is_dir() {
611            fs::create_dir_all(&p)
612                .await
613                .context("cannot find configuration object for reference")?;
614        }
615
616        Ok(p.join(CONFIG_FILE))
617    }
618
619    /// Write the config object in `<cache_root>/registry/oci/manifests/repository:<tag_or_latest>/config.json`
620    async fn write_locked_app_config(
621        &self,
622        reference: impl AsRef<str>,
623        bytes: impl AsRef<[u8]>,
624    ) -> Result<()> {
625        let cfg = std::str::from_utf8(bytes.as_ref())?;
626        tracing::debug!("Pulled config: {}", cfg);
627
628        let c = self.lockfile_path(reference).await?;
629        fs::write(&c, &cfg).await.map_err(anyhow::Error::from)
630    }
631
632    /// Create a new wasm layer based on a file.
633    async fn wasm_layer(file: &Path) -> Result<ImageLayer> {
634        tracing::trace!("Reading wasm module from {:?}", file);
635        Ok(ImageLayer::new(
636            fs::read(file)
637                .await
638                .with_context(|| format!("cannot read wasm module {}", quoted_path(file)))?,
639            WASM_LAYER_MEDIA_TYPE.to_string(),
640            None,
641        ))
642    }
643
644    /// Create a new data layer based on a file.
645    async fn data_layer(file: &Path, media_type: String) -> Result<ImageLayer> {
646        tracing::trace!("Reading data file from {:?}", file);
647        Ok(ImageLayer::new(
648            fs::read(&file)
649                .await
650                .with_context(|| format!("cannot read file {}", quoted_path(file)))?,
651            media_type,
652            None,
653        ))
654    }
655
656    fn content_ref_for_layer(&self, layer: &ImageLayer) -> ContentRef {
657        ContentRef {
658            // Inline small content as an optimization and to work around issues
659            // with OCI implementations that don't support very small blobs.
660            inline: (layer.data.len() <= self.opts.content_ref_inline_max_size)
661                .then(|| layer.data.to_vec()),
662            digest: Some(layer.sha256_digest()),
663            ..Default::default()
664        }
665    }
666
667    /// Save a credential set containing the registry username and password.
668    pub async fn login(
669        server: impl AsRef<str>,
670        username: impl AsRef<str>,
671        password: impl AsRef<str>,
672    ) -> Result<()> {
673        let registry = registry_from_input(server);
674
675        // First, validate the credentials. If a user accidentally enters a wrong credential set, this
676        // can catch the issue early rather than getting an error at the first operation that needs
677        // to use the credentials (first time they do a push/pull/up).
678        Self::validate_credentials(&registry, &username, &password).await?;
679
680        // Save an encoded representation of the credential set in the local configuration file.
681        let mut auth = AuthConfig::load_default().await?;
682        auth.insert(registry, username, password)?;
683        auth.save_default().await
684    }
685
686    /// Insert a token in the OCI client token cache.
687    pub async fn insert_token(
688        &mut self,
689        reference: &Reference,
690        op: RegistryOperation,
691        token: RegistryTokenType,
692    ) {
693        self.oci.tokens.insert(reference, op, token).await;
694    }
695
696    /// Validate the credentials by attempting to send an authenticated request to the registry.
697    async fn validate_credentials(
698        server: impl AsRef<str>,
699        username: impl AsRef<str>,
700        password: impl AsRef<str>,
701    ) -> Result<()> {
702        let client = dkregistry::v2::Client::configure()
703            .registry(server.as_ref())
704            .insecure_registry(false)
705            .username(Some(username.as_ref().into()))
706            .password(Some(password.as_ref().into()))
707            .build()
708            .context("cannot create client to send authentication request to the registry")?;
709
710        match client
711            // We don't need to configure any scopes, we are only testing that the credentials are
712            // valid for the intended registry.
713            .authenticate(&[""])
714            .await
715        {
716            Ok(_) => Ok(()),
717            Err(e) => bail!(format!(
718                "cannot authenticate as {} to registry {}: {}",
719                username.as_ref(),
720                server.as_ref(),
721                e
722            )),
723        }
724    }
725
726    /// Construct the registry authentication based on the reference.
727    async fn auth(reference: &Reference) -> Result<RegistryAuth> {
728        let server = reference
729            .resolve_registry()
730            .strip_suffix('/')
731            .unwrap_or_else(|| reference.resolve_registry());
732
733        match AuthConfig::get_auth_from_default(server).await {
734            Ok(c) => Ok(c),
735            Err(_) => match docker_credential::get_credential(server) {
736                Err(e) => {
737                    tracing::trace!(
738                        "Cannot retrieve credentials from Docker, attempting to use anonymous auth: {}",
739                        e
740                    );
741                    Ok(RegistryAuth::Anonymous)
742                }
743
744                Ok(DockerCredential::UsernamePassword(username, password)) => {
745                    tracing::trace!("Found Docker credentials");
746                    Ok(RegistryAuth::Basic(username, password))
747                }
748                Ok(DockerCredential::IdentityToken(_)) => {
749                    tracing::trace!(
750                        "Cannot use contents of Docker config, identity token not supported. Using anonymous auth"
751                    );
752                    Ok(RegistryAuth::Anonymous)
753                }
754            },
755        }
756    }
757
758    /// Build the OCI client configuration given the insecure option.
759    fn build_config(insecure: bool) -> oci_distribution::client::ClientConfig {
760        let protocol = if insecure {
761            oci_distribution::client::ClientProtocol::Http
762        } else {
763            oci_distribution::client::ClientProtocol::Https
764        };
765
766        oci_distribution::client::ClientConfig {
767            protocol,
768            default_token_expiration_secs: DEFAULT_TOKEN_EXPIRATION_SECS,
769            ..Default::default()
770        }
771    }
772}
773
774/// Unpack contents of the provided archive layer, represented by bytes and its
775/// corresponding digest, into the provided cache.
776/// A temporary staging directory is created via tempfile::tempdir() to store
777/// the unpacked contents prior to writing to the cache.
778pub async fn unpack_archive_layer(
779    cache: &Cache,
780    bytes: impl AsRef<[u8]>,
781    digest: impl AsRef<str>,
782) -> Result<()> {
783    // Write archive layer to cache as usual
784    cache.write_data(&bytes, &digest).await?;
785
786    // Unpack archive into a staging dir
787    let path = cache
788        .data_file(&digest)
789        .context("unable to read archive layer from cache")?;
790    let staging_dir = tempfile::tempdir()?;
791    crate::utils::unarchive(path.as_ref(), staging_dir.path()).await?;
792
793    // Traverse unpacked contents and if a file, write to cache by digest
794    // (if it doesn't already exist)
795    for entry in WalkDir::new(staging_dir.path()) {
796        let entry = entry?;
797        if entry.file_type().is_file() && !entry.file_type().is_dir() {
798            let bytes = tokio::fs::read(entry.path()).await?;
799            let digest = format!("sha256:{}", sha256::hex_digest_from_bytes(&bytes));
800            if cache.data_file(&digest).is_ok() {
801                tracing::debug!(
802                    "Skipping unpacked asset {:?}; file already exists",
803                    entry.path()
804                );
805            } else {
806                tracing::debug!("Adding unpacked asset {:?} to cache", entry.path());
807                cache.write_data(bytes, &digest).await?;
808            }
809        }
810    }
811    Ok(())
812}
813
814fn digest_from_url(manifest_url: &str) -> Option<String> {
815    // The URL is in the form "https://host/v2/refname/manifests/sha256:..."
816    let manifest_url = Url::parse(manifest_url).ok()?;
817    let mut segments = manifest_url.path_segments()?;
818    let last = segments.next_back()?;
819    if last.contains(':') {
820        Some(last.to_owned())
821    } else {
822        None
823    }
824}
825
826fn registry_from_input(server: impl AsRef<str>) -> String {
827    // We want to allow a user to login to both https://ghcr.io and ghcr.io.
828    let server = server.as_ref();
829    let server = match server.parse::<Url>() {
830        Ok(url) => url.host_str().unwrap_or(server).to_string(),
831        Err(_) => server.to_string(),
832    };
833    // DockerHub is commonly referenced as 'docker.io' but needs to be 'index.docker.io'
834    match server.as_str() {
835        "docker.io" => "index.docker.io".to_string(),
836        _ => server,
837    }
838}
839
840fn all_annotations(
841    locked_app: &LockedApp,
842    explicit: Option<BTreeMap<String, String>>,
843    predefined: InferPredefinedAnnotations,
844) -> Option<BTreeMap<String, String>> {
845    use spin_locked_app::{MetadataKey, APP_DESCRIPTION_KEY, APP_NAME_KEY, APP_VERSION_KEY};
846    const APP_AUTHORS_KEY: MetadataKey<Vec<String>> = MetadataKey::new("authors");
847
848    if predefined == InferPredefinedAnnotations::None {
849        return explicit;
850    }
851
852    // We will always, at minimum, have a `created` annotation, so if we don't already have an
853    // anootations collection then we may as well create one now...
854    let mut current = explicit.unwrap_or_default();
855
856    let authors = locked_app
857        .get_metadata(APP_AUTHORS_KEY)
858        .unwrap_or_default()
859        .unwrap_or_default();
860    if !authors.is_empty() {
861        let authors = authors.join(", ");
862        add_inferred(
863            &mut current,
864            oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS,
865            Some(authors),
866        );
867    }
868
869    let name = locked_app.get_metadata(APP_NAME_KEY).unwrap_or_default();
870    add_inferred(
871        &mut current,
872        oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_TITLE,
873        name,
874    );
875
876    let description = locked_app
877        .get_metadata(APP_DESCRIPTION_KEY)
878        .unwrap_or_default();
879    add_inferred(
880        &mut current,
881        oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_DESCRIPTION,
882        description,
883    );
884
885    let version = locked_app.get_metadata(APP_VERSION_KEY).unwrap_or_default();
886    add_inferred(
887        &mut current,
888        oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_VERSION,
889        version,
890    );
891
892    let created = chrono::Utc::now().to_rfc3339();
893    add_inferred(
894        &mut current,
895        oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_CREATED,
896        Some(created),
897    );
898
899    Some(current)
900}
901
902fn add_inferred(map: &mut BTreeMap<String, String>, key: &str, value: Option<String>) {
903    if let Some(value) = value {
904        if let std::collections::btree_map::Entry::Vacant(e) = map.entry(key.to_string()) {
905            e.insert(value);
906        }
907    }
908}
909
910/// Takes a relative path and turns it into a format that is safe
911/// for putting into a registry where it might end up on any host.
912#[cfg(target_os = "windows")]
913fn portable_path(rel_path: &Path) -> PathBuf {
914    assert!(
915        rel_path.is_relative(),
916        "portable_path requires paths to be relative"
917    );
918    let portable_path = rel_path.to_string_lossy().replace('\\', "/");
919    PathBuf::from(portable_path)
920}
921
922/// Takes a relative path and turns it into a format that is safe
923/// for putting into a registry where it might end up on any host.
924/// This is a no-op on Unix systems, but is needed for Windows.
925#[cfg(not(target_os = "windows"))]
926fn portable_path(rel_path: &Path) -> PathBuf {
927    rel_path.into()
928}
929
930/// Takes a string intended for use as part of a path and makes it
931/// compatible with the local filesystem.
932#[cfg(target_os = "windows")]
933fn fs_safe_segment(segment: &str) -> impl AsRef<Path> {
934    segment.replace(':', "_")
935}
936
937/// Takes a string intended for use as part of a path and makes it
938/// compatible with the local filesystem.
939/// This is a no-op on Unix systems, but is needed for Windows.
940#[cfg(not(target_os = "windows"))]
941fn fs_safe_segment(segment: &str) -> impl AsRef<Path> + '_ {
942    segment
943}
944
945#[cfg(test)]
946mod test {
947    use super::*;
948    use wit_parser::{LiftLowerAbi, ManglingAndAbi};
949
950    #[test]
951    fn can_parse_digest_from_manifest_url() {
952        let manifest_url = "https://ghcr.io/v2/itowlson/osf/manifests/sha256:0a867093096e0ef01ef749b12b6e7a90e4952eda107f89a676eeedce63a8361f";
953        let digest = digest_from_url(manifest_url).unwrap();
954        assert_eq!(
955            "sha256:0a867093096e0ef01ef749b12b6e7a90e4952eda107f89a676eeedce63a8361f",
956            digest
957        );
958    }
959
960    #[test]
961    fn can_derive_registry_from_input() {
962        #[derive(Clone)]
963        struct TestCase {
964            input: &'static str,
965            want: &'static str,
966        }
967        let tests: Vec<TestCase> = [
968            TestCase {
969                input: "docker.io",
970                want: "index.docker.io",
971            },
972            TestCase {
973                input: "index.docker.io",
974                want: "index.docker.io",
975            },
976            TestCase {
977                input: "https://ghcr.io",
978                want: "ghcr.io",
979            },
980        ]
981        .to_vec();
982
983        for tc in tests {
984            assert_eq!(tc.want, registry_from_input(tc.input));
985        }
986    }
987
988    // Convenience wrapper for deserializing from literal JSON
989    #[macro_export]
990    #[allow(missing_docs)] // it's test-only, but rust-analyzer gets mad
991    macro_rules! from_json {
992        ($($json:tt)+) => {
993            serde_json::from_value(serde_json::json!($($json)+)).expect("valid json")
994        };
995    }
996
997    #[tokio::test]
998    async fn can_assemble_layers() {
999        use spin_locked_app::locked::LockedComponent;
1000        use tokio::io::AsyncWriteExt;
1001
1002        let working_dir = tempfile::tempdir().unwrap();
1003
1004        // Set up component/file directory tree
1005        //
1006        // create component1 and component2 dirs
1007        let _ = tokio::fs::create_dir(working_dir.path().join("component1").as_path()).await;
1008        let _ = tokio::fs::create_dir(working_dir.path().join("component2").as_path()).await;
1009
1010        // create component "wasm" files
1011        let mut c1 = tokio::fs::File::create(working_dir.path().join("component1.wasm"))
1012            .await
1013            .expect("should create component wasm file");
1014        c1.write_all(b"c1")
1015            .await
1016            .expect("should write component wasm contents");
1017        let mut c2 = tokio::fs::File::create(working_dir.path().join("component2.wasm"))
1018            .await
1019            .expect("should create component wasm file");
1020        c2.write_all(b"c2")
1021            .await
1022            .expect("should write component wasm contents");
1023
1024        // component1 files
1025        let mut c1f1 = tokio::fs::File::create(working_dir.path().join("component1").join("bar"))
1026            .await
1027            .expect("should create component file");
1028        c1f1.write_all(b"bar")
1029            .await
1030            .expect("should write file contents");
1031        let mut c1f2 = tokio::fs::File::create(working_dir.path().join("component1").join("baz"))
1032            .await
1033            .expect("should create component file");
1034        c1f2.write_all(b"baz")
1035            .await
1036            .expect("should write file contents");
1037
1038        // component2 files
1039        let mut c2f1 = tokio::fs::File::create(working_dir.path().join("component2").join("baz"))
1040            .await
1041            .expect("should create component file");
1042        c2f1.write_all(b"baz")
1043            .await
1044            .expect("should write file contents");
1045
1046        // create a component with dependencies
1047        const TEST_WIT: &str = "
1048        package test:test;
1049
1050        interface a {
1051            a: func();
1052        }
1053
1054        world dep-a {
1055            export a;
1056        }
1057
1058        world root {
1059            import a;
1060        }
1061        ";
1062
1063        let root = generate_dummy_component(TEST_WIT, "root");
1064        let dep_a = generate_dummy_component(TEST_WIT, "dep-a");
1065
1066        let mut r = tokio::fs::File::create(working_dir.path().join("root.wasm"))
1067            .await
1068            .expect("should create component wasm file");
1069        r.write_all(&root)
1070            .await
1071            .expect("should write component wasm contents");
1072
1073        let mut a = tokio::fs::File::create(working_dir.path().join("dep_a.wasm"))
1074            .await
1075            .expect("should create component wasm file");
1076        a.write_all(&dep_a)
1077            .await
1078            .expect("should write component wasm contents");
1079
1080        #[derive(Clone)]
1081        struct TestCase {
1082            name: &'static str,
1083            opts: Option<ClientOpts>,
1084            locked_components: Vec<LockedComponent>,
1085            expected_layer_count: usize,
1086            expected_error: Option<&'static str>,
1087            compose_mode: ComposeMode,
1088        }
1089
1090        let tests: Vec<TestCase> = [
1091            TestCase {
1092                name: "Two component layers",
1093                opts: None,
1094                locked_components: from_json!([{
1095                    "id": "component1",
1096                    "source": {
1097                        "content_type": "application/wasm",
1098                        "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1099                        "digest": "digest",
1100                }},
1101                {
1102                    "id": "component2",
1103                    "source": {
1104                        "content_type": "application/wasm",
1105                        "source": format!("file://{}", working_dir.path().join("component2.wasm").to_str().unwrap()),
1106                        "digest": "digest",
1107                }}]),
1108                expected_layer_count: 2,
1109                expected_error: None,
1110                compose_mode: ComposeMode::Skip,
1111            },
1112            TestCase {
1113                name: "One component layer and two file layers",
1114                opts: Some(ClientOpts{content_ref_inline_max_size: 0}),
1115                locked_components: from_json!([{
1116                "id": "component1",
1117                "source": {
1118                    "content_type": "application/wasm",
1119                    "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1120                    "digest": "digest",
1121                },
1122                "files": [
1123                    {
1124                        "source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
1125                        "path": working_dir.path().join("component1").join("bar").to_str().unwrap()
1126                    },
1127                    {
1128                        "source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
1129                        "path": working_dir.path().join("component1").join("baz").to_str().unwrap()
1130                    }
1131                ]
1132                }]),
1133                expected_layer_count: 3,
1134                expected_error: None,
1135                compose_mode: ComposeMode::Skip,
1136            },
1137            TestCase {
1138                name: "One component layer and one file with inlined content",
1139                opts: None,
1140                locked_components: from_json!([{
1141                "id": "component1",
1142                "source": {
1143                    "content_type": "application/wasm",
1144                    "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1145                    "digest": "digest",
1146                },
1147                "files": [
1148                    {
1149                        "source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
1150                        "path": working_dir.path().join("component1").join("bar").to_str().unwrap()
1151                    }
1152                ]
1153                }]),
1154                expected_layer_count: 1,
1155                expected_error: None,
1156                compose_mode: ComposeMode::Skip,
1157            },
1158            TestCase {
1159                name: "One component layer and one dependency component layer skipping composition",
1160                opts: Some(ClientOpts{content_ref_inline_max_size: 0}),
1161                locked_components: from_json!([{
1162                "id": "component1",
1163                "source": {
1164                    "content_type": "application/wasm",
1165                    "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1166                    "digest": "digest",
1167                },
1168                "dependencies": {
1169                    "test:comp2": {
1170                        "source": {
1171                            "content_type": "application/wasm",
1172                            "source": format!("file://{}", working_dir.path().join("component2.wasm").to_str().unwrap()),
1173                            "digest": "digest",
1174                        },
1175                        "export": null,
1176                    }
1177                }
1178                }]),
1179                expected_layer_count: 2,
1180                expected_error: None,
1181                compose_mode: ComposeMode::Skip,
1182            },
1183            TestCase {
1184                name: "Component has no source",
1185                opts: None,
1186                locked_components: from_json!([{
1187                "id": "component1",
1188                "source": {
1189                    "content_type": "application/wasm",
1190                    "source": "",
1191                    "digest": "digest",
1192                }
1193                }]),
1194                expected_layer_count: 0,
1195                expected_error: Some("Invalid URL: \"\""),
1196                compose_mode: ComposeMode::Skip,
1197            },
1198            TestCase {
1199                name: "Duplicate component sources",
1200                opts: None,
1201                locked_components: from_json!([{
1202                    "id": "component1",
1203                    "source": {
1204                        "content_type": "application/wasm",
1205                        "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1206                        "digest": "digest",
1207                }},
1208                {
1209                    "id": "component2",
1210                    "source": {
1211                        "content_type": "application/wasm",
1212                        "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1213                        "digest": "digest",
1214                }}]),
1215                expected_layer_count: 1,
1216                expected_error: None,
1217                compose_mode: ComposeMode::Skip,
1218            },
1219            TestCase {
1220                name: "Duplicate file paths",
1221                opts: Some(ClientOpts{content_ref_inline_max_size: 0}),
1222                locked_components: from_json!([{
1223                "id": "component1",
1224                "source": {
1225                    "content_type": "application/wasm",
1226                    "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1227                    "digest": "digest",
1228                },
1229                "files": [
1230                    {
1231                        "source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
1232                        "path": working_dir.path().join("component1").join("bar").to_str().unwrap()
1233                    },
1234                    {
1235                        "source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
1236                        "path": working_dir.path().join("component1").join("baz").to_str().unwrap()
1237                    }
1238                ]},
1239                {
1240                    "id": "component2",
1241                    "source": {
1242                        "content_type": "application/wasm",
1243                        "source": format!("file://{}", working_dir.path().join("component2.wasm").to_str().unwrap()),
1244                        "digest": "digest",
1245                },
1246                "files": [
1247                    {
1248                        "source": format!("file://{}", working_dir.path().join("component2").to_str().unwrap()),
1249                        "path": working_dir.path().join("component2").join("baz").to_str().unwrap()
1250                    }
1251                ]
1252                }]),
1253                expected_layer_count: 4,
1254                expected_error: None,
1255                compose_mode: ComposeMode::Skip,
1256            },
1257            TestCase {
1258                name: "One component layer and one dependency component layer with composition",
1259                opts: Some(ClientOpts{content_ref_inline_max_size: 0}),
1260                locked_components: from_json!([{
1261                "id": "component-with-deps",
1262                "source": {
1263                    "content_type": "application/wasm",
1264                    "source": format!("file://{}", working_dir.path().join("root.wasm").to_str().unwrap()),
1265                    "digest": "digest",
1266                },
1267                "dependencies": {
1268                    "test:test/a": {
1269                        "source": {
1270                            "content_type": "application/wasm",
1271                            "source": format!("file://{}", working_dir.path().join("dep_a.wasm").to_str().unwrap()),
1272                            "digest": "digest",
1273                        },
1274                        "export": null,
1275                    }
1276                }
1277                }]),
1278                expected_layer_count: 1,
1279                expected_error: None,
1280                compose_mode: ComposeMode::All,
1281            },
1282        ]
1283        .to_vec();
1284
1285        for tc in tests {
1286            let triggers = Default::default();
1287            let metadata = Default::default();
1288            let variables = Default::default();
1289            let mut locked = LockedApp {
1290                spin_lock_version: Default::default(),
1291                components: tc.locked_components,
1292                triggers,
1293                metadata,
1294                variables,
1295                must_understand: Default::default(),
1296                host_requirements: Default::default(),
1297            };
1298
1299            let mut client = Client::new(false, Some(working_dir.path().to_path_buf()))
1300                .await
1301                .expect("should create new client");
1302            if let Some(o) = tc.opts {
1303                client.opts = o;
1304            }
1305
1306            match tc.expected_error {
1307                Some(e) => {
1308                    assert_eq!(
1309                        e,
1310                        client
1311                            .assemble_layers(&mut locked, AssemblyMode::Simple, tc.compose_mode)
1312                            .await
1313                            .unwrap_err()
1314                            .to_string(),
1315                        "{}",
1316                        tc.name
1317                    )
1318                }
1319                None => {
1320                    assert_eq!(
1321                        tc.expected_layer_count,
1322                        client
1323                            .assemble_layers(&mut locked, AssemblyMode::Simple, tc.compose_mode)
1324                            .await
1325                            .unwrap()
1326                            .len(),
1327                        "{}",
1328                        tc.name
1329                    )
1330                }
1331            }
1332        }
1333    }
1334
1335    fn generate_dummy_component(wit: &str, world: &str) -> Vec<u8> {
1336        let mut resolve = wit_parser::Resolve::default();
1337        let package_id = resolve.push_str("test", wit).expect("should parse WIT");
1338        let world_id = resolve
1339            .select_world(&[package_id], Some(world))
1340            .expect("should select world");
1341
1342        let mut wasm = wit_component::dummy_module(
1343            &resolve,
1344            world_id,
1345            ManglingAndAbi::Legacy(LiftLowerAbi::Sync),
1346        );
1347        wit_component::embed_component_metadata(
1348            &mut wasm,
1349            &resolve,
1350            world_id,
1351            wit_component::StringEncoding::UTF8,
1352        )
1353        .expect("should embed component metadata");
1354
1355        let mut encoder = wit_component::ComponentEncoder::default()
1356            .validate(true)
1357            .module(&wasm)
1358            .expect("should set module");
1359        encoder.encode().expect("should encode component")
1360    }
1361
1362    fn annotatable_app() -> LockedApp {
1363        let mut meta_builder = spin_locked_app::values::ValuesMapBuilder::new();
1364        meta_builder
1365            .string("name", "this-is-spinal-tap")
1366            .string("version", "11.11.11")
1367            .string("description", "")
1368            .string_array("authors", vec!["Marty DiBergi", "Artie Fufkin"]);
1369        let metadata = meta_builder.build();
1370        LockedApp {
1371            spin_lock_version: Default::default(),
1372            must_understand: vec![],
1373            metadata,
1374            host_requirements: Default::default(),
1375            variables: Default::default(),
1376            triggers: Default::default(),
1377            components: Default::default(),
1378        }
1379    }
1380
1381    fn as_annotations(annotations: &[(&str, &str)]) -> Option<BTreeMap<String, String>> {
1382        Some(
1383            annotations
1384                .iter()
1385                .map(|(k, v)| (k.to_string(), v.to_string()))
1386                .collect(),
1387        )
1388    }
1389
1390    #[test]
1391    fn no_annotations_no_infer_result_is_no_annotations() {
1392        let locked_app = annotatable_app();
1393        let explicit = None;
1394        let infer = InferPredefinedAnnotations::None;
1395
1396        assert!(all_annotations(&locked_app, explicit, infer).is_none());
1397    }
1398
1399    #[test]
1400    fn explicit_annotations_no_infer_result_is_explicit_annotations() {
1401        let locked_app = annotatable_app();
1402        let explicit = as_annotations(&[("volume", "11"), ("dimensions", "feet")]);
1403        let infer = InferPredefinedAnnotations::None;
1404
1405        let annotations =
1406            all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
1407        assert_eq!(2, annotations.len());
1408        assert_eq!("11", annotations.get("volume").unwrap());
1409        assert_eq!("feet", annotations.get("dimensions").unwrap());
1410    }
1411
1412    #[test]
1413    fn no_annotations_infer_all_result_is_auto_annotations() {
1414        let locked_app = annotatable_app();
1415        let explicit = None;
1416        let infer = InferPredefinedAnnotations::All;
1417
1418        let annotations =
1419            all_annotations(&locked_app, explicit, infer).expect("should now have annotations");
1420        assert_eq!(4, annotations.len());
1421        assert_eq!(
1422            "Marty DiBergi, Artie Fufkin",
1423            annotations
1424                .get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
1425                .expect("should have authors annotation")
1426        );
1427        assert_eq!(
1428            "this-is-spinal-tap",
1429            annotations
1430                .get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_TITLE)
1431                .expect("should have title annotation")
1432        );
1433        assert_eq!(
1434            "11.11.11",
1435            annotations
1436                .get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_VERSION)
1437                .expect("should have version annotation")
1438        );
1439        assert!(
1440            !annotations
1441                .contains_key(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_DESCRIPTION),
1442            "empty description should not have generated annotation"
1443        );
1444        assert!(
1445            annotations
1446                .contains_key(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_CREATED),
1447            "creation annotation should have been generated"
1448        );
1449    }
1450
1451    #[test]
1452    fn explicit_annotations_infer_all_gets_both_sets() {
1453        let locked_app = annotatable_app();
1454        let explicit = as_annotations(&[("volume", "11"), ("dimensions", "feet")]);
1455        let infer = InferPredefinedAnnotations::All;
1456
1457        let annotations =
1458            all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
1459        assert_eq!(6, annotations.len());
1460        assert_eq!(
1461            "11",
1462            annotations
1463                .get("volume")
1464                .expect("should have retained explicit annotation")
1465        );
1466        assert_eq!(
1467            "Marty DiBergi, Artie Fufkin",
1468            annotations
1469                .get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
1470                .expect("should have authors annotation")
1471        );
1472    }
1473
1474    #[test]
1475    fn explicit_annotations_take_precedence_over_inferred() {
1476        let locked_app = annotatable_app();
1477        let explicit = as_annotations(&[
1478            ("volume", "11"),
1479            (
1480                oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS,
1481                "David St Hubbins, Nigel Tufnel",
1482            ),
1483        ]);
1484        let infer = InferPredefinedAnnotations::All;
1485
1486        let annotations =
1487            all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
1488        assert_eq!(
1489            5,
1490            annotations.len(),
1491            "should have one custom, one predefined explicit, and three inferred"
1492        );
1493        assert_eq!(
1494            "11",
1495            annotations
1496                .get("volume")
1497                .expect("should have retained explicit annotation")
1498        );
1499        assert_eq!(
1500            "David St Hubbins, Nigel Tufnel",
1501            annotations
1502                .get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
1503                .expect("should have authors annotation"),
1504            "explicit authors should have taken precedence"
1505        );
1506    }
1507}