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