Skip to main content

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