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