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!("Cannot retrieve credentials from Docker, attempting to use anonymous auth: {}", e);
736                    Ok(RegistryAuth::Anonymous)
737                }
738
739                Ok(DockerCredential::UsernamePassword(username, password)) => {
740                    tracing::trace!("Found Docker credentials");
741                    Ok(RegistryAuth::Basic(username, password))
742                }
743                Ok(DockerCredential::IdentityToken(_)) => {
744                    tracing::trace!("Cannot use contents of Docker config, identity token not supported. Using anonymous auth");
745                    Ok(RegistryAuth::Anonymous)
746                }
747            },
748        }
749    }
750
751    /// Build the OCI client configuration given the insecure option.
752    fn build_config(insecure: bool) -> oci_distribution::client::ClientConfig {
753        let protocol = if insecure {
754            oci_distribution::client::ClientProtocol::Http
755        } else {
756            oci_distribution::client::ClientProtocol::Https
757        };
758
759        oci_distribution::client::ClientConfig {
760            protocol,
761            default_token_expiration_secs: DEFAULT_TOKEN_EXPIRATION_SECS,
762            ..Default::default()
763        }
764    }
765}
766
767/// Unpack contents of the provided archive layer, represented by bytes and its
768/// corresponding digest, into the provided cache.
769/// A temporary staging directory is created via tempfile::tempdir() to store
770/// the unpacked contents prior to writing to the cache.
771pub async fn unpack_archive_layer(
772    cache: &Cache,
773    bytes: impl AsRef<[u8]>,
774    digest: impl AsRef<str>,
775) -> Result<()> {
776    // Write archive layer to cache as usual
777    cache.write_data(&bytes, &digest).await?;
778
779    // Unpack archive into a staging dir
780    let path = cache
781        .data_file(&digest)
782        .context("unable to read archive layer from cache")?;
783    let staging_dir = tempfile::tempdir()?;
784    crate::utils::unarchive(path.as_ref(), staging_dir.path()).await?;
785
786    // Traverse unpacked contents and if a file, write to cache by digest
787    // (if it doesn't already exist)
788    for entry in WalkDir::new(staging_dir.path()) {
789        let entry = entry?;
790        if entry.file_type().is_file() && !entry.file_type().is_dir() {
791            let bytes = tokio::fs::read(entry.path()).await?;
792            let digest = format!("sha256:{}", sha256::hex_digest_from_bytes(&bytes));
793            if cache.data_file(&digest).is_ok() {
794                tracing::debug!(
795                    "Skipping unpacked asset {:?}; file already exists",
796                    entry.path()
797                );
798            } else {
799                tracing::debug!("Adding unpacked asset {:?} to cache", entry.path());
800                cache.write_data(bytes, &digest).await?;
801            }
802        }
803    }
804    Ok(())
805}
806
807fn digest_from_url(manifest_url: &str) -> Option<String> {
808    // The URL is in the form "https://host/v2/refname/manifests/sha256:..."
809    let manifest_url = Url::parse(manifest_url).ok()?;
810    let mut segments = manifest_url.path_segments()?;
811    let last = segments.next_back()?;
812    if last.contains(':') {
813        Some(last.to_owned())
814    } else {
815        None
816    }
817}
818
819fn registry_from_input(server: impl AsRef<str>) -> String {
820    // We want to allow a user to login to both https://ghcr.io and ghcr.io.
821    let server = server.as_ref();
822    let server = match server.parse::<Url>() {
823        Ok(url) => url.host_str().unwrap_or(server).to_string(),
824        Err(_) => server.to_string(),
825    };
826    // DockerHub is commonly referenced as 'docker.io' but needs to be 'index.docker.io'
827    match server.as_str() {
828        "docker.io" => "index.docker.io".to_string(),
829        _ => server,
830    }
831}
832
833fn all_annotations(
834    locked_app: &LockedApp,
835    explicit: Option<BTreeMap<String, String>>,
836    predefined: InferPredefinedAnnotations,
837) -> Option<BTreeMap<String, String>> {
838    use spin_locked_app::{MetadataKey, APP_DESCRIPTION_KEY, APP_NAME_KEY, APP_VERSION_KEY};
839    const APP_AUTHORS_KEY: MetadataKey<Vec<String>> = MetadataKey::new("authors");
840
841    if predefined == InferPredefinedAnnotations::None {
842        return explicit;
843    }
844
845    // We will always, at minimum, have a `created` annotation, so if we don't already have an
846    // anootations collection then we may as well create one now...
847    let mut current = explicit.unwrap_or_default();
848
849    let authors = locked_app
850        .get_metadata(APP_AUTHORS_KEY)
851        .unwrap_or_default()
852        .unwrap_or_default();
853    if !authors.is_empty() {
854        let authors = authors.join(", ");
855        add_inferred(
856            &mut current,
857            oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS,
858            Some(authors),
859        );
860    }
861
862    let name = locked_app.get_metadata(APP_NAME_KEY).unwrap_or_default();
863    add_inferred(
864        &mut current,
865        oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_TITLE,
866        name,
867    );
868
869    let description = locked_app
870        .get_metadata(APP_DESCRIPTION_KEY)
871        .unwrap_or_default();
872    add_inferred(
873        &mut current,
874        oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_DESCRIPTION,
875        description,
876    );
877
878    let version = locked_app.get_metadata(APP_VERSION_KEY).unwrap_or_default();
879    add_inferred(
880        &mut current,
881        oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_VERSION,
882        version,
883    );
884
885    let created = chrono::Utc::now().to_rfc3339();
886    add_inferred(
887        &mut current,
888        oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_CREATED,
889        Some(created),
890    );
891
892    Some(current)
893}
894
895fn add_inferred(map: &mut BTreeMap<String, String>, key: &str, value: Option<String>) {
896    if let Some(value) = value {
897        if let std::collections::btree_map::Entry::Vacant(e) = map.entry(key.to_string()) {
898            e.insert(value);
899        }
900    }
901}
902
903/// Takes a relative path and turns it into a format that is safe
904/// for putting into a registry where it might end up on any host.
905#[cfg(target_os = "windows")]
906fn portable_path(rel_path: &Path) -> PathBuf {
907    assert!(
908        rel_path.is_relative(),
909        "portable_path requires paths to be relative"
910    );
911    let portable_path = rel_path.to_string_lossy().replace('\\', "/");
912    PathBuf::from(portable_path)
913}
914
915/// Takes a relative path and turns it into a format that is safe
916/// for putting into a registry where it might end up on any host.
917/// This is a no-op on Unix systems, but is needed for Windows.
918#[cfg(not(target_os = "windows"))]
919fn portable_path(rel_path: &Path) -> PathBuf {
920    rel_path.into()
921}
922
923/// Takes a string intended for use as part of a path and makes it
924/// compatible with the local filesystem.
925#[cfg(target_os = "windows")]
926fn fs_safe_segment(segment: &str) -> impl AsRef<Path> {
927    segment.replace(':', "_")
928}
929
930/// Takes a string intended for use as part of a path and makes it
931/// compatible with the local filesystem.
932/// This is a no-op on Unix systems, but is needed for Windows.
933#[cfg(not(target_os = "windows"))]
934fn fs_safe_segment(segment: &str) -> impl AsRef<Path> + '_ {
935    segment
936}
937
938#[cfg(test)]
939mod test {
940    use super::*;
941    use wit_parser::{LiftLowerAbi, ManglingAndAbi};
942
943    #[test]
944    fn can_parse_digest_from_manifest_url() {
945        let manifest_url = "https://ghcr.io/v2/itowlson/osf/manifests/sha256:0a867093096e0ef01ef749b12b6e7a90e4952eda107f89a676eeedce63a8361f";
946        let digest = digest_from_url(manifest_url).unwrap();
947        assert_eq!(
948            "sha256:0a867093096e0ef01ef749b12b6e7a90e4952eda107f89a676eeedce63a8361f",
949            digest
950        );
951    }
952
953    #[test]
954    fn can_derive_registry_from_input() {
955        #[derive(Clone)]
956        struct TestCase {
957            input: &'static str,
958            want: &'static str,
959        }
960        let tests: Vec<TestCase> = [
961            TestCase {
962                input: "docker.io",
963                want: "index.docker.io",
964            },
965            TestCase {
966                input: "index.docker.io",
967                want: "index.docker.io",
968            },
969            TestCase {
970                input: "https://ghcr.io",
971                want: "ghcr.io",
972            },
973        ]
974        .to_vec();
975
976        for tc in tests {
977            assert_eq!(tc.want, registry_from_input(tc.input));
978        }
979    }
980
981    // Convenience wrapper for deserializing from literal JSON
982    #[macro_export]
983    #[allow(missing_docs)] // it's test-only, but rust-analyzer gets mad
984    macro_rules! from_json {
985        ($($json:tt)+) => {
986            serde_json::from_value(serde_json::json!($($json)+)).expect("valid json")
987        };
988    }
989
990    #[tokio::test]
991    async fn can_assemble_layers() {
992        use spin_locked_app::locked::LockedComponent;
993        use tokio::io::AsyncWriteExt;
994
995        let working_dir = tempfile::tempdir().unwrap();
996
997        // Set up component/file directory tree
998        //
999        // create component1 and component2 dirs
1000        let _ = tokio::fs::create_dir(working_dir.path().join("component1").as_path()).await;
1001        let _ = tokio::fs::create_dir(working_dir.path().join("component2").as_path()).await;
1002
1003        // create component "wasm" files
1004        let mut c1 = tokio::fs::File::create(working_dir.path().join("component1.wasm"))
1005            .await
1006            .expect("should create component wasm file");
1007        c1.write_all(b"c1")
1008            .await
1009            .expect("should write component wasm contents");
1010        let mut c2 = tokio::fs::File::create(working_dir.path().join("component2.wasm"))
1011            .await
1012            .expect("should create component wasm file");
1013        c2.write_all(b"c2")
1014            .await
1015            .expect("should write component wasm contents");
1016
1017        // component1 files
1018        let mut c1f1 = tokio::fs::File::create(working_dir.path().join("component1").join("bar"))
1019            .await
1020            .expect("should create component file");
1021        c1f1.write_all(b"bar")
1022            .await
1023            .expect("should write file contents");
1024        let mut c1f2 = tokio::fs::File::create(working_dir.path().join("component1").join("baz"))
1025            .await
1026            .expect("should create component file");
1027        c1f2.write_all(b"baz")
1028            .await
1029            .expect("should write file contents");
1030
1031        // component2 files
1032        let mut c2f1 = tokio::fs::File::create(working_dir.path().join("component2").join("baz"))
1033            .await
1034            .expect("should create component file");
1035        c2f1.write_all(b"baz")
1036            .await
1037            .expect("should write file contents");
1038
1039        // create a component with dependencies
1040        const TEST_WIT: &str = "
1041        package test:test;
1042
1043        interface a {
1044            a: func();
1045        }
1046
1047        world dep-a {
1048            export a;
1049        }
1050
1051        world root {
1052            import a;
1053        }
1054        ";
1055
1056        let root = generate_dummy_component(TEST_WIT, "root");
1057        let dep_a = generate_dummy_component(TEST_WIT, "dep-a");
1058
1059        let mut r = tokio::fs::File::create(working_dir.path().join("root.wasm"))
1060            .await
1061            .expect("should create component wasm file");
1062        r.write_all(&root)
1063            .await
1064            .expect("should write component wasm contents");
1065
1066        let mut a = tokio::fs::File::create(working_dir.path().join("dep_a.wasm"))
1067            .await
1068            .expect("should create component wasm file");
1069        a.write_all(&dep_a)
1070            .await
1071            .expect("should write component wasm contents");
1072
1073        #[derive(Clone)]
1074        struct TestCase {
1075            name: &'static str,
1076            opts: Option<ClientOpts>,
1077            locked_components: Vec<LockedComponent>,
1078            expected_layer_count: usize,
1079            expected_error: Option<&'static str>,
1080            compose_mode: ComposeMode,
1081        }
1082
1083        let tests: Vec<TestCase> = [
1084            TestCase {
1085                name: "Two component layers",
1086                opts: None,
1087                locked_components: from_json!([{
1088                    "id": "component1",
1089                    "source": {
1090                        "content_type": "application/wasm",
1091                        "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1092                        "digest": "digest",
1093                }},
1094                {
1095                    "id": "component2",
1096                    "source": {
1097                        "content_type": "application/wasm",
1098                        "source": format!("file://{}", working_dir.path().join("component2.wasm").to_str().unwrap()),
1099                        "digest": "digest",
1100                }}]),
1101                expected_layer_count: 2,
1102                expected_error: None,
1103                compose_mode: ComposeMode::Skip,
1104            },
1105            TestCase {
1106                name: "One component layer and two file layers",
1107                opts: Some(ClientOpts{content_ref_inline_max_size: 0}),
1108                locked_components: from_json!([{
1109                "id": "component1",
1110                "source": {
1111                    "content_type": "application/wasm",
1112                    "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1113                    "digest": "digest",
1114                },
1115                "files": [
1116                    {
1117                        "source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
1118                        "path": working_dir.path().join("component1").join("bar").to_str().unwrap()
1119                    },
1120                    {
1121                        "source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
1122                        "path": working_dir.path().join("component1").join("baz").to_str().unwrap()
1123                    }
1124                ]
1125                }]),
1126                expected_layer_count: 3,
1127                expected_error: None,
1128                compose_mode: ComposeMode::Skip,
1129            },
1130            TestCase {
1131                name: "One component layer and one file with inlined content",
1132                opts: None,
1133                locked_components: from_json!([{
1134                "id": "component1",
1135                "source": {
1136                    "content_type": "application/wasm",
1137                    "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1138                    "digest": "digest",
1139                },
1140                "files": [
1141                    {
1142                        "source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
1143                        "path": working_dir.path().join("component1").join("bar").to_str().unwrap()
1144                    }
1145                ]
1146                }]),
1147                expected_layer_count: 1,
1148                expected_error: None,
1149                compose_mode: ComposeMode::Skip,
1150            },
1151            TestCase {
1152                name: "One component layer and one dependency component layer skipping composition",
1153                opts: Some(ClientOpts{content_ref_inline_max_size: 0}),
1154                locked_components: from_json!([{
1155                "id": "component1",
1156                "source": {
1157                    "content_type": "application/wasm",
1158                    "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1159                    "digest": "digest",
1160                },
1161                "dependencies": {
1162                    "test:comp2": {
1163                        "source": {
1164                            "content_type": "application/wasm",
1165                            "source": format!("file://{}", working_dir.path().join("component2.wasm").to_str().unwrap()),
1166                            "digest": "digest",
1167                        },
1168                        "export": null,
1169                    }
1170                }
1171                }]),
1172                expected_layer_count: 2,
1173                expected_error: None,
1174                compose_mode: ComposeMode::Skip,
1175            },
1176            TestCase {
1177                name: "Component has no source",
1178                opts: None,
1179                locked_components: from_json!([{
1180                "id": "component1",
1181                "source": {
1182                    "content_type": "application/wasm",
1183                    "source": "",
1184                    "digest": "digest",
1185                }
1186                }]),
1187                expected_layer_count: 0,
1188                expected_error: Some("Invalid URL: \"\""),
1189                compose_mode: ComposeMode::Skip,
1190            },
1191            TestCase {
1192                name: "Duplicate component sources",
1193                opts: None,
1194                locked_components: from_json!([{
1195                    "id": "component1",
1196                    "source": {
1197                        "content_type": "application/wasm",
1198                        "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1199                        "digest": "digest",
1200                }},
1201                {
1202                    "id": "component2",
1203                    "source": {
1204                        "content_type": "application/wasm",
1205                        "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1206                        "digest": "digest",
1207                }}]),
1208                expected_layer_count: 1,
1209                expected_error: None,
1210                compose_mode: ComposeMode::Skip,
1211            },
1212            TestCase {
1213                name: "Duplicate file paths",
1214                opts: Some(ClientOpts{content_ref_inline_max_size: 0}),
1215                locked_components: from_json!([{
1216                "id": "component1",
1217                "source": {
1218                    "content_type": "application/wasm",
1219                    "source": format!("file://{}", working_dir.path().join("component1.wasm").to_str().unwrap()),
1220                    "digest": "digest",
1221                },
1222                "files": [
1223                    {
1224                        "source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
1225                        "path": working_dir.path().join("component1").join("bar").to_str().unwrap()
1226                    },
1227                    {
1228                        "source": format!("file://{}", working_dir.path().join("component1").to_str().unwrap()),
1229                        "path": working_dir.path().join("component1").join("baz").to_str().unwrap()
1230                    }
1231                ]},
1232                {
1233                    "id": "component2",
1234                    "source": {
1235                        "content_type": "application/wasm",
1236                        "source": format!("file://{}", working_dir.path().join("component2.wasm").to_str().unwrap()),
1237                        "digest": "digest",
1238                },
1239                "files": [
1240                    {
1241                        "source": format!("file://{}", working_dir.path().join("component2").to_str().unwrap()),
1242                        "path": working_dir.path().join("component2").join("baz").to_str().unwrap()
1243                    }
1244                ]
1245                }]),
1246                expected_layer_count: 4,
1247                expected_error: None,
1248                compose_mode: ComposeMode::Skip,
1249            },
1250            TestCase {
1251                name: "One component layer and one dependency component layer with composition",
1252                opts: Some(ClientOpts{content_ref_inline_max_size: 0}),
1253                locked_components: from_json!([{
1254                "id": "component-with-deps",
1255                "source": {
1256                    "content_type": "application/wasm",
1257                    "source": format!("file://{}", working_dir.path().join("root.wasm").to_str().unwrap()),
1258                    "digest": "digest",
1259                },
1260                "dependencies": {
1261                    "test:test/a": {
1262                        "source": {
1263                            "content_type": "application/wasm",
1264                            "source": format!("file://{}", working_dir.path().join("dep_a.wasm").to_str().unwrap()),
1265                            "digest": "digest",
1266                        },
1267                        "export": null,
1268                    }
1269                }
1270                }]),
1271                expected_layer_count: 1,
1272                expected_error: None,
1273                compose_mode: ComposeMode::All,
1274            },
1275        ]
1276        .to_vec();
1277
1278        for tc in tests {
1279            let triggers = Default::default();
1280            let metadata = Default::default();
1281            let variables = Default::default();
1282            let mut locked = LockedApp {
1283                spin_lock_version: Default::default(),
1284                components: tc.locked_components,
1285                triggers,
1286                metadata,
1287                variables,
1288                must_understand: Default::default(),
1289                host_requirements: Default::default(),
1290            };
1291
1292            let mut client = Client::new(false, Some(working_dir.path().to_path_buf()))
1293                .await
1294                .expect("should create new client");
1295            if let Some(o) = tc.opts {
1296                client.opts = o;
1297            }
1298
1299            match tc.expected_error {
1300                Some(e) => {
1301                    assert_eq!(
1302                        e,
1303                        client
1304                            .assemble_layers(&mut locked, AssemblyMode::Simple, tc.compose_mode)
1305                            .await
1306                            .unwrap_err()
1307                            .to_string(),
1308                        "{}",
1309                        tc.name
1310                    )
1311                }
1312                None => {
1313                    assert_eq!(
1314                        tc.expected_layer_count,
1315                        client
1316                            .assemble_layers(&mut locked, AssemblyMode::Simple, tc.compose_mode)
1317                            .await
1318                            .unwrap()
1319                            .len(),
1320                        "{}",
1321                        tc.name
1322                    )
1323                }
1324            }
1325        }
1326    }
1327
1328    fn generate_dummy_component(wit: &str, world: &str) -> Vec<u8> {
1329        let mut resolve = wit_parser::Resolve::default();
1330        let package_id = resolve.push_str("test", wit).expect("should parse WIT");
1331        let world_id = resolve
1332            .select_world(package_id, Some(world))
1333            .expect("should select world");
1334
1335        let mut wasm = wit_component::dummy_module(
1336            &resolve,
1337            world_id,
1338            ManglingAndAbi::Legacy(LiftLowerAbi::Sync),
1339        );
1340        wit_component::embed_component_metadata(
1341            &mut wasm,
1342            &resolve,
1343            world_id,
1344            wit_component::StringEncoding::UTF8,
1345        )
1346        .expect("should embed component metadata");
1347
1348        let mut encoder = wit_component::ComponentEncoder::default()
1349            .validate(true)
1350            .module(&wasm)
1351            .expect("should set module");
1352        encoder.encode().expect("should encode component")
1353    }
1354
1355    fn annotatable_app() -> LockedApp {
1356        let mut meta_builder = spin_locked_app::values::ValuesMapBuilder::new();
1357        meta_builder
1358            .string("name", "this-is-spinal-tap")
1359            .string("version", "11.11.11")
1360            .string("description", "")
1361            .string_array("authors", vec!["Marty DiBergi", "Artie Fufkin"]);
1362        let metadata = meta_builder.build();
1363        LockedApp {
1364            spin_lock_version: Default::default(),
1365            must_understand: vec![],
1366            metadata,
1367            host_requirements: Default::default(),
1368            variables: Default::default(),
1369            triggers: Default::default(),
1370            components: Default::default(),
1371        }
1372    }
1373
1374    fn as_annotations(annotations: &[(&str, &str)]) -> Option<BTreeMap<String, String>> {
1375        Some(
1376            annotations
1377                .iter()
1378                .map(|(k, v)| (k.to_string(), v.to_string()))
1379                .collect(),
1380        )
1381    }
1382
1383    #[test]
1384    fn no_annotations_no_infer_result_is_no_annotations() {
1385        let locked_app = annotatable_app();
1386        let explicit = None;
1387        let infer = InferPredefinedAnnotations::None;
1388
1389        assert!(all_annotations(&locked_app, explicit, infer).is_none());
1390    }
1391
1392    #[test]
1393    fn explicit_annotations_no_infer_result_is_explicit_annotations() {
1394        let locked_app = annotatable_app();
1395        let explicit = as_annotations(&[("volume", "11"), ("dimensions", "feet")]);
1396        let infer = InferPredefinedAnnotations::None;
1397
1398        let annotations =
1399            all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
1400        assert_eq!(2, annotations.len());
1401        assert_eq!("11", annotations.get("volume").unwrap());
1402        assert_eq!("feet", annotations.get("dimensions").unwrap());
1403    }
1404
1405    #[test]
1406    fn no_annotations_infer_all_result_is_auto_annotations() {
1407        let locked_app = annotatable_app();
1408        let explicit = None;
1409        let infer = InferPredefinedAnnotations::All;
1410
1411        let annotations =
1412            all_annotations(&locked_app, explicit, infer).expect("should now have annotations");
1413        assert_eq!(4, annotations.len());
1414        assert_eq!(
1415            "Marty DiBergi, Artie Fufkin",
1416            annotations
1417                .get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
1418                .expect("should have authors annotation")
1419        );
1420        assert_eq!(
1421            "this-is-spinal-tap",
1422            annotations
1423                .get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_TITLE)
1424                .expect("should have title annotation")
1425        );
1426        assert_eq!(
1427            "11.11.11",
1428            annotations
1429                .get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_VERSION)
1430                .expect("should have version annotation")
1431        );
1432        assert!(
1433            !annotations
1434                .contains_key(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_DESCRIPTION),
1435            "empty description should not have generated annotation"
1436        );
1437        assert!(
1438            annotations
1439                .contains_key(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_CREATED),
1440            "creation annotation should have been generated"
1441        );
1442    }
1443
1444    #[test]
1445    fn explicit_annotations_infer_all_gets_both_sets() {
1446        let locked_app = annotatable_app();
1447        let explicit = as_annotations(&[("volume", "11"), ("dimensions", "feet")]);
1448        let infer = InferPredefinedAnnotations::All;
1449
1450        let annotations =
1451            all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
1452        assert_eq!(6, annotations.len());
1453        assert_eq!(
1454            "11",
1455            annotations
1456                .get("volume")
1457                .expect("should have retained explicit annotation")
1458        );
1459        assert_eq!(
1460            "Marty DiBergi, Artie Fufkin",
1461            annotations
1462                .get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
1463                .expect("should have authors annotation")
1464        );
1465    }
1466
1467    #[test]
1468    fn explicit_annotations_take_precedence_over_inferred() {
1469        let locked_app = annotatable_app();
1470        let explicit = as_annotations(&[
1471            ("volume", "11"),
1472            (
1473                oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS,
1474                "David St Hubbins, Nigel Tufnel",
1475            ),
1476        ]);
1477        let infer = InferPredefinedAnnotations::All;
1478
1479        let annotations =
1480            all_annotations(&locked_app, explicit, infer).expect("should still have annotations");
1481        assert_eq!(
1482            5,
1483            annotations.len(),
1484            "should have one custom, one predefined explicit, and three inferred"
1485        );
1486        assert_eq!(
1487            "11",
1488            annotations
1489                .get("volume")
1490                .expect("should have retained explicit annotation")
1491        );
1492        assert_eq!(
1493            "David St Hubbins, Nigel Tufnel",
1494            annotations
1495                .get(oci_distribution::annotations::ORG_OPENCONTAINERS_IMAGE_AUTHORS)
1496                .expect("should have authors annotation"),
1497            "explicit authors should have taken precedence"
1498        );
1499    }
1500}