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