Skip to main content

spin_oci/
client.rs

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